The report was "giá trị thay đổi của các thông số quá nhỏ, không thể hiện được
trên thị giác của ảnh" — at the doc's own rates a full +100 was worth 0.060 of
luma on BLACKS, 0.082 on HIGHLIGHTS and 0.042 on WHITES, and the probe that ran
the frame through the pass read BLACKS +100 moving its mean by 0.0001. Every
rate below is now the largest its own move allows, measured rather than
inherited: 0.058 -> 0.080 on BLACKS, 0.082 -> 0.113 on HIGHLIGHTS and 0.042 ->
0.080 on WHITES, with SHADOWS' 0.151 left where it was because it already bit.
- `TONE_BLACK_LIFT` 0.7 -> 0.93. The ceiling is a FOLD, not a slope: past
0.9387 the doc's own square root carries luma backwards inside its window
(0.94 folds 3.4e-6, 0.95 folds 8.9e-5) and a gradient wears it as a band.
0.93 is the last round rate under it — monotone on the check's 1/32768
grid, and the 1e-4 of travel between it and 0.94 is not a code value.
- `TONE_BLACK_CRUSH` 0.85 -> 8.0. The doc's own form — `L * (1 + amount * W
* 0.85)` — is bounded by its own window, which is 1 only AT the floor, so
its whole visible travel at full -100 is 0.019 of luma: five code values on
a black patch, and a rate past 1 drives the product negative and clips the
toe to a flat black instead of deepening it. The toe's own EXPONENT,
`L -> W * (L/W)^(1 + rate * W)`, is monotone for ANY rate and worth 0.054,
while x = 0 stays on 0 and the 0.18 edge stays on 1 — both anchors and the
compact support kept.
- `TONE_HIGH_GAIN` 2.5 -> 14.0, with the head term moved from `(1 - L)` to
`(1 - L)^2`. The linear headroom dies too slowly to keep the rate's own
ceiling off the clamp: above a gain worth 2.6 the move overshoots 1.0, the
clamp draws a plateau and the ramp falls back over it by 0.018 — the fold
the knee check now measures as a drawdown from the running maximum. Squared,
the move has died out by the time the ramp reaches the clamp: 14.0 is
fold-free at both signs and still lands 1.44x of the 1.5x the quarter it
owns is allowed.
- `TONE_WHITE_GAIN` 1.5 -> 3.0, which takes the top of the ramp TO the
ceiling from 0.92 up and leaves the clamp to flatten what is left. That is
the doc's own §2.4, where a WHITE is the frame's clipping point ("giới hạn
cháy sáng") and not a Hermite that cannot move the head; it is felt only
above the 0.80 shoulder and the head is still exactly 1.0 on 1.0.
`FILM_TONE` is re-solved for the squared head, which is worth less at the 0.75
knot for the same rate: monochrome -0.16 -> -0.1143 and mono-high-contrast
+0.83 -> +0.5929. The four knots the stocks are tuned to do not move — 0.22 and
0.7375 on Acros, 0.17 and 0.815 on Acros HC — and the check pins each of them.
The knee check's guard is REPLACED. The old one compared the first cell of the
sweep against the second (a slope at 1/512), which is blind to a fold that
starts later: it passed a ramp whose own drawdown was 0.018. The new one walks
1/32768 of the ramp and measures `running max - value` for each knob at both
signs, over the four moves alone and then over a 243-combination sweep, so what
is pinned is the fold itself and where it is.
Two folds are pinned rather than removed, both named in the check:
- SHADOWS -100 dips 0.018 (4.6 code values) around 0.06..0.11 of its own
accord. It is the doc's §2.2 formula — `L *= 1 + amount * W * (1 - L)^1.8`
— where the window rises faster than the light, and it PREDATES this change.
The monotone rewrite (`L' = 1 - (1 - L)^(1 - SH * |a| * W(L))`) is written
out beside it and was NOT taken: it is exact but it costs the knob 30-50% of
its crush.
- WHITE +100 rests a plateau on the clamp from 0.92 up. That is what §2.4 asks
of the knob and the ramp is non-decreasing through it, so it is not a fold.
`scripts/tone-base-check.mjs`'s mirror of the pass takes the same three moves
(its own `toneBlack` and the squared head) so the pixel it predicts is still the
pixel the pass draws.
Checked: node scripts/highlight-knee-check.mjs; node scripts/tone-base-check.mjs;
node scripts/auto-tone-check.mjs; node scripts/half-check.mjs; node
scripts/mask-wb-check.mjs; node scripts/preview-match-check.mjs; node
scripts/raw-develop-check.mjs; node scripts/white-level-check.mjs; node
scripts/wb-table-check.mjs; node scripts/sharpen-check.mjs; node
scripts/denoise-check.mjs; npx tsc --noEmit.
RecipesCam
RecipesCam is a camera and photo-editing app built around recipes — reusable
looks that carry a film simulation plus a full set of adjustments. You shoot or
open a photo, dial in a look, and keep it as a recipe you can apply again, share
as a .recipe file, or save to your account.
It ships twice from one repository: a React Native (Expo) app for iOS and
Android, and a self-contained web build under docker/ that runs the same
render pipeline in the browser.
What it does
- Shoot with a recipe applied. Live viewfinder, GPS-tagged captures, and the recipe baked into the saved file.
- Film simulations. Built-in looks — PROVIPES, VELVIPES, CLASSIC CHRIPES, CLASSIC NEGIPES, ASTIPES, ETERNIPES, ACRIPES, B&W HIGH CONTRAST and the LC STREETLIFE pair — each with its own grain and tone curve.
- The full adjustment set. Exposure, contrast, highlights and shadows, saturation, colour temperature, clarity, grain, and an HSL mixer with a colour picker that samples straight off the photo.
- Geometry. Crop to a fixed ratio or free-form, quarter turns, and a straighten ruler, plus printed frames (classic border, retro instant, wall frame portrait/landscape).
- Finishing. Watermark and GPS stamp, EXIF carried through the export, JPEG written with a proper 300 DPI JFIF header.
- On-device upscaling. A Real-ESRGAN pass runs locally when an export asks for more pixels than the source has — no server sees the photo.
- Recipes. Save, favourite, export and import
.recipefiles; the web build keeps them in your account, the phone build also keeps them on device.
The two builds
| Build | Where | Stack |
|---|---|---|
| iOS / Android | repo root | Expo + React Native, @shopify/react-native-skia for the render pipeline, NativeWind for styling |
| Web | docker/ |
Vite + React, CanvasKit (canvaskit-wasm) for the same pipeline, Fastify + SQLite API for accounts and recipes |
The render engine is shared by design: the web build compiles the app's own
src/utils/* and type definitions unchanged, with @shopify/react-native-skia
aliased to a CanvasKit shim (docker/frontend/src/engine/skiaShim.ts). A look
looks the same on both because it is the same code.
Running the web build
cd docker
cp .env.example .env
docker compose up -d --build
# → http://localhost:8090
Photos never leave the browser: grading, framing, watermarking and JPEG
encoding all run in the visitor's tab; the API only stores accounts and recipe
JSON. See docker/README.md for the layout and the proxy setup.
Running the app
npm install
npx expo start # Expo Go / dev client
npx expo run:android # or run:ios for a native build
Repository layout
App.tsx, src/ the Expo app: screens, tool rail, viewfinder, shaders
docker/ the web build (frontend + API + compose file)
frontend/shared/ vendored copies of the app's types and utils
frontend/src/engine/ CanvasKit shim, export engine, super-resolution
docs/ privacy policy
THIRD_PARTY_NOTICES.md licences of the bundled fonts, models and libraries
Licence
See LICENSE and THIRD_PARTY_NOTICES.md.