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:
2026-10-09 20:04:24 +07:00
parent e520068c68
commit e1707bd9b9
4 changed files with 236 additions and 58 deletions
+109 -52
View File
@@ -5,7 +5,7 @@ import { api } from './api';
import { keep, recall } from './remember';
import { readGps, readSpecs, specsLine } from './engine/imageOps';
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop';
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import {
cachedFolders,
cachedPhotos,
@@ -20,11 +20,11 @@ import {
listEditedIds,
listFolders,
listWalkedDirs,
makeTile,
makeThumb,
makeThumbs,
normPath,
onLibraryBlocked,
pickFolder,
putPhotoThumb,
readPhotoFile,
readPhotos,
reconnectPhotosForFolders,
@@ -1057,6 +1057,52 @@ function Catalogue({ admin }: { admin: boolean }) {
[rootName]
);
// MAKING THUMBS: the tiles a folder's frames are short of, made from the frames
// themselves. One job at a time — the target the reader is on — and a job the
// reader has moved away from gives up at its next frame: a tile is a decode, and
// two jobs over the same frames read the same disk twice for nothing.
const thumbJob = useRef(0);
const thumbTarget = useRef<string | null>(null);
const makeThumbsFor = useCallback(
async (folder: LibraryFolder, rel: string, opts: { below?: boolean; loud?: boolean } = {}) => {
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
// the frames the job has queued and read them again.
if (!opts.loud && thumbTarget.current === key) return 0;
const job = ++thumbJob.current;
thumbTarget.current = key;
try {
const made = await makeThumbs(folder, rel, {
below: opts.below,
shouldStop: () => thumbJob.current !== job,
});
if (opts.loud) setNote(t('lib.thumbsMade', { n: made, folder: folder.label ?? folder.name }));
return made;
} finally {
// The target is let go by the job that stood for it: one cut off in the
// middle is not the one that says the target has been looked at.
if (thumbJob.current === job) thumbTarget.current = null;
}
},
[t]
);
// Selecting a folder asks for its frames' tiles: whatever the catalogue is short
// of is made in the background, so the wall the reader scrolls to is painted
// rather than decoded a frame at a time. The open node's own frames — and the
// branch under it when the screen is drawing the branch — which is the list the
// wall would scroll and no more. Silent: the tiles filling in are the word. The
// count is a dependency because a reading lands a folder's frames a batch at a
// time, and a frame that arrives after the tiles were asked for is a frame with
// none.
useEffect(() => {
if (!loaded || !node || !rootFolder) return;
const rel = relOf(node);
if (rel === null) return;
void makeThumbsFor(rootFolder, rel, { below: deep });
}, [deep, loaded, makeThumbsFor, node, photos.length, relOf, rootFolder]);
// The tree: one row per picked folder, then one per subfolder, indented by its
// depth — the folders the walk has named, whether or not a frame has been read
// out of them, each carrying how many frames sit at or under it. While a scan is
@@ -2221,6 +2267,35 @@ function Catalogue({ admin }: { admin: boolean }) {
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
>
{tile(photo)}
{/* The score under the picture, the same five stars the
stage gives: the wall is where a roll is gone
through, and going back to the stage for every
number is the trip this saves. The click stops at
the star — a score is not a pick, and the card's
own click is what selects the frame. */}
<div
className="lib-stars"
data-key={`lib-stars-${photo.id}`}
role="group"
aria-label={t('lib.rating')}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
data-key={`lib-star-${photo.id}-${n}`}
aria-pressed={(photo.star ?? 0) >= n}
aria-label={t('lib.starHint', { n })}
title={t('lib.starHint', { n })}
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
>
★
</button>
))}
</div>
<div className="adm-meta">
<b>{photo.name}</b>
<span>
@@ -2481,12 +2556,12 @@ function Catalogue({ admin }: { admin: boolean }) {
{t('lib.rename')}
</button>
) : null}
{/* Three things can be asked of a roll: bring it up to date, read it
from the top again, or leave it — and the last only of the
reading that is this roll's, or of the request for it that is
still waiting its turn. The first two are always there, because
a reading already running is not a reason a reader cannot ask
for the next one: it queues. */}
{/* Four things can be asked of a roll: bring it up to date, read it
from the top again, give it the tiles it is short of, or leave
it — and the last only of the reading that is this roll's, or of
the request for it that is still waiting its turn. The others are
always there, because a reading already running is not a reason a
reader cannot ask for the next one: it queues. */}
{live?.folder === menu.folder.name || queued ? (
<button
type="button"
@@ -2525,6 +2600,24 @@ function Catalogue({ admin }: { admin: boolean }) {
<span className="lib-menu-icon" aria-hidden="true">⟳</span>
{t('lib.rescan')}
</button>
{/* The tiles this roll is short of, made from the frames themselves —
the same job the screen runs when a folder is opened, asked for by
hand on the row under the pointer, and over what lies under that
row: a folder is read whole or not at all. */}
<button
type="button"
className="lib-menu-item"
data-key={`lib-thumbs-${menu.folder.name}`}
onClick={() => {
const folder = menu.folder!;
const rel = menu.rel ?? '';
setMenu(null);
void makeThumbsFor(folder, rel, { loud: true });
}}
>
<span className="lib-menu-icon" aria-hidden="true">▤</span>
{t('lib.makeThumbs')}
</button>
{/* A row with folders under it is a row that folds, and the branch
it holds goes with it: a reader who is done with a tree means
the whole of it shut, not the one row they happened to point
@@ -2834,52 +2927,16 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
if (idsToFetch.length === 0) return;
// The strip draws one square whatever the frame behind it, so the preview a RAW
// or a HEIC hands back — a whole develop, seconds of it — is shrunk to the
// wall's size the one time it is paid for and then kept: every later visit
// reads the tile back instead of the file.
const tileOf = async (id: string, bytes: Uint8Array) => {
const preview = new Blob([bytes as BlobPart], { type: 'image/jpeg' });
const made = (await makeTile(preview)) ?? preview;
void putPhotoThumb(id, made);
return made;
};
// The tile is `makeThumb`'s business: the store first, the backup behind it,
// and only then the file — a RAW's own preview, or a decode. A frame the disk
// will not hand over is a frame with no tile rather than a strip that stops.
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
if (!alive) return;
const updates: Record<string, string> = {};
for (const photo of list) {
let blob = thumbMap.get(photo.id) ?? photo.thumb ?? null;
if (!blob && photo.handle) {
try {
const file = await readPhotoFile(photo);
if (!alive) return;
const name = file.name;
if (isHeicName(name)) {
const fullBytes = new Uint8Array(await file.arrayBuffer());
let prevBytes = await heicThumbnail(fullBytes);
if (!prevBytes) prevBytes = await heicToJpeg(fullBytes, 320);
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
} else if (isRawName(name) || isTiffName(name)) {
const fullBytes = new Uint8Array(await file.arrayBuffer());
const prevBytes = await rawThumbnail(fullBytes, name);
if (prevBytes) blob = await tileOf(photo.id, prevBytes);
} else {
// A frame the wall can decode itself: the tile is made here, at the
// size the wall paints, because this is the first time anyone has
// looked at the frame. The walk never made one — a roll reads in a
// fraction of the time without it — so the decode is paid on the
// frame the reader draws and kept for every visit after.
blob = (await makeTile(file)) ?? file;
if (blob !== file) void putPhotoThumb(photo.id, blob);
}
} catch {
blob = null;
}
}
if (blob && alive) {
updates[photo.id] = cacheObjectUrl(photo.id, blob);
}
if (!alive) return;
const kept = thumbMap.get(photo.id) ?? photo.thumb ?? null;
const blob = kept ?? (photo.handle ? await makeThumb(photo) : null);
if (blob) updates[photo.id] = cacheObjectUrl(photo.id, blob);
}
if (alive && Object.keys(updates).length > 0) {
setUrls((prev) => ({ ...prev, ...updates }));
+119 -2
View File
@@ -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();
+4 -2
View File
@@ -248,12 +248,14 @@ export const en: Dict = {
'lib.rename': 'RENAME FOLDER',
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
'lib.update': 'UPDATE...',
'lib.rescan': 'RESCAN FROM THE TOP',
'lib.rescan': 'RESCAN...',
'lib.makeThumbs': 'MAKE THUMBS',
'lib.thumbsMade': 'Made {n} thumbnails for {folder}.',
'lib.resume': 'KEEP READING {folder}',
'lib.resumeHint': 'The last reading of this folder was cut off. Press to read it on from where it stopped.',
'lib.remove': 'REMOVE',
'lib.reconnect': 'GRANT ACCESS AGAIN',
'lib.nodeHint': 'Right-click to rename, update, rescan, collapse or remove the folder.',
'lib.nodeHint': 'Right-click to rename, update, rescan, make the missing thumbnails, collapse or remove the folder.',
'lib.collapseAll': 'COLLAPSE ALL',
'lib.collapse': 'COLLAPSE',
'lib.colHint': 'Right-click the empty part of this column to add a folder.',
+4 -2
View File
@@ -260,12 +260,14 @@ export const vi = {
'lib.rename': 'ĐỔI TÊN THƯ MỤC',
'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):',
'lib.update': 'CẬP NHẬT...',
'lib.rescan': 'QUÉT LẠI TỪ ĐẦU',
'lib.rescan': 'QUÉT LẠI...',
'lib.makeThumbs': 'TẠO ẢNH THU NHỎ',
'lib.thumbsMade': 'Đã tạo {n} ảnh thu nhỏ cho {folder}.',
'lib.resume': 'ĐỌC TIẾP {folder}',
'lib.resumeHint': 'Lượt đọc thư mục này bị cắt ngang. Bấm để đọc tiếp từ chỗ đã dừng.',
'lib.remove': 'BỎ',
'lib.reconnect': 'CẤP LẠI QUYỀN',
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, thu gọn hay bỏ thư mục.',
'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, tạo ảnh thu nhỏ còn thiếu, thu gọn hay bỏ thư mục.',
'lib.collapseAll': 'THU GỌN TẤT CẢ',
'lib.collapse': 'THU GỌN',
'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',