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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user