Files
RecipesCam/docker
3dtours f1385d8a08 web: hide what the brush paints, in cells, and never in a blur
HEAL borrows a patch of the photo and pastes it over what the brush covers. The
other half of the same gesture is the opposite thing — a patch of the photo the
user does not want shown to anyone, a face at a table, a plate, a badge, the
number on a note at the edge of the frame — and hiding it is the second tool on
the same layer: MOSAIC, next to HEAL in the FX row. Everything the two tools
share was already shared by the time this landed: one layer, one circle riding
the pointer, one wheel, one gesture that is one undo step, spots stored as
fractions of the render so the preview and the export draw the same circle. Only
what a spot MEANS split, and it split into two files over the piece of physics
both of them were already carrying: heal.ts and mosaic.ts, and brush.ts under
them for the size and the spacing of the circle they both lay.

What a mosaic spot does is destroy what it covers rather than replace it. The
frame is cut into square cells of MOSAIC_CELL (0.02 of the width — 5.12px on the
probe's 256px photo, 40px on a 2048px one) and every pixel of a cell takes the
colour found at that cell's own middle, read with img.eval so the block is the
snapshot's bilinear tap and not a neighbour's cell. What is under the circle is
still a picture of that place, at a resolution nothing can be read out of. A blur
was never in the running: it leaves the SHAPE of what it hides — a face under a
blur is still a face, a plate still a plate — and the arrangement is exactly what
the user is asking to keep to themselves. Cells coarse enough to lose the
arrangement are what "do not show this to anyone" needs, and the blockiness is
the price of it.

The cells are one grid over the whole frame, not one grid per spot: a pixel's
cell comes from its own position, and every block reads the snapshot rather than
the output, so two overlapping spots never pixelate a pixelation and a run lays
one band with no seam where its circles cross. The rim is hard for the same
reason in reverse — a feather would mix the cells back into the sharp photo along
the edge, which is a half-hidden thing leaking the arrangement it exists to hide.
A mosaic spot borrows nothing, so the layer draws no donor circle beside the
cursor: the second circle appears only when a spot has a source ('sx' in it),
which is the one place the two tools' DOM parts company. Each tool keeps its own
brush size, and each CLEAR chip clears only its own list, because the size a
dust speck is healed at is never the size a face is hidden at.

The recipe carries the list as adjustments.mosaic — x, y, r, the same fractions
HEAL stores, and readMosaic guards them the same way — and the renderer builds
one RuntimeEffect per count exactly as it does for HEAL (mosaicEffectFor), the
pass sitting right after the heal pass so a repair made on the same photo ends up
underneath the cells that hide the rest of it. The backend needed nothing: a
recipe is spread through as it stands, so a saved photo keeps its mosaic and a
shared one opens with it.

Verified:
  mosaic-skia-lab.cjs (scratchpad, CanvasKit against the bundled mosaic.ts) — 27
    passed, 0 failed: the cell rides in the frame block in the render's own
    pixels and is a fraction of the WIDTH, so it is square on any shape; 4912
    cells inside a spot each carry one colour, and 164/164 of them carry the
    colour at their own middle; the 2px white dot on the dark square reads
    250 -> 20; nothing outside the circle changed (0 stray pixels) while the
    cells reach the rim (852 pixels at the edge); a spot wider than the frame
    still runs; overlapping spots share one grid over 6335 pixels with 0
    differing between them (no cascade); readMosaic refuses a zero radius, an
    off-photo spot, junk and a missing list, and keeps a forty-spot list whole.
  mosaic-probe.cjs (the rebuilt app at http://localhost:8090) — 51 PASS, 0 FAIL,
    no page errors: FX offers a MOSAIC chip that arms the same brush layer and
    says which tool it is painting for; the wheel sizes each tool on its own
    (8.0% up, 5.0% back) and the circle follows it; a click lays exactly one spot
    with no borrowed patch beside it; the pixels of the cell are one colour (0
    levels across, cell 5.12px); the dot is unreadable (250 -> 15); nothing
    outside the circle changed (0 pixels, worst 0) and the cells are not the
    photo that was there (221/509 pixels changed); UNDO gives the photo back
    exactly and REDO hides it again; a drag paints ONE band 25.6px wide, as wide
    as the brush, standing for 5 points of travel and laying 5 spots that leave
    0 pixels outside them changed, with the step within a cell 3.43 levels
    against 21.25 between cells (635 + 157 pairs) — the cells are flat and their
    borders jump; one gesture is one undo step; arming HEAL and arming MOSAIC
    hand the pointer over and back with each tool's spots intact; CLEAR hands the
    photo back pixel for pixel and leaves no chip behind.
  The probe's own reading is deliberately a shape, not a colour: the app's
    preview is the engine's render at preview scale with a JPEG on top (and its
    auto dynamic range), so a cell's colour read back from the base would be two
    encodings apart. The exact cell colour is the Skia lab's claim, where no
    encoder sits between the shader and the reading.
  heal-probe.cjs 49 PASS / 0 FAIL against the same build, heal-search-lab.cjs 15,
    heal-skia-lab.cjs 27, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8 — the brush
    HEAL paints with is the one MOSAIC now paints with.
  Regressions against the rebuilt app, 0 fail: landing-test.cjs 172,
    pro-gate-test.cjs 27, award-column-probe.cjs 18, otp-code-probe.cjs 10,
    tone-curve-probe.cjs 42; backend npm test 180 passed, 0 failed; frontend
    tsc --noEmit clean.

ponytail: the cell is a fixed fraction of the width, not a fraction of the brush,
so a brush smaller than one cell paints a single block's colour; tying the cell
to the radius would mean a cell size per spot in the recipe, which is a recipe
change this tool does not need yet. The grid is one grid for the whole frame, so
a run of overlapping spots and one wide spot give the same blocks, and the run's
circles are laid spot by spot — drawing a run as one region wants a stroke id in
the recipe, the same change HEAL's own run is waiting on. A spot is in the
recipe by its fractions alone, so what the export prints is the mosaic the user
saw, and the original pixels under it are gone from the record on purpose.
2026-09-23 22:03:33 +07:00
..
…

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.