Image directory
A second-stage image-as-password: instead of one image opening one gate, a registry of many enrolled images each opens a different destination — link-shortener mechanics keyed by perceptual hashes. Built around two new primitives: findBestMatch() for the search and <ImageDirectoryGate> for the UX. The party flow: take a photo, caption it with the gate URL, auto-enroll, distribute.
Try it
A live directory you can extend. Paste an image to see which entry it matches; enroll new entries via camera or upload. Everything is in-memory — refresh to clear.
Try the directory gate
Show your image
Last result
Live directory
1 entries- 5x5 Turns One!https://luma.com/9ynq3wzfp:70979f580f30f2c2 d:090235c3c0404040 a:0e1fe742c0c0c080
Enroll a new entry
Using ImageDirectoryGate in a real page
The component takes any list of entries with the DirectoryEntry shape. EventEntry from src/lib/events.ts is the canonical superset.
import ImageDirectoryGate from "@/components/ui/ImageDirectoryGate";
import { EVENTS } from "@/lib/events";
export default function Page() {
return (
<ImageDirectoryGate
entries={EVENTS}
tolerance="standard"
onMatch={(m) => {
// Redirect, render content, fire analytics, etc.
window.location.href = m.entry.externalUrl ?? "/";
}}
onMiss={(closest) => {
// Optional: log analytics about near-misses
}}
debug
/>
);
}import { findBestMatch } from "@/lib/explorations/image-as-password";
import { EVENTS } from "@/lib/events";
const { matched, closest } = await findBestMatch(blob, EVENTS, {
tolerance: "standard",
});
if (matched) {
// matched.entry — the directory entry that won
// matched.result — full per-hash distances/thresholds
// matched.combinedDistance — sum of distances across hashes
}Party-distribution flow
The intended use case. Take photos at a party, caption each with the gate URL, distribute to guests with different destinations baked in.
- Pick the gate URL. Decide where guests will present their photos — e.g.
5x5.nyc/open-sesame. This is the page that hosts<ImageDirectoryGate>. - Take photos. Use the camera flow above. Each photo gets a caption overlay (default: the gate URL itself) so it's self-describing — anyone holding the print/JPEG knows where to present it.
- Set per-photo destinations. Each enrolled entry carries an
externalUrl. One photo could route to a luma RSVP, another to a private playlist, another to a thank-you page. The same gate handles all of them. - Distribute. Print the photos, AirDrop them, drop them in a Slack thread. As long as the recipient can present a recognizable copy at the gate URL, they get routed to the right destination.
- Persist enrollments. The demo keeps additions in memory; for a real party, copy the EventEntry snippets into
src/lib/events.tsand ship a deploy. Or wire up a backend (next section).
The caption is rendered onto the photo bytes before hashing. Two consequences worth knowing:
- The hash is now caption-aware. Re-captioning the same source photo produces a different hash. Useful for deduplication.
- The text in the caption is a meaningful chunk of mid-frequency content for pHash, which makes the captioned photo more distinctive than a plain portrait or gradient.
Persistence
The demo above keeps enrollments in component state — they vanish on refresh. For real use you need either a static-config build or a real backend.
The directory in the demo above lives in component state and resets on refresh. Three production strategies, in increasing order of complexity:
- Static config. Add to
src/lib/events.ts, ship a deploy. Each enrollment is a PR. Best for curated experiences with low enrollment churn (party host generates everything ahead of time). - Vercel KV / Postgres / Blob. Add an enrollment API route (
POST /api/enroll) that stores hashes in KV. The gate page fetches the directory at request time. Enables real-time enrollment without rebuilds. Pair with auth so only the host can enroll. - URL-encoded enrollment. Skip the database entirely: encode the entry (id + hashes + url) into a query string on the photo's caption (
?e=base64...) and have the gate page read it on load. Fully client-side, but the URL becomes long and reveals the destination.
For 5x5's use case (party invites that don't change after the host generates them), option 1 is plenty.