3dtours b795517d9f web: read a patch's light before pasting it, and paint with the brush
The brush was not healing: clicking a speck deleted one black spot and made
another, and the borrowed patch landed in a light the spot was not in, so the
repair read as a mark of its own. The circle the brush draws also slid off to
the side of the pointer as soon as the photo was zoomed in, and a drag showed
itself as a row of overlapping circles rather than as a brush being drawn.

The search was comparing the wrong thing. findHealSource scored a candidate
against the spot's own PATCH_TAPS — the centre and a ring at half the radius,
which is INSIDE the brush, where the dust is. The patch that matches a speck
best is then the one carrying a speck of its own, which is exactly how "heal a
spot" became "move it a few pixels": with a neighbour sitting at the 2.6r ring
the search itself prefers, the winner was that neighbour, 26 dark pixels pasted
where the repair was meant to be.

The taps are split now, by what they are for. The light a repair has to sit in
is read off the spot's RING — twelve taps at 1.15r, just outside the dust, the
scale the eye reads a spot's surroundings at — and taken as their MEDIAN,
because the ring can only be a little way out: some of its taps land on the
speck's own softened edge, and a mean drags the whole light down by them (the
eight-tap mean read 84 where the ground was 150, and with the gate below that
refused every candidate on the frame). What a candidate would actually paste is
the mean of its own inside taps, now including the ring at HEAL_FEATHER of the
radius — the circle is copied at full strength out to there, so that is where a
neighbour's dust leaking into the patch shows up and the middle of the patch
would never see it — and its cleanliness is how much those taps spread around
their own mean: dust is an outlier in its own neighbourhood, grain is not.

A candidate from another light is not scored at all. Past LIGHT_GATE (20 levels
of the 0-255 the sampler answers in) the patch IS the mark the user is
complaining about, so the search returns null rather than sending a wrong clone
and the caller leaves the speck alone. Within the gate the score is light * 3 +
cleanliness, so the light decides and cleanliness breaks the ties the eye would
not see. A spot the search refuses is not laid down at all — healUp skips it
instead of recording a self-patch, which was a repair that changed nothing —
and a stroke that is refused end to end reports no spots, which addHealSpots
already treats as nothing to do: no step in the history, no spot on the photo.

The ring had to be a fraction, not an offset. healPos was the pointer's pixels
inside the layer, and the layer carries the stage's transform, so a zoom scaled
that offset a second time: at 1:1 the pointer sat at screen x 846.5 and the
ring was drawn at 1288 — 442px away, the same distance the user sees as "the
circle is in the wrong place when I zoom in". The pointer is stored as a
fraction of the photo now — healPoint already answers one for the spot it lays
— and drawn as a percentage of the layer, so the layer's own transform scales it
once; off the photo there is no ring. The eyedropper's icon had the same shape
of bug (its sample was always right — pickAt reads the photo's own rect) and got
the same fix in the same file, since it was two lines.

The stroke is one mark of the brush. The trail was a circle per point of travel,
laid one HEAL_SPACING (0.6) radii apart, which is what a row of beads looks
like; it is one SVG path with round caps and round joins now, its width the
brush's own diameter and its colour the accent at 45%, so what the pointer draws
reads as the band it is about to lay down. The count of travel is kept on the
element (data-points) so the probe can still hold the run it becomes to the run
it showed.

Verified:
  heal-search-lab.cjs (scratchpad, Node against the bundled heal.ts) — 15 PASS,
    0 FAIL: one speck alone is repaired, from a patch that is clean field, and
    its light is 0.0 levels off the spot's own; a speck with a neighbour exactly
    at the search's first ring borrows from the far side with 0 dark pixels
    pasted; a speck ringed with dust in all eight directions skips past the ring
    (0 pasted); a speck in the corner stays inside the frame; a speck at the lip
    of a shadow, where every reachable patch is 60 against a ground of 150, is
    refused (null); ground with a dark edge through it is not a refusal — the
    repair comes from the light side and its light is 0.0 levels off.
  heal-skia-lab.cjs — 27 PASS, 0 FAIL (the shader and the search unchanged in
    everything the search is not asked here).
  heal-probe.cjs (the rebuilt app at http://localhost:8090) — 49 PASS, 0 FAIL,
    no page errors: the circle rides the pointer at the size the chip reads; one
    click heals a speck to 151 with its four neighbours field; the borrowed
    patch is a real distance away and is clean field; a drag shows ONE mark,
    6.1px wide against a 6.1px brush, standing for 15 points of travel, lays
    exactly 15 spots, clears on release, and UNDO takes the whole stroke back;
    25 spots carried with the first healed speck still first; everything gone
    after a reload; CLEAR brings it all back.
  heal-zoom-geom.cjs — 5 PASS, 0 FAIL: at fit and at 1:1 the ring's screen
    centre is the pointer (846.5,452.5 both times, against 1288 before), the
    ring keeps the brush's size on screen, and a repair made at a zoom lands
    under the pointer.
  heal-zoom-probe.cjs — 8 PASS, 0 FAIL: on a structured 2048px photo at 1:1 the
    speck goes, the donor is at least a ring away, the patched circle is within
    1.07 levels of the ground it landed on, the donor's own circle is drawn on
    the pixels it borrowed; on a navy field with three specks, two repairs land
    0.0 levels from their ground.
  heal-look2.cjs (scratchpad, PNGs in /home/locpham): the pair case used to
    paste its neighbour and read min 3 inside the healed circle — the pasted
    dust — and reads 151 now, the untouched second speck alone in the frame;
    the big-speck case (dust r=9 under a 6px brush) now lays NO spot at all,
    which is the refusal working: the speck is left alone instead of smeared.
  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.
  web tsc --noEmit clean.

ponytail: a refusal leaves the speck on the photo and nothing on the screen —
the user closes the brush in a size that covers it and clicks again — which is
the honest half of the trade the user asked for, but it is silent; a hint would
mean a toast or a shake, and neither is worth a component. The gate is a
flat 20 levels, not a percentage of the local contrast, so a photo with a hard
edge through the brush's own ring reads as one light and can still take a donor
from the other side of it. The search reads the preview JPEG rather than the
original, so a patch near the preview's own edges is chosen from the pixels the
user is looking at, not from the ones the export will print. And the run a
stroke leaves behind is still drawn as its spots, circle by circle, because each
one is a repair with a borrowed patch of its own — drawing the laid run as one
band would need the recipe to remember the gesture (a stroke id on the spots),
which is a recipe change and not what was asked.
2026-09-23 21:36:33 +07:00
…
…
…
…
…
…
…
…
…
…
…

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 .recipe files; 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.

S
Description
No description provided
Readme 7.8 GiB
Languages
TypeScript 92.9%
Kotlin 5.7%
JavaScript 1.3%