Files
RecipesCam/docker
3dtours 34f8601c91 studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels
Two specs, one commit: the develop state becomes the panel column the Lightroom
spec draws, and HIGHLIGHT, SHADOW, WHITE and BLACK stop being edits and become
shapes of the tone curve, the way the mapping spec measures them.

The column. The left rail used to hand LIGHT a row of chips and nothing else;
the four tone knobs were chips that opened a curve, and the rest of the develop
state lived in the chip row's own vocabulary. `DevelopPanels` renders the five
sections of the spec instead — PROFILE, WB, TONE, PRESENCE, DETAIL & EFFECTS —
as an accordion, all open, and every parameter the recipe holds has a row in it
with a `data-key` off the parameter name: slider, value readout, double-click to
default. Sliders are always visible, so a knob is one drag away instead of two
taps, and TEMPERATURE and TINT draw their gradient underneath (blue through amber,
green through pink) so the direction is on the control. The PRO looks the spec
marks stay in the list but locked, tagged PRO, and tapping one asks for PRO —
they are shown, not hidden, and not silently dropped.

WHITE and BLACK move out of the WB group. They were the temperature group's
extremes, which is what a white balance control does — the toe and the shoulder
of the same ramp — but the spec puts them with the tone knobs and gives them the
two ends of the tone curve, and that is what they now are. `wb` is TEMPERATURE
and TINT and nothing else; `whites` and `blacks` sit in `iq` beside `highlights`
and `shadows`, labelled WHITE and BLACK, in the tone panel where the slider lives.
A recipe written before this commit still reads: the keys are unchanged.

The four knobs. The first pass of the mapping spec added a mask per zone onto the
channel: `luma += knob * mask * intensity`, and the shader followed it. It is the
wrong shape, and the twin harness in `highlight-knee-check.mjs` shows why — the
four masks are not a partition of the ramp. They sum to one at the ends and to
zero at the midpoint, so an adjustment in the middle of a zone is applied where
the mask is half and not at all where the mask has fallen to nothing, and the
ramp inverts: with every knob at its stop the curve folds over itself, slope −5
at t=0.87, and the twin catches it as a non-monotone ramp.

So each knob moves a knot on the curve instead, which is the reading the spec's
own mask geometry points at — BLACK peak at 0.00, SHADOW 0.00→0.25→0.50,
HIGHLIGHT 0.50→0.75→1.00, WHITE peak at 1.00 — and the shader builds the curve
through those four anchors. `TONE_ANCHOR` is 0.25: one full knob at its stop is a
quarter of the range at that knot, so the range is 0.75..1.00 at the top and
0.00..0.25 at the bottom, and the anchors stay ordered (`a0 ≤ a1 ≤ 0.5 ≤ a3 ≤ a4`)
by clamping each against its neighbour. Between knots the curve is a straight
line, and 0.5 is untouched by every knob, so a knob at zero is the identity
exactly rather than nearly, and any combination of the four is monotone. The mask
sum survives where the spec is right about it: it hints the split between the two
dark zones and the two light ones, nothing else.

The hue is kept the way the spec keeps it: work in luma, then scale the chroma
offset — `rgb = luma_new + (rgb - luma_old) * luma_new / luma_old` — so a
saturated red stays the same red and only its brightness moves. The ratio is
clamped to 0.55..1.35 because at luma near zero the division is the whole
highlight of the picture on one code value.

Verified:

- `node scripts/highlight-knee-check.mjs` passes. It pins the settled shader —
  four masks, four anchors, the four `mix` lines — and asserts the constructions
  it replaced are gone, then drives a twin of the ramp in JS: the masks do not
  overlap, every knob at zero is the identity, the midpoint is 0.5 for all 162
  combinations of the four knobs, every combination is monotone, the amplitude at
  each stop is a quarter, and the DR offsets land on 0.12 and 0.82. The folded
  case from the additive build is in the harness as a regression.
- `npx tsc --noEmit` clean; `npm run build` emits `index-DXIIw2F1.js` and
  `index-A4pA1U5f.css`; `library-check.mjs`, `scan-nav-check.mjs`,
  `roll-walk-check.mjs`, `auto-tone-check`, `half-check`, `preview-match-check`
  and `white-level-check` all pass against the bundle — the catalogue, the RAW
  path, auto tone and the white level are untouched by the panel move.
- Driven in a real browser (`tone-live-check.mjs`, Chromium against
  `vite preview`, a P1010256.JPG in the source control, mean luma of the preview
  canvas read before and after each knob): neutral 184.25, WHITE +1 187.35,
  BLACK +1 186.35, SHADOW +1 194.53, EXPOSURE +1 206.99, HIGHLIGHT −1 173.80.
  Every knob moves the picture the way the spec says it should and none of them
  moves it much — a stop of a knob is a quarter of a zone, not a level.
- The same run asserts the built DOM: five panels, the 23 `data-key` rows,
  `dev-temperature` in WB, `dev-whites`, `dev-blacks`, `dev-highlight` and
  `dev-shadow` together in TONE, the gradient classes on the two white balance
  sliders, and the chip slots the panel is handed. The only failed request is
  `/api/events`, which is the backend this preview does not run.

ponytail: the recovery of blown highlights that used to sit under HIGHLIGHT — a
per-channel rolloff in linear light — is gone, deleted rather than ported. The
additive mask is why it was there: HIGHLIGHT had to do two jobs because a mask
could not shape a curve. Now that WHITE owns the top end, HIGHLIGHT only bends,
and the per-channel rolloff is a second knob for the same picture. Bring it back
as its own parameter if a frame ever clips badly enough to need it.

Also dropped: DR used to ride along as two additive terms. That is where the fold
at t=0.238 came from, BLACK −1 and SHADOW −1 together — the two terms pushed the
ramp past its own end. It shifts the knots now, which is what the film sims
always meant by it, and the numbers in the sims were kept and their meaning
recommented (classic-chrome toe 0.22, head 0.7375, etc.).

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 16:38:56 +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.