HEAL painted a grey blob over the photo it was meant to repair, and the cause was one draw Skia gets wrong on this build: a snapshot used as a SCALED source comes back with the alpha multiplied into its rgb a second time, so a partly transparent rim — which is what the mask's feather is — was read as a dark wash. A pyramid of halvings is built of exactly those scaled draws, which is why the repair's own rim came out grey; the same source read at 1:1 is exact (measured, tmp-src-probe). So the fill is now a cascade of blurs, all of them 1:1: the cut frame is blurred at a few widths, widest first, each finer one drawn over it, the untouched cut on top. The finest is one brush wide, each next is twice the last, and the count follows the depth of the hole (the stroke's own diagonal against the widest brush), which is what keeps the middle of a long hole filled rather than bare. Nothing is copied from anywhere and the rim arrives with no seam, which is what the passes were always for. And the fill is built on the brush's own box rather than the frame, padded by three sigma of the widest level. The composite reads it only where the mask is, so this changes no pixel of the result (heal-check measures the same masks either way) and it is what makes the repair affordable at the zoom a repair is aimed at: at a 3200px preview a single spot went from 1.7s of frozen main thread to 1.2s, and the heal's own share of that from 0.8s to 0.3s. What is left is the pipeline itself, which is a separate matter. The FIX brush outlived its tab. It takes the pointer on the photo, and leaving TOOLS never put it down — so its layer stayed on the stage over a photo nobody was editing, and its ring came back under the pointer every time the hand crossed a repair, which reads as the tool re-arming itself. It now goes down with the tab, with another chip or another strip, and on a press outside its own two chips, the same way the mask tool does. And the Immich button was missing for a visitor who had just connected a server: the studio and the library are two screens of one page, both mounted, so coming back is a route change and not a mount — the studio's own effect, which reads the server list once a session, never ran again. It is read again on a popstate that lands on /app. Checks: node scripts/heal-check.mjs (the cascade and the box, to the letter), npx tsc --noEmit, and the studio driven in a browser: the button appears on the way back from the library, the brush goes down with TOOLS and re-arms with HEAL, and a stroke over a photo lays one band with no grey in it.
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.