← Back to Home
Wedding Photo Platform

A Face-Searchable Gallery for 7,500 Photographs

Solo Engineer · Next.js + Offline ML Pipeline · Jun – Sep 2026
Explore the public demo ↗

Overview

A photographer delivered roughly 7,500 raw JPEGs across 196GB. The job was to turn that into something guests would actually enjoy — a narrative site telling the day in chapters, eight immersive per-event reels, per-event albums, a full gallery of every surviving photograph, and a "People" view where anyone can find every picture they appear in.

The interesting constraint was cost. This runs on a Cloudflare R2 free tier with no budget behind it, so deduplication, compression, face recognition, and clustering all happen offline on a laptop before anything ships. No per-image API calls, no inference at request time, no recurring bill.

The real site is private to the family, so the public demo is the version to explore: the same code with a placeholder couple, 1,004 of the photographs across eight event albums, and a People page of 20 — every face frosted, but each person's page still finds every photograph they are in. The pipeline figures below come from the full run, since that is the scale the work was built for.

Next.js 16 React 19 TypeScript TensorFlow.js face-api sharp Cloudflare R2 Vitest Playwright

Problem & Solution

The Problem

  • 196GB of raw delivery, far too large to host or browse
  • Roughly a third of the photos were near-duplicate burst frames
  • No way for a guest to find the photos they are actually in
  • Hosted ML face APIs bill per image — unaffordable at this volume
  • Bulk media in git would make the repo unusable

The Solution

  • Offline pipeline: hash, dedupe, resize, detect, cluster, upload
  • Perceptual hashing with sharpness scoring keeps the best frame
  • Face clustering groups every photo by the people in it
  • Local models with no API keys and no per-image cost
  • Media lives in object storage; only manifests are committed

The Pipeline

Stage 1–2 · Dedupe
7,504 source JPEGs perceptually hashed and scored for sharpness, then reduced to 5,036 survivors by removing 2,468 near-duplicates — the sharpest frame in each burst wins
Stage 3 · Resize
Survivors re-encoded to AVIF display and thumbnail sizes plus a face-detection input, compressing 196GB of source down to 1.31GB
Stage 4–5 · Faces
17,731 faces detected across the gallery and clustered by recognition embedding into 92 distinct people, each appearing in 4 to 150 photographs
Stage 6 · Publish
10,164 files uploaded to Cloudflare R2 alongside two manifests that the site server-renders from, so photos download from the HTML rather than after a client fetch
Stage 7 · Curation
A local-only admin tool for correcting clustering mistakes and assigning photos to event sections, writing an overrides file the upload stage prefers over raw output
Total Cost
Roughly 2.5 hours of wall time on a laptop, almost all of it in resize and hash — the machine-learning stages take under six minutes combined, and nothing bills per image

Technical Approach

Public Demo

Showing the Work Without Showing the Wedding

  • A second deployment of the same codebase with a placeholder couple and 1,004 real photographs in which every face is frosted, served from a separate bucket and Vercel project
  • Faces are found by three independent detectors — the stored pass, a more sensitive tiled face-api pass, and macOS Vision — because each caught faces the others missed
  • Photos with legible text, crowd shots, or a person whose face no detector boxed are left out rather than risked; masks are baked into new files with EXIF and GPS stripped
  • The demo build swaps in placeholder content, then fails if any name, date, domain, or bucket from the real wedding survives in the output
  • Margin notes with hand-drawn arrows explain the features to a visitor, and they are kept off the real site by construction

Testing & Quality

Two Layers, Both Network-Safe

  • 58 Vitest files covering units, hooks, components, API routes, and full page integration — fast, no browser, no network
  • Playwright specs for the things jsdom cannot observe: the production admin gate, and real lazy-loading and windowing behaviour
  • Every test is network-safe by construction, because a suite that hit real object storage on each run would burn the free tier it exists to protect
  • Pipeline tests operate on scratch temp directories, never the real cache or the curated overrides file
  • The upload script's dry-run test runs the real command-line tool as a subprocess, proving the actual guard works rather than a mock of it
  • The suite was built ahead of a visual refactor so behaviour had a regression net, and it caught real bugs during that work

What This Demonstrates