Initial implementation of latent: film-effects editor + develop-later server
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>
This commit is contained in:
Fredrik Johansson
2026-07-16 15:19:48 +02:00
commit 5a5c0da8a6
20 changed files with 2611 additions and 0 deletions

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Copy to .env to override docker-compose.yml defaults.
# Image to deploy — set by CI/CD normally; only needed for a manual
# docker compose up.
IMAGE=repo.explewd.com/explewd/latent:latest
# Host port to expose (container always listens on 3000).
HOST_PORT=3050
# Randomized "send to develop" delay range, in hours.
DEVELOP_MIN_HOURS=2
DEVELOP_MAX_HOURS=48
# Retention. Instant/share-link images are a casual "just give me a link"
# flow and get a shorter TTL; delayed images must stay alive past
# develop_at with room to actually go view them, so their window is
# measured from develop_at, not upload time.
INSTANT_TTL_HOURS=24
DELAYED_VIEW_WINDOW_HOURS=168
MAX_UPLOAD_BYTES=20971520
RATE_LIMIT_PER_HOUR=30

View File

@@ -0,0 +1,53 @@
name: Docker
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive image name and tags
id: meta
shell: bash
run: |
set -euo pipefail
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1
fi
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.host }}
username: ${{ github.actor }}
# Create a Gitea PAT with packages:write scope and add it as a
# repository secret named TKNTKN (Settings → Secrets)
password: ${{ secrets.TKNTKN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
${{ steps.meta.outputs.image }}:latest
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
.env
data
.keep_vault

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:24-slim
# better-sqlite3 has a native addon with no prebuilt binary for this
# platform/Node combo yet — build it from source, then drop the toolchain.
RUN apt-get update -q && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY server/package*.json server/
RUN npm install --prefix server --omit=dev \
&& apt-get purge -y python3 make g++ && apt-get autoremove -y
COPY server/src/ server/src/
COPY index.html style.css app.js favicon.svg ./
# Do NOT copy data/ — mutable runtime volume holding the SQLite db and
# image blobs, would get clobbered on every redeploy if baked in.
ENV NODE_ENV=production
ENV PORT=3000
ENV DATA_DIR=/app/data
EXPOSE 3000
CMD ["node", "server/src/index.js"]

135
PROPOSAL.md Normal file
View File

@@ -0,0 +1,135 @@
# 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. 248 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.

60
README.md Normal file
View File

@@ -0,0 +1,60 @@
# latent
A phone-photo editor with a "develop it later" option. Upload a photo,
apply film-style effects (grain, light leaks, vignette, halation, color
cast) entirely in the browser, then either download it right away or send
it off to "develop" — the server holds it behind a randomized delay
(hours to days) before the link actually resolves to the image. Getting
your photos back later, on purpose, for no practical reason except that
it's a fun mechanic.
Named after the *latent image*, the photographic term for the picture
that already exists on exposed film but is invisible until developed.
See [PROPOSAL.md](PROPOSAL.md) for the original design writeup.
## Two finishing paths off one editor
1. **Instant** — edit, then download directly, entirely client-side.
Optionally generate a share link (explicit action, not automatic).
2. **Delayed** — edit, then "send to develop": uploads to server storage,
assigns a random `develop_at` within a configured range, and gives back
a link immediately — it just won't resolve to the actual image until
that time passes.
## Running locally
```
npm install --prefix server
npm start --prefix server
```
Then open http://localhost:3000. `DATA_DIR` defaults to `./data`
(gitignored) for the SQLite db and image blobs.
## Deploying
`docker compose up` using the provided `docker-compose.yml` /
`.env.example` (copy `.env.example` to `.env` and adjust). CI builds and
pushes the image on push to `main` via `.gitea/workflows/docker.yml`.
## Config
All via env vars, see `.env.example`:
- `DEVELOP_MIN_HOURS` / `DEVELOP_MAX_HOURS` — randomized delay range for
the "send to develop" path (default 248h).
- `INSTANT_TTL_HOURS` — retention for share links off the instant path
(default 24h; deliberately shorter than the delayed path since it's a
more casual "just give me a link" flow).
- `DELAYED_VIEW_WINDOW_HOURS` — how long a delayed image stays available
*after* `develop_at` passes, before the TTL sweep deletes it (default
168h / 7 days).
- `MAX_UPLOAD_BYTES`, `RATE_LIMIT_PER_HOUR` — basic abuse limits; no
accounts or auth on any endpoint, the link itself is the credential.
## Non-goals
No accounts, no galleries, no e2e encryption (nothing here is sensitive —
the whole point is a link other people can open), no collaborative
editing.

305
app.js Normal file
View File

@@ -0,0 +1,305 @@
(() => {
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
const dropCard = document.getElementById('drop-card');
const editorCard = document.getElementById('editor-card');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const grainSlider = document.getElementById('grain');
const vignetteSlider = document.getElementById('vignette');
const halationSlider = document.getElementById('halation');
const leakSlider = document.getElementById('leak');
const rerollLeakBtn = document.getElementById('reroll-leak');
const presetRow = document.getElementById('preset-row');
const resetBtn = document.getElementById('reset-btn');
const newPhotoBtn = document.getElementById('new-photo-btn');
const downloadBtn = document.getElementById('download-btn');
const shareBtn = document.getElementById('share-btn');
const developBtn = document.getElementById('develop-btn');
const resultPanel = document.getElementById('result-panel');
// Offscreen canvases reused across renders so we're not allocating per frame.
const work = document.createElement('canvas');
const workCtx = work.getContext('2d');
const bloom = document.createElement('canvas');
const bloomCtx = bloom.getContext('2d');
const noiseCanvas = document.createElement('canvas');
const noiseCtx = noiseCanvas.getContext('2d');
let originalImg = null;
const PRESETS = {
none: { filter: 'none' },
// "pick a vibe" presets rather than raw white-balance sliders, per
// PROPOSAL.md — expressed as CSS filter strings applied at draw time.
warm: { filter: 'sepia(0.6) saturate(1.8) hue-rotate(-15deg) contrast(1.1) brightness(1.05)' },
cool: { filter: 'saturate(1.9) hue-rotate(165deg) contrast(1.2) brightness(0.9)' },
faded: { filter: 'sepia(0.2) saturate(0.35) contrast(0.7) brightness(1.15)' },
};
const params = {
preset: 'none',
grain: 0,
vignette: 0,
halation: 0,
leak: 0,
leakSeed: Math.random(),
};
function loadFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
originalImg = img;
URL.revokeObjectURL(url);
const maxDim = 2400; // cap so a raw phone photo doesn't choke canvas ops
let w = img.naturalWidth;
let h = img.naturalHeight;
if (Math.max(w, h) > maxDim) {
const scale = maxDim / Math.max(w, h);
w = Math.round(w * scale);
h = Math.round(h * scale);
}
canvas.width = w;
canvas.height = h;
work.width = w;
work.height = h;
bloom.width = w;
bloom.height = h;
dropCard.hidden = true;
editorCard.hidden = false;
resultPanel.hidden = true;
resetParams();
render();
};
img.src = url;
}
function resetParams() {
params.preset = 'none';
params.grain = 0;
params.vignette = 0;
params.halation = 0;
params.leak = 0;
params.leakSeed = Math.random();
grainSlider.value = 0;
vignetteSlider.value = 0;
halationSlider.value = 0;
leakSlider.value = 0;
updateValueLabels();
[...presetRow.children].forEach(b => b.classList.toggle('active', b.dataset.preset === 'none'));
}
function updateValueLabels() {
document.getElementById('grain-value').textContent = params.grain;
document.getElementById('vignette-value').textContent = params.vignette;
document.getElementById('halation-value').textContent = params.halation;
document.getElementById('leak-value').textContent = params.leak;
}
// Recomputes the full stack from the original image every time — never
// chains operations onto already-processed pixels — so dragging any
// slider back to 0 reproduces the source exactly.
function render() {
if (!originalImg) return;
const w = canvas.width;
const h = canvas.height;
workCtx.filter = PRESETS[params.preset].filter;
workCtx.globalCompositeOperation = 'source-over';
workCtx.clearRect(0, 0, w, h);
workCtx.drawImage(originalImg, 0, 0, w, h);
workCtx.filter = 'none';
if (params.halation > 0) applyHalation(w, h);
if (params.vignette > 0) applyVignette(w, h);
if (params.leak > 0) applyLightLeak(w, h);
if (params.grain > 0) applyGrain(w, h);
ctx.clearRect(0, 0, w, h);
ctx.drawImage(work, 0, 0);
}
// Bloom around bright areas: blur a thresholded bright-pass and screen it
// back over the original.
function applyHalation(w, h) {
bloomCtx.clearRect(0, 0, w, h);
bloomCtx.filter = 'brightness(180%) contrast(400%) saturate(120%)';
bloomCtx.drawImage(work, 0, 0);
bloomCtx.filter = 'none';
const blurPx = Math.max(4, Math.round(Math.min(w, h) * 0.02));
bloomCtx.filter = `blur(${blurPx}px)`;
bloomCtx.drawImage(bloom, 0, 0);
bloomCtx.filter = 'none';
workCtx.save();
workCtx.globalCompositeOperation = 'screen';
workCtx.globalAlpha = (params.halation / 100) * 0.85;
workCtx.drawImage(bloom, 0, 0);
workCtx.restore();
}
function applyVignette(w, h) {
const amount = params.vignette / 100;
const cx = w / 2;
const cy = h / 2;
const outerR = Math.hypot(cx, cy);
const grad = workCtx.createRadialGradient(cx, cy, outerR * 0.35, cx, cy, outerR);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, `rgba(0,0,0,${amount * 0.85})`);
workCtx.save();
workCtx.globalCompositeOperation = 'multiply';
workCtx.fillStyle = grad;
workCtx.fillRect(0, 0, w, h);
workCtx.restore();
}
// Warm streak/bloom near a randomized edge point, rotated per seed so
// repeat use doesn't look identical — no pre-baked PNG assets needed,
// it's a procedural gradient.
function applyLightLeak(w, h) {
const seed = params.leakSeed;
const angle = seed * Math.PI * 2;
const edgeX = w / 2 + Math.cos(angle) * w * 0.7;
const edgeY = h / 2 + Math.sin(angle) * h * 0.7;
const radius = Math.max(w, h) * (0.5 + seed * 0.3);
const grad = workCtx.createRadialGradient(edgeX, edgeY, 0, edgeX, edgeY, radius);
const hue = 25 + seed * 30; // warm amber/red range
grad.addColorStop(0, `hsla(${hue}, 90%, 60%, ${(params.leak / 100) * 0.9})`);
grad.addColorStop(0.5, `hsla(${hue + 10}, 90%, 55%, ${(params.leak / 100) * 0.35})`);
grad.addColorStop(1, 'hsla(0, 0%, 0%, 0)');
workCtx.save();
workCtx.globalCompositeOperation = 'screen';
workCtx.fillStyle = grad;
workCtx.fillRect(0, 0, w, h);
workCtx.restore();
}
function applyGrain(w, h) {
// Noise is generated at reduced resolution and scaled up — a
// per-pixel random overlay at full phone-photo resolution is
// needlessly slow and reads no differently once blended in.
const nw = Math.max(1, Math.round(w / 3));
const nh = Math.max(1, Math.round(h / 3));
noiseCanvas.width = nw;
noiseCanvas.height = nh;
const imgData = noiseCtx.createImageData(nw, nh);
const d = imgData.data;
for (let i = 0; i < d.length; i += 4) {
const v = Math.random() * 255;
d[i] = v;
d[i + 1] = v;
d[i + 2] = v;
d[i + 3] = 255;
}
noiseCtx.putImageData(imgData, 0, 0);
workCtx.save();
workCtx.globalCompositeOperation = 'overlay';
workCtx.globalAlpha = (params.grain / 100) * 0.6;
workCtx.imageSmoothingEnabled = false;
workCtx.drawImage(noiseCanvas, 0, 0, w, h);
workCtx.restore();
}
function showResult(html, isError) {
resultPanel.innerHTML = html;
resultPanel.classList.toggle('error', !!isError);
resultPanel.hidden = false;
}
function canvasToBlob() {
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.92));
}
async function upload(delayed) {
const btn = delayed ? developBtn : shareBtn;
btn.disabled = true;
showResult('uploading&hellip;');
try {
const blob = await canvasToBlob();
const res = await fetch(`/api/upload?delayed=${delayed}`, {
method: 'POST',
headers: { 'Content-Type': 'image/jpeg' },
body: blob,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `upload failed (${res.status})`);
}
const data = await res.json();
const link = `${location.origin}${data.url}`;
if (delayed) {
showResult(
`sent to develop. link: <a href="${link}">${link}</a><br>` +
`ready sometime in the next ${data.developInHours} hours &mdash; ` +
`same link resolves once it's done.`
);
} else {
showResult(`share link: <a href="${link}">${link}</a>`);
}
} catch (err) {
showResult(err.message || 'something went wrong', true);
} finally {
btn.disabled = false;
}
}
fileInput.addEventListener('change', () => loadFile(fileInput.files[0]));
['dragover', 'dragenter'].forEach(evt =>
dropZone.addEventListener(evt, e => {
e.preventDefault();
dropZone.classList.add('drag-over');
})
);
['dragleave', 'dragend', 'drop'].forEach(evt =>
dropZone.addEventListener(evt, () => dropZone.classList.remove('drag-over'))
);
dropZone.addEventListener('drop', e => {
e.preventDefault();
loadFile(e.dataTransfer.files[0]);
});
[...presetRow.children].forEach(btn => {
btn.addEventListener('click', () => {
params.preset = btn.dataset.preset;
[...presetRow.children].forEach(b => b.classList.toggle('active', b === btn));
render();
});
});
grainSlider.addEventListener('input', () => { params.grain = Number(grainSlider.value); updateValueLabels(); render(); });
vignetteSlider.addEventListener('input', () => { params.vignette = Number(vignetteSlider.value); updateValueLabels(); render(); });
halationSlider.addEventListener('input', () => { params.halation = Number(halationSlider.value); updateValueLabels(); render(); });
leakSlider.addEventListener('input', () => { params.leak = Number(leakSlider.value); updateValueLabels(); render(); });
rerollLeakBtn.addEventListener('click', () => { params.leakSeed = Math.random(); render(); });
resetBtn.addEventListener('click', () => { resetParams(); render(); });
newPhotoBtn.addEventListener('click', () => {
originalImg = null;
fileInput.value = '';
editorCard.hidden = true;
dropCard.hidden = false;
resultPanel.hidden = true;
});
downloadBtn.addEventListener('click', async () => {
const blob = await canvasToBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `latent-${Date.now()}.jpg`;
a.click();
URL.revokeObjectURL(url);
});
shareBtn.addEventListener('click', () => upload(false));
developBtn.addEventListener('click', () => upload(true));
})();

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
app:
image: ${IMAGE}
container_name: latent
restart: unless-stopped
pull_policy: always
ports:
- "${HOST_PORT:-3050}:3090"
environment:
- DEVELOP_MIN_HOURS=${DEVELOP_MIN_HOURS:-2}
- DEVELOP_MAX_HOURS=${DEVELOP_MAX_HOURS:-48}
- INSTANT_TTL_HOURS=${INSTANT_TTL_HOURS:-24}
- DELAYED_VIEW_WINDOW_HOURS=${DELAYED_VIEW_WINDOW_HOURS:-168}
- MAX_UPLOAD_BYTES=${MAX_UPLOAD_BYTES:-20971520}
- RATE_LIMIT_PER_HOUR=${RATE_LIMIT_PER_HOUR:-30}
volumes:
# Named volume, not a bind mount into the build context — the SQLite
# db and image blobs live here and must survive image redeploys.
- latent-data:/app/data
volumes:
latent-data:

