A press has to mean what it lands on. A drag is a run of spots and the run has a spine, so a press within half a brush radius of that spine takes the drag and dragging moves it, while a press in the outer half of the spots — where the run is not, and a new repair is wanted — lays a stroke against it. Before, a brush near a repaired one was read as a drag OF it: the second stroke was folded into the first and the first moved under the user. Spots carry the drag they came from (stroke), older recipes still answer to the old radius, and the outline of a repaired run is drawn idle so it can be taken hold of (app.css). A repair already made is part of the photo it was made in. The renderer now solves one drag at a time, in the order they were drawn, each against the frame the drags before it have already left — so a stroke laid beside a repaired one invents its pixels from a frame that no longer holds what the first took away, and the repair the user has already accepted does not move when they brush next to it. Measured (probe-seam.mjs, a second stroke laid 2.5 brush radii from the first, counted in levels over the first drag's own circles): 0 at the worst pixel, 0 of 19189 pixels moved by more than two. The ring the first repair left behind was the fill's and not the model's. The model's pixels were laid through the mask's own ramp, so the outer quarter of every spot — where the mask still weighs the fill in at up to full strength — was the fill's, and the fill is the light of the WHOLE box it was built in: a stroke a third of the frame long is filled by blurs up to sixteen brush radii wide, so what it brings there is the box's average and not the rim's. The model is the repair wherever it has one, so it now owns the spot out to 0.92 of its radius (HEAL_PATCH_STOPS) and the fill only meets the photo in the sliver the mask itself fades over. Measured with the same probe: 0.76 levels of mean change over the repair where it was 1.86, 8.5 at the worst pixel where it was 14.2, and the edge strength at the rim after the repair 0.92 where the photo's own edges around it are 0.5 (1.52 before) — the band that was 8 to 11 levels dark is gone. matchToRim goes with them: the relaxation it ran over every crop measures nothing the band above does not already do, and the probe's numbers are identical with it removed.
RecipesCam web — self-contained stack
A Docker-hosted web build of RecipesCam. Everything it needs is in this folder: move it to another machine, run two commands, and the app is up. It does not need the React Native project around it.
cp .env.example .env
docker compose up -d --build
# → http://localhost:8090
What runs where
| Service | Image | Role |
|---|---|---|
frontend |
nginx:1.27-alpine (built by frontend/Dockerfile) |
Static SPA + /api/ reverse proxy |
api |
node:22-slim (built by backend/Dockerfile) |
Accounts + saved recipes, SQLite on ./data |
frontend resolves api through Docker's embedded DNS and proxies /api/* to
it — that is why the API container is named api and why it is not published on
the host. Only ${WEB_PORT:-8090} is exposed.
Photos never leave the browser. The CanvasKit render pipeline (grade, frame, watermarks, JPEG encode) runs in the visitor's tab; the API only stores recipes as JSON.
Layout
docker-compose.yml the stack
.env.example WEB_PORT
data/ SQLite (created on first run, gitignored)
backend/ Fastify + better-sqlite3 API, own Dockerfile
frontend/ Vite + React + CanvasKit SPA, own Dockerfile + nginx.conf
shared/ vendored copies of the app's types + utils (see below)
src/engine/ skiaShim.ts (CanvasKit) + exportEngine.ts (render pipeline)
+ session.ts (localStorage/IndexedDB studio persistence)
Vendored files
frontend/shared/{types/index.ts,utils/*.ts} are byte-identical copies of
src/types/index.ts and ten src/utils/*.ts files from the React Native
project (@shopify/react-native-skia is aliased to src/engine/skiaShim.ts in
vite.config.ts + tsconfig.json, so those files compile unchanged):
cinemaShader colorUtils defaultRecipes exifWrite frameUtils jpegDpi
paramDefs recipeShare skiaImage toneShader.
The landing page needs no CDN: frontend/public/assets/fonts/*.woff2 are the
seven self-hosted faces behind the three font groups the Themes menu offers
(Plus Jakarta Sans / Inter / JetBrains Mono, Fraunces / Be Vietnam Pro /
Courier Prime, Be Vietnam Pro / Space Mono — all SIL OFL, pulled from Google
Fonts, vietnamese + latin + latin-ext subsets), and
frontend/public/assets/samples/s*.jpg are the six placeholder negatives the
film strip, preset tester and QR card show (swap them for real graded stills
whenever we have them). Its one foreign request is the QR image from
api.qrserver.com, which degrades to an empty slot offline.
When the app changes one of them, copy it back in — the renderer is only "parity" for as long as these stay in sync:
cd <repo>/docker/frontend/shared/utils
cp <repo>/src/utils/<name>.ts .
Operations
docker compose logs -f api # API log
docker compose restart api # after backend/src changes (rebuild: --build)
docker compose down # stop; ./data survives
Backup is the ./data folder — that is the whole database.
Checks
curl -s http://localhost:8090/api/health # {"ok":true}
curl -sI http://localhost:8090/ # 200, index.html
Then open the UI, drop a photo in, and confirm the preview shows the picture and
EXPORT downloads a JPEG that opens. The preview going solid black while the
export still reports a plausible size is the one failure mode worth knowing: it
means the CanvasKit GPU surfaces lost their shared GrDirectContext (see
frontend/src/engine/skiaShim.ts), and with no GPU the raster fallback renders
the same pipeline correctly, just slower.