Two zoom-only defects in the FRAME watermark boxes, both of them units mistakes
between what the stage measures and what the engine draws.
measure() read the box off img.getBoundingClientRect(). Zoom is a TRANSFORM on
the photo, and every layer above it (wm, crop, pick, compare, straighten)
carries that same transform itself, so a rect read off the transformed element
comes back already scaled and is then scaled again by the layer. It only shows
once measure() runs while the stage is zoomed, which is exactly what the zoom
itself causes: onStageZoom re-cuts the preview copy, the new src fires onLoad,
and measure() re-runs on the transformed photo. Measured on the built app (a
2560x1706 upload, a 955px stage, four wheel notches -> 1.749x): the layer came
out at -651,-796 2921x1947 against the photo's own 43,-241 1670x1113 — the zoom
printed twice — with the box at 809,951 while the mark sits at 878,757 and the
baked text at 882,765. The user's report exactly: the textbox not anchored where
the mark was placed, and its frame not drawn where the text is.
The box is now the photo's LAYOUT box (offsetLeft/offsetTop/offsetWidth/
offsetHeight, whose offsetParent is the relative-positioned .canvas-wrap), which
no transform can touch. Same run after: layer 43,-242 1670x1114 against the
photo 43,-241 1670x1113, box x 878 against the mark's own 0.5 x 1670 = 835 + the
handle, and the box at 878,757 against the text at 882,765.
The corner handle mixed the other way round. It read the pointer's travel as
(e.clientX - d.left) / d.width — a SCREEN distance over an UNSCALED width, and
with d.left the mark's own anchor rather than where the handle was grabbed. At
scale 1 the handle sits exactly one box width from that anchor, so f came out 1
and the drag read true; zoomed, the same pull arrives s times larger and the box
it grows was itself s times too wide. Measured: an 80px pull at 1.749x reported
size 1.503 and left the box 163 -> 420px where 163 -> 241px was asked for. The
handlers now take the photo's unscaled width and height plus the scale it is
drawn at (rect.width / offsetWidth) and divide the pointer's screen delta by
both, so a pull reads the same at any zoom, and the face size and the y
compensation it feeds are computed from the photo's own width — that is the width
the engine draws the fraction against. Same pull after: 163 -> 241px, the
top-left corner left where it was.
Verified:
wm-zoom-probe.cjs (new, scratchpad) — 2560x1706 upload, four notches of wheel
held on the handle's own corner: layer vs photo box, box vs the baked white
text, the box's growth against the zoom, then an 80px handle pull. 9/9 on
http://localhost:8090 (built asset index-BOwzIcQD.js). On the previous build
the same probe is 5/9 — the layer, anchoring and size checks above.
wm-test.cjs 23, wm-font-test.cjs 38, wm-font-registry-test.cjs 26,
wm-gps-test.cjs 27, wm-gps-date-test.cjs 18, crop-frame-test.cjs,
compare-test.cjs 25, compare-crop-test.cjs, zoom-test.cjs 25 — 0 fail at
scale 1, where the old arithmetic happened to be right.
web tsc --noEmit clean.
ponytail: offsetWidth/offsetHeight round to whole CSS pixels, so the box can sit
half a pixel off the photo's own box — under a box whose tolerance is the
dashed hairline, not worth un-applying the transform by hand; the day something
reads those pixels, compute the fit instead, as baseRect already does.
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.