fix(studio): file a catalogue frame's look and its tile ten seconds after the stage goes still

This commit is contained in:
2026-10-13 15:41:59 +07:00
parent db98aa6876
commit 4888bfda77
2 changed files with 457 additions and 51 deletions
+83 -51
View File
@@ -112,6 +112,12 @@ const MODEL_GAIN = 4;
// on every notch.
const PREVIEW_QUANTUM = 512;
const PREVIEW_DEBOUNCE_MS = 120;
// How long the stage has to be still before the look goes back to the shelf: the
// tile it shows for a catalogue frame is a develop of that look and a write into
// the store the shelf reads, and paying both behind every frame of a slider drag
// is the drag the reader feels. Ten seconds of nothing asked of the stage is a
// look the reader has stopped making. See the filing effects in the studio.
const LIBRARY_EXPORT_MS = 10_000;
// 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).
@@ -1067,49 +1073,11 @@ 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);
}
// The tile the shelf shows for this frame is not made here. It is a
// develop of the look and a write into the store the shelf reads, and a
// develop behind every frame of a slider drag is the drag the reader
// feels — it is made on the idle clock below instead, with the look
// itself. See `LIBRARY_EXPORT_MS`.
if (!comparing) {
setCompareUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
@@ -2538,16 +2506,80 @@ export function Workspace() {
openLibraryPhotoRef.current = openLibraryPhoto;
}, [openLibraryPhoto]);
// What the catalogue remembers about a frame is the look it was left at, filed
// back on every change — the whole look, not just the recipe, so the next
// session reopens the frame cropped, straightened and marked as it was left.
// Debounced, because a slider drag fires this per frame of the drag and
// IndexedDB is on the wrong side of that.
// What the catalogue remembers about a frame is the look it was left at — the
// whole look, not just the recipe, so the next session reopens the frame
// cropped, straightened and marked as it was left — and the tile the shelf
// draws for it, which is that same look developed once more and written into
// the store the shelf reads. Both go back on an idle clock: a slider drag asks
// for a preview per frame of the drag, and a develop and a store write behind
// every one of those is the drag the reader feels. Ten seconds with nothing
// asked of the stage is a look the reader has stopped making — the shelf hears
// about it once, and the looks still being made are paid for by nothing.
const filedLookRef = useRef<{ id: string; run: () => Promise<void> } | null>(null);
const filedTokenRef = useRef(0);
const flushFiledLook = useCallback(() => {
const job = filedLookRef.current;
filedLookRef.current = null;
if (job) void job.run().catch(() => undefined);
}, []);
useEffect(() => {
if (!libPhotoId || !source) return;
const timer = setTimeout(() => void saveEdit(libPhotoId, studioState), 600);
if (!libPhotoId || !source || !engine || !previewBytes) return;
// A look waiting to go back belongs to the frame it was made on: opening
// another frame in the shelf files the one left behind first, or the last
// ten seconds of its editing is thrown away by the frame switch.
if (filedLookRef.current && filedLookRef.current.id !== libPhotoId) flushFiledLook();
const id = libPhotoId;
const token = ++filedTokenRef.current;
filedLookRef.current = {
id,
run: async () => {
await saveEdit(id, studioState);
// A crop still being framed is not the frame's yet, and the tile is the
// look alone: the turn, straighten, crop, frame and stamps are the
// library's to draw on top of every tile it shows, so the tile is made
// with none of them on it. The hovered look is not the frame's either —
// the shelf keeps no tile for a look the pointer only crossed.
if (cropEditing) return;
const bytes = (
await renderPhoto({
source: previewBytes,
recipe,
frameId: 'none',
useGeotag: false,
gpsInfo: null,
assets: engine.assets,
options: {
...stageOptions,
photoRotation: 0,
photoStraighten: 0,
photoFlipH: false,
photoFlipV: false,
cropRect: null,
watermark: null,
gpsWatermark: null,
},
})
)?.bytes;
const tile = bytes ? await makeTile(new Blob([bytes as BlobPart], { type: 'image/jpeg' })) : null;
if (tile && token === filedTokenRef.current) await putPhotoThumb(id, tile);
},
};
const timer = setTimeout(flushFiledLook, LIBRARY_EXPORT_MS);
return () => clearTimeout(timer);
}, [libPhotoId, source, studioState]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libPhotoId, source, engine, previewBytes, recipe, studioState, stageOptions, cropEditing]);
// The look a reader walks away from is the look they made: leaving the studio,
// the tab or the app files what is waiting there and then, rather than leaving
// the catalogue showing the frame as it stood ten seconds before the last edit.
useEffect(() => {
window.addEventListener('pagehide', flushFiledLook);
return () => {
window.removeEventListener('pagehide', flushFiledLook);
flushFiledLook();
};
}, [flushFiledLook]);
// The same filing for the frame's own standing: the catalogue opened this
// frame at an angle, and a turn made here is that number's to keep — the