Files
Fredrik JohanssonandClaude Sonnet 5 cf0f5fca2c Initial commit: postcard v1
Phone photo -> composited postcard (canvas templates, stamp corner,
EXIF-aware date/location, self-hosted handwriting font) with a small
server for share links, day-context caption drafting off goonk's
day-summary API, and Nominatim reverse geocoding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 19:49:03 +02:00

6.6 KiB

Proposal: postcard — turn a phone photo into a sendable postcard

Status: proposed, not built. Self-contained — written so a fresh agent with no prior conversation context can pick this up and implement it without anything explained first. No repo exists yet; this file is the seed of one.

Motivation

Upload a photo from your phone (or any device), it gets composited onto a vintage postcard template — border, stamp corner, a handwritten-style caption — and you get a shareable link or a downloadable image. The caption is auto-drafted from whatever context is available for that day (git activity, what was playing, a note title) so the postcard reads like it was actually written from that day, not just a filter applied to a photo.

Structurally this is latent's upload → transform → deliver shape, with the transform swapped from film-emulation filters to postcard compositing, and one addition: a short generated caption line instead of (or alongside) canvas filters. Reuse latent's validated pieces wholesale rather than re-deriving them — same phone-upload flow, same blob+SQLite+TTL server, same no-accounts/link-is-the-credential model.

Architecture

Static/client-heavy frontend (canvas compositing, same "no build step" family as latent/typo/flit/wisp) plus a small single-process server, copied from latent/server almost unchanged: db.js (SQLite metadata), ids.js (opaque random IDs), cleanup.js (interval TTL sweep), ratelimit.js (basic IP throttle on upload), routes.js, index.js. No accounts, no auth — the link is the credential, same as latent/wisp.

Editing (client-side, <canvas>)

  1. Upload — <input type="file" capture> for direct camera access, normal file picker as fallback (same open question latent already flagged, same answer: ship both).
  2. Template — a handful of postcard border/stamp-corner presets (plain cream border, torn-edge, faded color-photo-era border) — pick a vibe, not a full editor. Reuse latent's "presets, not sliders" call for the same reason: faster to ship, more toy than tool.
  3. Caption — an editable text field, pre-filled by a generated draft (see below), rendered onto the card in a handwriting-style webfont. Editable because auto-drafted text should be a starting point, not the final word — the person sending it should be able to make it actually theirs.
  4. Postmark/stamp corner — today's date and a small generated "location" stamp if EXIF GPS is present (round-tripped through the same reverse-geocoding approach rewind/galr already use, if one exists — otherwise just the date, no invented location).
  5. Compositing is canvas-based, non-destructive within the session (recompute from the original photo + a param object, matching latent's pattern) so switching templates doesn't require re-uploading.

Caption generation

The interesting new piece latent didn't need. Draft a one-line caption from whatever of these is available and non-empty for the photo's date (EXIF date if present, else upload date):

  • Git activity that day (via the same Gitea-API aggregate-count approach rewind already built — "spent the day untangling a deploy script" / "quiet one, no commits")
  • What was playing (Spotify plays log, same source /wrapped//now already read — "[artist] on repeat")
  • A note/blog title from that date, if one exists

This is explicitly a remix of existing fragments, not a generative free-text model call — same spirit as the haiku/found-poetry idea below: assemble from real fragments of that day, phrase them into one short sentence with a small set of template shapes ("Spent the day {git_fragment}. {spotify_fragment} the whole time." / "{spotify_fragment}. {git_fragment}, mostly."), and always leave it editable. If none of the three sources have data for that date, fall back to a small set of generic postcard-voice lines ("Wish you were here. Mostly wasn't.") rather than leaving the field empty.

Finishing paths

Same two-path split as latent:

  1. Instant — composite, then download directly, entirely client-side, no server touched. Optional "generate share link" (explicit action, not automatic).
  2. Send — uploads the composited image to server storage, returns a link. No delayed-reveal mechanic here (that's latent's gimmick, not this one) — the postcard metaphor's own delay is real mail taking a few days to arrive, which the link doesn't need to fake.

Server

Copied from latent/server's shape almost directly:

Table postcards:

column type notes
id text PK random opaque ID, used in the URL
blob_path text composited image on disk
mime text
created_at integer
expires_at integer TTL sweep target, same as latent

Endpoints:

  • POST /api/upload — body: composited image bytes. Returns { id, url }.
  • GET /p/:id — serves the postcard image directly (no develop-later gate — this app's whole point is immediacy, unlike latent).
  • TTL sweep, same interval-based pattern as latent/wisp.

No auth. Rate-limit /api/upload the same way latent does.

Non-goals

  • No accounts, no "your postcards" history — every card stands alone via its own link, same throwaway philosophy as latent/wisp.
  • No delayed-reveal mechanic — that's latent's gimmick; this one's point is speed, not anticipation.
  • No free-text LLM-generated captions — template-and-fragment assembly from real personal data only, kept editable, never presented as if the app "wrote" it.
  • No collaborative/multi-recipient cards — one photo, one card, one link.

Open questions

  • Where the day-context lookups live — rewind and /wrapped//now already have working Gitea-API and Spotify-log integrations; decide whether postcard's server calls those APIs directly (network dependency on goonk's API being reachable) or whether this only makes sense as a goonk-hosted feature rather than a fully separate sibling project. Worth resolving before writing routes.js.
  • Location stamp source — confirm whether galr/rewind already do reverse geocoding anywhere reusable, or whether this ships without a location stamp in a first pass.
  • Handwriting webfont choice and licensing — pick one, check its license allows self-hosting.
  • Image size limits — same question latent flagged, same likely answer (downscale on upload, cap stored size).
  • Retention window — shorter than latent's, probably — a postcard link is meant to be opened once by the recipient, not browsed later.