diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index e1654e6..ba70dd3 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -29,6 +29,11 @@ const THUMB_QUALITY = 0.75; // Frames go down in batches, so a 3000-file folder is 60 transactions rather // than 3000 of them. const BATCH = 50; +// Frames read at once. Reading a frame is nearly all waiting — the bytes come off +// the disk, the decode runs on a thread of its own — and a wait that is not spent +// on the next frame is time the roll does not get back. Past a few, the disk and +// the decoder are the limit and the page has less room to breathe. +const LANES = 4; export interface LibraryFolder { // The directory's own name: the store's key, and the prefix of every frame id @@ -204,46 +209,78 @@ export async function ensurePermission(handle: FileSystemHandle, write = false): // --- thumbnails ------------------------------------------------------------ -interface Bitmap { - width: number; - height: number; - close: () => void; +// EXIF is one JPEG segment, and a segment is 64KB at most — but only a JPEG keeps +// it in the first one, so the slice that is handed to the parser is a few hundred +// 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. +const HEAD_BYTES = 256 * 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; } -function downscale(blob: Blob, draw: (bitmap: Bitmap, w: number, h: number) => Promise): Promise { - return createImageBitmap(blob) - .then(async (bitmap) => { - try { - const scale = Math.min(1, THUMB_MAX / Math.max(bitmap.width, bitmap.height)); - return await draw(bitmap, Math.max(1, Math.round(bitmap.width * scale)), Math.max(1, Math.round(bitmap.height * scale))); - } finally { - bitmap.close(); - } - }) - .catch(() => 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. +async function sizeOf(blob: Blob): Promise<{ width: number; height: number } | null> { + try { + const probe = await createImageBitmap(blob, { resizeWidth: PROBE_PX }); + const size = { width: probe.width, height: probe.height }; + probe.close(); + return size; + } catch { + return null; + } } -// A canvas rather than OffscreenCanvas: this runs on the main thread anyway (the -// scan is a background chore, not a render), and the element works in every -// browser the studio supports. -async function toThumbnail(bitmap: Bitmap, w: number, h: number): Promise { - const canvas = document.createElement('canvas'); - canvas.width = w; - canvas.height = h; - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - ctx.drawImage(bitmap as unknown as CanvasImageSource, 0, 0, w, h); - return new Promise((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/jpeg', THUMB_QUALITY)); -} - -async function makeThumbnail(file: File, bytes: Uint8Array): Promise { - if (!isRawName(file.name)) return downscale(file, toThumbnail); - // A RAW is unpacked to the preview the camera wrote inside it — a seek and a - // copy, where a develop is a full decode of every pixel. A file that carries - // no preview gets no tile; the studio develops it the moment it is opened. - const preview = await rawThumbnail(bytes); - if (!preview) return null; - return downscale(new Blob([preview as BlobPart], { type: 'image/jpeg' }), toThumbnail); +// The tile, read at the size the tile is painted at. Asking the decoder for 512 +// on the long edge scales on the way out of the decoder — a quarter of the pixels +// 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 { + try { + const known = size ?? (await sizeOf(blob)); + if (!known) return null; + const wide = known.width >= known.height; + const bitmap = await createImageBitmap(blob, wide ? { resizeWidth: THUMB_MAX } : { resizeHeight: THUMB_MAX }); + try { + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.drawImage(bitmap, 0, 0); + return await new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY)); + } finally { + bitmap.close(); + } + } catch { + return null; + } } // --- scanning -------------------------------------------------------------- @@ -273,10 +310,10 @@ export interface ScanProgress { // down, one bounded pass at a time, with the folder the reader just clicked // pulled to the front of the queue. `jump` is that click. // -// ponytail: read one frame at a time, whole, on the main thread — a RAW is read -// as its bytes for LibRaw and released again. A folder of 5000 RAW files takes -// minutes and makes the page lumpy while it runs. Move the walk into a worker -// (LibRaw is happy in one) if a catalogue that large is ever real. +// ponytail: the lanes run on the main thread. The reading itself is done +// elsewhere (disk, the decoder's own threads), so what is left here is small — +// but a 5000-frame folder still makes the page lumpy. Move the walk into a +// worker (LibRaw is happy in one) if a catalogue that large is ever real. export async function scanFolder( folder: LibraryFolder, onProgress?: (p: ScanProgress) => void, @@ -326,6 +363,41 @@ export async function scanFolder( tx.onerror = () => reject(tx.error); }); }; + // One frame, end to end: its bytes, its tile, its shutter time, and the row the + // catalogue keeps. A frame that has not moved is dropped on its size and its + // time before a byte of it is read, which is what makes a second scan of the + // same folder cheap; a frame that will not read at all is skipped by the lane + // that ran it — one bad file in a folder is not a failed folder. + const readOne = async (handle: FileSystemFileHandle, rel: string): Promise => { + const file = await handle.getFile(); + const id = photoId(folder.name, rel); + const seen = known.get(id); + if (seen && seen.size === file.size && seen.taken && seen.taken === file.lastModified && seen.thumb) return; + // A RAW is read whole because that is the only way LibRaw can seek to the + // preview the camera wrote inside it; a JPEG is handed to the decoder as it + // is, and only its header is read for the date. Both tiles come off what the + // camera wrote — a RAW's preview is a copy, where a develop is every pixel. + const raw = isRawName(file.name); + const bytes = new Uint8Array(await (raw ? file.arrayBuffer() : file.slice(0, HEAD_BYTES).arrayBuffer())); + const preview = raw ? await rawThumbnail(bytes) : null; + const thumb = await (raw ? (preview ? tile(new Blob([preview as BlobPart], { type: 'image/jpeg' })) : null) : tile(file)); + const taken = (await readCapturedAt(bytes)) ?? file.lastModified; + const cut = rel.lastIndexOf('/'); + batch.push({ + id, + folder: folder.name, + dir: cut < 0 ? '' : rel.slice(0, cut), + name: handle.name, + handle, + thumb, + taken, + size: file.size, + addedAt: seen?.addedAt ?? Date.now(), + }); + progress.added++; + if (batch.length >= BATCH) await flush(); + }; + let stop = false; let walked = false; // One bounded pass at a time, and the pass yields between frames, so a folder @@ -349,6 +421,13 @@ export async function scanFolder( // new to look at rather than the list growing under it. progress.dirs = dirs.slice(); progress.total += entries.length - from; + // A few frames at a time, not one: reading a frame is almost all waiting — + // the bytes come off the disk, the decode runs on a thread of its own — and + // the wait of one frame is spent on the next one instead of sitting still. + // Lanes are why the same roll lands in a fraction of the time; past a few the + // disk and the decoder are the limit. The column still counts a frame the + // moment the scan reaches it, so which frames are up is unchanged. + const lanes = new Set>(); for (const { handle, rel } of entries.slice(from)) { if (shouldStop?.()) { stop = true; @@ -356,36 +435,14 @@ export async function scanFolder( } progress.done++; count(rel); - try { - const file = await handle.getFile(); - const id = photoId(folder.name, rel); - const seen = known.get(id); - if (seen && seen.size === file.size && seen.taken && seen.taken === file.lastModified && seen.thumb) { - onProgress?.(progress); - continue; - } - const bytes = new Uint8Array(await file.arrayBuffer()); - const [thumb, taken] = [await makeThumbnail(file, bytes), (await readCapturedAt(bytes)) ?? file.lastModified]; - const cut = rel.lastIndexOf('/'); - batch.push({ - id, - folder: folder.name, - dir: cut < 0 ? '' : rel.slice(0, cut), - name: handle.name, - handle, - thumb, - taken, - size: file.size, - addedAt: seen?.addedAt ?? Date.now(), - }); - progress.added++; - if (batch.length >= BATCH) await flush(); - } catch { - // A frame that will not read is a frame the catalogue skips: one bad file - // in a folder is not a failed folder. - } + const lane = readOne(handle, rel) + .catch(() => undefined) + .finally(() => lanes.delete(lane)); + lanes.add(lane); + if (lanes.size >= LANES) await Promise.race(lanes); onProgress?.(progress); } + await Promise.all(lanes); } while (!walked && !stop); await flush(); return progress; diff --git a/docker/frontend/src/engine/rawDevelop.ts b/docker/frontend/src/engine/rawDevelop.ts index 288a22c..4906803 100644 --- a/docker/frontend/src/engine/rawDevelop.ts +++ b/docker/frontend/src/engine/rawDevelop.ts @@ -222,8 +222,9 @@ async function cameraPreview(raw: LibRaw): Promise { // studio develops it the moment it is opened. // // ponytail: still a whole LibRaw open per file, reading the file's bytes into -// memory to do it. Fine for the scan's one-at-a-time walk; give the catalogue a -// worker and a sync-access handle if a 10k-frame folder ever turns up. +// memory to do it — and with the scan's lanes, a few of those opens at once on a +// folder of RAW. Give the catalogue a worker and a sync-access handle if a +// 10k-frame folder ever turns up. export async function rawThumbnail(bytes: Uint8Array): Promise { const raw = new LibRaw(); try {