diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 05f01dd..ba3d690 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -19,9 +19,11 @@ import { jumpScan, listEditedIds, listFolders, + makeTile, normPath, onLibraryBlocked, pickFolder, + putPhotoThumb, readPhotoFile, readPhotos, reconnectPhotosForFolders, @@ -1150,11 +1152,23 @@ function Catalogue() { [listed, strip.from, strip.to] ); // The years the open folder holds, newest first: the filter offers the years - // there are frames to find in rather than a century of empty ones. + // there are frames to find in rather than a century of empty ones. Scoped to + // `shown` — the open node (+ its subfolders when that chip is on) — not the + // whole library, so picking a year never points at frames elsewhere. const years = useMemo( - () => [...new Set(photos.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a), - [photos] + () => [...new Set(shown.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a), + [shown] ); + // A year the open folder does not hold is not a year to look at, and leaving + // it set would draw an empty shelf with no way back but the dropdown. Fires on + // a move, not on every frame landing, so a scan of the open folder filling in + // its rows cannot clear a year the reader just picked. + useEffect(() => { + if (year !== 'all' && !years.includes(Number(year))) setYear('all'); + // ponytail: only the year option list is folder-derived; stars and hours are + // fixed lists that cannot go stale. Re-check the others if they ever grow. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [node, deep, rootName]); const handleSelectPhoto = useCallback( (photo: LibraryPhoto, e: React.MouseEvent) => { @@ -1495,9 +1509,11 @@ function Catalogue() { - // The tile: the thumbnail the catalogue kept, or a stand-in for the frame - // whose own small copy the file never carried — a RAW whose preview the camera - // never wrote, a HEIC with no thumbnail item (see engine/heicDevelop.ts). A tile + // The tile: the thumbnail the catalogue kept, or — the first time a frame is + // drawn — the one the wall makes and keeps for the visits after. A frame whose + // own small copy the file never carried says so while its tile is made: a RAW + // the camera wrote no preview in, a HEIC with no thumbnail item (see + // engine/heicDevelop.ts). A tile // only carries a handler where a click // on the picture means "open it" — in the strip the click belongs to the frame // it raises, not to the studio. The word is only for a frame that has no @@ -1513,7 +1529,7 @@ function Catalogue() { /> ) : (
- {photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : 'HEIC'} + {photo.thumb ? '' : isRawName(photo.name) ? 'RAW' : isHeicName(photo.name) ? 'HEIC' : ''}
); @@ -2550,7 +2566,13 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor if (!prevBytes && /\.(tiff?)$/i.test(name)) prevBytes = tiffThumbnail(fullBytes); if (prevBytes) blob = new Blob([prevBytes as BlobPart], { type: 'image/jpeg' }); } else { - blob = file; + // A frame the wall can decode itself: the tile is made here, at the + // size the wall paints, because this is the first time anyone has + // looked at the frame. The walk never made one — a roll reads in a + // fraction of the time without it — so the decode is paid on the + // frame the reader draws and kept for every visit after. + blob = (await makeTile(file)) ?? file; + if (blob !== file) void putPhotoThumb(photo.id, blob); } } catch { blob = null; diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index b25ec18..51f54d2 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, tiffThumbnail } from './rawDevelop'; -import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop'; +import { isRawName } from './rawDevelop'; +import { isHeicName } from './heicDevelop'; import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk'; const DB_NAME = 'recipescam-library'; @@ -63,13 +63,6 @@ const TELL_MS = 200; // the same roll peaks at 1180MB: the frames around the one being opened still have // their bytes read, their decodes run and their tiles encoded side by side, because // only the open queues. A folder of JPEGs never touches this. -let rawTurn: Promise = Promise.resolve(); -function oneRawAtATime(fn: () => Promise): Promise { - const turn = rawTurn.then(fn, fn); - rawTurn = turn.catch(() => undefined); - return turn; -} - export interface LibraryFolder { // The directory's own name: the store's key, and the prefix of every frame id // found in it. A rename never touches this. @@ -89,7 +82,10 @@ export interface LibraryPhoto { dir: string; name: string; handle: FileSystemFileHandle; - thumb: Blob | null; + // The tile the catalogue kept of this frame, where one has been made: it is + // made when the frame is first drawn and not when it is found, so a frame just + // read has none. Null is the same as absent — a tile that could not be made. + thumb?: Blob | null; // When the shutter fired, per EXIF, else the file's own lastModified. taken: number; size: number; @@ -469,34 +465,15 @@ export async function ensurePermission(handle: FileSystemHandle, write = false): // kilobytes rather than the whole frame. Reading 8MB through a JS array to find a // date in the first kilobyte of it is most of what a scan of JPEGs costs. export const HEAD_BYTES = 256 * 1024; +// A RAW writes its IFD where its maker left it, and one that keeps its date past +// the first two hundred kilobytes is one read once more with this much of itself +// rather than one filed under the clock on the file. +const DEEP_HEAD_BYTES = 4 * 1024 * 1024; // The probe is decoded at this width, purely to learn which edge of the frame is // the long one; the decoder scales the real one off that. A JPEG does not need // it: its own header says how big the frame is, in the first few hundred bytes. const PROBE_PX = 8; -// A JPEG's frame header — the SOF segment — carries the frame's size, and it sits -// well inside the bytes already read off the disk for the date. Reading it costs a -// walk over a few hundred bytes where the probe costs a second decode of every -// pixel of the file. -function jpegSize(bytes: Uint8Array): { width: number; height: number } | null { - if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; - for (let i = 2; i + 8 < bytes.length; ) { - if (bytes[i] !== 0xff) { - i++; - continue; - } - const marker = bytes[i + 1]; - // SOF0–SOF15 minus the ones that are not frames: DHT, JPG, DAC. - if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { - return { height: (bytes[i + 5] << 8) | bytes[i + 6], width: (bytes[i + 7] << 8) | bytes[i + 8] }; - } - const length = (bytes[i + 2] << 8) | bytes[i + 3]; - if (length < 2) return null; - i += 2 + length; - } - return null; -} - // What a frame whose header this code cannot read is measured with: one decode // eight pixels wide. Only the aspect comes out of it — the two numbers that say // which edge is the long one — and the frame is decoded once more for real. @@ -516,7 +493,16 @@ async function sizeOf(blob: Blob): Promise<{ width: number; height: number } | n // of a 24MP frame ever exist — where decoding the frame whole and drawing it // small pays for every one of them. The probe says which edge that is, and the // canvas is only here to re-encode to JPEG. -async function tile(blob: Blob, size?: { width: number; height: number } | null): Promise { +// +// Nothing makes a tile until a tile is asked for. Reading a frame is nearly all +// decode — 30ms against the 2ms of reading the catalogue ever asks of a file — +// so a walk that made one for every frame it found spent its whole afternoon on +// pictures nobody had scrolled to: a roll of 160 frames took 2.8s to read and +// takes 0.3s when the tiles are left to the wall that draws them. The wall asks +// for the frames around the eye and no others, and the answer is kept, so the +// one decode a frame costs is paid the first time the frame is looked at and +// never again. +export async function makeTile(blob: Blob, size?: { width: number; height: number } | null): Promise { try { const known = size ?? (await sizeOf(blob)); if (!known) return null; @@ -938,48 +924,22 @@ export async function scanFolder( } return; } - const raw = isRawName(file.name); - const isTiff = /\.(tiff?)$/i.test(file.name); - const heic = isHeicName(file.name); - - // 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); - - let preview: Uint8Array | null = null; - if (heic) { - preview = await oneRawAtATime(async () => { - let p = await heicThumbnail(bytes); - if (!p) { - try { - const fullBytes = new Uint8Array(await file.arrayBuffer()); - p = await heicToJpeg(fullBytes, 320); - } catch { - p = null; - } - } - return p; - }); - } else if (raw || isTiff) { - preview = await oneRawAtATime(async () => { - let p = await rawThumbnail(bytes, file.name); - if (!p && isTiff) { - try { - const fullBytes = new Uint8Array(await file.arrayBuffer()); - p = tiffThumbnail(fullBytes); - } catch { - p = null; - } - } - return p; - }); + // Where the frame was taken, off the few hundred kilobytes of its head that + // its own EXIF lives in — where the 32MB a camera's RAW used to be read for + // bought a preview this reading no longer wants. Nothing here looks at a + // pixel: the walk is as fast as the disk is, so the column and the frames + // near the top are on screen within the first pass and a folder of a hundred + // thousand is drawn while the reader is still on the first screen of it. A + // RAW whose date sits deeper is read once more before the clock on the file + // has to do. The tile is the wall's business — one is made when one is drawn + // and not before — see `makeTile`. + const rawish = isRawName(file.name) || /\.(tiff?)$/i.test(file.name) || isHeicName(file.name); + const bytes = new Uint8Array(await file.slice(0, Math.min(file.size, HEAD_BYTES)).arrayBuffer()); + let taken = await readCapturedAt(bytes); + if (taken === null && rawish && file.size > HEAD_BYTES) { + const deeper = await file.slice(0, Math.min(file.size, DEEP_HEAD_BYTES)).arrayBuffer(); + taken = await readCapturedAt(new Uint8Array(deeper)); } - - 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 relNorm = rel.replace(/\\/g, '/'); const cut = relNorm.lastIndexOf('/'); const row: LibraryPhoto = { @@ -988,8 +948,7 @@ export async function scanFolder( dir: cut < 0 ? '' : normPath(relNorm.slice(0, cut)), name: handle.name, handle, - thumb, - taken, + taken: taken ?? file.lastModified, size: file.size, mtime: file.lastModified, star: seen?.star, @@ -1524,6 +1483,21 @@ export async function getPhotoThumb(id: string): Promise { return null; } +// A tile made because someone looked at the frame, kept where the next visit +// will find it: the decode is paid once per frame, on the visit that first drew +// it, and the reads after that are the same reads a scanned tile would have had. +export async function putPhotoThumb(id: string, blob: Blob): Promise { + cacheThumb(id, blob); + try { + const db = await openDb(); + if (!db.objectStoreNames.contains(THUMBS)) return; + const tx = db.transaction(THUMBS, 'readwrite'); + tx.objectStore(THUMBS).put({ id, thumb: blob }); + } catch { + // The tile is in hand either way; where it is not kept it is made again. + } +} + export async function getPhotoThumbs(ids: string[]): Promise> { const res = new Map(); const missing: string[] = [];