5
favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="15" fill="#0c0a08" stroke="#e8a33d" stroke-width="1.5"/>
<circle cx="16" cy="16" r="7" fill="none" stroke="#e8a33d" stroke-width="1.5"/>
<circle cx="16" cy="16" r="2.5" fill="#e8a33d"/>
</svg>

After

Width:  |  Height:  |  Size: 287 B

92
index.html Normal file
View File

@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>latent</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<div class="header-row">
<h1>latent</h1>
</div>
<p class="hint">film-style edits, in the browser — develop instantly, or wait</p>
<div class="card" id="drop-card">
<label class="drop-zone" id="drop-zone" for="file-input">
<span class="drop-zone-label">choose a photo, or drag one here</span>
<span class="drop-zone-sub">nothing leaves your browser until you say so</span>
</label>
<input type="file" id="file-input" accept="image/*" capture="environment" hidden>
</div>
<div class="card" id="editor-card" hidden>
<div class="canvas-wrap">
<canvas id="canvas"></canvas>
</div>
<div class="controls">
<div class="control-group">
<div class="control-label">vibe</div>
<div class="preset-row" id="preset-row">
<button class="preset-btn active" data-preset="none">none</button>
<button class="preset-btn" data-preset="warm">expired warm</button>
<button class="preset-btn" data-preset="cool">cross-processed</button>
<button class="preset-btn" data-preset="faded">faded</button>
</div>
</div>
<div class="control-group">
<div class="control-label">
<span>grain</span>
<span class="control-value" id="grain-value">0</span>
</div>
<input type="range" id="grain" min="0" max="100" value="0" class="slider">
</div>
<div class="control-group">
<div class="control-label">
<span>vignette</span>
<span class="control-value" id="vignette-value">0</span>
</div>
<input type="range" id="vignette" min="0" max="100" value="0" class="slider">
</div>
<div class="control-group">
<div class="control-label">
<span>halation</span>
<span class="control-value" id="halation-value">0</span>
</div>
<input type="range" id="halation" min="0" max="100" value="0" class="slider">
</div>
<div class="control-group">
<div class="control-label">
<span>light leak</span>
<span class="control-value" id="leak-value">0</span>
</div>
<input type="range" id="leak" min="0" max="100" value="0" class="slider">
<button class="btn btn-small" id="reroll-leak">reroll placement</button>
</div>
</div>
<div class="actions actions-row">
<button class="btn" id="reset-btn">reset</button>
<button class="btn" id="new-photo-btn">new photo</button>
<button class="btn btn-primary" id="download-btn">download</button>
</div>
<div class="actions actions-row">
<button class="btn" id="share-btn">generate share link</button>
<button class="btn btn-develop" id="develop-btn">send to develop</button>
</div>
<div id="result-panel" class="result-panel" hidden></div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

