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 { keep, recall } from './remember';
import { readGps, readSpecs, specsLine } from './engine/imageOps'; import { readGps, readSpecs, specsLine } from './engine/imageOps';
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop'; import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
import { heicThumbnail, heicToJpeg, isHeicName } from './engine/heicDevelop'; import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import { import {
cachedFolders, cachedFolders,
cachedPhotos, cachedPhotos,
@@ -20,11 +20,11 @@ import {
listEditedIds, listEditedIds,
listFolders, listFolders,
listWalkedDirs, listWalkedDirs,
makeTile, makeThumb,
makeThumbs,
normPath, normPath,
onLibraryBlocked, onLibraryBlocked,
pickFolder, pickFolder,
putPhotoThumb,
readPhotoFile, readPhotoFile,
readPhotos, readPhotos,
reconnectPhotosForFolders, reconnectPhotosForFolders,
@@ -1057,6 +1057,52 @@ function Catalogue({ admin }: { admin: boolean }) {
[rootName] [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 // 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 // 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 // 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)} onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
> >
{tile(photo)} {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"> <div className="adm-meta">
<b>{photo.name}</b> <b>{photo.name}</b>
<span> <span>
@@ -2481,12 +2556,12 @@ function Catalogue({ admin }: { admin: boolean }) {
{t('lib.rename')} {t('lib.rename')}
</button> </button>
) : null} ) : null}
{/* Three things can be asked of a roll: bring it up to date, read it {/* Four 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 from the top again, give it the tiles it is short of, or leave
reading that is this roll's, or of the request for it that is it — and the last only of the reading that is this roll's, or of
still waiting its turn. The first two are always there, because the request for it that is still waiting its turn. The others are
a reading already running is not a reason a reader cannot ask always there, because a reading already running is not a reason a
for the next one: it queues. */} reader cannot ask for the next one: it queues. */}
{live?.folder === menu.folder.name || queued ? ( {live?.folder === menu.folder.name || queued ? (
<button <button
type="button" type="button"
@@ -2525,6 +2600,24 @@ function Catalogue({ admin }: { admin: boolean }) {
<span className="lib-menu-icon" aria-hidden="true">⟳</span> <span className="lib-menu-icon" aria-hidden="true">⟳</span>
{t('lib.rescan')} {t('lib.rescan')}
</button> </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 {/* 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 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 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; if (idsToFetch.length === 0) return;
// The strip draws one square whatever the frame behind it, so the preview a RAW // The tile is `makeThumb`'s business: the store first, the backup behind it,
// or a HEIC hands back — a whole develop, seconds of it — is shrunk to the // and only then the file — a RAW's own preview, or a decode. A frame the disk
// wall's size the one time it is paid for and then kept: every later visit // will not hand over is a frame with no tile rather than a strip that stops.
// 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;
};
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => { void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
if (!alive) return;
const updates: Record<string, string> = {}; const updates: Record<string, string> = {};
for (const photo of list) { for (const photo of list) {
let blob = thumbMap.get(photo.id) ?? photo.thumb ?? null; if (!alive) return;
if (!blob && photo.handle) { const kept = thumbMap.get(photo.id) ?? photo.thumb ?? null;
try { const blob = kept ?? (photo.handle ? await makeThumb(photo) : null);
const file = await readPhotoFile(photo); if (blob) updates[photo.id] = cacheObjectUrl(photo.id, blob);
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 && Object.keys(updates).length > 0) { if (alive && Object.keys(updates).length > 0) {
setUrls((prev) => ({ ...prev, ...updates })); setUrls((prev) => ({ ...prev, ...updates }));
+119 -2
View File
@@ -14,8 +14,8 @@
// smart collections) that hand-rolled cursors would make ugly. // smart collections) that hand-rolled cursors would make ugly.
import type { Recipe } from '../../shared/types'; import type { Recipe } from '../../shared/types';
import { readCapturedAt } from './imageOps'; import { readCapturedAt } from './imageOps';
import { isRawName, isTiffName } from './rawDevelop'; import { isRawName, isTiffName, rawThumbnail } from './rawDevelop';
import { isHeicName } from './heicDevelop'; import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop';
import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk'; import { openAt, walkPass, type Walk, type WalkFile } from './rollWalk';
const DB_NAME = 'recipescam-library'; const DB_NAME = 'recipescam-library';
@@ -1919,6 +1919,123 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
return res; 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> { export async function readPhotos(folder?: string): Promise<LibraryPhoto[] | null> {
try { try {
const db = await openDb(); const db = await openDb();
+4 -2
View File
@@ -248,12 +248,14 @@ export const en: Dict = {
'lib.rename': 'RENAME FOLDER', 'lib.rename': 'RENAME FOLDER',
'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):', 'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):',
'lib.update': 'UPDATE...', '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.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.resumeHint': 'The last reading of this folder was cut off. Press to read it on from where it stopped.',
'lib.remove': 'REMOVE', 'lib.remove': 'REMOVE',
'lib.reconnect': 'GRANT ACCESS AGAIN', '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.collapseAll': 'COLLAPSE ALL',
'lib.collapse': 'COLLAPSE', 'lib.collapse': 'COLLAPSE',
'lib.colHint': 'Right-click the empty part of this column to add a folder.', '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.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.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.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.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.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.remove': 'BỎ',
'lib.reconnect': 'CẤP LẠI QUYỀN', '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.collapseAll': 'THU GỌN TẤT CẢ',
'lib.collapse': 'THU GỌN', '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.', 'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.',