feat(library): a shelf that fills in as it reads, and one that carries the studio's look and frame back with it

- tiles land one at a time. The screenful a picked folder asks for was built all
  at once, and a HEIC or a RAW in it is a decode of its own, so the strip stood
  empty until every one of them was done: 2293ms on a roll of eighteen, with the
  batch arriving in one go (0/18 then 18/18 in a single step). Tiles are built in
  two lanes now and each one goes up the moment it is drawn — first picture at
  36ms, then 1, 2, 4, 6, 8, 9, 11, 12, 14, 15, 16, 17, 18 of 18, done at 2496ms.
  The threading is handed back between tiles (`scheduler.yield`), so a click
  still answers while the strip fills: no long tasks either side, worst frame gap
  169ms -> 164ms, a view toggle 49ms -> 41ms. The tiles are the same tiles; the
  wall's is drawn from the same counter.
- the studio's look reaches the shelf. A frame graded in the studio writes the
  tile the shelf draws, at neutral geometry — no turn, crop, watermark or frame,
  since the shelf turns a tile itself — and every screen holding the tile is told
  the moment it is written, so the row repaints without a reload.
- the shelf comes back on the frame the studio was holding. The frame a visit
  opened is kept with the visit, and coming back from the studio raises it: the
  strip centres it and the wall brings its card to the middle, even when the
  frame sits in another folder of the same tree. A shelf that opened on whatever
  it was left standing on sent the reader looking for the frame they had just
  been working on.
