diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index f7d77c6..41f5ca9 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -666,7 +666,12 @@ export function Library() { const read = live && !live.progress.from ? live.progress.counts : null; const nodes = useMemo(() => { const hits = new Map(); - const counted = (key: string, hit: number | undefined) => read?.[key] ?? hit ?? 0; + // The reading's own count is a floor, never a ceiling: a row says at least + // what the catalogue already holds of it. A roll being read again says its + // own size while the reading is still walking up to it — where the reading's + // number alone would have the head of the tree count 29980 while the line + // above it counts a hundred thousand, one question with two answers. + const counted = (key: string, hit: number | undefined) => Math.max(hit ?? 0, read?.[key] ?? 0); const foldersUnder = new Map>(); // One path per folder between the picked one and `rel`, so a row is drawn for // every folder on the way to a frame as well as for the frame's own. @@ -976,8 +981,14 @@ export function Library() { {t('lib.hint')} + {/* Nothing is drawn until the reading knows what it is reading: a + roll of nothing over nothing is the line talking before the + first pass has come back, which is a scan of a hundred + thousand frames announced as `0/0`. */} - {live ? t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added }) : ''} + {live && live.progress.total + ? t('lib.scanning', { done: live.progress.done, total: live.progress.total, added: live.progress.added }) + : ''} {folders.length > 0 ? ( diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index fdefd50..f695908 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -22,10 +22,16 @@ const FOLDERS = 'folders'; const PHOTOS = 'photos'; const EDITS = 'edits'; -// The grid cell is ~220px wide and a retina display doubles it: 512 on the long -// edge is the largest a tile ever shows, and it costs ~30KB per frame. -const THUMB_MAX = 512; -const THUMB_QUALITY = 0.75; +// The tile the catalogue keeps is the same tile the grid cell and the strip both +// paint, and there is one of them per frame of a roll that reaches six figures. +// At 512 on the long edge a tile weighed 42KB and a hundred and sixty thousand of +// them weighed six and a half gigabytes of the reader's disk — for a grid cell +// that is ~240px wide and a strip tile that is 132. A quarter of the pixels and a +// lower quality put the same picture in the same cell: the tile is now ~12KB, and +// the roll a quarter of the disk it was. The quality is the one Lightroom keeps +// its own grid previews at, and at this size the eye cannot tell it from 0.75. +const THUMB_MAX = 256; +const THUMB_QUALITY = 0.6; // Frames go down in batches, so a 3000-file folder is 60 transactions rather // than 3000 of them. const BATCH = 50; @@ -38,6 +44,11 @@ const LANES = 4; // catalogue is read back on: the position is the whole of what the walk has found // and not read, and writing it is a string of a few megabytes on a long roll. const WALK_MS = 2000; +// How often the screen is told how far the reading has come: the column is +// rebuilt out of the count, and a rebuild a frame is a reading that spends its +// time drawing itself. Five times a second is a counter that moves to the eye +// and a page that is doing the reading instead. +const TELL_MS = 200; // One LibRaw open at a time, whoever asks for it. A RAW is opened inside a worker // the library builds with a quarter of a gigabyte of linear memory of its own @@ -305,7 +316,10 @@ async function tile(blob: Blob, size?: { width: number; height: number } | null) const canvas = document.createElement('canvas'); canvas.width = bitmap.width; canvas.height = bitmap.height; - const ctx = canvas.getContext('2d'); + // Opaque: a frame is a rectangle of colour with nothing behind it, and a + // context told so does not carry a channel of noughts through every draw + // and every encode — a quarter less memory on each tile in flight. + const ctx = canvas.getContext('2d', { alpha: false }); if (!ctx) return null; ctx.drawImage(bitmap, 0, 0); return await new Promise((resolve) => canvas.toBlob((out) => resolve(out), 'image/jpeg', THUMB_QUALITY)); @@ -563,7 +577,33 @@ export async function scanFolder( const key = `${folder.name}/${path}`; counts[key] = (counts[key] ?? 0) + 1; } + }; + // The frames the catalogue already holds of the folder being read, which is + // what the reading is measured against: a roll of a hundred thousand that has + // been read once before is a reading of a hundred thousand frames even while + // the walk is still up in its first folders, and one kept to a subfolder is + // measured against the frames under that one alone. + const held = bounded + ? [...known.keys()].filter((id) => id.startsWith(`${folder.name}/${from}`)).length + : known.size; + // What the screen is told, and how often. The column is redrawn out of the + // count and the count moves with every frame, so telling the screen a frame at + // a time is a whole tree rebuilt a frame at a time — on a roll of a hundred + // thousand that is the reading spending its afternoon drawing itself, and the + // reason a scan looks like it has started over from the top. Every frame still + // lands in `counts`; what waits is the copy the screen reads, and the count of + // frames reached. + let toldAt = 0; + const publish = (force = false) => { + const at = Date.now(); + if (!force && at - toldAt < TELL_MS) return; + toldAt = at; progress.counts = { ...counts }; + // Never backwards, and never less than the frames the catalogue holds: the + // walk finds the rest of them as it goes, and a line reading `29980/61211` + // over a roll of a hundred thousand is the counter talking, not the roll. + progress.total = Math.max(progress.total, progress.done + entries.length, held); + onProgress?.(progress); }; // The folders this scan has walked into, kept for the whole of it: the column // is redrawn from this list, so it only ever grows. A reading that was cut off @@ -735,7 +775,7 @@ export async function scanFolder( .finally(() => lanes.delete(lane)); lanes.add(lane); if (lanes.size >= LANES) await Promise.race(lanes); - onProgress?.(progress); + publish(); } await Promise.all(lanes); }; @@ -778,15 +818,15 @@ export async function scanFolder( // something new to look at rather than the list growing under it, and a // reading that is cut off hands the same list on with its position. progress.dirs = dirs.slice(); - // Every frame found is a frame the reading has or will reach, so what is - // still in the queue is what is left of the total. - progress.total = progress.done + entries.length; // The position before a frame of this pass is looked at: a reload here // reads the pass's frames again, and a frame already in the catalogue is // skipped anyway — where the other order would leave the last pass's // frames out of the catalogue until the next visit. await write(); - onProgress?.(progress); + // A pass is worth telling the screen about whether the clock says so or + // not: the names of a whole layer have just arrived, and the column is + // built from them. + publish(true); } await drain(); } while (!walked && !stop); @@ -796,6 +836,9 @@ export async function scanFolder( // one folder never wrote one down, and the file it would clear is the whole // roll's — a position another reading is in the middle of. if (!stop && !bounded) await clearWalk(folder.name); + // The last word, whatever the clock says: the frames the reading got to are + // the frames the column is about to be told about one last time. + publish(true); return progress; }