web: give each member a photo folder and burn the strip into the export

Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.

EXPORT now burns that strip into the file: the amber #TAG over the photo's
top-left plus a dark caption band below carrying the recipe name and the
ISO / grain / warmth line. The live preview stays clean, and the saved
upload stays clean too — the reel draws its own frame from the stored
labels, so a burned band would tag the tag twice.

Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
This commit is contained in:
2026-09-18 10:56:26 +07:00
parent 43d86b4b6f
commit b4d5d2926b
14 changed files with 576 additions and 22 deletions
+87 -3
View File
@@ -8,9 +8,9 @@ import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel';
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 { download, fileToBytes, readGps, readIso, resizedJpeg } from './engine/imageOps';
import { loadPhoto, loadState, savePhoto, saveState } from './engine/session';
import { api, type SavedRecipe, type User } from './api';
import { api, type Photo, type SavedRecipe, type User } from './api';
import { TIERS } from './config/tiers';
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs';
@@ -27,6 +27,8 @@ import {
} from '../shared/types';
import type { MsgKey } from './i18n/vi';
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
const MAX_PHOTOS = 12;
// Preview renders on a downscaled copy; export always re-renders the original
// bytes, so nothing shown here is baked into the file.
const PREVIEW_MAX = 1600;
@@ -185,6 +187,8 @@ export function Workspace() {
const [peek, setPeek] = useState(false);
const [gps, setGps] = useState<GPSInfo | null>(() => session.gps ?? null);
// ISO off the loaded photo's EXIF; null means the strip prints AUTO.
const [iso, setIso] = useState<number | null>(null);
const [gpsManual, setGpsManual] = useState(() => session.gpsManual ?? { lat: '', lng: '' });
const [place, setPlace] = useState(() => session.place ?? '');
const [useGeotag, setUseGeotag] = useState(() => session.useGeotag ?? false);
@@ -285,8 +289,12 @@ export function Workspace() {
};
}, []);
const [exporting, setExporting] = useState(false);
const [savingPhoto, setSavingPhoto] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [saved, setSaved] = useState<SavedRecipe[]>([]);
// This account's own folder. Only the count matters here; /photos owns the
// management screen. The API is the real cap — this only labels the button.
const [myPhotos, setMyPhotos] = useState<Photo[]>([]);
// Recipes made in this session, guest ones included. A guest's copy lives
// here and nowhere else, so reloading the page is what deletes it.
const [created, setCreated] = useState<Recipe[]>([]);
@@ -339,9 +347,11 @@ export function Workspace() {
useEffect(() => {
if (!user) {
setSaved([]);
setMyPhotos([]);
return;
}
api.listRecipes().then((r) => setSaved(r.recipes)).catch(() => setSaved([]));
api.listMyPhotos().then((r) => setMyPhotos(r.photos)).catch(() => setMyPhotos([]));
}, [user]);
// --- preview render -------------------------------------------------------
@@ -408,6 +418,7 @@ export function Workspace() {
async (bytes: Uint8Array, name: string, blob: Blob, keepGeo = false) => {
const preview = await resizedJpeg(bytes, PREVIEW_MAX);
const found = await readGps(bytes);
const foundIso = await readIso(bytes);
// The photo's own shape drives the default crop frame. Read off the
// downscaled copy — same aspect, a fraction of the decode cost.
const dims = await createImageBitmap(new Blob([preview as BlobPart], { type: 'image/jpeg' }))
@@ -429,6 +440,7 @@ export function Workspace() {
previewJobRef.current++;
setPreviewBytes(preview);
setGps(found);
setIso(foundIso);
// A new photo starts a new history whose floor is the default look, so
// UNDO walks all the way back and still leaves the photo loaded.
setUndo([defaultLook()]);
@@ -643,6 +655,19 @@ export function Workspace() {
input.click();
}, [importRecipeFile]);
// The strip this look shows — the tagline off the recipe name, and the
// technical line off the camera (EXIF ISO, or AUTO) plus the recipe's own
// grain, warmth and monochrome state. Burned into the download; stored with
// a saved photo so the landing reel can frame it the same way.
const strip = useMemo(() => {
const name = (recipe.name || 'RECIPE').trim();
const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase();
const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0));
const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250);
const tail = recipe.baseFilter === 'monochrome' ? 'MONO' : `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`;
return { tag: `#${slug || 'RECIPE'}`, title: name, meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}` };
}, [recipe.name, recipe.adjustments, recipe.baseFilter, iso]);
const exportPhoto = useCallback(async () => {
if (!engine || !source) return;
setExporting(true);
@@ -662,6 +687,8 @@ export function Workspace() {
wallframeLandscape: frameId === 'wallframe-landscape',
dpi: 300,
sharpen: true,
// The file carries the strip; the live stage never does.
caption: strip,
photoRotation: rotation,
cropRect: cropLive ? cropRect : null,
watermark: markOn && markText
@@ -695,7 +722,60 @@ export function Workspace() {
} finally {
setExporting(false);
}
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t]);
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t, strip]);
// SAVE PHOTO: the stage as it stands, uploaded to this account's own folder.
// The upload is left clean (no burned strip) so the landing reel can draw its
// own `.lp-shot` frame around it; the labels are stored alongside instead.
// The 12-photo cap is the API's, mirrored here so the button can say no.
const savePhotoToFolder = useCallback(async () => {
if (!user) {
setAuthMode('login');
return;
}
if (!engine || !source) {
setError(t('save.photoNoSource'));
return;
}
if (myPhotos.length >= MAX_PHOTOS) {
setError(t('save.photoFull', { max: MAX_PHOTOS }));
return;
}
setSavingPhoto(true);
setError(null);
try {
const src = TIERS.user.maxExportDim
? await resizedJpeg(source.bytes, TIERS.user.maxExportDim, 0.95)
: source.bytes;
const out = await renderPhoto({
source: src,
recipe,
frameId: cropEditing ? 'none' : frameId,
useGeotag,
gpsInfo: gps,
assets: engine.assets,
options: {
wallframeLandscape: frameId === 'wallframe-landscape',
dpi: 300,
sharpen: true,
photoRotation: rotation,
cropRect: cropLive ? cropRect : null,
watermark: markOn && markText ? { text: markText, x: 0.5, y: 0.92, ...markStyle } : null,
gpsWatermark: useGeotag && gps ? { x: 0.06, y: 0.7, size: 1.2, ...gpsStyle } : null,
},
});
if (!out) throw new Error('render returned null');
const file = new File([out.bytes as BlobPart], `${strip.tag.slice(1).toLowerCase() || 'photo'}.jpg`, {
type: 'image/jpeg',
});
const { photo } = await api.uploadPhoto(file, strip);
setMyPhotos((prev) => [photo, ...prev]);
} catch (err) {
setError(t('save.photoFailed', { msg: err instanceof Error ? err.message : String(err) }));
} finally {
setSavingPhoto(false);
}
}, [user, engine, source, myPhotos.length, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, t]);
// --- strips ---------------------------------------------------------------
// The cascade: a chip opens the next column, and the column it came from stays
@@ -1111,10 +1191,14 @@ export function Workspace() {
recipeName={recipe.name}
user={user}
exporting={exporting}
savingPhoto={savingPhoto}
canUndo={undo.length > 0}
onUndo={undoLast}
onReset={reset}
onSavePhoto={() => void savePhotoToFolder()}
onExport={exportPhoto}
photoCount={myPhotos.length}
photoMax={MAX_PHOTOS}
onAuth={() => setAuthMode('login')}
onSignup={() => setAuthMode('signup')}
onLogout={async () => {