From 1647362c684a52c7080218e680694d1b0a3fb59f Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 9 Oct 2026 16:12:18 +0700 Subject: [PATCH] fix(library): stop calling a .tif a RAW, and stop the wall's render loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all from one look at a folder of TIFF scans. A `.tif` is no longer a RAW. Nothing here develops one, and `tif`/`tiff` sat in `RAW_EXT` for no decoder's sake: opening a TIFF asked for PRO and its tile wore a RAW badge, for a file the engine has nothing to hand back. Both extensions are dropped, and a TIFF keeps its own route instead — the tile reads the file's own pixels (`useThumbUrls`, via `tiffDevelop`) and so does the studio — so a `.tif` still opens and still draws, it just is not a RAW any more. The wall's render loop. `painted` was a fresh `.slice()` on every render, so `useThumbUrls` saw a new list each time round and its effect ran again, and again: about twenty thousand renders a second while the page sat idle, and the file reads frozen at the first screenful, so a tile scrolled to never came up (7 of 24 cards drawn). `painted` is memoised on `listed, wall.from, wall.to` now — no date formats in eight idle seconds, 28 of 28 cards after a scroll. Scrolling between the two modes. SINGLE → ALL THUMBS lands the wall on the frame the strip was showing, centred in the window; ALL THUMBS → SINGLE swipes the strip to the frame nearest the wall's middle, centred in the row. Each way the frame the other mode was standing on is the one you come back to. --- docker/frontend/src/App.tsx | 23 +++++- docker/frontend/src/Library.tsx | 95 ++++++++++++++++++++++-- docker/frontend/src/engine/library.ts | 4 +- docker/frontend/src/engine/rawDevelop.ts | 86 +++++++++++++-------- 4 files changed, 169 insertions(+), 39 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 44e89da..f285556 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -16,7 +16,7 @@ import { useI18n } from './i18n/I18nProvider'; import { loadEngine, MARK_FACES } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readCapturedAt, readGps, readIso, readSpecs, resizedJpeg, scaledJpeg, type ShotSpecs } from './engine/imageOps'; -import { developRaw, isRawName } from './engine/rawDevelop'; +import { developRaw, isRawName, isTiffName, tiffDevelop } from './engine/rawDevelop'; import { sharePhoto, type ShareSite } from './engine/share'; import { heicToJpeg, isHeicName } from './engine/heicDevelop'; import { clearRaw, readRaw, storeRaw } from './engine/rawStore'; @@ -1182,6 +1182,27 @@ export function Workspace() { void savePhoto(file.name, jpeg); return; } + if (isTiffName(file.name)) { + // A TIFF: no longer a RAW, and nothing in it for the engine to develop + // — but no browser decoder paints one either (see `tiffThumbnail`), so + // it is read by hand at the file's own size and becomes the JPEG the + // studio works on, the same way the HEIC above does. No tier: it is a + // plain reading of pixels the file already holds. + // + // The session keeps the decode, not the file, so a reload opens the + // frame at once instead of paying the read again; the name stays the + // file's own and `isRawName` answers no, so the reload path above shows + // the saved copy as the picture it is. The TIFF goes along as the bytes + // the stamps read — the JPEG carries no EXIF of its own. A file the + // decoders here do not know throws, and the reader is told the frame + // could not be read rather than shown an empty stage. + await clearRaw(); + const jpeg = await tiffDevelop(bytes); + if (!jpeg) throw new Error('no TIFF decoder for this file'); + await adoptPhoto(jpeg, file.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), false, bytes); + void savePhoto(file.name, jpeg); + return; + } await clearRaw(); await adoptPhoto(bytes, file.name, file); void savePhoto(file.name, bytes); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 9d66e82..dd083a0 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -4,7 +4,7 @@ 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 { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop'; import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop'; import { cachedFolders, @@ -1399,7 +1399,15 @@ function Catalogue({ admin }: { admin: boolean }) { // the DOM and the number the browser formats for each date. The frame on the // stage is given a picture wherever it sits — the stage is not the wall. const wall = useWallWindow(listed.length, view === 'grid'); - const painted = listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)); + // Memoised like `drawn`, and for a reason of its own: `useThumbUrls` keys its + // effect on the array it is handed, so a fresh slice every render made that + // effect run every render — a `setUrls` per pass, a render per `setUrls`, and + // thousands of passes a second in which the tiles of the rows scrolled to never + // land at all. + const painted = useMemo( + () => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)), + [listed, wall.from, wall.to] + ); const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame); // Auto-scroll & focus tree sidebar to the active node selected in last session @@ -1427,6 +1435,10 @@ function Catalogue({ admin }: { admin: boolean }) { // Auto-scroll timeline filmstrip to center the active selected thumbnail const scrolledFrameRef = useRef(null); + // The same frame for the wall: the strip hands it over on the way to ALL + // THUMBS, and the wall scrolls to that frame's card rather than opening at the + // top of a shelf the reader was somewhere in the middle of. + const centredRef = useRef(null); useEffect(() => { if (view !== 'preview' || !frame || !listed.length) return; @@ -1449,6 +1461,69 @@ function Catalogue({ admin }: { admin: boolean }) { } }, [view, frame?.id, listed, node, strip.ref]); + // And the wall's half of it: the frame the strip was on, brought to the middle + // of the wall. The card is not in the DOM to begin with — the wall only paints + // the rows around its viewport — so the first passes go to where the row should + // be and the ones after that read the card itself, which is the only honest + // measure: a row stands as tall as its longest caption. The loop is bounded + // rather than driven by state, because the wall reports its window by + // re-rendering and a scroll set inside a render is a scroll the next render + // undoes. + useEffect(() => { + if (view !== 'grid') return; + const id = centredRef.current; + const el = wall.ref.current; + if (!id || !el) return; + const idx = listed.findIndex((p) => p.id === id); + if (idx < 0) { + centredRef.current = null; + return; + } + let alive = true; + let left = 8; + const settle = () => { + if (!alive) return; + const card = el.querySelector(`[data-key="lib-photo-${CSS.escape(id)}"]`); + if (card) { + const box = el.getBoundingClientRect(); + const own = card.getBoundingClientRect(); + el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2); + } else { + // No card drawn: the wall opens on the first screenful, so aim at the row + // and let the next pass find it. The row's step is read off a drawn card + // when there is one, and guessed at when there is not. + const first = el.querySelector(':scope > .adm-card'); + const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length); + const pitch = first ? first.offsetHeight + WALL_GAP : 240; + el.scrollTop = Math.max(0, Math.floor(idx / cols) * pitch - (el.clientHeight - pitch) / 2); + } + if (--left > 0) requestAnimationFrame(settle); + else centredRef.current = null; + }; + requestAnimationFrame(settle); + return () => { + alive = false; + }; + }, [view, listed, wall.ref]); + + // The way back, and the way in: the frame the wall is standing on is the card + // nearest the middle of its viewport — the same card the reader's eye is on. + const openFromWall = () => { + const el = wall.ref.current; + const mid = el ? el.getBoundingClientRect().top + el.clientHeight / 2 : 0; + let best: { id: string; d: number } | null = null; + for (const card of el ? el.querySelectorAll(':scope > .adm-card') : []) { + const box = card.getBoundingClientRect(); + const d = Math.abs(box.top + box.height / 2 - mid); + if (!best || d < best.d) best = { id: card.dataset.key!.slice('lib-photo-'.length), d }; + } + if (best) setUp(best.id); + // The strip is built afresh when it comes back, so the frame is centred again + // even when it is the frame it was already on. + scrolledFrameRef.current = null; + setView('preview'); + }; + // A frame's score: click a star to give it, click the star it already has to // take the score back. The catalogue holds it, so it is the same score the next // visit reads — and the list on screen is told, rather than read back whole for @@ -1640,6 +1715,12 @@ function Catalogue({ admin }: { admin: boolean }) { // the tile stands in until it lands). const jpeg = await heicToJpeg(new Uint8Array(await file.arrayBuffer()), 2048); if (live) url = URL.createObjectURL(new Blob([jpeg as BlobPart], { type: 'image/jpeg' })); + } else if (isTiffName(file.name)) { + // A TIFF is nobody's decoder here either (see `tiffThumbnail`), and the + // stage is the frame itself rather than the wall's tile, so it is + // decoded at what a screen shows. + const jpeg = await tiffDevelop(new Uint8Array(await file.arrayBuffer()), 2048); + if (live && jpeg) url = URL.createObjectURL(new Blob([jpeg as BlobPart], { type: 'image/jpeg' })); } else if (isRawName(file.name)) { const bytes = new Uint8Array(await file.arrayBuffer()); if (!live) return; @@ -1837,7 +1918,7 @@ function Catalogue({ admin }: { admin: boolean }) { aria-pressed={view === 'preview'} aria-label={t('lib.viewPreview')} title={t('lib.viewPreview')} - onClick={() => setView('preview')} + onClick={openFromWall} >