Bridge TIFF/DICOM: Full-Page Viewer + Server-Side PNG Conversion for Ruby AI
Date: 2026-05-07Status: Approved for implementation
Problem
Two separate gaps:- TIFF files have no viewer — users can’t see what they uploaded
- TIFF and DICOM can’t be passed to GPT Vision (Ruby AI) — TIFF is explicitly blocked, DICOM conversion is slow and client-side
Part 1 — TIFF Full-Page Viewer
Overview
TIFF files open in a new browser tab showing a full-page viewer built withreact-tiff. No server-side work needed for viewing — the component decodes TIFF client-side via its bundled utif dependency.
New Route
/files/tiff-viewer (query param ?id=<fileId>) — a lazy-loaded page within the existing React SPA. Opens via window.open('/files/tiff-viewer?id=xxx', '_blank').
The new tab loads the app fresh, re-authenticates from existing session storage, then shows the viewer.
TiffViewerPage Component
- Reads
fileIdfrom query params - Uses TanStack Query (
useQuery) to fetch the file blob — automatic caching means re-opening the same TIFF doesn’t re-download - Creates a blob URL from the cached result:
URL.createObjectURL(blob) - Renders
<TIFFViewer tiff={blobUrl} lang="en" paginate="ltr" zoomable printable /> - Revokes blob URL on unmount
- Shows loading spinner (TanStack
isLoading) and error state (isError) declaratively
Triggering the Viewer
- BridgeInbox: clicking the thumbnail/eye icon on a TIFF card opens the viewer in a new tab
- FileManager / XRayWorkstation: same — TIFF files open viewer instead of FilePreviewDialog
- DICOM files continue using DicomViewer as today (unchanged)
Dependencies
import 'react-tiff/dist/index.css' in the page component.Note: pulls in
i18next + react-i18next — lazy-loaded with the route so no impact on main bundle.
Part 2 — Server-Side PNG Conversion for Ruby AI
Overview
At upload time, server converts TIFF and DICOM files to PNG in the background. By the time a doctor triggers Ruby AI analysis, the PNG is already sitting in R2 — zero latency at analysis time.Upload Flow (unchanged for Bridge)
Bridge posts toPOST /api/v1/protected/files/upload as today. No Bridge app changes.
Server:
- Stores original file in R2 (unchanged)
- Inserts
patientFilesrecord —conversion_status = 'pending'for TIFF/DICOM,'none'for all others - Calls
ctx.waitUntil(convertAndStorePng(...))— non-blocking background work - Returns response to Bridge immediately
Background Conversion (convertAndStorePng)
Runs inside the existing CF Worker request via ctx.waitUntil(). No new infrastructure, no queues.
TIFF path:
- Decode with
utif2(pure JS, already in repo) → raw RGBA pixel array - Write pixels to
OffscreenCanvas(capped at 2048px longest dimension) - Export as PNG blob via
canvas.convertToBlob({ type: 'image/png' }) - Upload PNG to R2 as
{originalKey}-preview.png
- Parse with
dcmjs(pure JS) — extract raw pixel data + metadata (rows, cols, bits allocated, rescale slope/intercept) - Apply dental bone windowing manually: centre 700, width 3000
output = clamp((px * slope + intercept - (centre - width/2)) / width * 255, 0, 255)
- Write grayscale pixels to
OffscreenCanvasas RGBA (R=G=B=output, A=255), capped at 2048px - Export as PNG blob → upload to R2 as
{originalKey}-preview.png
conversion_status = 'done', preview_key = '{originalKey}-preview.png' — then fire SSE event file_conversion_ready on the clinic channel.
On failure: update DB — conversion_status = 'failed'. No retry. Staff re-uploads from Bridge if needed.
Database
Migration adds two columns topatient_files:
conversion_status values: 'none' | 'pending' | 'done' | 'failed'
No index needed — only queried per individual file fetch, never bulk-scanned.
DB writes per file: 2 total (insert pending, update done/failed). No polling reads.
SSE Event
useEventBus() — reacts to this event to flip card badge in memory. No polling, no new endpoint.
Download Endpoint Extension
GET /api/v1/protected/files/:id/download?preview=true — serves preview_key from R2 instead of the original file. Same auth and authorization checks as the normal download.
BridgeInbox UI
- TIFF/DICOM cards with
conversion_status = 'pending'show an amberProcessing…badge - On
file_conversion_readySSE event for that fileId: badge clears (done) or turns redUnavailable(failed) - Eye icon click on TIFF → opens TiffViewerPage in new tab (works regardless of conversion status — viewing and AI conversion are independent)
XRayRubyPanel
- File metadata (including
conversion_status,preview_key) fetched via TanStack Query — panel re-renders automatically when conversion status changes (invalidate query key onfile_conversion_readySSE event) - Reads
conversion_statusandpreview_keyfrom the cached query result (no extra request on open) pending→ Ruby button disabled, tooltip “Preparing image…”done→ downloads PNG via?preview=true, sends base64 to/ai/dicom-analysis— skips client-side DWV conversion entirelyfailed→ static error banner “Image could not be prepared for AI analysis”none(JPG/PNG) → existing flow unchanged- Removes the TIFF rejection block and the
convertDicomBlobToPngBase64client-side call for Bridge-sourced files
What Does Not Change
- Bridge app: no changes
- Upload API contract: no changes
- R2 original file: always kept
- DicomViewer.tsx: uses original DICOM, unaffected
- Manual uploads via FileManager: same conversion logic applies
Constraints & Edge Cases
- Multi-page TIFF for Ruby: use first page only (index 0). react-tiff viewer handles all pages for viewing.
- DICOM compressed transfer syntax (JPEG 2000 etc.): dcmjs handles common syntaxes; decompression failure → mark
failed, log error. - OffscreenCanvas: CF Workers supports 2D context. If unavailable at runtime, catch and mark
failed. - Worker CPU time:
ctx.waitUntil()on paid CF Workers plan allows up to 30 CPU seconds — sufficient for dental DICOM sizes (typically 2–20 MB). - PNG size cap: 2048px longest dimension — matches existing client-side DWV limit, stays within GPT Vision payload limits.

