Skip to main content

Bridge TIFF/DICOM: Full-Page Viewer + Server-Side PNG Conversion for Ruby AI

Date: 2026-05-07
Status: Approved for implementation

Problem

Two separate gaps:
  1. TIFF files have no viewer — users can’t see what they uploaded
  2. TIFF and DICOM can’t be passed to GPT Vision (Ruby AI) — TIFF is explicitly blocked, DICOM conversion is slow and client-side
This spec addresses both with independent solutions.

Part 1 — TIFF Full-Page Viewer

Overview

TIFF files open in a new browser tab showing a full-page viewer built with react-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 fileId from 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

npm install react-tiff
Import CSS: 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 to POST /api/v1/protected/files/upload as today. No Bridge app changes. Server:
  1. Stores original file in R2 (unchanged)
  2. Inserts patientFiles record — conversion_status = 'pending' for TIFF/DICOM, 'none' for all others
  3. Calls ctx.waitUntil(convertAndStorePng(...)) — non-blocking background work
  4. 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
DICOM path:
  • 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 OffscreenCanvas as RGBA (R=G=B=output, A=255), capped at 2048px
  • Export as PNG blob → upload to R2 as {originalKey}-preview.png
On success: update DB — 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 to patient_files:
ALTER TABLE patient_files
  ADD COLUMN conversion_status TEXT NOT NULL DEFAULT 'none',
  ADD COLUMN preview_key TEXT;
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

{ "type": "file_conversion_ready", "fileId": "...", "status": "done" }
BridgeInbox already uses 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 amber Processing… badge
  • On file_conversion_ready SSE event for that fileId: badge clears (done) or turns red Unavailable (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 on file_conversion_ready SSE event)
  • Reads conversion_status and preview_key from 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 entirely
  • failed → static error banner “Image could not be prepared for AI analysis”
  • none (JPG/PNG) → existing flow unchanged
  • Removes the TIFF rejection block and the convertDicomBlobToPngBase64 client-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.