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:
+109
-52
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user