fix(library): a folder opens on a screenful of tiles, not the whole roll
Choosing another row in the column read the whole roll back out of the store and asked it for a tile of every frame in one transaction: 20 000 requests in flight at once at 40 000 frames, a 200 ms block of the main thread per switch, and a mint job over the whole folder. The rows are the ones the screen is holding already, the store is asked a chunk at a time, and a folder opening under the pointer is the head of the list — the wall mints the rest as it scrolls. MAKE THUMBS by hand still walks the whole folder.
This commit is contained in:
@@ -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<string | null>(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
|
||||
|
||||
@@ -1956,6 +1956,12 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
|
||||
// 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<Blob | null> {
|
||||
}
|
||||
|
||||
// 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<string[]> {
|
||||
const missing: string[] = [];
|
||||
try {
|
||||
const db = await openDb();
|
||||
if (!db.objectStoreNames.contains(THUMBS)) return ids;
|
||||
await new Promise<void>((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<void>((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<string[]> {
|
||||
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<number> {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user