The request was a mask's EXPOSURE losing hue, and the mask pass has not done that since6d60d45: measured today through a page the service worker controls, with an ellipse over the whole frame and +1 EV, the pixels inside move exactly as the frame's own knob moves them — identical to the byte (mask+1 vs frame+1 over four codes: 619 pixels of 1,709,450, all of them on the rim), and the hue each one leaves behind is the same distribution to a hundredth of a degree over the 370,606 pixels that carry a hue in both reads (mean 2.07°, p99 15.31° for the mask against 15.32° for the frame; on the vivid pixels, mean 0.83° at +1 EV). The one place that still says otherwise is a doc on the Android branch, whose §3.2 quotes the old line. But the symptom is real, and the build that produces it is the one from before that commit, where maskAdjust multiplied the three channels by the stop: c = c * half(pow(2.0, a.x)); Three channels clip by three different amounts, so the differences between them stop being scaled together and the hue goes with them. That bundle could still be what a visitor runs, because of two files the deploy never took away: - index.html was the only document the server handed over with no Cache-Control at all (the .mjs, /assets, /wasm and /models locations all name their policy, sw.js and the manifest both opted out). With no header the browser is free to guess a freshness window out of Last-Modified — a tenth of the file's age — and answer a navigation from its own cache for hours after a deploy. The page it answers with names the previous build's hashed bundle, so the previous shader is what runs, and a hard reload is the only way out. The SPA fallback lands on the same file (an internal redirect re-matches locations), so /app and /library were covered by the same guess. - sw.js is the second place the pin lived. Its navigations are network-first, but a plain fetch is not the network: it can be answered by the browser's cache, so the network never came first — and the old shell's hashed bundle, once fetched, is a STATIC path that the cache-first rule serves forever. So: nginx names the policy for the shell, with the isolation pair restated because an add_header in a location drops every inherited one and index.html is the document that needs them — the wasm renderer's SharedArrayBuffer is behind that pair. The worker reads its navigations past the browser's cache, precaches the shell the same way (a cache.add of '/' consults that cache like any other fetch, so a shell read inside a stale window would be stored as the offline shell of the build that replaced it), and VERSION goes to v2, whose activate drops the cache the old worker pinned — the old shell and the old bundle with it. The root fix is still6d60d45: this commit is what lets it reach the browser. Verified: nginx -t on the shipped config, and a container of this config against a copy of index.html hands / and /app `Cache-Control: no-cache` with all five original headers intact, while /assets/index-abc.js keeps `public, immutable` (30 days) — the exact location does not shadow the hashed bundle. node --check on sw.js. The measurements above come from the live 8090 build inside a persistent profile whose page is controlled by the worker (controlled true, crossOriginIsolated true, bundle index-CKOd57MG.js), the same session that pinned the build the fix is about. Co-authored-by: PenguinHarness <noreply@penguin.local>
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.