1252
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
server/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "latent-server",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Small server for latent's two persistence paths: share links and delayed development",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"express": "^4.19.2"
}
}

25
server/src/cleanup.js Normal file
View File

@@ -0,0 +1,25 @@
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
import { sweepRateLimitWindows } from './ratelimit.js'
// Interval-based sweep, not push-based — deletes blobs + rows past
// expires_at regardless of whether they were ever viewed, per
// PROPOSAL.md's "deliberately dumb" precedent from wisp/keep-watch.
export function sweepExpiredImages() {
const now = Math.floor(Date.now() / 1000)
const expired = db.prepare(`SELECT id FROM images WHERE expires_at < ?`).all(now)
for (const { id } of expired) {
fs.rm(blobPath(id), { force: true }, () => {})
}
if (expired.length > 0) {
db.prepare(`DELETE FROM images WHERE expires_at < ?`).run(now)
}
sweepRateLimitWindows()
}
export function startCleanupJob() {
return setInterval(sweepExpiredImages, config.cleanupIntervalSeconds * 1000)
}

24
server/src/config.js Normal file
View File

@@ -0,0 +1,24 @@
import path from 'node:path'
export const config = {
port: Number(process.env.PORT ?? 3000),
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
// "Hours to days" per PROPOSAL.md — server picks a develop_at uniformly
// at random in this range on every delayed upload.
developMinHours: Number(process.env.DEVELOP_MIN_HOURS ?? 2),
developMaxHours: Number(process.env.DEVELOP_MAX_HOURS ?? 48),
// Retention: the instant/share-link path is a casual "just give me a
// link" flow, so it gets a much shorter TTL than the delayed path.
// Delayed images must stay alive past develop_at with room to actually
// go view them, so their TTL is measured from develop_at, not upload time.
instantTtlHours: Number(process.env.INSTANT_TTL_HOURS ?? 24),
delayedViewWindowHours: Number(process.env.DELAYED_VIEW_WINDOW_HOURS ?? 168), // 7 days after develop_at
maxUploadBytes: Number(process.env.MAX_UPLOAD_BYTES ?? 20 * 1024 * 1024), // 20MB
cleanupIntervalSeconds: Number(process.env.CLEANUP_INTERVAL_SECONDS ?? 60 * 15),
// Basic IP-based throttle so /api/upload can't become an open file host.
rateLimitPerHour: Number(process.env.RATE_LIMIT_PER_HOUR ?? 30),
}

