A press anywhere on a mask's outline takes hold of it to turn it, and the turn it asked for was read as an absolute bearing: the direction from the shape's pin to wherever the finger now happened to be. The outline is a grip a hand lands on wherever it likes — a ramp's line runs the whole way across the photo — so the moment a press was set down on it the shape swung round to face that finger. A press on the edge of a ramp lying across the photo stood it upright on a ten-pixel move, and a shape already turned snapped to the angle of the press before it had been dragged at all. The turn is now what the hand turns: the change in bearing about the pin since the press, which on the first move is the press itself. A finger that lands on the outline and stays there leaves the angle exactly as it found it; one that carries the shape round by a quarter turn turns it by a quarter turn, whatever angle it was at to begin with. An ellipse adds that turn to the angle it stores, a ramp spins its two ends about its own middle, and both are measured in the photo's own pixels so the two bearings are the same kind of thing. ponytail: The turn is read against the previous pointer position the drag already keeps, so it costs one subtraction and no state. A pointer that leaves the photo mid-turn keeps the angle it stood at — the rule the brush already follows — and the next move on the picture resumes from the last position that was on it. Verified: tsc clean; mask-probe 52/0 on the dev server and again on 8090 — a press on a ramp's own edge that travels a hundredth of the photo's width leaves the ramp at the angle it was taken hold of at (ends 0.500 and 0.500), and on a shape already turned the same press keeps it upright (0.562,0.260 and 0.558,0.860); a quarter turn of the hand on the edge turns the ramp a quarter of the way round (ends 0.200 and 0.800, one pin, no second shape laid); the pin still takes the shape with the hand to where the hand went (0.560,0.560, no sideways throw); the rim of an ellipse still turns with the hand on it (1 pin); another chip still leaves 0 mask columns and the mask chip brings 1 back; DELETE still takes the chosen shape off the photo with its grade (64 -> 255, then back to 61). Against the code before this change the same probe fails 9, both angle checks among them — the ramp lands at 0.850,0.560 and 0.860,0.562, that is, wherever the finger was. brush-edit 33/0, heal-idle 23/0, heal-zoom-drag 28/0, landing/pro-gate/ award-column/otp-code/tone-curve/hsl-panel/grain-controls/chip-edge/chips-desk/ slider-reset/temp-swatch all ALL PASS, backend 180/0.
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.