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:
+103
-11
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user