diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 24546ba..51fc7c0 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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} diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 031a9f6..d88d0cd 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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(() => 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(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 { +function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record { const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]); const [urls, setUrls] = useState>(() => { @@ -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 = {}; + const landed: Record = {}; 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; } diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 16c3668..60cbc3a 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -1840,11 +1840,24 @@ export async function getPhotoThumb(id: string): Promise { 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 { 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> // 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 { - 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 { + const s = (globalThis as { scheduler?: { yield?: () => Promise } }).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 diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index e9a4fd0..7d01773 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -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 ? (
{specsLine(shotSpecs ?? null, place, dims)} + {photoName ? ( + + {photoName} + + ) : null}
) : null}