The last commit pasted the borrowed patch at the light of the place it lands in,
and read that light as one number per spot: the mean of the ring around the dust
minus the mean of the same ring around the patch. That number is a light the
place has when the ring is all one thing. It is not a light the place has when
the ring is not. A twig, a hairline, the edge of a table under the brush, and a
minority of the taps stand on the thing rather than on the ground: the mean then
follows the minority — it is a colour the place never had — and the patch is
pasted in it. The donor was of exactly the right light, the search had gated it
at LIGHT_GATE, and the repair still lands as a dark blotch. A mean is the wrong
estimator for a ring that is not one thing; the search already knew that, and
reads its own ring as a median for the same reason.
So the light is read once PER DIRECTION. Each of the sixteen taps is a pair of
readings — the place's ring and the patch's ring at the same sixteen places —
and a pixel takes the correction of the two readings it lies between,
interpolated by its own angle around the spot, in the same single draw. The rim
then meets the place all the way round instead of on average: a tap that landed
on the twig bends the part of the rim near the twig, and the far side of the
circle is left where it was. Sixteen taps rather than eight because a tap's
influence reaches only as far as the next tap, so the finer the ring, the less
of the rim one hard pixel of the photo can drag with it.
The second half of the change came out of the app, not out of the lab. With the
per-direction reading and no other change, heal-probe.cjs went from 49 PASS to
41 PASS / 8 FAIL: the repair's own centre came out 15-18 levels dark on a flat
field, with the frame around it clean. The reason is the estimator again, from
the other end — one direction is one pair of pixels and carries no averaging, so
whatever the ring reads at that direction, the patch gets in full. And the ring
at RING_R alone is not clear of the dust: a speck spreads about a pixel past
where it is drawn in the pixels the shader samples, so the nearest taps sit
inside the dust's own soft edge and read the dust's light. The mean had been
hiding it: one contaminated tap in eight is a level off; the same tap read whole
is the blotch. The ring is now a pixel further out again (RING_PAD), in the same
pixels the sampler works in — a fraction of the radius would be nothing at all at
the sensor-dust end of the brush, which is where this tool is aimed — and the
probe is back to 49 PASS / 0 FAIL.
Measured on a sweep of the two ways of reading it (heal-ring-sweep.cjs, CanvasKit,
three scenes, the step the eye reads at the rim plus the level of the patch's own
middle against the ground it landed in, levels out of 255):
scene shipped mean 8@1.15 this: 16 taps, per direction
uniform light difference step 0, centre 0 step 0, centre 0
twig across the ring step 53 (mean 16.4), step 56 (mean 2.4),
centre 34 dark centre 0
brush fits the speck centre 5 dark centre 0
The worst step on the twig scene is unchanged — that is the twig's own edge
crossing the rim, which no level can meet, and the floor the copy set at 85. What
moved is the average (16.4 levels to 2.4) and the level of the patch's middle,
which is the blotch: 34 levels of a place that never had them, down to none.
No new dependency. cv.seamlessClone is the same thing this shader already does —
the membrane half of a Poisson edit — and OpenCV.js would be 5-10 MB off a CDN,
solved on the CPU per spot, outside the one draw the preview, the recipe and the
export all read from: the correction is recomputed from the snapshot on every
render, which is why the preview and the exported file agree by construction and
why a saved photo opens onto the same repair. It also cannot run in the worker
the brush paints in or against the fractions the recipe stores.
Verified:
heal-blotch-lab.cjs (scratchpad, CanvasKit, no browser) — new, 12 PASS / 0
FAIL, and 8 PASS / 4 FAIL against the bundle built from 57ade27, which is what
the lab is for. Two scenes, each run twice through the real pipeline: the
mean (kept inline in the lab as the "before") against healSkSL from the
bundled heal.ts. Twig across the ring: the mean puts the patch's middle down
at 116 against a ground of 150 (34 levels dark), the module at 150. Brush
fitting the speck exactly: 145 against 150, the module 150. In both, the dust
is gone rather than dimmed, and the frame away from the circle is the photo.
heal-edge-lab.cjs — 9 PASS / 0 FAIL, its ring metric narrowed to the rim the
ring has already handed back to the light (one tap spacing either side of the
band excluded: the rim nearest the band is bent toward the band on purpose,
and that bend is the fix, not an error). Light side of the rim 0 levels off
(the one mean level: 32), and the band the ring caught is met at 34 against
the copy's 90 — under the old ceiling, not over it.
heal-seam-lab.cjs 10, heal-skia-lab.cjs 28, heal-search-lab.cjs 15,
heal-probe.cjs 49, heal-zoom-geom.cjs 5, heal-zoom-probe.cjs 8,
mosaic-skia-lab.cjs 27, mosaic-probe.cjs 51 — all 0 FAIL, against the rebuilt
app at http://localhost:8090 (docker compose up -d --build frontend).
Regressions against the rebuilt app, rc=0, 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 correction lives on the rim — every pixel takes the two readings it
lies between and interpolates — so it is the boundary of a Poisson edit and not
its interior: a repair laid over something the patch cannot reproduce still
carries the patch's own texture inside, bent to fit the rim, and the other half
of the solve (a correction that relaxes inside the circle, a Jacobi ping-pong per
spot) lands when a real photo shows an interior the eye can find from the rim
alone. A direction's level is one tap pair on each side, so the grain the mean used
to average away now rides the rim as a wedge of a level or two across the circle —
a tangential average of three taps per direction is the rung for that, when a
photo shows the wedge. RING_PAD is one pixel because that is the width of the
dust's own soft edge in the sampler's pixels; a speck whose blur is wider than
that still reaches the ring. The source is still found by the ring search — eight
directions at three distances, each mirrored — and not by PatchMatch, and the
recipe still stores fractions with no correction in it, so nothing about this
change is versioned in a saved photo.
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.