diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index e0897b3..647a251 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -194,6 +194,12 @@ const WALL_ROWS = 2; // thread handed back between them (`breathe`), is a shelf that fills in front of // the reader — a screenful of frames read end to end is a dead screen. const WALL_LANES = 2; +// A folder is walked into with a screenful in sight, not the whole roll: the tiles +// asked for when a folder opens are the ones about to be drawn, and the wall mints +// the rest as it scrolls. A job over every frame of the roll is a decode of every +// frame of the roll — half an hour of disk for a folder nobody has scrolled past +// the first screen of. +const THUMB_AHEAD = 400; // What `.adm-grid` leaves between its rows. A spacer has to stand the same // distance past the last row it replaces as the row itself would have. const WALL_GAP = 12; @@ -1074,7 +1080,11 @@ function Catalogue({ admin }: { admin: boolean }) { const thumbJob = useRef(0); const thumbTarget = useRef(null); const makeThumbsFor = useCallback( - async (folder: LibraryFolder, rel: string, opts: { below?: boolean; loud?: boolean } = {}) => { + async ( + folder: LibraryFolder, + rel: string, + opts: { below?: boolean; loud?: boolean; rows?: LibraryPhoto[] } = {} + ) => { const key = `${normPath(folder.name)}|${normPath(rel)}|${opts.below === false ? 0 : 1}`; // A quiet ask for the target already being worked is the work in hand: the // catalogue lands a batch at a time, and every batch is not a reason to drop @@ -1083,8 +1093,12 @@ function Catalogue({ admin }: { admin: boolean }) { const job = ++thumbJob.current; thumbTarget.current = key; try { - const made = await makeThumbs(folder, rel, { + const made = await makeThumbs(opts.rows ?? folder, rel, { below: opts.below, + // The whole of a folder is the reader asking for it by hand; a folder + // opening under the pointer is a screenful, and the wall makes the rest + // as it scrolls. + limit: opts.loud ? undefined : THUMB_AHEAD, shouldStop: () => thumbJob.current !== job, }); if (opts.loud) setNote(t('lib.thumbsMade', { n: made, folder: folder.label ?? folder.name })); @@ -1110,7 +1124,16 @@ function Catalogue({ admin }: { admin: boolean }) { if (!loaded || !node || !rootFolder) return; const rel = relOf(node); if (rel === null) return; - void makeThumbsFor(rootFolder, rel, { below: deep }); + // The rows come from the screen, which is holding the whole catalogue already: + // reading one folder's worth back out of the store is a walk of the disk for + // rows that are in hand. One folder's worth of it — the open one — so a roll + // picked alongside it is not asked for as well. + // ponytail: keyed on the count, not the array, so a star or a turn landing does + // not set the job off again; the rows of that same render are what goes. + const normRoot = normPath(rootFolder.name); + const rows = photos.filter((p) => normPath(p.folder ?? '') === normRoot); + void makeThumbsFor(rootFolder, rel, { below: deep, rows }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [deep, loaded, makeThumbsFor, node, photos.length, relOf, rootFolder]); // The tree: one row per picked folder, then one per subfolder, indented by its diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 713e9cb..c3ece58 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -1956,6 +1956,12 @@ export async function getPhotoThumbs(ids: string[]): Promise> // stutter, and it bought nothing: the wall mints the frames in view itself. const THUMB_LANES = 2; +// The frames a folder is short of a tile for are asked for a chunk at a time. One +// transaction for the lot is one request in flight for every frame of the folder, +// and a roll of six figures is the page out of memory before the first answer +// lands; a frame at a time is as many transactions as there are frames. +const MISSING_CHUNK = 500; + // The breath between frames: the thread handed back so the page repaints and a // click still lands while the shelves fill in. This used to be the idle queue, and // that is what broke it — on a page somebody is using, an idle callback is parked @@ -2037,26 +2043,31 @@ async function makeOneThumb(photo: LibraryPhoto): Promise { } // Which of these frames the catalogue is short of a tile for: the rows the walk -// wrote down that nothing has drawn yet. One transaction for the lot — asking a -// frame at a time is a thousand transactions over a folder of a thousand. +// wrote down that nothing has drawn yet. The rows go in a chunk at a time (see +// MISSING_CHUNK) — one transaction for the lot is one request in flight for every +// frame of the folder, and asking a frame at a time is a thousand transactions +// over a folder of a thousand. export async function missingThumbs(ids: string[]): Promise { const missing: string[] = []; try { const db = await openDb(); if (!db.objectStoreNames.contains(THUMBS)) return ids; - await new Promise((resolve, reject) => { - const tx = db.transaction(THUMBS, 'readonly'); - const store = tx.objectStore(THUMBS); - for (const id of ids) { - if (getCachedThumb(id)) continue; - const req = store.get(id); - req.onsuccess = () => { - if (!(req.result as { thumb?: Blob } | undefined)?.thumb) missing.push(id); - }; - } - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); + for (let at = 0; at < ids.length; at += MISSING_CHUNK) { + const chunk = ids.slice(at, at + MISSING_CHUNK); + await new Promise((resolve, reject) => { + const tx = db.transaction(THUMBS, 'readonly'); + const store = tx.objectStore(THUMBS); + for (const id of chunk) { + if (getCachedThumb(id)) continue; + const req = store.get(id); + req.onsuccess = () => { + if (!(req.result as { thumb?: Blob } | undefined)?.thumb) missing.push(id); + }; + } + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } } catch { // A store that will not answer is not a folder with its tiles: nothing is // reported missing, and nothing is made again for a read that failed. @@ -2065,20 +2076,25 @@ export async function missingThumbs(ids: string[]): Promise { return missing; } -// MAKE THUMBS: every frame of the folder — and, unless told otherwise, of what -// lies under it — given a tile, and only the frames the catalogue is short of one. +// MAKE THUMBS: the frames of the folder — and, unless told otherwise, of what lies +// under it — given a tile, and only the frames the catalogue is short of one. The +// rows can be handed in by a caller that already has them (the screen holds the +// whole catalogue); otherwise they are read back out of the store, which is a walk +// of a roll of six figures off the disk before a single tile is made. `limit` is +// the head of the list only: the whole of the folder is a walk down a folder asked +// for on purpose, not a folder opening under the pointer. // A tile is a decode, so the frames are read two at a time with the thread handed // back between them (see THUMB_LANES), and a job the caller has moved on from gives // up at its next frame: a reader who has walked to another folder is not waiting // for this one. export async function makeThumbs( - folder: LibraryFolder, + folder: LibraryFolder | LibraryPhoto[], rel = '', - opts: { below?: boolean; shouldStop?: () => boolean } = {} + opts: { below?: boolean; shouldStop?: () => boolean; limit?: number } = {} ): Promise { const below = opts.below !== false; const dir = normPath(rel); - const rows = (await readPhotos(normPath(folder.name))) ?? []; + const rows = Array.isArray(folder) ? folder : (await readPhotos(normPath(folder.name))) ?? []; const want = rows.filter((p) => { const pDir = normPath(p.dir ?? ''); if (pDir === dir) return true; @@ -2086,9 +2102,10 @@ export async function makeThumbs( // every row of it and a row asked of a subfolder takes what lies under that. return below && pDir.startsWith(dir === '' ? '' : `${dir}/`); }); - const missing = await missingThumbs(want.map((p) => p.id)); + const asked = opts.limit ? want.slice(0, opts.limit) : want; + const missing = await missingThumbs(asked.map((p) => p.id)); if (!missing.length) return 0; - const byId = new Map(want.map((p) => [p.id, p])); + const byId = new Map(asked.map((p) => [p.id, p])); const queue = [...missing]; let made = 0; await Promise.all(