25
server/src/db.js Normal file
View File

@@ -0,0 +1,25 @@
import Database from 'better-sqlite3'
import fs from 'node:fs'
import path from 'node:path'
import { config } from './config.js'
fs.mkdirSync(config.dataDir, { recursive: true })
fs.mkdirSync(path.join(config.dataDir, 'blobs'), { recursive: true })
export const db = new Database(path.join(config.dataDir, 'latent.db'))
db.pragma('journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS images (
id TEXT PRIMARY KEY,
blob_path TEXT NOT NULL,
mime TEXT NOT NULL,
created_at INTEGER NOT NULL,
develop_at INTEGER,
expires_at INTEGER NOT NULL
);
`)
export function blobPath(id) {
return path.join(config.dataDir, 'blobs', id)
}

23
server/src/ids.js Normal file
View File

@@ -0,0 +1,23 @@
import { randomBytes } from 'node:crypto'
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
// 128-bit random value, rendered as base62 — unguessable, since the link
// itself is the only credential (no accounts, per PROPOSAL.md).
function randomBase62(bits) {
const bytes = randomBytes(Math.ceil(bits / 8) + 4) // headroom for the mod-bias trim below
let value = 0n
for (const b of bytes) value = (value << 8n) | BigInt(b)
let out = ''
const base = BigInt(BASE62.length)
while (value > 0n) {
out = BASE62[Number(value % base)] + out
value /= base
}
return out.padStart(Math.ceil(bits / Math.log2(62)), '0')
}
export function newImageId() {
return randomBase62(128)
}

29
server/src/index.js Normal file
View File

@@ -0,0 +1,29 @@
import express from 'express'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { config } from './config.js'
import { apiRouter, imageRouter } from './routes.js'
import { sweepExpiredImages, startCleanupJob } from './cleanup.js'
const app = express()
// Deployed behind a reverse proxy in production (same as the other
// single-process toys in this family) — needed for accurate req.ip in
// the rate limiter.
app.set('trust proxy', true)
// Mounted before the static frontend and before any body parser: the
// upload route reads the raw request stream itself and must not have it
// consumed by express.json()/express.raw() first.
app.use('/api', apiRouter)
app.use('/', imageRouter)
const staticRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
app.use(express.static(staticRoot))
sweepExpiredImages()
startCleanupJob()
app.listen(config.port, () => {
console.log(`latent server listening on :${config.port}`)
})

28
server/src/ratelimit.js Normal file
View File

@@ -0,0 +1,28 @@
import { config } from './config.js'
// Deliberately dumb in-memory fixed-window counter, not a distributed
// rate limiter — this is a single-process toy server, per PROPOSAL.md.
const windows = new Map() // ip -> { count, windowStart }
export function checkRateLimit(ip) {
const now = Date.now()
const hourMs = 60 * 60 * 1000
const entry = windows.get(ip)
if (!entry || now - entry.windowStart > hourMs) {
windows.set(ip, { count: 1, windowStart: now })
return true
}
entry.count += 1
return entry.count <= config.rateLimitPerHour
}
// Prevents the Map from growing forever across long-running processes.
export function sweepRateLimitWindows() {
const now = Date.now()
const hourMs = 60 * 60 * 1000
for (const [ip, entry] of windows) {
if (now - entry.windowStart > hourMs) windows.delete(ip)
}
}

150
server/src/routes.js Normal file
View File

@@ -0,0 +1,150 @@
import { Router } from 'express'
import fs from 'node:fs'
import { config } from './config.js'
import { db, blobPath } from './db.js'
import { newImageId } from './ids.js'
import { checkRateLimit } from './ratelimit.js'
export const apiRouter = Router()
export const imageRouter = Router()
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp'])
function randomInt(min, max) {
return Math.floor(min + Math.random() * (max - min))
}
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
apiRouter.post('/upload', (req, res) => {
const ip = req.ip
if (!checkRateLimit(ip)) {
res.status(429).json({ error: 'too many uploads, try again later' })
return
}
const mime = (req.get('Content-Type') || '').split(';')[0].trim()
if (!ALLOWED_MIME.has(mime)) {
res.status(415).json({ error: 'unsupported image type' })
return
}
const contentLength = Number(req.get('Content-Length') ?? 0)
if (contentLength > config.maxUploadBytes) {
res.status(413).json({ error: 'file too large' })
return
}
const delayed = req.query.delayed === 'true'
const id = newImageId()
const dest = blobPath(id)
const writeStream = fs.createWriteStream(dest, { flags: 'wx' })
let bytesReceived = 0
let aborted = false
req.on('data', chunk => {
bytesReceived += chunk.length
if (bytesReceived > config.maxUploadBytes) {
aborted = true
writeStream.destroy()
req.destroy()
}
})
req.pipe(writeStream)
writeStream.on('error', () => {
fs.rm(dest, { force: true }, () => {})
if (!res.headersSent) res.status(500).json({ error: 'write failed' })
})
writeStream.on('finish', () => {
if (aborted) {
fs.rm(dest, { force: true }, () => {})
if (!res.headersSent) res.status(413).json({ error: 'file too large' })
return
}
const now = Math.floor(Date.now() / 1000)
let developAt = null
let expiresAt
if (delayed) {
const hours = randomInt(config.developMinHours, config.developMaxHours)
developAt = now + hours * 3600
expiresAt = developAt + config.delayedViewWindowHours * 3600
} else {
expiresAt = now + config.instantTtlHours * 3600
}
db.prepare(
`INSERT INTO images (id, blob_path, mime, created_at, develop_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, dest, mime, now, developAt, expiresAt)
res.json({
id,
url: `/i/${id}`,
developInHours: delayed ? Math.ceil((developAt - now) / 3600) : null,
})
})
})
imageRouter.get('/i/:id', (req, res) => {
const image = db.prepare(`SELECT * FROM images WHERE id = ?`).get(req.params.id)
const now = Math.floor(Date.now() / 1000)
if (!image || image.expires_at < now || !fs.existsSync(image.blob_path)) {
res.status(404).send(renderPage('not found', '<p>this one\'s gone — wrong link, or it already expired.</p>'))
return
}
if (image.develop_at && image.develop_at > now) {
const remainingHours = Math.ceil((image.develop_at - now) / 3600)
res.status(200).send(renderPage('still developing', developingBody(remainingHours)))
return
}
res.setHeader('Content-Type', image.mime)
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
fs.createReadStream(image.blob_path).pipe(res)
})
// Rounded to a vague bucket rather than an exact countdown — a little
// mystery is the point, per PROPOSAL.md's open question on this page.
function developingBody(remainingHours) {
let phrase
if (remainingHours <= 1) phrase = 'any time now'
else if (remainingHours <= 6) phrase = 'in a few hours'
else if (remainingHours <= 24) phrase = 'later today or tomorrow'
else if (remainingHours <= 72) phrase = 'in a couple of days'
else phrase = 'in a few days'
return `
<div class="develop-wrap">
<div class="fog"></div>
<p>still developing &mdash; check back <strong>${escapeHtml(phrase)}</strong>.</p>
<p class="hint">same link, it'll just work once it's ready.</p>
</div>
`
}
function renderPage(title, bodyHtml) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>latent &mdash; ${escapeHtml(title)}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app">
<div class="header-row"><h1>latent</h1></div>
<div class="card">${bodyHtml}</div>
</div>
</body>
</html>`
}

317
style.css Normal file
View File

@@ -0,0 +1,317 @@
:root {
--bg: #0c0a08;
--bg-alt: #14100c;
--text: #cbc3b8;
--muted: #6b6258;
--heading: #f2e9dc;
--accent: #e8a33d;
--accent-dim: rgba(232, 163, 61, 0.12);
--accent-border:rgba(232, 163, 61, 0.28);
--border: rgba(255, 245, 230, 0.08);
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
--error: #ff6b6b;
--error-dim: rgba(255, 107, 107, 0.14);
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
font: 16px/1.6 var(--sans);
color: var(--text);
background: var(--bg);
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
#app {
width: 640px;
max-width: 100%;
margin: 0 auto;
padding: 32px 16px 64px;
}
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
p { margin: 0; }
/* ── Header ─────────────────────────────────────────────────────────── */
.header-row {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 8px;
}
.header-row h1 {
font-size: 32px;
font-weight: 700;
letter-spacing: -0.5px;
text-align: center;
margin: 0;
}
.header-row h1::before {
content: '> ';
color: var(--muted);
font-weight: 400;
}
.hint {
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
text-align: center;
margin-bottom: 32px;
}
/* ── Card ───────────────────────────────────────────────────────────── */
.card {
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
background: var(--bg-alt);
box-shadow: var(--shadow);
margin-bottom: 16px;
}
/* ── Drop zone ──────────────────────────────────────────────────────── */
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 48px 16px;
border: 1px dashed var(--border);
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: border-color 0.15s, background 0.15s;
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--accent-border);
background: var(--accent-dim);
}
.drop-zone-label {
font-family: var(--mono);
font-size: 14px;
color: var(--text);
}
.drop-zone-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
}
/* ── Canvas ─────────────────────────────────────────────────────────── */
.canvas-wrap {
display: flex;
justify-content: center;
margin-bottom: 20px;
border-radius: 8px;
overflow: hidden;
background: #000;
}
#canvas {
max-width: 100%;
max-height: 60vh;
display: block;
}
/* ── Controls ───────────────────────────────────────────────────────── */
.controls {
display: flex;
flex-direction: column;
gap: 16px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.control-label {
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.control-value {
color: var(--accent);
}
.slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: var(--border);
outline: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--bg-alt);
}
.slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--bg-alt);
}
.preset-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preset-btn {
font-family: var(--mono);
font-size: 12px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
}
.preset-btn:hover { border-color: var(--accent-border); }
.preset-btn.active {
background: var(--accent-dim);
border-color: var(--accent-border);
color: var(--accent);
}
/* ── Actions / buttons ──────────────────────────────────────────────── */
.actions-row {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
flex-wrap: wrap;
}
.btn {
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.01em;
}
.btn:hover { border-color: var(--accent-border); color: var(--heading); }
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.5; cursor: default; transform: none; }
.btn-small {
font-size: 11px;
padding: 6px 10px;
align-self: flex-start;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #000;
font-weight: 700;
}
.btn-primary:hover { background: #f0b25a; border-color: #f0b25a; color: #000; }
.btn-develop {
border-color: rgba(120, 140, 255, 0.3);
color: #b7c2ff;
}
.btn-develop:hover { border-color: rgba(120, 140, 255, 0.6); color: #d6dcff; }
/* ── Result panel ───────────────────────────────────────────────────── */
.result-panel {
margin-top: 18px;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
font-family: var(--mono);
font-size: 12px;
color: var(--text);
text-align: center;
}
.result-panel a {
color: var(--accent);
word-break: break-all;
}
.result-panel.error { color: var(--error); border-color: var(--error-dim); }
/* ── Developing page (server-rendered) ─────────────────────────────── */
.develop-wrap {
text-align: center;
padding: 64px 16px;
}
.fog {
width: 220px;
height: 220px;
margin: 0 auto 24px;
border-radius: 8px;
background: linear-gradient(135deg, #1a1712, #0c0a08);
border: 1px solid var(--border);
position: relative;
overflow: hidden;
}
.fog::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 50%, rgba(232,163,61,0.08), transparent 70%);
animation: drift 6s ease-in-out infinite;
}
@keyframes drift {
0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
50% { transform: scale(1.15) translate(4%, -4%); opacity: 1; }
}