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
+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 {