web: keep a saved photo's look in EXIF and its own row

EXPORT no longer burns the caption strip: the pixels stay the photo's own
and the look travels as metadata — ImageDescription (0x010e) for the tag,
UserComment (0x9286, ASCII header) for the recipe JSON.

SAVE PHOTO now stores the look with the frame (photos.recipe) and the
uploader's consent for the community film strip (photos.consent, PATCH
/api/photos/:id for the owner). The landing reel skips non-consented frames,
and a new MY PHOTOS tab lists the account's saves, reopens one with the
settings it was stored with, and carries the two consent switches.
This commit is contained in:
2026-09-18 12:49:05 +07:00
parent cf4b01d4b3
commit 0627f8dd91
9 changed files with 292 additions and 39 deletions
+103 -11
View File
@@ -295,9 +295,15 @@ export function Workspace() {
// Bumped by RESET while CREATE is open: the form above the button listens for
// it and drops back to its defaults.
const [createReset, setCreateReset] = useState(0);
// 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.
// This account's own folder. The list drives the MY PHOTOS tab, which reopens
// a saved frame with the settings it was stored with; the count labels the
// save button. The API is the real cap — this only labels the button.
const [myPhotos, setMyPhotos] = useState<Photo[]>([]);
// Consent for the NEXT save: does it join the landing film strip? Sticky, so
// the answer survives several saves. A guest's frames never reach the reel.
const [shareToStrip, setShareToStrip] = useState(true);
// The saved photo standing on the stage, if any — its own consent chip shows.
const [openPhotoId, setOpenPhotoId] = useState<number | null>(null);
// 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[]>([]);
@@ -702,8 +708,9 @@ export function Workspace() {
// 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.
// grain, warmth and monochrome state. Metadata only: an exported file keeps
// clean pixels (the strip rides EXIF), and a saved photo stores it 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();
@@ -713,6 +720,13 @@ export function Workspace() {
return { tag: `#${slug || 'RECIPE'}`, title: name, meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}` };
}, [recipe.name, recipe.adjustments, recipe.baseFilter, iso]);
// Everything needed to put this photo back on the stage: the recipe plus the
// frame and geotag the stage held. EXIF and a saved photo both carry it.
const photoSettings = useMemo(
() => ({ ...recipe, frameId: cropEditing ? 'none' : frameId, useGeotag }),
[recipe, frameId, cropEditing, useGeotag]
);
const exportPhoto = useCallback(async () => {
if (!engine || !source) return;
setExporting(true);
@@ -732,8 +746,6 @@ 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
@@ -747,7 +759,8 @@ export function Workspace() {
if (!out) throw new Error('render returned null');
// Encode → JFIF density 300 (inside renderPhoto) → EXIF rebuild, the same
// order the app uses. The source photo donates its camera/lens tags.
// order the app uses. The source photo donates its camera/lens tags; the
// recipe is stamped as metadata, never burned into the pixels.
const bytes = writeJpegExif(
out.bytes,
{
@@ -757,6 +770,8 @@ export function Workspace() {
height: out.height,
dpi: 300,
locationName: place || gps?.locality || null,
imageDescription: strip.tag,
userComment: JSON.stringify(photoSettings),
},
source.bytes
);
@@ -767,7 +782,7 @@ export function Workspace() {
} finally {
setExporting(false);
}
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t, strip]);
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t, strip, photoSettings]);
// 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
@@ -813,14 +828,57 @@ export function Workspace() {
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]);
const { photo } = await api.uploadPhoto(file, strip, { recipe: photoSettings, consent: shareToStrip });
// The list entry carries the recipe we just sent, so reopening works
// before the next /photos/mine refresh.
setMyPhotos((prev) => [{ ...photo, recipe: photoSettings }, ...prev]);
setOpenPhotoId(photo.id);
} 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]);
}, [user, engine, source, myPhotos.length, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, strip, photoSettings, shareToStrip, t]);
// Reopen one of this account's saved frames: the bytes come back as a File
// through the same door an upload uses, then the stored look goes on top.
const openMyPhoto = useCallback(
async (photo: Photo) => {
setBusy(t('auth.busy'));
setError(null);
try {
const res = await fetch(api.photoUrl(photo.id));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
await loadFile(new File([blob], `${photo.id}.jpg`, { type: blob.type || 'image/jpeg' }));
const saved = photo.recipe as (Recipe & { frameId?: FrameId; useGeotag?: boolean }) | null;
if (saved) {
applyRecipe(saved);
if (saved.frameId) setFrameId(saved.frameId);
setUseGeotag(!!saved.useGeotag);
}
setOpenPhotoId(photo.id);
} catch {
setError(t('err.decode'));
} finally {
setBusy(null);
}
},
[loadFile, applyRecipe, t]
);
// The saved frame's own switch. The reel reads `consent`, so the change is
// live as soon as the store answers it.
const togglePhotoConsent = useCallback(async (photo: Photo | undefined) => {
if (!photo) return;
const next = photo.consent === false;
try {
await api.setPhotoConsent(photo.id, next);
setMyPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, consent: next } : p)));
} catch {
// Leave the old value; the next load states the truth.
}
}, []);
// --- strips ---------------------------------------------------------------
// The cascade: a chip opens the next column, and the column it came from stays
@@ -1172,6 +1230,40 @@ export function Workspace() {
active: recipe.id === (entry.recipe as Recipe)?.id && recipe.name === entry.name,
onClick: () => applyRecipe({ ...(entry.recipe as Recipe), name: entry.name }),
}));
case 'photos': {
// MY PHOTOS: the account's own saved frames, newest first, plus the two
// consent switches. Opening one reopens it for a further edit.
const open = myPhotos.find((p) => p.id === openPhotoId);
const rows: ChipDef[] = [
{
key: 'strip-next',
label: t(shareToStrip ? 'myp.newOn' : 'myp.newOff'),
active: shareToStrip,
onClick: () => setShareToStrip((v) => !v),
},
];
if (open) {
rows.push({
key: 'strip-open',
label: t(open.consent === false ? 'myp.thisOff' : 'myp.thisOn'),
active: open.consent !== false,
onClick: () => void togglePhotoConsent(open),
});
}
if (myPhotos.length === 0) {
rows.push({ key: 'no-photos', label: t('myp.empty'), disabled: true, onClick: () => {} });
} else {
rows.push(
...myPhotos.map((p) => ({
key: `photo-${p.id}`,
label: p.title ?? `#${p.id}`,
active: p.id === openPhotoId,
onClick: () => void openMyPhoto(p),
}))
);
}
return rows;
}
case 'light':
return [...paramChips(PARAM_DEFS.iq), groupChip('dr')];
case 'wb':
+1 -1
View File
@@ -173,7 +173,7 @@ export function Landing() {
// The labels the uploader's studio stored — the tagline, the title and
// the ISO/grain line. A contribution from before those existed keeps the
// plain community defaults.
...photos.filter((p) => p.slot === 'strip').map((p) => ({
...photos.filter((p) => p.slot === 'strip' && p.consent !== false).map((p) => ({
tag: p.tag ?? '#COMMUNITY',
title: { en: p.title ?? 'Community Frame', vi: p.title ?? 'Khung cộng đồng' },
meta: p.meta ?? `#${p.id}`,
+14 -1
View File
@@ -46,6 +46,12 @@ export interface Photo {
tag: string | null;
title: string | null;
meta: string | null;
// The uploader's consent to show this frame on the landing film strip. Own
// rows carry it back; the public reel only returns consented ones anyway.
consent?: boolean;
// The look the photo was saved with, on the owner's own listing only — the
// studio reads it back so a saved photo can be reopened for editing.
recipe?: Recipe | null;
}
// What a caller may attach to an upload. Same three labels.
@@ -137,17 +143,24 @@ export const api = {
// labels ride the query string, since the body is the image itself.
listPhotos: () => call<{ photos: Photo[] }>('/photos'),
listMyPhotos: () => call<{ photos: Photo[] }>('/photos/mine'),
uploadPhoto: async (file: File, labels?: PhotoLabels) => {
uploadPhoto: async (file: File, labels?: PhotoLabels, opts?: { recipe?: Recipe; consent?: boolean }) => {
const q = new URLSearchParams();
if (labels?.tag) q.set('tag', labels.tag);
if (labels?.title) q.set('title', labels.title);
if (labels?.meta) q.set('meta', labels.meta);
// The look rides the query string too, so the photo reopens with its own
// settings. The server caps the length and drops anything not an object.
if (opts?.recipe) q.set('recipe', JSON.stringify(opts.recipe));
if (opts?.consent === false) q.set('consent', '0');
const suffix = q.size > 0 ? `?${q}` : '';
const res = await uploadBytes(`/api/photos${suffix}`, file, MAX_PHOTO_DIM, MAX_PHOTO_UPLOAD);
const body = await readJson(res);
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
return body as unknown as { photo: Photo };
},
// Toggle this photo's place on the landing film strip. Owner-only.
setPhotoConsent: (id: number, consent: boolean) =>
call<{ id: number; consent: boolean }>(`/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ consent }) }),
// The owner's own delete; an admin may pass any id here too.
deletePhoto: (id: number) => call<void>(`/photos/${id}`, { method: 'DELETE' }),
photoUrl: (id: number) => `/api/photos/${id}/file`,
+6
View File
@@ -29,6 +29,7 @@ export const en: Dict = {
'tab.presets': 'PRESETS',
'tab.favorited': 'FAVORITED',
'tab.photos': 'MY PHOTOS',
'tab.light': 'LIGHT',
'tab.wb': 'WB',
'tab.fx': 'FX',
@@ -112,6 +113,11 @@ export const en: Dict = {
'save.photoFull': 'That is all {max} — remove one in My photos.',
'save.photoNoSource': 'Load a photo first.',
'save.photoFailed': 'Could not save the photo: {msg}',
'myp.newOn': 'NEW SAVES: FILM STRIP',
'myp.newOff': 'NEW SAVES: PRIVATE',
'myp.thisOn': 'THIS PHOTO: FILM STRIP',
'myp.thisOff': 'THIS PHOTO: PRIVATE',
'myp.empty': 'NO SAVED PHOTOS YET.',
'photo.contribute': 'CONTRIBUTE A PHOTO',
'photo.uploading': 'UPLOADING…',
+6
View File
@@ -36,6 +36,7 @@ export const vi = {
'tab.presets': 'PRESETS',
'tab.favorited': 'FAVORITED',
'tab.photos': 'ẢNH CỦA TÔI',
'tab.light': 'LIGHT',
'tab.wb': 'WB',
'tab.fx': 'FX',
@@ -119,6 +120,11 @@ export const vi = {
'save.photoFull': 'Đã đủ {max} ảnh — xoá bớt trong Ảnh của tôi.',
'save.photoNoSource': 'Chưa có ảnh nào để lưu.',
'save.photoFailed': 'Không lưu được ảnh: {msg}',
'myp.newOn': 'BẢN LƯU MỚI: LÊN DẢI PHIM',
'myp.newOff': 'BẢN LƯU MỚI: RIÊNG TƯ',
'myp.thisOn': 'ẢNH NÀY: LÊN DẢI PHIM',
'myp.thisOff': 'ẢNH NÀY: RIÊNG TƯ',
'myp.empty': 'CHƯA LƯU ẢNH NÀO.',
'photo.contribute': 'ĐÓNG GÓP ẢNH',
'photo.uploading': 'ĐANG TẢI…',
+3 -2
View File
@@ -1,15 +1,16 @@
import type { MsgKey } from '../i18n/vi';
import { useI18n } from '../i18n/I18nProvider';
// The app's seven tabs. WB leads LIGHT — white balance is the first thing the
// The app's eight tabs. WB leads LIGHT — white balance is the first thing the
// eye corrects, and the web rail draws them in the order the panel opens. The
// rail is a single column on desktop and becomes the bottom bar under 860px
// (see styles/app.css).
export type TabId = 'presets' | 'favorited' | 'wb' | 'light' | 'fx' | 'frame' | 'create';
export type TabId = 'presets' | 'favorited' | 'photos' | 'wb' | 'light' | 'fx' | 'frame' | 'create';
export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
{ id: 'favorited', label: 'tab.favorited', glyph: '★' },
{ id: 'photos', label: 'tab.photos', glyph: '▤' },
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
{ id: 'light', label: 'tab.light', glyph: '☀' },
{ id: 'fx', label: 'tab.fx', glyph: '✦' },