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 6a052cb9d9
commit 53e7606b37
4 changed files with 177 additions and 28 deletions
+54 -1
View File
@@ -20,10 +20,11 @@ import { developRaw, isRawName, isTiffName, tiffDevelop } from './engine/rawDeve
import { sharePhoto, type ShareSite } from './engine/share';
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
import { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
import { getPhoto, loadEdit, makeTile, putPhotoThumb, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
import { offerInstall } from './pwa/install';
import { cameraSupported } from './engine/camera';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session';
import { keep } from './remember';
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
import { api, type Photo, type SavedRecipe } from './api';
import { refreshAuth, useAuth } from './auth';
@@ -106,6 +107,10 @@ const MODEL_GAIN = 4;
// on every notch.
const PREVIEW_QUANTUM = 512;
const PREVIEW_DEBOUNCE_MS = 120;
// The catalogue frame the studio is holding, under the catalogue's own key: the
// shelf reads it when it comes back up and opens on that frame — the reader has
// just been working on it (see Library.tsx).
const LIBRARY_LAST_KEY = 'recipescam.library.last';
const FRAMES: { id: FrameId; label: MsgKey }[] = [
{ id: 'none', label: 'frame.none' },
@@ -921,6 +926,49 @@ export function Workspace() {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' }));
});
// A frame that came out of the catalogue leaves its own tile behind it:
// the shelf would otherwise keep showing the negative the reader has just
// graded. The tile is the look alone — the frame's turn, straighten, crop,
// frame and stamps are the library's to draw, and every tile it draws
// carries the frame's own standing on top — so the render in hand serves
// as the tile exactly while it carries none of them. The hovered look is
// not the frame's: only the committed one is filed.
if (libPhotoId && !preview && !cropEditing) {
const bare =
!options.cropRect &&
!options.watermark &&
!options.gpsWatermark &&
rotation === 0 &&
!straighten &&
!flipH &&
!flipV &&
frame === 'none';
const graded =
bare
? out.bytes
: (
await renderPhoto({
source: previewBytes,
recipe: shownRecipe,
frameId: 'none',
useGeotag: false,
gpsInfo: null,
assets: engine.assets,
options: {
...options,
photoRotation: 0,
photoStraighten: 0,
photoFlipH: false,
photoFlipV: false,
cropRect: null,
watermark: null,
gpsWatermark: null,
},
})
)?.bytes;
const tile = graded ? await makeTile(new Blob([graded as BlobPart], { type: 'image/jpeg' })) : null;
if (tile && token === renderToken.current) void putPhotoThumb(libPhotoId, tile);
}
if (!comparing) {
setCompareUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
@@ -2170,6 +2218,10 @@ export function Workspace() {
// frame on the neutral look and so at 0.
setRotation(row.rot ?? 0);
setLibPhotoId(id);
// The shelf is told which frame the studio is holding, so coming back to
// it opens on this one rather than on wherever the tree was left — and it
// outlives the visit, because the address bar no longer carries the id.
keep(LIBRARY_LAST_KEY, id);
} catch {
setError(t('err.decode'));
} finally {
@@ -4060,6 +4112,7 @@ export function Workspace() {
}}
shotSpecs={shotSpecs}
place={place}
photoName={source?.name ?? null}
onCropRectChange={setCropRect}
onStageZoom={onStageZoom}
canCompare={!!source}
+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;
}
+25 -16
View File
@@ -1840,11 +1840,24 @@ export async function getPhotoThumb(id: string): Promise<Blob | null> {
return null;
}
// Whoever is drawing a tile out of what the store holds (`Library.tsx`) is told
// about a tile rewritten under it: the URL it has is a copy of the old blob, and
// the write hands over the new one, so the row is redrawn without a second read.
const thumbListeners = new Set<(id: string, blob: Blob) => void>();
export function onThumbWritten(listener: (id: string, blob: Blob) => void): () => void {
thumbListeners.add(listener);
return () => {
thumbListeners.delete(listener);
};
}
// A tile made because someone looked at the frame, kept where the next visit
// will find it: the decode is paid once per frame, on the visit that first drew
// it, and the reads after that are the same reads a scanned tile would have had.
export async function putPhotoThumb(id: string, blob: Blob): Promise<void> {
cacheThumb(id, blob);
for (const listener of thumbListeners) listener(id, blob);
try {
const db = await openDb();
if (!db.objectStoreNames.contains(THUMBS)) return;
@@ -1930,23 +1943,19 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
// stutter, and it bought nothing: the wall mints the frames in view itself.
const THUMB_LANES = 2;
// The breath between frames. The idle queue where the browser has one — the tile
// then waits for a gap rather than making one — and a plain macrotask otherwise;
// the timeout is there because an idle queue on a busy page can stay empty.
// The breath between frames: the thread handed back so the page repaints and a
// click still lands while the shelves fill in. This used to be the idle queue, and
// that is what broke it — on a page somebody is using, an idle callback is parked
// for its whole timeout, so every tile cost 120ms of nothing and the tiles looked
// like they never came. `scheduler.yield` hands the thread over and comes straight
// back; a macrotask is the same thing where there is no such call.
//
// ponytail: the walk's own scan is not paced from here and does not stop for a
// tile; the two share the thread when a folder is opened mid-read. One budget for
// both is the next step if a scan ever runs visibly slower beside a tile job.
function breathe(): Promise<void> {
return new Promise((resolve) => {
const idle = (
globalThis as {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
}
).requestIdleCallback;
if (idle) idle(() => resolve(), { timeout: 120 });
else setTimeout(resolve, 0);
});
// ponytail: `THUMB_LANES` jobs share one budget — the thread is handed over after
// a tile, not on a clock. Pace by elapsed time if a folder of RAW ever wants it.
export function breathe(): Promise<void> {
const s = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
if (s?.yield) return s.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
// One frame's tile, in flight. The wall scrolls to a frame and asks for its tile
+10 -1
View File
@@ -288,6 +288,7 @@ export function ImageStage({
dims = null,
shotSpecs = null,
place = null,
photoName = null,
frameId = 'none',
framePanX = 0,
onFramePanXChange,
@@ -384,6 +385,9 @@ export function ImageStage({
dims?: { w: number; h: number } | null;
shotSpecs?: ShotSpecs | null;
place?: string | null;
// The file the stage is holding, as the disk named it: the row under the photo
// is where a reader checks that the frame they meant to open is the one open.
photoName?: string | null;
frameId?: FrameId;
framePanX?: number;
onFramePanXChange?: (panX: number) => void;
@@ -1998,9 +2002,14 @@ export function ImageStage({
{previewUrl ? (
<>
{shotSpecs || place || dims ? (
{shotSpecs || place || dims || photoName ? (
<div className="photo-meta-bar" data-key="photo-meta-bar">
{specsLine(shotSpecs ?? null, place, dims)}
{photoName ? (
<span className="photo-meta-name" data-key="photo-name">
{photoName}
</span>
) : null}
</div>
) : null}
<div className="toolbar">