All checks were successful
Docker / build-and-push (push) Successful in 3m29s
Canvas-based client-side editor (grain, vignette, halation, procedural light leaks, color-cast presets) plus a small Express/SQLite server for the two paths that need persistence: instant share links and delayed "send to develop" links with a randomized reveal time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
136 lines
6.5 KiB
Markdown
136 lines
6.5 KiB
Markdown
# Proposal: `latent` — a phone-photo editor with a "develop it later" option
|
||
|
||
**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
|
||
|
||
A small, fun webapp: upload a photo from your phone, apply film-style
|
||
manipulation (grain, light leaks, vignette, halation, color-cast) in the
|
||
browser, and either download it immediately or — the silly, on-brand
|
||
part — send it off to "develop," which holds it server-side behind a
|
||
randomized delay (hours to days) before the link actually resolves to the
|
||
image. Getting your photos back later, like real film, on purpose, for
|
||
no practical reason except that it's a fun mechanic.
|
||
|
||
Named `latent` after the *latent image* — the real photographic term for
|
||
the picture that already exists on exposed film but is invisible until
|
||
developed. Both halves of this app map onto that word directly.
|
||
|
||
Two independent finishing paths off one editor, not two separate apps:
|
||
|
||
1. **Instant** — edit, then download directly, entirely client-side, no
|
||
server touched. Optionally generate a share link for the edited image
|
||
(explicit user action — "generate share link" — not automatic).
|
||
2. **Delayed** — edit, then "send to develop": uploads the result to
|
||
server storage, assigns a `develop_at` time chosen randomly within a
|
||
configured range, and gives back a link that won't resolve to the
|
||
actual image until that time passes.
|
||
|
||
## Architecture
|
||
|
||
Static/client-heavy frontend (canvas-based editing, same "no build step,
|
||
plain HTML/CSS/JS" family as `typo`/`flit`/`wisp`'s frontend) plus a small
|
||
single-process server for the two paths that need persistence: share
|
||
links and delayed development. Reuses the shape already validated by
|
||
`wisp` — blob + SQLite metadata + a background TTL-style sweep — rather
|
||
than inventing a new storage pattern. No accounts, no auth: the link
|
||
itself is the credential, same as `wisp`.
|
||
|
||
Unlike `wisp`, this does **not** need end-to-end encryption — none of
|
||
this is sensitive, and the entire point is a link other people can open.
|
||
That actually makes the backend simpler than `wisp` despite doing more:
|
||
no client-side crypto, no key-in-fragment handling, just an opaque random
|
||
ID per stored image.
|
||
|
||
### Editing (client-side, no server involved until "generate link" or
|
||
"send to develop" is pressed)
|
||
|
||
All via `<canvas>` — pixel manipulation, no ML, no WASM needed for a first
|
||
pass:
|
||
|
||
- **Grain** — random noise overlay, opacity/size adjustable
|
||
- **Light leaks** — a handful of pre-baked semi-transparent PNG overlays
|
||
(warm streaks/blooms near an edge), randomized placement/rotation per
|
||
application so repeat use doesn't look identical
|
||
- **Vignette** — radial darken toward the edges
|
||
- **Halation** — bloom/glow around bright areas (blur a thresholded
|
||
bright-pixel mask, screen-blend back over the original)
|
||
- **Color cast / white balance shift** — a few presets (warm/expired-film
|
||
amber, cool/cross-processed teal, faded/low-contrast) rather than raw
|
||
sliders for every channel — keep the UI to "pick a vibe," not a full
|
||
Lightroom clone
|
||
- Adjustments should be non-destructive within a session (recompute from
|
||
the original + a param object, not chained lossy operations) so
|
||
dragging a slider back to zero actually gets you back to the original
|
||
|
||
### Server
|
||
|
||
Single process, SQLite for metadata (same pattern as `galr`/`stash`),
|
||
images as blobs on local disk (same pattern as `wisp`).
|
||
|
||
**Table `images`:**
|
||
|
||
| column | type | notes |
|
||
|---|---|---|
|
||
| `id` | text PK | random opaque ID, used in the URL |
|
||
| `blob_path` | text | where the file lives on disk |
|
||
| `mime` | text | |
|
||
| `created_at` | integer | |
|
||
| `develop_at` | integer, nullable | null = instant share (no delay gate); set = "send to develop" path |
|
||
| `expires_at` | integer | TTL sweep target — don't keep images forever regardless of path |
|
||
|
||
**Endpoints:**
|
||
|
||
- `POST /api/upload` — body: image bytes + `{ delayed: boolean }`. If
|
||
`delayed`, server picks `develop_at` randomly within a configured range
|
||
(e.g. env-configurable `DEVELOP_MIN_HOURS` / `DEVELOP_MAX_HOURS`) and
|
||
returns the link immediately — the whole point is you get the link
|
||
*before* it resolves, so you can send it to someone and both of you
|
||
wait. If not delayed, `develop_at` is null and the link resolves right
|
||
away (that's the "optional share link" path off instant download).
|
||
- `GET /i/:id` — serves the image if `develop_at` is null or already
|
||
passed; otherwise a small "still developing, check back in ~Nh" page
|
||
(server can compute and show a rough remaining time without revealing
|
||
the exact `develop_at`, to keep a little mystery — exact behavior is an
|
||
open question below).
|
||
- TTL sweep (interval-based, same "deliberately dumb, not push-based"
|
||
precedent as `keep watch`/`wisp`'s sweep) deletes blobs + rows past
|
||
`expires_at` regardless of whether they were ever viewed.
|
||
|
||
No auth on any endpoint — this is a public, no-accounts toy. Rate-limit
|
||
`/api/upload` (basic IP-based throttle) to avoid it becoming an open file
|
||
host.
|
||
|
||
## Open questions
|
||
|
||
- **Exact delay range** — "hours to days" is the pitch; needs an actual
|
||
default (e.g. 2–48 hours?) and whether it's configurable per-upload or
|
||
fixed server-side via env.
|
||
- **What the "still developing" page shows** — exact countdown vs. vague
|
||
"come back later" vs. a tiny animated placeholder (fogged/blank photo
|
||
silhouette) for flavor. Worth spending a *little* design effort here
|
||
since it's the whole gimmick.
|
||
- **Filter presets vs. raw controls** — start with presets-only (faster
|
||
to ship, more "fun toy" than "editing tool") and add fine controls
|
||
later if it turns out sliders are actually wanted.
|
||
- **Mobile upload flow specifics** — `<input type="file" capture>` for
|
||
direct camera access vs. just a normal file picker; probably both,
|
||
file picker as the fallback.
|
||
- **Image size limits / re-encoding** — phone camera photos can be large;
|
||
decide whether to downscale/re-encode on upload (bandwidth + storage)
|
||
or store as-is up to some cap.
|
||
- **Retention for the instant/non-delayed path** — does a same-length TTL
|
||
apply, or does it expire faster than the delayed path since it's a much
|
||
more casual "just give me a link" flow?
|
||
|
||
## Non-goals
|
||
|
||
- No accounts, no galleries, no "your uploads" history — every image
|
||
stands alone via its own link, consistent with `wisp`'s throwaway
|
||
philosophy.
|
||
- No e2e encryption — deliberately out of scope, see Architecture.
|
||
- No collaborative/multi-user editing — one uploader, one result.
|