diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index c61d671..988538b 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -5,6 +5,7 @@ import { api } from './api'; import { keep, recall } from './remember'; import { readGps, readSpecs, specsLine } from './engine/imageOps'; import { + cachedPhotos, canBrowseFolders, ensurePermission, getPhoto, @@ -12,9 +13,9 @@ import { jumpScan, listEditedIds, listFolders, - listPhotos, pickFolder, readPhotoFile, + readPhotos, removeFolder, renameFolder, scanBusy, @@ -22,6 +23,8 @@ import { setStar, startScan, stopScan, + unfinished, + watchRows, watchScan, type LibraryDir, type LibraryFolder, @@ -122,7 +125,10 @@ export function Library() { const { t } = useI18n(); const [folders, setFolders] = useState([]); const [ready, setReady] = useState>(new Set()); - const [photos, setPhotos] = useState([]); + // The strip starts on the catalogue the last screen read: coming back from the + // studio is a screen opening, not a page loading, and the frames the reader was + // looking at are already in hand. The read below replaces them a moment later. + const [photos, setPhotos] = useState(() => cachedPhotos()); // The folders the scan in flight has walked into: a roll's own shape is on // screen from the first pass, before the frames under its folders are read. // What a scan found is not filed away — the next visit draws the folders that @@ -208,8 +214,11 @@ export function Library() { const [backBusy, setBackBusy] = useState(false); const reload = useCallback(async () => { - const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]); - setPhotos(rows); + const [rows, edits] = await Promise.all([readPhotos(), listEditedIds()]); + // A read that came back with nothing is a read that failed, not a catalogue + // that emptied — and the frames it would have cleared are frames the reader + // was working their way through. The strip keeps them. + if (rows) setPhotos(rows); setEdited(edits); }, []); @@ -303,7 +312,9 @@ export function Library() { // A scan runs for as long as the roll takes, and this screen watches it rather // than running it: the studio may be up instead, and the scan carries on. The // names a pass has walked into are drawn as they arrive, a pass ahead of the - // frames under them; the catalogue behind the strip is read back on a timer. + // frames under them, and the frames themselves are handed over by the reading + // as it stores them — so the strip fills with the roll and the only read back + // is the one a reading in another window needs, whose rows never come here. useEffect(() => { // The folder the last pass was on, so the pass that comes back empty — the // one that says the scan is through — is recognisable as an end rather than @@ -324,6 +335,10 @@ export function Library() { // beside it, went past what a page is given and took the tab with it. On a // clock the strip still fills while the scan runs. let read = 0; + // And how many frames arrived the short way, handed over by the reading + // itself: a reading of this window's hands over every frame it stores, and + // there is no read to do for any of them. + let fresh = 0; const watching = () => { bump(); const session = scanSession(); @@ -354,14 +369,29 @@ export function Library() { if (written !== wrote && now - read >= CATALOGUE_MS) { wrote = written; read = now; - void reload(); + // Nothing came over the side, so the frames are another window's and + // there is nothing else to draw them from. The reading in this window + // handed its own over as they landed. + if (!fresh) void reload(); + fresh = 0; } }; + // The reading hands its frames over the moment their batch is stored, so the + // strip is the roll arriving rather than the roll being found again — a read + // of the whole catalogue costs every thumbnail in it, and on a folder of RAW + // that read beside a quarter of a gigabyte of decoder is what took the tab. + watchRows((rows) => { + fresh += rows.length; + setPhotos((prev) => [...prev, ...rows]); + }); const unwatch = watchScan(watching); // A scan already in flight when this screen opens is picked up where it is: // the reader who left for the studio comes back to the same roll. watching(); - return unwatch; + return () => { + unwatch(); + watchRows(null); + }; }, [reload]); // The catalogue outlives the tab; the permission to read those folders does @@ -438,20 +468,27 @@ export function Library() { const result = await startScan(folder); if (!quiet) setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total })); setDirs(result.dirs); - await reload(); + // No read back here. The reading handed its frames over as it stored + // them, and the screen watching it reads the catalogue back once the + // reading is done — the same read, half a beat later, and not a second + // one of the whole catalogue for a strip that is already drawn. } catch { setNote(t('lib.failed')); } }, - [reload, t] + [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 + // every time the tab is raised — but only when the last reading of it never + // finished. A reading that ran to its end has nothing left to find, and the + // reader who opens the library to choose a frame is not a reader who asked for + // their disk walked behind their back: on the folder they are opening, a walk + // is a hundred thousand names read off the disk and the band and the memory + // that goes with them, which is the whole of what this screen must not cost. + // A folder the reader wants looked at again says so itself, from the menu on + // its row; what is picked up here is the roll whose reading ended when the app + // did, and nothing else. No note either way: the ring on the row and the // progress line are the report. const rootKey = node ? node.split('/')[0] : null; // When the folder was last walked for being raised. It stands outside the @@ -474,8 +511,13 @@ export function Library() { // the several it took to walk it again. if (Date.now() - synced.current < CATCHUP_MS) return; synced.current = Date.now(); - void scanBusy().then((busy) => { - if (!busy && !scanSession()) void scan(root, true); + void scanBusy().then(async (busy) => { + if (busy || scanSession()) return; + // A position left on the disk is a roll that stopped in the middle of + // itself and the only thing that finishes it is reading it again. This + // is the question, and it is answered by one small file opened and shut + // — not by the walk it decides on. + if (await unfinished(root.name)) void scan(root, true); }); }; catchUp(); diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 95fd7b0..f69740b 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -550,6 +550,11 @@ export async function scanFolder( // hands the list on with the rest of its position. const dirs: LibraryDir[] = progress.dirs; let batch: LibraryPhoto[] = []; + // The frames this reading found for the first time, waiting to be handed on + // with the batch they landed in. A frame the catalogue already had is not one + // of these, however it came back — it is on the strip already, and a second + // copy of it is a second row under one key. + let added: LibraryPhoto[] = []; // The frames in hand: the ones a lane has been handed, and the ones whose row is // in `batch` and not yet in the catalogue. They are on neither side of the line a // position is written on — the catalogue does not hold them and the walk will not @@ -596,6 +601,16 @@ export async function scanFolder( tx.onerror = () => reject(tx.error); }); progress.written += rows.length; + // Out of the batch and onto the strip in one move, before the position is + // written: the frames are in the catalogue now, and the screen that draws + // them needs no read to find them. Ordered by nothing — the order they were + // read in is the order the roll was shot in, near enough, and the sort that + // settles it comes when the reading is through. + if (added.length) { + const fresh = added; + added = []; + rowSink?.(fresh); + } await write(); }; // One frame, end to end: its bytes, its tile, its shutter time, and the row the @@ -638,7 +653,7 @@ export async function scanFolder( const thumb = src ? await tile(src, size) : null; const taken = (await readCapturedAt(bytes)) ?? file.lastModified; const cut = rel.lastIndexOf('/'); - batch.push({ + const row: LibraryPhoto = { id, folder: folder.name, dir: cut < 0 ? '' : rel.slice(0, cut), @@ -652,7 +667,13 @@ export async function scanFolder( // keeps the stars it was given. star: seen?.star, addedAt: seen?.addedAt ?? Date.now(), - }); + }; + batch.push(row); + // A frame the catalogue already held is on the strip already: its row takes + // the place of the one there, at the end of the reading. Only the frame the + // reading met for the first time is handed over as it lands — that is the + // one the reader is waiting to see. + if (!seen) added.push(row); progress.added++; inHand = inHand.filter((r) => r !== rel); if (batch.length >= BATCH) await flush(); @@ -890,6 +911,30 @@ export function watchScan(fn: () => void): () => void { }; } +// The rows a reading has just stored, handed to the screen that is up as they +// land. A scan of a hundred thousand frames stores a batch a second, and the +// strip that waited to be told by a read of the whole catalogue spent the roll +// doing nothing else — a second and a half of the page held still, sixty +// megabytes of rows thrown away, every few seconds, while beside it a RAW +// decoder held a quarter of a gigabyte. The window that holds the reading is the +// one that hears this; a window watching from the side reads the catalogue back +// on its clock, as it always did. +let rowSink: ((rows: LibraryPhoto[]) => void) | null = null; + +export function watchRows(fn: ((rows: LibraryPhoto[]) => void) | null): void { + rowSink = fn; +} + +// Whether a reading of this folder was left half-done. A position on the disk is +// a roll that was cut off before its end and the only thing that finishes it is +// reading it again; a peer holding the same folder is the same answer, from the +// window that is already doing it. A question and not a walk: one small file is +// opened and shut, and not a name on the disk is looked at. +export async function unfinished(folder: string): Promise { + if (peer?.folder === folder) return true; + return (await loadWalk(folder)) !== null; +} + export function stopScan(): void { // A reading another window holds is stopped by telling it so: the button this // window drew is the same button, on the window that can act on it. @@ -949,18 +994,40 @@ export function startScan(folder: LibraryFolder): Promise { // --- reading --------------------------------------------------------------- -export async function listPhotos(folder?: string): Promise { +// The catalogue, read once and kept. STUDIO and LIBRARY are two screens of one +// page, and the reader who goes to the studio to develop a frame and comes back +// is not a reader who wants a hundred thousand rows read and sorted again to see +// the strip they were just looking at. The read is the one `listPhotos` always +// did; what it found is left here, and the screen that opens next draws from it +// before the read it just started has answered. +let cached: LibraryPhoto[] | null = null; + +export function cachedPhotos(): LibraryPhoto[] { + return cached ?? []; +} + +// Null, and not an empty list: a read that failed is not a catalogue with +// nothing in it, and the screen that would draw an empty strip on one is the +// screen that loses the frames it was showing — under a scan of its own, where +// the read is the thing that gives way first, that is every time. +export async function readPhotos(folder?: string): Promise { try { const rows = await ask(PHOTOS, 'readonly', (s) => folder ? s.index('folder').getAll(folder) : s.getAll() ); // Newest shutter first — a folder of stills reads in the order it was shot. - return rows.sort((a, b) => b.taken - a.taken); + rows.sort((a, b) => b.taken - a.taken); + if (!folder) cached = rows; + return rows; } catch { - return []; + return null; } } +export async function listPhotos(folder?: string): Promise { + return (await readPhotos(folder)) ?? []; +} + export async function getPhoto(id: string): Promise { try { return (await ask(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;