From b835415c20eaa50b812034f0a34b43b5ce6739e1 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 7 Oct 2026 18:43:11 +0700 Subject: [PATCH] fix(library): support thumbnail generation and display for HEIC and TIF files --- docker/frontend/src/Library.tsx | 44 +++++++++++++++++------ docker/frontend/src/engine/library.ts | 46 ++++++++++++++++-------- docker/frontend/src/engine/rawDevelop.ts | 1 + 3 files changed, 66 insertions(+), 25 deletions(-) diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 7822c1d..bc28655 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -4,8 +4,8 @@ import { go } from './nav'; import { api } from './api'; import { keep, recall } from './remember'; import { readGps, readSpecs, specsLine } from './engine/imageOps'; -import { isRawName, rawThumbnail } from './engine/rawDevelop'; -import { heicToJpeg, isHeicName } from './engine/heicDevelop'; +import { isRawName, rawThumbnail, tiffThumbnail } from './engine/rawDevelop'; +import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop'; import { cachedFolders, cachedPhotos, @@ -2443,18 +2443,40 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor if (idsToFetch.length === 0) return; - void getPhotoThumbs(idsToFetch).then((thumbMap) => { + void getPhotoThumbs(idsToFetch).then(async (thumbMap) => { if (!alive) return; - setUrls((prev) => { - const next = { ...prev }; - for (const photo of list) { - const blob = thumbMap.get(photo.id) ?? photo.thumb ?? null; - if (blob) { - next[photo.id] = cacheObjectUrl(photo.id, blob); + const updates: Record = {}; + for (const photo of list) { + let blob = thumbMap.get(photo.id) ?? photo.thumb ?? null; + if (!blob && photo.handle) { + try { + const file = await readPhotoFile(photo); + if (!alive) return; + const name = file.name; + if (isHeicName(name)) { + const fullBytes = new Uint8Array(await file.arrayBuffer()); + let prevBytes = await heicThumbnail(fullBytes); + if (!prevBytes) prevBytes = await heicToJpeg(fullBytes, 320); + if (prevBytes) blob = new Blob([prevBytes as BlobPart], { type: 'image/jpeg' }); + } else if (isRawName(name) || /\.(tiff?)$/i.test(name)) { + const fullBytes = new Uint8Array(await file.arrayBuffer()); + let prevBytes = await rawThumbnail(fullBytes, name); + if (!prevBytes && /\.(tiff?)$/i.test(name)) prevBytes = tiffThumbnail(fullBytes); + if (prevBytes) blob = new Blob([prevBytes as BlobPart], { type: 'image/jpeg' }); + } else { + blob = file; + } + } catch { + blob = null; } } - return next; - }); + if (blob && alive) { + updates[photo.id] = cacheObjectUrl(photo.id, blob); + } + } + if (alive && Object.keys(updates).length > 0) { + setUrls((prev) => ({ ...prev, ...updates })); + } }); return () => { diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 71178cc..04ba790 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -14,8 +14,8 @@ // smart collections) that hand-rolled cursors would make ugly. import type { Recipe } from '../../shared/types'; import { readCapturedAt } from './imageOps'; -import { isRawName, rawThumbnail } from './rawDevelop'; -import { heicThumbnail, isHeicName } from './heicDevelop'; +import { isRawName, rawThumbnail, tiffThumbnail } from './rawDevelop'; +import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop'; import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk'; const DB_NAME = 'recipescam-library'; @@ -905,20 +905,38 @@ export async function scanFolder( } const raw = isRawName(file.name); const isTiff = /\.(tiff?)$/i.test(file.name); - // A HEIC is read for its tile the way a RAW is, and reads less: the head of - // the file is the whole of what its own thumbnail item needs (see - // heicDevelop.ts), so it takes the same slice an ordinary photo does. const heic = isHeicName(file.name); - const RAW_HEADER_BYTES = 16 * 1024 * 1024; - const sliceBuffer = await file.slice(0, (raw || isTiff) ? Math.min(file.size, RAW_HEADER_BYTES) : HEAD_BYTES).arrayBuffer(); + + // Slice buffer: HEIC uses up to 4MB; RAW & TIFF use up to 32MB (or full file if smaller). + const MAX_SLICE_BYTES = (raw || isTiff) ? 32 * 1024 * 1024 : heic ? 4 * 1024 * 1024 : HEAD_BYTES; + const sliceBuffer = await file.slice(0, Math.min(file.size, MAX_SLICE_BYTES)).arrayBuffer(); const bytes = new Uint8Array(sliceBuffer); - const preview = heic - ? await heicThumbnail(bytes) - : raw || isTiff - ? await rawThumbnail(bytes, file.name) - : null; - const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : raw || heic ? null : file; - const size = preview ? jpegSize(preview) : raw || heic ? null : jpegSize(bytes); + + let preview: Uint8Array | null = null; + if (heic) { + preview = await heicThumbnail(bytes); + if (!preview) { + try { + const fullBytes = new Uint8Array(await file.arrayBuffer()); + preview = await heicToJpeg(fullBytes, 320); + } catch { + preview = null; + } + } + } else if (raw || isTiff) { + preview = await rawThumbnail(bytes, file.name); + if (!preview && isTiff) { + try { + const fullBytes = new Uint8Array(await file.arrayBuffer()); + preview = tiffThumbnail(fullBytes); + } catch { + preview = null; + } + } + } + + const src = preview ? new Blob([preview as BlobPart], { type: 'image/jpeg' }) : (raw || heic || isTiff) ? null : file; + const size = preview ? jpegSize(preview) : (raw || heic || isTiff) ? null : jpegSize(bytes); const thumb = src ? await tile(src, size) : null; const taken = (await readCapturedAt(bytes)) ?? file.lastModified; const cut = rel.lastIndexOf('/'); diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index 661d32a..3e6dc32 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -191,6 +191,7 @@ 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(',');