feat(studio): edits follow the photo — reopening it from LIBRARY restores the whole look

Settings changed in the studio were remembered per photo, but only as a
film recipe, so everything outside that recipe (straighten, flips, crop,
watermark, geotag, frame, the picked sim) was gone as soon as the photo
was reopened from the library in a later session.

Keep the whole StudioState instead and restore it when a library photo is
opened; rows written by older builds — a flat recipe — are still readable,
so photos edited before this change open with their look intact.

SAVE PHOTO is untouched: that remains the step that publishes a photo to
the landing film strip.
This commit is contained in:
2026-10-09 21:19:06 +07:00
parent 41f941dc59
commit 08b7b91ef9
2 changed files with 68 additions and 20 deletions
+50 -14
View File
@@ -23,7 +23,7 @@ import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
import { getPhoto, loadEdit, 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 } from './engine/session';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState, type StudioState } from './engine/session';
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
import { api, type Photo, type SavedRecipe } from './api';
import { refreshAuth, useAuth } from './auth';
@@ -466,12 +466,17 @@ export function Workspace() {
// the box's own position falls back to the default instead of going undefined.
const [markStyle, setMarkStyle] = useState<MarkStyle>(() => ({ ...DEFAULT_MARK_STYLE, ...session.markStyle }));
// Every knob the workspace holds, written back on change — the reload half of
// the session. Small JSON, so no debounce is worth the risk of losing the last
// Every knob the workspace holds, gathered once — the reload half of the
// session and the look a catalogue frame is filed at both write this same
// record. Small JSON, so no debounce is worth the risk of losing the last
// edit; the photo has its own store (engine/session.ts).
const studioState = useMemo<StudioState>(
() => ({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }),
[recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle],
);
useEffect(() => {
saveState({ recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle });
}, [recipe, simId, frameId, crop, cropRect, cropApplied, rotation, straighten, flipH, flipV, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]);
saveState(studioState);
}, [studioState]);
// Crop has three states: none, framed-but-uncommitted (the frame is draggable
// and the whole photo is shown), and applied (the crop alone is rendered).
@@ -601,6 +606,35 @@ export function Workspace() {
gpsStyle: { ...DEFAULT_GPS_STYLE },
};
}, []);
// A catalogue frame opened again comes back on exactly the look it was left
// at: restoreLook for everything UNDO remembers, and the three GPS knobs Look
// leaves out (they live on the session, not in the undo stack).
const restoreState = useCallback(
(s: StudioState) => {
const d = defaultLook();
restoreLook({
recipe: s.recipe ?? d.recipe,
simId: s.simId ?? d.simId,
frameId: s.frameId ?? d.frameId,
crop: s.crop ?? d.crop,
cropRect: s.cropRect ?? d.cropRect,
cropApplied: s.cropApplied ?? d.cropApplied,
rotation: s.rotation ?? d.rotation,
straighten: s.straighten ?? d.straighten,
flipH: s.flipH ?? d.flipH,
flipV: s.flipV ?? d.flipV,
markOn: s.markOn ?? d.markOn,
markText: s.markText ?? d.markText,
markStyle: { ...DEFAULT_MARK_STYLE, ...s.markStyle },
useGeotag: s.useGeotag ?? d.useGeotag,
gpsStyle: { ...DEFAULT_GPS_STYLE, ...s.gpsStyle },
});
setGps(s.gps ?? null);
setGpsManual(s.gpsManual ?? { lat: '', lng: '' });
setPlace(s.place ?? '');
},
[restoreLook, defaultLook],
);
const [exporting, setExporting] = useState(false);
// The export menu: the sizes are picked before the render starts, because a
// target bigger than the photo has a model run behind it first.
@@ -2112,9 +2146,9 @@ export function Workspace() {
);
// A frame from the local catalogue, handed over as `/app?lib=<id>`: the bytes
// come off the disk through the handle the catalogue kept, then the recipe that
// frame was last left at goes on top. A frame first opened has no recipe yet
// and arrives on the neutral stock, like any other file.
// come off the disk through the handle the catalogue kept, then the look that
// frame was last left at goes on top. A frame first opened has no look yet and
// arrives on the neutral stock, like any other file.
const openLibraryPhoto = useCallback(
async (id: string) => {
setBusy(t('auth.busy'));
@@ -2129,7 +2163,7 @@ export function Workspace() {
if (!row) throw new Error('not in the catalogue');
await loadFile(await readPhotoFile(row));
const saved = await loadEdit(id);
if (saved) applyRecipe(saved.recipe);
if (saved) restoreState(saved.state);
// The catalogue's own standing for this frame, which the reader set on
// the library's stage: the studio opens the negative the way they last
// saw it, at the same angle. It arrives after `loadFile`, which puts the
@@ -2142,7 +2176,7 @@ export function Workspace() {
setBusy(null);
}
},
[loadFile, applyRecipe, clearPreview, t]
[loadFile, restoreState, clearPreview, t]
);
useEffect(() => {
@@ -2150,13 +2184,15 @@ export function Workspace() {
}, [openLibraryPhoto]);
// What the catalogue remembers about a frame is the look it was left at, filed
// back on every change — debounced, because a slider drag fires this per frame
// of the drag and IndexedDB is on the wrong side of that.
// 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.
useEffect(() => {
if (!libPhotoId || !source) return;
const timer = setTimeout(() => void saveEdit(libPhotoId, recipe), 600);
const timer = setTimeout(() => void saveEdit(libPhotoId, studioState), 600);
return () => clearTimeout(timer);
}, [libPhotoId, source, recipe]);
}, [libPhotoId, source, studioState]);
// 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
+18 -6
View File
@@ -12,7 +12,7 @@
// FileSystemHandle by itself and the whole surface is seven calls — a dependency
// does not pay for itself here. Revisit if the catalogue grows queries (tags,
// smart collections) that hand-rolled cursors would make ugly.
import type { Recipe } from '../../shared/types';
import type { StudioState } from './session';
import { readCapturedAt } from './imageOps';
import { isRawName, isTiffName, rawThumbnail } from './rawDevelop';
import { heicThumbnail, heicToJpeg, isHeicName } from './heicDevelop';
@@ -131,7 +131,10 @@ export interface LibraryDir {
export interface LibraryEdit {
photoId: string;
recipe: Recipe;
// The whole look the frame was left at, the same record the studio keeps for
// the working photo — frame, crop, rotation, mirrors, marks and geotag change
// the picture as much as the recipe does, so the recipe alone was not enough.
state: StudioState;
updatedAt: number;
}
@@ -2150,18 +2153,27 @@ export async function readPhotoFile(photo: LibraryPhoto): Promise<File> {
// --- edits -----------------------------------------------------------------
export async function saveEdit(id: string, recipe: Recipe): Promise<void> {
export async function saveEdit(id: string, state: StudioState): Promise<void> {
try {
const edit: LibraryEdit = { photoId: id, recipe, updatedAt: Date.now() };
const edit: LibraryEdit = { photoId: id, state, updatedAt: Date.now() };
await ask(EDITS, 'readwrite', (s) => s.put(edit));
} catch {
// Private mode: the frame is still editable, it just forgets the recipe.
// Private mode: the frame is still editable, it just forgets the look.
}
}
export async function loadEdit(id: string): Promise<LibraryEdit | null> {
try {
return (await ask<LibraryEdit | undefined>(EDITS, 'readonly', (s) => s.get(id))) ?? null;
// A row filed before the whole look was kept carries the recipe alone: lift
// it into a state so the caller reads one shape, old rows included.
const row = await ask<(Partial<LibraryEdit> & { recipe?: StudioState['recipe'] }) | undefined>(
EDITS,
'readonly',
(s) => s.get(id),
);
if (!row) return null;
if (row.state) return row as LibraryEdit;
return { photoId: row.photoId ?? id, state: { recipe: row.recipe }, updatedAt: row.updatedAt ?? 0 };
} catch {
return null;
}