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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user