diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index caa73bf..72c88c3 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -892,10 +892,11 @@ export async function scanFolder( return; } const raw = isRawName(file.name); + const isTiff = /\.(tiff?)$/i.test(file.name); const RAW_HEADER_BYTES = 4 * 1024 * 1024; - const sliceBuffer = await file.slice(0, raw ? RAW_HEADER_BYTES : HEAD_BYTES).arrayBuffer(); + const sliceBuffer = await file.slice(0, (raw || isTiff) ? (isTiff ? Math.min(file.size, 16 * 1024 * 1024) : RAW_HEADER_BYTES) : HEAD_BYTES).arrayBuffer(); const bytes = new Uint8Array(sliceBuffer); - const preview = raw ? await rawThumbnail(bytes) : null; + const preview = (raw || isTiff) ? await rawThumbnail(bytes, file.name) : null; const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw ? null : file; const size = preview ? jpegSize(preview) : raw ? null : jpegSize(bytes); const thumb = src ? await tile(src, size) : null; diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index bf72b1d..06b9e4c 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -76,7 +76,7 @@ const SETTINGS = { noAutoBright: true, gamm: [1, 1] as [number, number], userQual: 3, - highlight: 0, + highlight: 2, }; // 16-bit samples, but everything below is [0,1] — Skia reads an F32 image as @@ -195,7 +195,6 @@ half4 main(float2 pos) { const RAW_EXT = [ 'arw', 'srf', 'sr2', 'cr2', 'cr3', 'crw', 'nef', 'nrw', 'dng', 'orf', 'raf', 'rw2', 'pef', 'srw', 'raw', 'rwl', '3fr', 'fff', 'iiq', 'mos', 'mrw', 'x3f', - 'tif', 'tiff', ]; export const RAW_ACCEPT = RAW_EXT.map((e) => `.${e}`).join(','); @@ -284,16 +283,64 @@ export function extractEmbeddedJpeg(buf: Uint8Array): Uint8Array | null { // above is a full decode of every pixel at full resolution. No preview inside // the file means no tile — the row still lists the frame by name, and the // studio develops it the moment it is opened. -export async function rawThumbnail(bytes: Uint8Array): Promise { +export function tiffThumbnail(bytes: Uint8Array): Uint8Array | null { + try { + const img = Skia.Image.MakeImageFromEncoded(bytes); + if (!img) return null; + try { + const w = img.width(); + const h = img.height(); + if (!w || !h) return null; + const n = 640; + const wide = w >= h; + const targetW = wide ? n : Math.max(1, Math.round((w / h) * n)); + const targetH = wide ? Math.max(1, Math.round((h / w) * n)) : n; + + const surface = Skia.Surface.MakeOffscreen(targetW, targetH) ?? Skia.Surface.Make(targetW, targetH); + if (!surface) return null; + const canvas = surface.getCanvas(); + canvas.drawImageRectCubic( + img, + Skia.XYWHRect(0, 0, w, h), + Skia.XYWHRect(0, 0, targetW, targetH), + 1 / 3, + 1 / 3 + ); + surface.flush(); + const snapshot = surface.makeImageSnapshot(); + surface.dispose(); + if (!snapshot) return null; + const jpeg = snapshot.encodeToBytes(Skia.ImageFormat.JPEG, 80); + snapshot.dispose(); + return jpeg ? new Uint8Array(jpeg) : null; + } finally { + img.delete(); + } + } catch { + return null; + } +} + +// The preview on its own, for the catalogue: a folder of RAW files has to show +// a tile per frame, and unpack_thumb is a seek and a copy where the develop +// above is a full decode of every pixel at full resolution. No preview inside +// the file means no tile — the row still lists the frame by name, and the +// studio develops it the moment it is opened. +export async function rawThumbnail(bytes: Uint8Array, fileName?: string): Promise { const fast = extractEmbeddedJpeg(bytes); if (fast) return fast; + if (fileName && /\.(tiff?)$/i.test(fileName)) { + const tiff = tiffThumbnail(bytes); + if (tiff) return tiff; + } + const raw = new LibRaw(); try { await raw.open(bytes as unknown as BufferSource, SETTINGS); return await cameraPreview(raw); } catch { - return null; + return tiffThumbnail(bytes); } finally { try { raw.dispose(); @@ -338,16 +385,96 @@ function previewGrid(jpeg: Uint8Array, w: number, h: number, n = MATCH_GRID): Ui const bmp = Skia.Image.MakeImageFromEncoded(jpeg); if (!bmp) return null; try { - // A preview of another shape is a crop of the frame, not the frame: fitting - // against it lines the two grids up on different scenes and fits nothing. - if (Math.abs(bmp.width() / bmp.height() / (w / h) - 1) > 0.02) return null; - if (bmp.width() < n * 4) return null; - return gridOf(bmp, n); + if (bmp.width() < n || bmp.height() < n) return null; + const targetAspect = w / h; + const bmpAspect = bmp.width() / bmp.height(); + let cropW = bmp.width(); + let cropH = bmp.height(); + if (Math.abs(bmpAspect - targetAspect) > 0.01) { + if (bmpAspect > targetAspect) { + cropW = Math.round(bmp.height() * targetAspect); + } else { + cropH = Math.round(bmp.width() / targetAspect); + } + } + const cropX = Math.round((bmp.width() - cropW) / 2); + const cropY = Math.round((bmp.height() - cropH) / 2); + + const surface = Skia.Surface.MakeOffscreen(n, n) ?? Skia.Surface.Make(n, n); + if (!surface) return null; + const canvas = surface.getCanvas(); + canvas.drawImageRectCubic( + bmp, + Skia.XYWHRect(cropX, cropY, cropW, cropH), + Skia.XYWHRect(0, 0, n, n), + 1 / 3, + 1 / 3 + ); + surface.flush(); + const px = canvas.readPixels(0, 0, { + width: n, + height: n, + colorType: Skia.ColorType.RGBA_8888, + alphaType: Skia.AlphaType.Unpremul, + colorSpace: Skia.ColorSpace.SRGB, + }) as Uint8Array | null; + surface.dispose(); + return px ? new Uint8Array(px.buffer, px.byteOffset, px.byteLength) : null; } finally { bmp.delete(); } } +function invert3x3(m: number[][]): number[][] | null { + const [a, b, c] = m[0]; + const [d, e, f] = m[1]; + const [g, h, i] = m[2]; + const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + if (Math.abs(det) < 1e-6) return null; + const invDet = 1.0 / det; + return [ + [(e * i - f * h) * invDet, (c * h - b * i) * invDet, (b * f - c * e) * invDet], + [(f * g - d * i) * invDet, (a * i - c * g) * invDet, (c * d - a * f) * invDet], + [(d * h - e * g) * invDet, (b * g - a * h) * invDet, (a * e - b * d) * invDet], + ]; +} + +function getCamToSrgbMatrix(cd: any): number[][] { + if (cd?.cam_xyz && Array.isArray(cd.cam_xyz) && cd.cam_xyz.length >= 3) { + const cx = cd.cam_xyz; + const scale = (Math.abs(cx[0][0]) > 100) ? 10000.0 : 1.0; + const rX = (cx[0][0] || 0) / scale, rY = (cx[0][1] || 0) / scale, rZ = (cx[0][2] || 0) / scale; + const gX = (cx[1][0] || 0) / scale, gY = (cx[1][1] || 0) / scale, gZ = (cx[1][2] || 0) / scale; + const bX = (cx[2][0] || 0) / scale, bY = (cx[2][1] || 0) / scale, bZ = (cx[2][2] || 0) / scale; + + const m00 = 3.2404542 * rX - 1.5371385 * rY - 0.4985314 * rZ; + const m01 = 3.2404542 * gX - 1.5371385 * gY - 0.4985314 * gZ; + const m02 = 3.2404542 * bX - 1.5371385 * bY - 0.4985314 * bZ; + + const m10 = -0.9692660 * rX + 1.8760108 * rY + 0.0415560 * rZ; + const m11 = -0.9692660 * gX + 1.8760108 * gY + 0.0415560 * gZ; + const m12 = -0.9692660 * bX + 1.8760108 * bY + 0.0415560 * bZ; + + const m20 = 0.0556434 * rX - 0.2040259 * rY + 1.0572252 * rZ; + const m21 = 0.0556434 * gX - 0.2040259 * gY + 1.0572252 * gZ; + const m22 = 0.0556434 * bX - 0.2040259 * bY + 1.0572252 * bZ; + + return [[m00, m01, m02], [m10, m11, m12], [m20, m21, m22]]; + } + + if (cd?.rgb_cam && Array.isArray(cd.rgb_cam) && cd.rgb_cam.length >= 3) { + const m = [ + [cd.rgb_cam[0][0], cd.rgb_cam[0][1], cd.rgb_cam[0][2]], + [cd.rgb_cam[1][0], cd.rgb_cam[1][1], cd.rgb_cam[1][2]], + [cd.rgb_cam[2][0], cd.rgb_cam[2][1], cd.rgb_cam[2][2]], + ]; + const inv = invert3x3(m); + if (inv) return inv; + } + + return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; +} + export async function developRaw(bytes: Uint8Array): Promise { const raw = new LibRaw(); let preview: Uint8Array | null = null; @@ -358,7 +485,7 @@ export async function developRaw(bytes: Uint8Array): Promise { const meta = await raw.metadata(true); const img = await raw.imageData(); const cd = meta?.color_data; - if (!img || !cd?.cam_mul || !cd.rgb_cam) throw new Error('RAW has no colour data'); + if (!img || !cd?.cam_mul || (!cd.rgb_cam && !cd.cam_xyz)) throw new Error('RAW has no colour data'); const { width: w, height: h } = img; const data = img.data as Uint16Array; @@ -374,8 +501,7 @@ export async function developRaw(bytes: Uint8Array): Promise { : [1, 1, 1, 1]; const green = rawMul[1] || rawMul[3] || 1; const mul = rawMul.map((v: number) => v / green); - const row = (i: number) => cd.rgb_cam[i].slice(0, 3); - const [r0, r1, r2] = [row(0), row(1), row(2)]; + const [r0, r1, r2] = getCamToSrgbMatrix(cd); const bandH = Math.max(1, Math.min(h, Math.floor(BAND_PIXELS / w))); const f32 = new Float32Array(w * bandH * 4); diff --git a/docker/recipescam-images.tar.gz.sha256 b/docker/recipescam-images.tar.gz.sha256 index 084cc65..ad8b1c8 100644 --- a/docker/recipescam-images.tar.gz.sha256 +++ b/docker/recipescam-images.tar.gz.sha256 @@ -1 +1 @@ -60f452b2fa01abce7084a4e347b9c222a27d2bc63d8bbfec1cf75727cc29b2e9 recipescam-images.tar.gz +d749edf89fe3ff164055a13f1f434c1a362fd39b5939c16a2f3db0986502ef91 recipescam-images.tar.gz