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}