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
+16
View File
@@ -85,6 +85,22 @@ export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
}
}
// EXIF ISO of the loaded photo, for the strip's technical line. `pick` keeps
// the parse to the tags that matter; a screenshot or a film scan has none, and
// the caller then prints "AUTO" instead of inventing a number.
export async function readIso(bytes: Uint8Array): Promise<number | null> {
try {
const tags = (await exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })) as
| { ISO?: unknown; ISOSpeedRatings?: unknown }
| undefined;
const raw = tags?.ISO ?? tags?.ISOSpeedRatings;
const n = Number(Array.isArray(raw) ? raw[0] : raw);
return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
} catch {
return null;
}
}
export function download(bytes: Uint8Array, filename: string, type = 'image/jpeg'): void {
const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type }));
const a = document.createElement('a');