Persist the studio session across reloads

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).
This commit is contained in:
2026-09-17 19:12:29 +07:00
parent 1446e852b1
commit 51e572b9ce
3 changed files with 149 additions and 23 deletions
+1
View File
@@ -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
+63 -23
View File
@@ -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<string | null>(null);
const [busy, setBusy] = useState<string | null>(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<string | null>(null);
const [frameId, setFrameId] = useState<FrameId>('none');
const [crop, setCrop] = useState<Exclude<CropRatio, 'free'>>('none');
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(0);
const [simId, setSimId] = useState<string | null>(() => session.simId ?? null);
const [frameId, setFrameId] = useState<FrameId>(() => session.frameId ?? 'none');
const [crop, setCrop] = useState<Exclude<CropRatio, 'free'>>(() => session.crop ?? 'none');
const [rotation, setRotation] = useState<0 | 90 | 180 | 270>(() => session.rotation ?? 0);
const [openParam, setOpenParam] = useState<string | null>(null);
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [tab, setTab] = useState<TabId>('presets');
const [peek, setPeek] = useState(false);
const [gps, setGps] = useState<GPSInfo | null>(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<GPSInfo | null>(() => 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<Recipe[]>([]);
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(() => {
+85
View File
@@ -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<CropRatio, 'free'>;
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<IDBDatabase> {
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<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise<T> {
const db = await openDb();
try {
return await new Promise<T>((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<void> {
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;
}
}