feat(library): give a folder its missing thumbnails, and the wall its stars
Four asks, one screen: a folder can be given the tiles it is short of, by hand or on the way in; the rescan row says RESCAN...; and the wall of thumbnails scores a frame without the trip to the stage. - Library.tsx + i18n: "RESCAN FROM THE TOP" is "RESCAN..." in both languages. The reading is unchanged — a row's roll is still read from the top, its subfolders included. - engine/library.ts + Library.tsx: the tile of one frame is one function now (`makeThumb`: the store, the backup behind it, then the file's own preview for a RAW or a HEIC and a decode for anything else). The wall's lazy path calls it instead of keeping its own copy of the same branches. - engine/library.ts: `missingThumbs` asks which of a folder's rows no tile was ever drawn for, in one transaction, and `makeThumbs` makes those — a few frames at a time, and giving up when the caller has moved on. - Library.tsx: the folder menu carries MAKE THUMBS, which reads the row's folder and what lies under it; and opening a folder asks for its own frames' tiles by itself, silently, in the background — the branch too when the screen is drawing the branch. A frame that lands after the ask is asked for again, which is why the frame count is a dependency of the effect. - Library.tsx: a wall card carries the same five stars the stage does, under the picture, and rates the frame on the click. The click stops at the star, so scoring a frame is not picking it. Verified in Chrome at 1440x900 against OPFS standing in for the disk, three frames with one of them in a subfolder: opening the folder leaves two tiles (the node's own frames) and MAKE THUMBS takes the roll to three, the frame in the subfolder included; clearing the store and reloading brings the two back on the open; the menu reads RESCAN... and MAKE THUMBS; and the wall's fourth star lights four, writes star 4 to the catalogue row, and takes it back on the second click. The same checks pass on the built bundle at :8090.
This commit is contained in:
@@ -14,8 +14,8 @@
|
||||
// smart collections) that hand-rolled cursors would make ugly.
|
||||
import type { Recipe } from '../../shared/types';
|
||||
import { readCapturedAt } from './imageOps';
|
||||
import { isRawName, isTiffName } from './rawDevelop';
|
||||
import { isHeicName } from './heicDevelop';
|
||||
import { isRawName, isTiffName, rawThumbnail } from './rawDevelop';
|
||||
import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop';
|
||||
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
|
||||
|
||||
const DB_NAME = 'recipescam-library';
|
||||
@@ -1919,6 +1919,123 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
|
||||
return res;
|
||||
}
|
||||
|
||||
// A RAW or a HEIC hands back a preview that is a whole develop, seconds of it:
|
||||
// shrunk to the tile's size the one time it is paid for and then kept, so every
|
||||
// later visit reads the tile back instead of the file.
|
||||
async function tileOf(id: string, preview: Blob): Promise<Blob> {
|
||||
const made = (await makeTile(preview)) ?? preview;
|
||||
void putPhotoThumb(id, made);
|
||||
return made;
|
||||
}
|
||||
|
||||
// The tile of one frame, made there and then — the folder the reader just opened,
|
||||
// or the one frame the wall is drawing. The store is asked first and the backup
|
||||
// behind it, so a tile that is somewhere already is not decoded a second time;
|
||||
// only a frame nothing has is read off the disk, and which way that goes is the
|
||||
// file's own: a RAW or a HEIC gives its preview, anything else is decoded.
|
||||
//
|
||||
// ponytail: a TIFF has no preview reader here, so it is decoded whole through the
|
||||
// browser (`makeTile`) — `tiffDevelop` is the way in if a folder of scans ever
|
||||
// makes that wait a real one.
|
||||
export async function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||
const kept = await getPhotoThumb(photo.id);
|
||||
if (kept) return kept;
|
||||
if (!photo.handle) return null;
|
||||
try {
|
||||
const file = await readPhotoFile(photo);
|
||||
const name = file.name;
|
||||
let blob: Blob | null = null;
|
||||
if (isHeicName(name)) {
|
||||
const full = new Uint8Array(await file.arrayBuffer());
|
||||
const preview = (await heicThumbnail(full)) ?? (await heicToJpeg(full, 320));
|
||||
if (preview) blob = await tileOf(photo.id, new Blob([preview as BlobPart], { type: 'image/jpeg' }));
|
||||
} else if (isRawName(name) || isTiffName(name)) {
|
||||
const full = new Uint8Array(await file.arrayBuffer());
|
||||
const preview = await rawThumbnail(full, name);
|
||||
if (preview) blob = await tileOf(photo.id, new Blob([preview as BlobPart], { type: 'image/jpeg' }));
|
||||
} else {
|
||||
// A frame the browser decodes itself: the tile is made here, at the size the
|
||||
// wall paints, and kept — the walk never made one, so the decode is paid on
|
||||
// the visit that first draws the frame. A frame the decoder would not shrink
|
||||
// is drawn as it is rather than written to the store whole.
|
||||
const made = await makeTile(file);
|
||||
blob = made ?? file;
|
||||
if (made) await putPhotoThumb(photo.id, made);
|
||||
}
|
||||
return blob;
|
||||
} catch {
|
||||
// A frame the disk will not hand over is a frame with no tile, not a job that
|
||||
// stopped: the rest of the folder is still read.
|
||||
return 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.
|
||||
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);
|
||||
});
|
||||
} 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.
|
||||
return [];
|
||||
}
|
||||
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.
|
||||
// A tile is a decode, so the frames are read a few at a time, 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,
|
||||
rel = '',
|
||||
opts: { below?: boolean; shouldStop?: () => boolean } = {}
|
||||
): Promise<number> {
|
||||
const below = opts.below !== false;
|
||||
const dir = normPath(rel);
|
||||
const rows = (await readPhotos(normPath(folder.name))) ?? [];
|
||||
const want = rows.filter((p) => {
|
||||
const pDir = normPath(p.dir ?? '');
|
||||
if (pDir === dir) return true;
|
||||
// `''` is the picked folder itself, so a row asked of the whole roll takes
|
||||
// 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));
|
||||
if (!missing.length) return 0;
|
||||
const byId = new Map(want.map((p) => [p.id, p]));
|
||||
const queue = [...missing];
|
||||
let made = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(LANES, queue.length) }, async () => {
|
||||
while (queue.length) {
|
||||
if (opts.shouldStop?.()) return;
|
||||
const photo = byId.get(queue.shift()!);
|
||||
if (photo && (await makeThumb(photo))) made++;
|
||||
}
|
||||
})
|
||||
);
|
||||
return made;
|
||||
}
|
||||
|
||||
export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
|
||||
try {
|
||||
const db = await openDb();
|
||||
|
||||
Reference in New Issue
Block a user