A gradient mask had its own tone formula and its own exposure, and both disagreed with the frame's. Before any of this was tidied, the mask ran a smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp while the frame moved the knots of a four-zone ramp, and the mask's exposure was a stop on sRGB-encoded values while the frame's was a stop on light. Two names, four moves, and the same slider meant different things depending on whether the pixels were inside the shape you drew — the divergence §3.3 of the Android port's compat doc warns about. The maths is one string now (TONE_MATH_SKSL, interpolated by both passes): the ramp the four knots build, the hue-preserving rebuild behind it, the transfer pair, and exposureMove. A mask calls the same functions the frame calls. The rebuild carries the chroma instead of re-scaling it. Lightness takes the curve and the colour rides the difference — the channel differences move by ONE shared scale k, pulled back only where the cube has no room left. The doc's ratio (R_new = R_old * Luma_new / Luma_old) was the old reading and it is exact only while nothing clips: a channel past 1.0 stops being scaled with its neighbours and the hue goes with it. Measured on a flat patch frame, a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at 57.4°, and under L = 0.5 the same ratio multiplied a near-black pixel's cast by x30 — colour noise amplified, which is why the 0.55..1.35 clamp was there. The scale is the chroma's own now: over 135 knob combinations on seven colours and five greys, the ramp moves the luma and the hue does not move at all (Δ < 1e-9°). EXPOSURE gets the same treatment, which is what the second half of the request was: the linear domain decides where the luma is going, and the pixel is rebuilt onto it through the same lightMove. The old pass multiplied the three channels in linear light, so +1 EV clipped them by three different amounts: measured on the scratchpad probe, 29.2° of hue drift on a skin tone at +1 EV and 33.3° at +2, against 0.00° here. The stop is applied as a ratio on the pixel's own encoded luma rather than pointed straight at the encoded linear target, which is what makes the knob exactly the identity at 0 EV — the transfer does not commute with the luma weights, so pointing at it brightened a colour by a couple of code values even at zero. A grey is the knob it always was: 128 through +1 EV is 176, the same number the linear per-channel multiply put there, so nothing a user has dialled in moves. Verified: `npx tsc --noEmit` clean, `npm run build` clean. highlight-knee-check now runs EXPOSURE_SKSL for real — compiled with CanvasKit and four pixels pushed through it, agreeing with the twin to a code value on a grey at +1 EV (176), a skin tone at +1 EV and +2 EV, and a shadow at -2 EV; it also pins the hue, the cube, the identity at 0 EV and the black pixel that has no light to move. mask-wb-check compiles the mask pass and pushes the same stops through it: 128 through +1 EV is 176, through -1 EV is 92, +2 EV lands the channel on the ceiling at 255 and holds the hue within 3°. auto-tone-check, preview-match-check, white-level-check, raw-develop-check, half-check and roll-walk-check all pass. Live on the built bundle in a 1440x950 browser: the LIGHT panel's EXPOSURE +1 EV takes the mid grey of a flat patch frame from 0.502 to 0.690 (a stop on light gives 0.686) and moves no patch's hue at -1 EV (Δ 0.00°), and a linear gradient mask's EXPOSURE +1 EV and HIGHLIGHT +100 move the pixels inside the mask (luma 185.9 -> 211.1 and 185.9 -> 197.5) while the corner outside it does not move at all (220.2 -> 220.2), with no console error. Co-authored-by: PenguinHarness <noreply@penguin.local>
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.