A live-event photo booth for iPad and mobile browsers. Guests capture framed photos at /{event} and the projector-friendly Live Gallery at /{event}/live receives incremental updates. The application is Next.js on Cloudflare Workers with two R2 stores and no database or WebSockets.
/— build a canonical Event slug and open its Booth, Live Gallery, or Admin./{event}— Booth: choose a Frame, capture, composite, and upload./{event}/live— public Live Gallery./{event}/admin— select Frames, rotate the Booth Key, moderate exact photos, and export./frame-lab— calibrate photo slots against artwork and export manifest JSON.
Event slugs are canonical lowercase a-z, 0-9, and hyphens (for example tb9-x7k2). API requests reject aliases instead of silently turning different names into the same Event. Gallery URLs are public capabilities, so use an unguessable slug when the event is private.
EventStore owns Event keys, configuration, photo ordering, and the incremental Photo Feed:
PHOTOSis the public-delivery R2 bucket. It contains photo bytes at{event}/{timestamp}-{suffix}.jpgand the health canary used to test the complete write/read/public-read path.STATEis private R2 storage. It contains Event configuration and Booth Key hashes underevents/{event}/plus health reporting state.- Old
_config/{event}.jsonand_health/state.jsonrecords inPHOTOSare read lazily and copied intoSTATE. They are not deleted; rollback remains possible.
The Live Gallery retains the Photo Feed cursor and asks only for objects after it. The Booth Session writes every completed composite to an ordered Photo Outbox before upload. IndexedDB makes the outbox survive reloads and reconnects; if IndexedDB is unavailable, an explicit in-memory degraded mode keeps the current page usable but cannot survive a reload. A later capture can never replace an earlier failed one.
Deletion is exact-key moderation: the admin must supply both a canonical Event and a complete photo key belonging to that Event. There is no prefix or bulk delete route. The safety rule is absolute: never empty either R2 bucket or the Vercel backup store.
Architecture decisions and vocabulary are in CONTEXT.md and docs/adr.
Each design drop is a Frame Pack under public/templates/<pack>/ with a manifest.json beside its PNG artwork. The manifest declares the canvas, slots, fit, shot count, timing, and draw layers. Create and validate packs with:
bun run scaffold:frames summer-party "Summer Party"
bun run validate:framesUse /frame-lab to load PNG artwork, drag/resize openings in exact canvas pixels, preview the composite, and copy/download the manifest. Put the exported manifest and artwork in the pack directory, then run validation. The Booth always requires a fresh Frame choice after a completed capture.
bun install
bun dev
bun run typecheck
bun run typecheck:tests
bun test
bun run validate:frames
bun run buildgetUserMedia requires HTTPS or localhost; final camera verification must happen on a real iPad or phone. CI runs application type-checking, test type-checking, tests, Frame Pack validation, and a production build.
Local, staging, and production have distinct Worker names, PHOTOS buckets, STATE buckets, public photo domains, and secrets. A hostname pointing to production is not staging.
# Exercise staging, then promote the same reviewed source.
bun run deploy:staging
bun run deploy:productionThe R2 buckets, public bucket custom domains, and Worker secrets must exist before deployment; commands are in the deployment runbook. Bare bun run deploy intentionally fails so an operator must name staging or production; never deploy the unnamed/default Wrangler environment to an event domain.
For the event-day checklist and rollback procedure, see pre-event readiness and deployment and rollback.
The old Vercel Blob store remains a backup. Reconciliation checks R2 and adds only objects that are missing; it never deletes or overwrites either store.
bun run reconcile:backup -- --env productionbun run migrate:vercel and bun scripts/migrate-to-r2.ts remain compatibility entries for the same additive reconciliation. Keep BLOB_READ_WRITE_TOKEN in .env.local, never in source or shell history.