- the frame's name stands under the picture, in the studio's own meta bar.
This commit is contained in:
2026-10-09 21:52:38 +07:00
parent 859174d492
commit 7b3086b1db
4 changed files with 177 additions and 28 deletions
+88 -10
View File
@@ -8,6 +8,7 @@ import { readGps, readSpecs, specsLine } from './engine/imageOps';
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import {
breathe,
cachedFolders,
cachedPhotos,
canBrowseFolders,
@@ -25,6 +26,7 @@ import {
makeThumbs,
normPath,
onLibraryBlocked,
onThumbWritten,
pickFolder,
readPhotoFile,
readPhotos,
@@ -96,6 +98,9 @@ const MIN_STAR_KEY = 'recipescam.library.minStar';
const YEAR_KEY = 'recipescam.library.year';
const FROM_KEY = 'recipescam.library.from';
const TO_KEY = 'recipescam.library.to';
// The frame the studio is holding (written on its side, see App.tsx): the shelf
// comes back up on that frame rather than on wherever the tree was left.
const LAST_KEY = 'recipescam.library.last';
function TimeRangeSelectInput({
from,
@@ -184,6 +189,11 @@ const STRIP_KEEP = 8;
// cards is a couple of hundred pixels tall, so this is the row under the eye and
// two beyond it — enough that a flick lands on pictures.
const WALL_ROWS = 2;
// How many tiles the wall reads and decodes at once when it is the one asking.
// The wall's own frames come first and they come visibly: two at a time, the
// thread handed back between them (`breathe`), is a shelf that fills in front of
// the reader — a screenful of frames read end to end is a dead screen.
const WALL_LANES = 2;
// What `.adm-grid` leaves between its rows. A spacer has to stand the same
// distance past the last row it replaces as the row itself would have.
const WALL_GAP = 12;
@@ -343,6 +353,10 @@ function Catalogue({ admin }: { admin: boolean }) {
// the first, the way a node that is gone falls back to its folder.
const [up, setUp] = useState<string | null>(() => recall(FRAME_KEY));
const [view, setView] = useState<'preview' | 'grid'>(() => (recall(VIEW_KEY) === 'grid' ? 'grid' : 'preview'));
// This screen is mounted for the life of the tab and only shown and hidden
// (see Shell in main.tsx), so a visit that comes back from the studio is not a
// mount: the address is what says the shelf is the screen up again.
const [here, setHere] = useState(() => window.location.pathname.startsWith('/library'));
// How the thumbnail view reads the open folder: every frame it holds, or only
// the ones that were given this many stars, shot in this year, shot between
// these hours — and in the order the reader picked. A way of looking, not part
@@ -1444,7 +1458,20 @@ function Catalogue({ admin }: { admin: boolean }) {
() => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
[listed, wall.from, wall.to]
);
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame);
// A tile the studio graded while this screen was dark: the copy in hand is the
// negative's, and `onThumbWritten` hands over the one that replaces it. The
// counter is what makes the rows look again — the list is unchanged, so nothing
// else would make them.
const [tilesAt, setTilesAt] = useState(0);
useEffect(
() =>
onThumbWritten((id, blob) => {
cacheObjectUrl(id, blob);
setTilesAt((n) => n + 1);
}),
[]
);
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame, tilesAt);
// Auto-scroll & focus tree sidebar to the active node selected in last session
const treeRef = useRef<HTMLElement>(null);
@@ -1540,7 +1567,39 @@ function Catalogue({ admin }: { admin: boolean }) {
return () => {
alive = false;
};
}, [view, listed, wall.ref]);
// `up` is in the list for the frame the studio hands back: it is the one that
// moves when the shelf comes up on another frame, and a wall standing where it
// already was is a wall that would not follow it.
}, [view, listed, wall.ref, up]);
// This screen's own comings and goings: it never unmounts, so `here` is the
// only sign of the shelf being the screen up again (see `here` above).
useEffect(() => {
const onPop = () => setHere(window.location.pathname.startsWith('/library'));
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
// Coming back from the studio, the shelf opens on the frame the studio was
// holding rather than on wherever the tree was left — that frame is the one the
// reader has just been working on. Both views are sent to it: the strip centres
// it and the wall, if that is the view up, scrolls to its card. It may also sit
// in another folder of the same tree (a frame id and a tree key are spelled the
// same way), and a frame the open node cannot list is a frame the shelf cannot
// raise at all, so the folder goes with it.
const wasHere = useRef(here);
useEffect(() => {
const came = here && !wasHere.current;
wasHere.current = here;
if (!came) return;
const last = recall(LAST_KEY);
if (!last) return;
setUp(last);
scrolledFrameRef.current = null;
centredRef.current = last;
const dir = last.slice(0, last.lastIndexOf('/'));
if (dir && nodes.some((n) => n.key === dir)) setNode(dir);
}, [here, nodes]);
// The way back, and the way in: the frame the wall is standing on is the card
// nearest the middle of its viewport — the same card the reader's eye is on.
@@ -2881,7 +2940,7 @@ function cacheObjectUrl(id: string, blob: Blob): string {
return url;
}
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Record<string, string> {
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record<string, string> {
const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]);
const [urls, setUrls] = useState<Record<string, string>>(() => {
@@ -2920,23 +2979,42 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null): Recor
// 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.
//
// Every tile is drawn the moment it lands, and the file is worked a couple of
// frames at a time with the thread handed back between them. Waiting for the
// whole shelf and painting once at the end is a screen that shows nothing for
// seconds and answers nothing while it waits; this way the frames come in one
// after another and a click during the fill still lands.
void getPhotoThumbs(idsToFetch).then(async (thumbMap) => {
const updates: Record<string, string> = {};
const landed: Record<string, string> = {};
for (const photo of list) {
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 (kept) landed[photo.id] = cacheObjectUrl(photo.id, kept);
}
if (alive && Object.keys(updates).length > 0) {
setUrls((prev) => ({ ...prev, ...updates }));
if (alive && Object.keys(landed).length > 0) {
setUrls((prev) => ({ ...prev, ...landed }));
}
const queue = list.filter((p) => !(p.id in landed) && p.handle);
await Promise.all(
Array.from({ length: Math.min(WALL_LANES, queue.length) }, async () => {
while (alive && queue.length) {
const photo = queue.shift()!;
const blob = await makeThumb(photo);
if (!alive) return;
if (blob) setUrls((prev) => ({ ...prev, [photo.id]: cacheObjectUrl(photo.id, blob) }));
await breathe();
}
})
);
});
return () => {
alive = false;
};
}, [list]);
// `stamp` is a tile rewritten under this row: the list is the same one, and
// what has to happen again is the read of the store.
}, [list, stamp]);
return urls;
}