From fbe9a1bb5bfa7ad11bb4a5a312c523a1b641083c Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 29 Sep 2026 06:33:41 +0700 Subject: [PATCH] web: a roll is read where it was left, and only for the frames that moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reader with a large roll ran into three things at once, and they were one thing: a reading is dropped the moment the tab goes, and the second one over the same folder took as long as the first. The catalogue was never emptied — `scanFolder` has no delete anywhere in it — but it read every frame again. The test that was meant to skip a frame that has not moved compared the frame's *shutter* time with the file's write time (`seen.taken === file.lastModified`), two numbers that are equal only by accident: a still's EXIF date is when the picture was taken, not when the file was written. So a rescan of any indexed roll went back to the disk for every file, decoded every frame and wrote it back — which is what reads as "it threw the index away and started over", and it cost the same minutes the first read did. A frame that cannot say when it was taken was worse off: it falls back to the file's own time, so it matched, was skipped forever, and never picked up an edit. A row now carries `mtime`, the write time the browser reports for the file, and a frame is skipped on the same size and the same write time — which is what the comment over that line always claimed. A row filed before the field existed has no `mtime` and is read one last time. On the 36-frame roll the bench serves (24 JPEG 8.2MB + 12 RAW 22.6MB, two levels deep): first reading 5209ms the same roll again 2887ms 24/36 frames read again first reading 5320ms the same roll again 603ms 0/36 frames read again And the screen starts that reading itself. Opening LIBRARY on a roll whose reading ended when the app did now walks it again on the way in — and again when the tab is raised — so the frames it never got to are read with no one asking, and frames that landed in the folder since are picked up by the same walk. The scan belongs to the tab and the walk skips what the catalogue already holds, so a frame that has not moved is a name, a size and a time and nothing else; the run that does it says nothing in the toolbar, the ring on the row and the progress line are the report. The folder menu's commands lead with a mark of their own — fold ▴, rename ✎, scan ↻, forget ✕, reconnect ⚿, add + — the way the tool rail and the view switch already do: a column of marks reads at a glance where a block of uppercase does not. Verified: library-check.mjs — 43 steps, all passed, six of them new: the folder menu's three marks, the row menu's four, the add-only menu's one, the refused folder's lone reconnect carrying its ⚿, a reading cut short that goes on by itself (three rows back, and the bytes read are the two frames the catalogue had lost, not the one it still held), and the folder read again from its own menu. The stand-in folder now carries `__fake` on both handle kinds — a folder handle that does not is one the screen cannot ask about after a reload, which is a folder it offers to reconnect — and the frames it hands out report one write time instead of `Date.now()` per call, which is what a real handle does and what a frame is skipped on. scan-nav-check.mjs — all passed, the row still counting the reading as it comes rather than the catalogue standing still. roll-walk-check.mjs — all passed. frontend tsc --noEmit clean. ponytail: nothing watches the folder, so a roll that changes under a screen left open is picked up on the next visit or the next raise, not on the change — a FileSystemObserver when the browsers ship one. A frame is skipped on size and time alone, so an edit that keeps both is invisible until that frame is read again; the row's own rescan is the way to ask for exactly that. Co-authored-by: PenguinHarness --- docker/frontend/scripts/library-check.mjs | 119 +++++++++++++++++++++- docker/frontend/src/Library.tsx | 34 ++++++- docker/frontend/src/engine/library.ts | 18 +++- docker/frontend/src/styles/app.css | 7 ++ 4 files changed, 170 insertions(+), 8 deletions(-) diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index cacfbd3..0cec1b9 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -36,6 +36,14 @@ function step(name, ok, detail = '') { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); return ok; } +// The mark each command of an open context menu leads with. Every command carries +// one of its own: a column of marks reads at a glance where a block of uppercase +// does not, and a mark the platform's fonts have no glyph for is a box. +const menuMarks = () => + page.$$eval('[data-key="lib-menu"] .lib-menu-item', (els) => + els.map((e) => (e.querySelector('.lib-menu-icon')?.textContent ?? '').trim()) + ); +const marked = (marks) => marks.length > 0 && marks.every((m) => [...m].length === 1) && new Set(marks).size === marks.length; const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); if (!playwright) { @@ -94,23 +102,56 @@ const context = await browser.newContext(); // the way back out of IndexedDB. await context.addInitScript( ({ origin, names, subs, folder }) => { + // A folder the browser will not let the page read is the one state a run of + // its own has to ask for: the check sets the key, reloads, and the handles + // come back refused. + window.__deny = localStorage.getItem('check.deny') === '1'; const bytes = new Map(); const load = async (name) => { if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer())); return bytes.get(name); }; + // What a scan costs is the bytes it reads, so they are counted: a part cut + // off a file remembers the file it came off, and every `arrayBuffer` call on + // the whole or the part lands on that file's name. A frame the catalogue + // already holds is left on its size and its write time without a byte of it + // being read, which is the claim the run below makes. + const reads = (window.__reads = {}); + const off = new WeakMap(); + const part = Blob.prototype.slice; + Blob.prototype.slice = function (...args) { + const cut = part.apply(this, args); + off.set(cut, off.get(this) ?? this.name ?? ''); + return cut; + }; + const pull = Blob.prototype.arrayBuffer; + Blob.prototype.arrayBuffer = function () { + const name = off.get(this) ?? this.name ?? ''; + if (name) reads[name] = (reads[name] ?? 0) + 1; + return pull.apply(this); + }; const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { - value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream' }), + value: async () => + new File([await load(name)], name, { + type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream', + // A real handle reports one write time for one file, and the same one + // on every read; a fresh `Date.now()` per call would make every frame + // look touched and hide what is being checked. + lastModified: Date.UTC(2026, 0, 1), + }), }); return handle; }; // The picked folder holds `names` at its top level and the subfolders in // `subs`, each `{ files, dirs }` — a roll really is a folder of dated // directories inside dated directories, which is what the tree column walks. + // `__fake` on both kinds is what the revival below goes by: a handle that + // comes back out of IndexedDB is a plain object with no methods on it, and a + // folder that is not revived is a folder the screen has no permission for. const dirHandle = (label, node) => { - const handle = { kind: 'directory', name: label }; + const handle = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(handle, { values: { value: () => @@ -119,8 +160,8 @@ await context.addInitScript( ...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)), ][Symbol.iterator](), }, - queryPermission: { value: async () => 'granted' }, - requestPermission: { value: async () => 'granted' }, + queryPermission: { value: async () => (window.__deny ? 'denied' : 'granted') }, + requestPermission: { value: async () => (window.__deny ? 'denied' : 'granted') }, }); return handle; }; @@ -333,6 +374,15 @@ await page.click('[data-key="lib-node-CheckRoll/2026"]', { button: 'right' }); await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); step('a right click on a folder opens its menu', menu.join(',') === 'lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu)); +// Every command leads with a mark of its own, the way the tool rail and the view +// switch do: a column of marks reads at a glance where a block of uppercase does +// not, and a mark that is not in the font the platform ships is a box. +const marks = await menuMarks(); +step( + 'every command in the folder menu leads with its own mark', + marks.length === menu.length && marked(marks), + JSON.stringify(marks) +); await page.keyboard.press('Escape'); step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null); @@ -372,6 +422,8 @@ await page.click('[data-key="lib-tree"]', { button: 'right', position: { x: 10, await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); const blank = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); step('a right click on the empty column offers a folder', blank.join(',') === 'lib-menu-add', JSON.stringify(blank)); +const blankMarks = await menuMarks(); +step('and that one command carries its mark too', blankMarks.length === 1 && marked(blankMarks), JSON.stringify(blankMarks)); await page.keyboard.press('Escape'); step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null); @@ -398,6 +450,8 @@ step( rowMenu.join(',') === 'lib-menu-collapse,lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(rowMenu), ); +const rowMarks = await menuMarks(); +step('and its four commands each lead with a mark of their own', rowMarks.length === 4 && marked(rowMarks), JSON.stringify(rowMarks)); await page.click('[data-key="lib-menu-collapse"]'); await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, { timeout: 10_000, @@ -567,6 +621,63 @@ step( `held at version ${held}, ${seenAgain} tiles on the other tab` ); +// A reading that was cut short — the app closed, the tab was gone — is finished by +// the next visit with no one asking for it: the walk starts on the way in, the +// frame the catalogue still holds is left on its size and its write time, and the +// two it never got to are read. Two rows are taken out to stand for a scan that +// stopped before it reached them. +await page.evaluate(async (keep) => { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + const rows = await new Promise((resolve) => { + const req = db.transaction('photos').objectStore('photos').getAll(); + req.onsuccess = () => resolve(req.result); + }); + await new Promise((resolve, reject) => { + const tx = db.transaction('photos', 'readwrite'); + for (const row of rows) if (!row.name.endsWith(keep)) tx.objectStore('photos').delete(row.id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +}, RAW); +await page.reload(); +await page + .waitForFunction((n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n, 3, { timeout: 60_000, polling: 50 }) + .catch(() => {}); +const restored = await page.$$eval(thumbs, (els) => els.length).catch(() => 0); +const read = await page.evaluate(() => window.__reads ?? {}); +step( + 'an interrupted reading goes on by itself, over the frames it still holds', + restored === 3 && !read[RAW] && read[JPG] > 0, + `${restored} tiles, ${JSON.stringify(read)}` +); + +// The last of the six marks is the one only a folder whose permission has lapsed +// ever shows — and the one command that can do anything about it. The folder comes +// back refused, offers its way back, and takes it. +await page.evaluate(() => localStorage.setItem('check.deny', '1')); +await page.reload(); +await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 }); +await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); +await page.waitForSelector('[data-key="lib-reconnect-CheckRoll"]', { timeout: 10_000 }); +const refused = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); +const refusedMarks = await menuMarks(); +step( + 'a folder the browser refuses offers the one command that answers it, marked', + refused.join(',') === 'lib-menu-collapse,lib-reconnect-CheckRoll' && marked(refusedMarks) && refusedMarks[1] === '⚿', + `${JSON.stringify(refused)} ${JSON.stringify(refusedMarks)}` +); +await page.evaluate(() => localStorage.removeItem('check.deny')); +await page.evaluate(() => (window.__deny = false)); +await page.click('[data-key="lib-reconnect-CheckRoll"]'); +await page + .waitForFunction(() => document.querySelectorAll('[data-key^="lib-thumb-"]').length === 3, null, { timeout: 60_000, polling: 50 }) + .catch(() => {}); +step('and taking it reads the folder again', (await page.$$eval(thumbs, (els) => els.length)) === 3); + await browser.close(); sampleServer.close(); app.kill('SIGTERM'); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 1cee070..e8094c2 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -221,11 +221,11 @@ export function Library() { }, [menu]); const scan = useCallback( - async (folder: LibraryFolder) => { + async (folder: LibraryFolder, quiet = false) => { setNote(null); try { const result = await startScan(folder); - setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total })); + if (!quiet) setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total })); setDirs(result.dirs); await reload(); } catch { @@ -235,6 +235,30 @@ export function Library() { [reload, t] ); + // The folder this screen is on is read back to the disk on the way in, and again + // every time the tab is raised. A frame that has not moved costs a name, a size + // and a write time and nothing else, so the walk is cheap — and cheap for the + // two things the reader keeps running into: a roll whose first reading ended + // when the app did (nothing of it was lost, and nothing finished it either), and + // frames that landed in the folder since. No note: the ring on the row and the + // progress line are the report. + const rootKey = node ? node.split('/')[0] : null; + useEffect(() => { + const root = folders.find((f) => f.name === rootKey); + if (!loaded || !root || !ready.has(root.name)) return; + const catchUp = () => { + if (!scanSession()) void scan(root, true); + }; + catchUp(); + const raised = () => { + if (document.visibilityState === 'visible') catchUp(); + }; + document.addEventListener('visibilitychange', raised); + return () => document.removeEventListener('visibilitychange', raised); + // `folders` and `ready` are read here, not watched: the two only ever gain a + // name or a permission, and neither is a reason to read the disk again. + }, [loaded, rootKey, scan]); + const add = useCallback(async () => { setNote(null); try { @@ -778,6 +802,7 @@ export function Library() { setFolded(new Set(nested)); }} > + {t('lib.collapseAll')} ) : null} @@ -793,6 +818,7 @@ export function Library() { void rename(menu.folder!); }} > + {t('lib.rename')} @@ -830,6 +858,7 @@ export function Library() { void reconnect(menu.folder!); }} > + {t('lib.reconnect')} ) @@ -844,6 +873,7 @@ export function Library() { void add(); }} > + {t('lib.add')} )} diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index ba70dd3..3e44c6f 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -58,6 +58,12 @@ export interface LibraryPhoto { // When the shutter fired, per EXIF, else the file's own lastModified. taken: number; size: number; + // When the file itself was last written, as the browser reports it. This — with + // the size — is what says a frame has not moved since the last scan and does not + // have to be read again; `taken` is the shutter's own time and says nothing + // about the file. A frame stored before this field existed has none, and is + // read one last time. + mtime?: number; addedAt: number; } @@ -304,7 +310,10 @@ export interface ScanProgress { } // Walk the folder, keep what is new or changed, and leave the rest alone: a -// second scan of a 2000-file folder only reads the frames that moved. +// second scan of a 2000-file folder only reads the frames that moved. A scan +// that was cut off — the tab closed, the reader stopped it — is finished by the +// next one: the frames it got through are skipped on their size and their time, +// so asking again on the way in is the whole of resuming. // // The walk itself lives in `rollWalk`: layer by layer from the picked folder // down, one bounded pass at a time, with the folder the reader just clicked @@ -372,7 +381,11 @@ export async function scanFolder( 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; + // Unchanged means the same size and the same write time: the frame is left + // exactly as the catalogue already holds it. This is what makes a second scan + // of the same folder cheap, and what finishes a roll whose first scan was cut + // short — the frames it got through are skipped, the rest are read. + if (seen && seen.size === file.size && seen.mtime === 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 @@ -392,6 +405,7 @@ export async function scanFolder( thumb, taken, size: file.size, + mtime: file.lastModified, addedAt: seen?.addedAt ?? Date.now(), }); progress.added++; diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 627fba8..6179fe4 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1109,7 +1109,14 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } letter-spacing: 0.04em; text-align: left; cursor: pointer; + /* Each command leads with a mark of its own — rename, scan, forget, fold — the + way the tool rail and the view switch do, so a menu read at a glance is a + column of marks rather than a block of uppercase. */ + display: flex; + align-items: center; + gap: 8px; } +.lib-menu-icon { flex: 0 0 auto; width: 12px; text-align: center; opacity: 0.85; } .lib-menu-item:hover:not(:disabled) { background: var(--accent-soft); color: var(--accent); } .lib-menu-item:disabled { opacity: 0.45; cursor: not-allowed; }