3dtours abfc6595e7 web: take hold of a repair, and draw a stroke as the mark it was
A repair laid on the photo was finished the moment it landed. The brush could
only put more spots down, so a repair aimed one brush-width off the speck was
deleted and laid again, and the patch a spot borrowed — the other half of what
the renderer works with — could not be moved at all. And a drag, which is one
mark of the brush and is drawn as one while it is being painted, came back as
the beads it is stored as: a run of circles a fraction of a radius apart, each
showing its own edge, so a long stroke over a scratch read as twenty repairs.

HEAL's spots can now be taken hold of. A press inside a spot's circle moves that
circle — the hole, or the patch it borrowed, one at a time, since the pair is
the user's to arrange — and the repair is re-rendered under the pointer as it
travels, off the same snapshot the preview and the export read from. A press
that does not travel only chooses the spot, and a chosen spot wears a small ×
just off its circle: click it and that one spot goes, the rest keep their
places, and UNDO takes it back. One gesture is still one step — the undo
boundary is the gesture's first actual change, so a drag is a single step
however far it went and a press that only chose a spot records nothing. The
recipe is written exactly as before, one list of fractions and radii, so a moved
or deleted repair survives a reload, rides UNDO and REDO, and reaches the
exported file through the numbers it always did.

The band a stroke leaves is now read back off the recipe's own spots: the ones
that overlap — which is what a drag lays, one spot every 0.6 of a radius — are
joined into one run and drawn as a single path of the brush's own width, and
only a spot with no such neighbour keeps the circle it is. One path per run
rather than one capsule per pair, because the band is translucent and a pair of
capsules would print a darker patch wherever they meet — which is what a row of
overlapping circles looks like in the first place. Two spots whose circles do
not overlap are two marks and stay two: a band drawn through the gap between
them would be paint that is not there. Nothing about a stroke is stored, so the
band is a reading of the geometry the shader works from, and a saved photo opens
onto the same band it was left with.

Three things came out of the probe rather than out of the design, and all three
are in here because the numbers said so:

  - The × first sat on the spot's corner at the brush's own radius. The default
    brush is 6px wide and the badge is 18px across, so the badge covered the
    circle: the next press on the repair — a user putting the spot down again —
    deleted it. Measured: after undo/redo, a press at the spot's centre left one
    spot instead of two. The badge now sits on the top-right diagonal at the
    circle's edge plus a badge's radius, so it can never take a press meant for
    the spot.
  - The hit test first took the hole before the patch. On the default brush the
    patch the search borrows sits about 8px from the hole it fills — inside any
    reach a pointer can use — so dragging the patch's own centre grabbed the hole
    and the patch never moved (measured: dragging the patch from (0.331, 0.300)
    to the neighbouring speck left it at (0.331, 0.300) and painted a stroke
    instead). The nearest circle now wins, and the hole wins a tie with its own
    patch.
  - The reach was first the circle plus 8px, for a brush turned down to a few
    pixels. heal-probe.cjs went to 48 PASS / 1 FAIL: eight clicks on a grid
    12.8px apart were meant to lay eight repairs and four of them landed, because
    four were within 8px of a patch circle and grabbed the spot instead. The
    reach is now the circle plus 4px: the same probe is 49/0 and the patch's
    centre is still 0px from the pointer that grabs it.

MOSAIC's spots are deliberately not held, and that is the one asymmetry here: a
repair is aimed, a mosaic cell is part of a region that gets painted over, and a
grab that could take a cell would also be one the user could not paint through.
Its cells are drawn as one band like HEAL's, since a mosaic stroke is the same
kind of mark.

Verified, on the rebuilt app at http://localhost:8090 (docker compose up -d
--build frontend):

  brush-edit-probe.cjs (new, 33 checks, 0 FAIL): the band is one path of the
    brush's width through all 15 spots of a drag, its length inside 2px of the
    polyline the spots stand for, every joined spot's border transparent and a
    lone spot's not; dragging the hole moves it to (0.550, 0.550) at the size it
    was laid and the speck comes back at (0.300, 0.300), UNDO/REDO move it back
    and forth in one step each; dragging the patch onto the neighbouring speck
    puts the speck back into the repair (level 5 on a field of 151) and UNDO
    returns it; a press chooses a spot and shows the ×, that press records no
    step (the next UNDO still takes the last repair back), the × deletes that
    spot and no other, and UNDO restores it; a mosaic drag's overlapping cells
    are one band and every cell in the run joins it, painting across mosaic
    already laid down paints more cells, and no mosaic spot is ever offered an ×.
  Unchanged and still green: heal-blotch-lab.cjs 12, heal-edge-lab.cjs 9,
    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.
  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: a stroke is still not stored — the band is derived from the spots that
overlap, so a stroke whose pointer jumped (a coalesced event, a fast flick) lays
spots further apart than the brush is wide and comes back as separate circles,
and a run breaks where the wheel changed the brush size mid-stroke. A stroke id
in the recipe, written once per gesture, is the rung for that, when a photo shows
a run the geometry cannot join. The hit test is the nearest circle within a few
pixels, so a press meant to paint a new repair within that reach of an existing
one moves the existing one instead — a shared modifier to paint regardless is
the rung there. Choosing a spot is an index into HEAL's list, so an UNDO that
changes the list under a chosen spot can leave the × on the spot that took its
place; the × is guarded against an index past the end but not against that. No
keyboard delete: the × is the whole affordance. And the band is drawn only while
the brush is armed — the spots are the recipe's, so nothing outside FX sees
them, which is the same as it was.
2026-09-24 07:21:01 +07:00
2026-07-17 09:33:24 +07:00
2026-09-16 18:11:17 +07:00
2026-07-17 09:33:24 +07:00
2026-07-17 09:33:24 +07:00
2026-07-17 09:33:24 +07:00
2026-07-17 09:33:24 +07:00
2026-07-17 09:33:24 +07:00
2026-07-17 09:33:24 +07:00
2026-07-17 09:33:24 +07:00
2026-07-17 09:33:24 +07:00
2026-07-17 09:33:24 +07:00
2026-07-17 09:33:24 +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 6.6 GiB
Languages
TypeScript 92.9%
Kotlin 5.7%
JavaScript 1.3%