From 08b7b91ef9dd5d6821e4142bc3a7a56f3d2dd1c3 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 9 Oct 2026 21:19:06 +0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20edits=20follow=20the=20photo=20?= =?UTF-8?q?=E2=80=94=20reopening=20it=20from=20LIBRARY=20restores=20the=20?= =?UTF-8?q?whole=20look?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docker/frontend/src/App.tsx | 64 +++++++++++++++++++++------ docker/frontend/src/engine/library.ts | 24 +++++++--- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 5037339..24546ba 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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(() => ({ ...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( + () => ({ 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=`: 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 diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 20dda52..16c3668 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -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 { // --- edits ----------------------------------------------------------------- -export async function saveEdit(id: string, recipe: Recipe): Promise { +export async function saveEdit(id: string, state: StudioState): Promise { 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 { try { - return (await ask(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 & { 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; }