web: read the roll through four lanes and a JPEG's header

A scan of 36 real files off the local disk took 8957ms — 249ms a frame — and
the page was idle for nearly all of it: a CPU profile over the walk is 61.5%
idle, so what the catalogue was doing was waiting, not working. Of that time the
bytes and the LibRaw preview are 2656ms and the tile 5761ms, while the EXIF read
is 14ms of the lot. One frame at a time spends the disk's latency and the
decoder's thread on nothing, and a JPEG was read whole — 8MB through a JS array
to find a date in its first kilobyte — then decoded at 24MP to be drawn at 512px.

The walk now runs four frames at a time. A frame that is new is read as a 256KB
header slice unless it is a RAW, which LibRaw has to have whole to seek to the
preview inside it; the shutter time comes off that slice. The tile is asked of
the decoder at 512 on the long edge, so a quarter of the pixels of a 24MP frame
are ever allocated, and the aspect comes from the frame's own SOF header for a
JPEG and from an eight-pixel decode for anything else; the canvas is left to
re-encode what comes back. The column still counts a frame the moment the scan
reaches it, so which frames are up is unchanged, and a frame that has not moved
is still dropped on its size and its time before a byte of it is read.

Measured on the same roll (24 JPEG 8.2MB + 12 RAW 22.6MB, two levels deep,
served over loopback with no added delay):
  one frame at a time   8957ms   249ms/frame
  four lanes            5474ms   152ms/frame
  + the header slice    4793ms   133ms/frame
Six lanes came out worse than four (5259ms) and is not what this does: past a
few the disk and the decoder are the limit.

Verified:
  library-check.mjs — 35 steps, all passed. scan-nav-check.mjs and
    roll-walk-check.mjs — all passed.
  frontend tsc --noEmit clean. Live 8090 on index-CdakqRi8.js matching dist/:
  /, /library and /app 200 with 0 console errors.

ponytail: the lanes run on the main thread, and a RAW still reads whole per file
— a few LibRaw opens at once now — so a 5000-frame folder is still lumpy; move
the walk into a worker and a RAW's preview onto a sync handle when that is real.
A frame smaller than 512 is now scaled up to it rather than drawn at its own
size, which is invisible at the 132–220px a tile is painted at; keep the old fit
if a print is ever taken off a tile.

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-28 22:01:03 +07:00
parent 134489a5ec
commit 77729ad99a
2 changed files with 128 additions and 70 deletions
+125 -68
View File
@@ -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<Blob | null>): Promise<Blob | null> {
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<Blob | null> {
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<Blob | null>((resolve) => canvas.toBlob((blob) => resolve(blob), 'image/jpeg', THUMB_QUALITY));
}
async function makeThumbnail(file: File, bytes: Uint8Array): Promise<Blob | null> {
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<Blob | null> {
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<Blob | null>((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<void> => {
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<Promise<void>>();
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;
+3 -2
View File
@@ -222,8 +222,9 @@ async function cameraPreview(raw: LibRaw): Promise<Uint8Array | null> {
// 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<Uint8Array | null> {
const raw = new LibRaw();
try {