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 }));