From 51e572b9ce6e024deaa8dad878ac295ddcad3ef7 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 17 Sep 2026 19:12:29 +0700 Subject: [PATCH] Persist the studio session across reloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The working photo and every knob the workspace holds now survive a reload, for guests as much as for signed-in users: - engine/session.ts: the knobs go to localStorage (rc.studio.v1) as small JSON; the photo goes to IndexedDB, because a 12MP JPEG does not fit in localStorage. Both fail soft (private mode, quota) — the studio still works, it just forgets. - App.tsx: state seeds from the stored snapshot synchronously, so the first paint already holds the user's settings; the boot effect pulls the photo back and adopts it with keepGeo, so the restored params are not clobbered by the photo's own EXIF. Recipes a guest creates with SAVE RECIPE stay session-only, as asked — they are still gone on reload (create-test asserts it). --- docker/README.md | 1 + docker/frontend/src/App.tsx | 86 ++++++++++++++++++++------- docker/frontend/src/engine/session.ts | 85 ++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 docker/frontend/src/engine/session.ts diff --git a/docker/README.md b/docker/README.md index 0791c21..2548265 100644 --- a/docker/README.md +++ b/docker/README.md @@ -35,6 +35,7 @@ backend/ Fastify + better-sqlite3 API, own Dockerfile frontend/ Vite + React + CanvasKit SPA, own Dockerfile + nginx.conf shared/ vendored copies of the app's types + utils (see below) src/engine/ skiaShim.ts (CanvasKit) + exportEngine.ts (render pipeline) + + session.ts (localStorage/IndexedDB studio persistence) ``` ### Vendored files diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 6d12683..0a7d46c 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { useI18n } from './i18n/I18nProvider'; import { loadEngine } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readGps, resizedJpeg } from './engine/imageOps'; +import { loadPhoto, loadState, savePhoto, saveState } from './engine/session'; import { api, type SavedRecipe, type User } from './api'; import { TIERS } from './config/tiers'; import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes'; @@ -85,6 +86,11 @@ interface LoadedSource { export function Workspace() { const { t } = useI18n(); + // Session restore: read once, synchronously, so the first paint already holds + // the user's own settings instead of a flash of the defaults. The photo comes + // back from IndexedDB in the boot effect below. + const session = useMemo(loadState, []); + const [engine, setEngine] = useState<{ assets: RenderAssets; gpu: boolean } | null>(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(null); @@ -98,25 +104,35 @@ export function Workspace() { ...DEFAULT_RECIPES[0], id: 'web-current', name: DEFAULT_RECIPES[0]?.name ?? 'PROVIA', + ...session.recipe, })); - const [simId, setSimId] = useState(null); - const [frameId, setFrameId] = useState('none'); - const [crop, setCrop] = useState>('none'); - const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(0); + const [simId, setSimId] = useState(() => session.simId ?? null); + const [frameId, setFrameId] = useState(() => session.frameId ?? 'none'); + const [crop, setCrop] = useState>(() => session.crop ?? 'none'); + const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0); const [openParam, setOpenParam] = useState(null); const [openGroup, setOpenGroup] = useState(null); const [showRecipes, setShowRecipes] = useState(false); const [tab, setTab] = useState('presets'); const [peek, setPeek] = useState(false); - const [gps, setGps] = useState(null); - const [gpsManual, setGpsManual] = useState({ lat: '', lng: '' }); - const [place, setPlace] = useState(''); - const [useGeotag, setUseGeotag] = useState(false); - const [gpsStyle, setGpsStyle] = useState({ color: '#f59e0b', showName: true, showTime: true }); - const [markText, setMarkText] = useState(''); - const [markOn, setMarkOn] = useState(false); - const [markStyle, setMarkStyle] = useState({ color: '#ffffff', size: 1 }); + const [gps, setGps] = useState(() => session.gps ?? null); + const [gpsManual, setGpsManual] = useState(() => session.gpsManual ?? { lat: '', lng: '' }); + const [place, setPlace] = useState(() => session.place ?? ''); + const [useGeotag, setUseGeotag] = useState(() => session.useGeotag ?? false); + const [gpsStyle, setGpsStyle] = useState( + () => session.gpsStyle ?? { color: '#f59e0b', showName: true, showTime: true } + ); + const [markText, setMarkText] = useState(() => session.markText ?? ''); + const [markOn, setMarkOn] = useState(() => session.markOn ?? false); + const [markStyle, setMarkStyle] = useState(() => session.markStyle ?? { color: '#ffffff', size: 1 }); + + // 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 + // edit; the photo has its own store (engine/session.ts). + useEffect(() => { + saveState({ recipe, simId, frameId, crop, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle }); + }, [recipe, simId, frameId, crop, rotation, gps, gpsManual, place, useGeotag, gpsStyle, markText, markOn, markStyle]); const [undo, setUndo] = useState([]); const [exporting, setExporting] = useState(false); @@ -146,6 +162,18 @@ export function Workspace() { .me() .then((r) => alive && setUser(r.user)) .catch(() => undefined); // signed out is a valid state — the demo needs no account + // ...and the photo half of the session, back out of IndexedDB. + loadPhoto() + .then((photo) => { + if (!alive || !photo) return; + return adoptPhoto( + photo.bytes, + photo.name, + new Blob([photo.bytes as BlobPart], { type: 'image/jpeg' }), + true + ); + }) + .catch(() => undefined); return () => { alive = false; }; @@ -202,29 +230,41 @@ export function Workspace() { }, [engine, previewBytes, recipe, frameId, crop, rotation, useGeotag, gps, gpsStyle, markOn, markText, markStyle]); // --- input --------------------------------------------------------------- + // One photo, two ways in: a drop/pick (which also stores it for the next + // visit) and the session restore, whose params are already in state and so + // must not be overwritten by the photo's own EXIF. + const adoptPhoto = useCallback( + async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => { + const preview = await resizedJpeg(bytes, PREVIEW_MAX); + const found = await readGps(bytes); + setSource((prev) => { + if (prev) URL.revokeObjectURL(prev.url); + return { bytes, name, url: URL.createObjectURL(blob) }; + }); + setPreviewBytes(preview); + setGps(found); + if (keepGeo) return; + setUseGeotag(!!found); + if (!found) setGpsManual({ lat: '', lng: '' }); + }, + [] + ); + const loadFile = useCallback( async (file: File) => { setError(null); setBusy(t('auth.busy')); try { const bytes = await fileToBytes(file); - const preview = await resizedJpeg(bytes, PREVIEW_MAX); - const found = await readGps(bytes); - setSource((prev) => { - if (prev) URL.revokeObjectURL(prev.url); - return { bytes, name: file.name, url: URL.createObjectURL(file) }; - }); - setPreviewBytes(preview); - setGps(found); - setUseGeotag(!!found); - if (!found) setGpsManual({ lat: '', lng: '' }); + await adoptPhoto(bytes, file.name, file); + void savePhoto(file.name, bytes); } catch { setError(t('err.decode')); } finally { setBusy(null); } }, - [t] + [t, adoptPhoto] ); useEffect(() => { diff --git a/docker/frontend/src/engine/session.ts b/docker/frontend/src/engine/session.ts new file mode 100644 index 0000000..b1de73f --- /dev/null +++ b/docker/frontend/src/engine/session.ts @@ -0,0 +1,85 @@ +import type { CropRatio, FrameId, GPSInfo, Recipe } from '../../shared/types'; + +// Studio session: the working photo plus every knob the workspace holds, so a +// reload resumes exactly where the user left off — guest or signed in (the +// login cookie already outlives the tab; this covers the rest). +// +// ponytail: one session per browser, not per account — two accounts in the same +// browser share the working photo. Key it by user id if that ever matters. +const STATE_KEY = 'rc.studio.v1'; +const DB_NAME = 'recipescam-studio'; +const STORE = 'session'; +const PHOTO_KEY = 'photo'; + +export interface StudioState { + recipe?: Recipe; + simId?: string | null; + frameId?: FrameId; + crop?: Exclude; + rotation?: 0 | 90 | 180 | 270; + gps?: GPSInfo | null; + gpsManual?: { lat: string; lng: string }; + place?: string; + useGeotag?: boolean; + gpsStyle?: { color: string; showName: boolean; showTime: boolean }; + markText?: string; + markOn?: boolean; + markStyle?: { color: string; size: number }; +} + +export function loadState(): StudioState { + try { + const raw = localStorage.getItem(STATE_KEY); + return raw ? (JSON.parse(raw) as StudioState) : {}; + } catch { + return {}; + } +} + +export function saveState(state: StudioState): void { + try { + localStorage.setItem(STATE_KEY, JSON.stringify(state)); + } catch { + // Quota or private mode: the studio still works, it just forgets. + } +} + +// --- the photo: IndexedDB, because a 12MP JPEG does not fit in localStorage --- +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function idb(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise { + const db = await openDb(); + try { + return await new Promise((resolve, reject) => { + const req = run(db.transaction(STORE, mode).objectStore(STORE)); + req.onsuccess = () => resolve(req.result as T); + req.onerror = () => reject(req.error); + }); + } finally { + db.close(); + } +} + +export async function savePhoto(name: string, bytes: Uint8Array): Promise { + try { + await idb('readwrite', (s) => s.put({ name, bytes }, PHOTO_KEY)); + } catch { + // Private mode / no IndexedDB: the photo just will not survive a reload. + } +} + +export async function loadPhoto(): Promise<{ name: string; bytes: Uint8Array } | null> { + try { + const row = await idb<{ name: string; bytes: Uint8Array } | undefined>('readonly', (s) => s.get(PHOTO_KEY)); + return row && row.bytes instanceof Uint8Array ? { name: row.name, bytes: row.bytes } : null; + } catch { + return null; + } +}