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
@@ -68,6 +68,10 @@ export interface RenderOptions {
frameWindowZoom?: { s: number; u: number; v: number } | null;
photoRotation?: 0 | 90 | 180 | 270;
photoStraighten?: number;
// The strip the FILE carries: the amber `#TAG` over the photo and a caption
// band appended below it, mirroring the landing page's `.lp-shot` +
// `.lp-frame-meta`. Left off the live preview, which must not show it.
caption?: { tag: string; title: string; meta: string } | null;
}
export interface RenderInput {
@@ -699,6 +703,55 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
resultImage = sharpened;
}
}
// 9b. The strip the file carries (export only, never the preview). Drawn
// onto a taller surface so every frame variant is treated the same: the
// tagline sits on the photo's top-left, the caption band hangs below.
const caption = options?.caption ?? null;
if (caption) {
const W = resultImage.width();
const band = Math.round(W * 0.155);
const capSurface = createSurface(W, resultImage.height() + band);
const loaded = loadFaces(input.assets);
if (capSurface && loaded) {
own(capSurface);
const cc = capSurface.getCanvas();
cc.drawImage(resultImage, 0, 0, own(Skia.Paint()));
const pad = Math.round(W * 0.03);
// Amber over a black drop, so the tagline still reads on a bright sky.
const tagSize = Math.round(W * 0.026);
const tagFont = own(Skia.Font(loaded.typeface, tagSize));
const shadow = own(Skia.Paint());
shadow.setColor(Skia.Color('#000000'));
const amber = own(Skia.Paint());
amber.setColor(Skia.Color('#f59e0b'));
cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont);
cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont);
const bandTop = resultImage.height();
const bandPaint = own(Skia.Paint());
bandPaint.setColor(Skia.Color('#0b0b0b'));
cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint);
const titleSize = Math.round(W * 0.042);
const metaSize = Math.round(W * 0.026);
const titleFont = own(Skia.Font(loaded.typeface, titleSize));
const metaFont = own(Skia.Font(loaded.typeface, metaSize));
const titlePaint = own(Skia.Paint());
titlePaint.setColor(Skia.Color('#f2f2f2'));
const metaPaint = own(Skia.Paint());
metaPaint.setColor(Skia.Color('#9a9a9a'));
cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont);
cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7), metaPaint, metaFont);
flush(capSurface);
const composed = own(capSurface.makeImageSnapshot());
release(owned, resultImage);
resultImage = composed;
} else if (capSurface) {
capSurface.dispose();
}
}
let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
if (!bytes || bytes.length === 0) {
console.error('Failed to encode image to JPEG');
+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');