From 4f87f022da7ff0d44ade2d17d50718c11f68b6f6 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 1 Oct 2026 18:31:08 +0700 Subject: [PATCH] web: a reading of a hundred thousand frames says where it is once, counts what the catalogue holds, and keeps a quarter of the disk it kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A roll of a hundred thousand frames is read by a walk that tells the screen about every frame it lands. The column is redrawn out of that count, so the telling was a whole tree rebuilt a frame at a time — on the large library that is the reading spending its afternoon drawing itself, and to the reader it looks like a scan that has started over from the top every time the column breathes. Five tellings a second is a counter that still moves to the eye and a page that is doing the reading instead; every frame still lands in the counts, and only the copy the screen reads waits. What the counter said was wrong twice over, and both were the same mistake told two ways. The total was the frames already reached plus the frames still in the queue, so a roll whose catalogue already holds most of it announced itself as `29980/61211` — the numbers of the reading underneath, printed as if they were the numbers of the roll. The reading now measures itself against the frames the catalogue already holds of the folder it was pointed at: the roll's own size while the walk is still up in its first folders, and the frames under a subfolder alone when it was kept to one. And a row says at least what the catalogue holds of it, never less — where the reading's number alone had the head of the tree count 29980 while the line above it counted a hundred thousand, one question with two answers. Nor is a scan of nothing announced as `0/0` before its first pass has come back; the line waits until it knows what it is reading. The third thing is not a number but a weight. The tile kept for a frame is the 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 at the quality Lightroom keeps its own grid previews at puts the same picture in the same cell: the tile is now ~12KB and the roll a quarter of the disk it was. The canvas a tile is drawn onto is told it is opaque, so it no longer carries a channel of noughts through every draw and every encode. The stage loses a little softness at 2.4x, which is the trade and is known. --- docker/frontend/src/Library.tsx | 15 ++++++- docker/frontend/src/engine/library.ts | 63 ++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 12 deletions(-) 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; }