Initial implementation of latent: film-effects editor + develop-later server
All checks were successful
Docker / build-and-push (push) Successful in 3m29s
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:
22
.env.example
Normal file
22
.env.example
Normal 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
|
||||
53
.gitea/workflows/docker.yml
Normal file
53
.gitea/workflows/docker.yml
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
data
|
||||
.keep_vault
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal 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
135
PROPOSAL.md
Normal 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. 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.
|
||||
60
README.md
Normal file
60
README.md
Normal 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 2–48h).
|
||||
- `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
305
app.js
Normal 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…');
|
||||
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 — ` +
|
||||
`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
22
docker-compose.yml
Normal 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
5
favicon.svg
Normal 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
92
index.html
Normal 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
1252
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
server/package.json
Normal file
15
server/package.json
Normal 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
25
server/src/cleanup.js
Normal 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
24
server/src/config.js
Normal 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
25
server/src/db.js
Normal 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
23
server/src/ids.js
Normal 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
29
server/src/index.js
Normal 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
28
server/src/ratelimit.js
Normal 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
150
server/src/routes.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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 — 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 — ${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
317
style.css
Normal 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user