HEAL came back a stain larger and softer than the stain it was asked to remove, and the model's half of it answered in a colour that appears nowhere near the repair. Three causes, all inside `engine/inpaint.ts`: - The fallback's reading ring was the hole dilated by two pixels whatever the brush, so the boundary of the fill was the dirt's own penumbra and the fill came back as the dirt. The pad is now proportional to the hole's own radius (`rimPad`), the fill is an inverse-square average of the clean ring relaxed into one field by 36 Jacobi rounds, and the hole's grain is read against a 2px box of the clean pixels and taken from the pixel it mirrors through its own middle. The texture half before this was a no-op — `detail = rgba[p] - fR[idx]` with `fR[idx]` the same pixel's own colour, so every donor handed over zero and the hole was the smooth field alone (15.6% of the ground's detail). Measured on a stain of radius 10 with a halo to 16 under a 12px brush (`probe-fallback.mjs`): the repair landed 22.3 levels below the ground it sits in, and comes back 0.06 off it. - The plate the model reads under the mask was the mean of every pixel of the crop, so the network met a hard step at the mask's own edge and answered in that colour. It is now the rim's own light spread inward (`cropPlate`), and the mask is downsampled by "any hole pixel in this cell" rather than by a corner sample. Step across the mask's edge: 69.3 levels before, 9.5 after. - The same repair came back different every time the reader zoomed out of it: the crop was sized from the stage's own pixels on the zoom ladder (`PREVIEW_QUANTUM`), so each zoom handed the model a different grid, and every grid invents its own pixels. The crop's box is now its share of the FRAME (`INPAINT_REF_LONG`, `exportEngine` 6e), so one spot is one grid at every zoom and at the export's own size. The patch is deliberately not baked into the photo: a recipe stays reusable on other frames and the repair stays a pass. ponytail: the pixels handed to the model are still read from whatever the stage rendered at, so the answer is the same repair to within the resampling of its input; a patch cache keyed on the frame's identity and the spots is the rung that makes it identical, and the one to take if a zoom still shows a repair move. VIGNETTING now runs both ways (-10..10): down darkens the four corners, up lifts the lens' own falloff out of them with a white ink — the same curve and the same weight. EV was defined and live on the phone but absent from the wide panel's own Light row, which is `DevelopPanels.tsx` and nothing else. `npx tsc --noEmit`, `vite build`, `heal-check`, `studio-drag` and the other suites pass. `studio-export-check` fails one assertion here on the code at HEAD as well — a frame left behind files its waiting look 9s after the pick — so it is not this change.
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.