import { getReadDb } from './db';
import { getR2Service } from './r2';
import { patientFiles } from '../schema';
import { eq } from 'drizzle-orm';
import { publishEvent } from './event-bus';
import { encodePng, downscaleRgba } from './png-encoder';
// TIFF transfer syntaxes that require compressed pixel handling (skip gracefully)
const COMPRESSED_DICOM_UIDS = new Set([
'1.2.840.10008.1.2.4.50', // JPEG Baseline
'1.2.840.10008.1.2.4.51', // JPEG Extended
'1.2.840.10008.1.2.4.57', // JPEG Lossless
'1.2.840.10008.1.2.4.70', // JPEG Lossless SV1
'1.2.840.10008.1.2.4.90', // JPEG 2000 Lossless
'1.2.840.10008.1.2.4.91', // JPEG 2000
]);
const MAX_DIM = 2048;
async function tiffToRgba(buffer: ArrayBuffer): Promise<{ width: number; height: number; rgba: Uint8Array }> {
// Dynamic import so the module is only loaded when needed
const UTIF = (await import('utif2')).default;
const uint8 = new Uint8Array(buffer);
const ifds = UTIF.decode(uint8);
if (!ifds.length) throw new Error('No pages in TIFF');
UTIF.decodeImage(uint8, ifds[0]);
const rgba = UTIF.toRGBA8(ifds[0]);
const width: number = (ifds[0] as any).width ?? (ifds[0] as any).t256?.[0];
const height: number = (ifds[0] as any).height ?? (ifds[0] as any).t257?.[0];
if (!width || !height) throw new Error('Could not read TIFF dimensions');
return { width, height, rgba: new Uint8Array(rgba.buffer) };
}
async function dicomToRgba(buffer: ArrayBuffer): Promise<{ width: number; height: number; rgba: Uint8Array }> {
// Dynamic import
const dicomParser = (await import('dicom-parser')).default;
const byteArray = new Uint8Array(buffer);
const dataSet = dicomParser.parseDicom(byteArray);
// Reject compressed transfer syntaxes we can't decompress in Workers
const transferSyntax = dataSet.string('x00020010');
if (transferSyntax && COMPRESSED_DICOM_UIDS.has(transferSyntax.trim())) {
throw new Error(`Compressed DICOM transfer syntax not supported: ${transferSyntax}`);
}
const height = dataSet.uint16('x00280010'); // rows
const width = dataSet.uint16('x00280011'); // columns
if (!width || !height) throw new Error('Could not read DICOM dimensions');
const bitsAllocated = dataSet.uint16('x00280100') ?? 8;
const pixelRepresentation = dataSet.uint16('x00280103') ?? 0;
const slope = parseFloat(dataSet.string('x00281053') ?? '1') || 1;
const intercept = parseFloat(dataSet.string('x00281052') ?? '0') || 0;
// Dental bone windowing: centre 700, width 3000
const winCenter = parseFloat(dataSet.string('x00281050') ?? '700') || 700;
const winWidth = parseFloat(dataSet.string('x00281051') ?? '3000') || 3000;
const winLow = winCenter - winWidth / 2;
const pixElem = dataSet.elements.x7fe00010;
if (!pixElem) throw new Error('No pixel data element in DICOM');
let pixelData: Uint16Array | Int16Array | Uint8Array;
if (bitsAllocated === 16) {
const slice = byteArray.buffer.slice(pixElem.dataOffset, pixElem.dataOffset + pixElem.length);
pixelData = pixelRepresentation === 1 ? new Int16Array(slice) : new Uint16Array(slice);
} else {
pixelData = new Uint8Array(byteArray.buffer, pixElem.dataOffset, pixElem.length);
}
// Apply windowing: map HU range → [0, 255] grayscale, expand to RGBA
const totalPixels = width * height;
const rgba = new Uint8Array(totalPixels * 4);
for (let i = 0; i < totalPixels; i++) {
const hu = (pixelData[i]! * slope) + intercept;
const g = Math.max(0, Math.min(255, Math.round(((hu - winLow) / winWidth) * 255)));
const di = i * 4;
rgba[di] = g; rgba[di + 1] = g; rgba[di + 2] = g; rgba[di + 3] = 255;
}
return { width, height, rgba };
}
type ConversionEnv = {
R2_STORAGE?: any;
DATABASE_URL?: string;
CLINIC_HUB?: any;
[key: string]: any;
};
export async function convertAndStorePng(
env: ConversionEnv,
fileId: string,
clinicId: string,
originalKey: string,
mimeType: string
): Promise<void> {
const db = getReadDb();
const r2 = getR2Service(env);
if (!r2) return;
const isTiff = mimeType === 'image/tiff' || mimeType === 'image/tif';
const isDicom = mimeType === 'application/dicom';
if (!isTiff && !isDicom) return;
const previewKey = `${originalKey}-preview.png`;
try {
const r2Obj = await r2.getFile(originalKey);
if (!r2Obj) throw new Error('Original file not found in R2');
const buffer = await r2Obj.arrayBuffer();
const { width, height, rgba } = isTiff
? await tiffToRgba(buffer)
: await dicomToRgba(buffer);
const { rgba: scaledRgba, width: w, height: h } = downscaleRgba(rgba, width, height, MAX_DIM);
const pngBytes = await encodePng(w, h, scaledRgba);
await r2.uploadFile(previewKey, pngBytes, 'image/png', {
sourceFileId: fileId,
convertedFrom: mimeType,
});
await db.update(patientFiles)
.set({ conversionStatus: 'done', previewKey })
.where(eq(patientFiles.id, fileId));
await publishEvent(env as any, clinicId, {
type: 'file_conversion_ready',
fileId,
status: 'done',
} as any);
} catch (err) {
console.error('[image-conversion] failed for file', fileId, err);
try {
await db.update(patientFiles)
.set({ conversionStatus: 'failed' })
.where(eq(patientFiles.id, fileId));
await publishEvent(env as any, clinicId, {
type: 'file_conversion_ready',
fileId,
status: 'failed',
} as any);
} catch { /* best-effort */ }
}
}