A folder of RAW took the page down. The catalogue reads four frames at once —
that is what makes a roll land quickly, and for a JPEG it is free — but a RAW is
not read by this page at all: it is handed to libraw-wasm, which opens it inside
a worker of its own, and that worker is built with a quarter of a gigabyte of
linear memory (emscripten's shared memory, 256MB, instantiated per instance) and
the whole frame is copied into it. Four of those at once, on frames of 22.6MB,
is past what a renderer is given before a single tile is drawn, and the page goes
with it — which is the crash this answers.
One open at a time now, whoever asks for it: the gate wraps the read of a RAW and
nothing else, so the frames around the one being opened still have their bytes
read off the disk, their decodes run and their tiles encoded side by side. Only
the open queues. A folder of JPEGs never touches the gate and keeps all four
lanes.
The tile also stops asking the decoder a question the bytes answer. A JPEG — and
the preview a camera writes inside a RAW is one — carries its frame size in its
own header, in the first few hundred bytes the read already holds for the date.
`jpegSize` reads it there, and `tile` is handed the size instead of decoding the
whole frame a second time only to learn which edge is long. `jpegSize` existed
for exactly this and had no caller; it has one now.
Verified:
- library-check.mjs, scan-nav-check.mjs, roll-walk-check.mjs all pass against the
built bundle — the catalogue still reads a roll, a RAW still becomes a tile off
its own preview, a frame still says where it was shot, an interrupted reading
still goes on by itself.
- A stand-in folder answered `showDirectoryPicker`, 48 frames of 22.6MB, resident
memory of the whole browser process tree sampled every 150ms:
- before: 4 frames read at once, peak RSS 1335MB, 565MB before the roll was
picked, 12s, 48 tiles, no error;
- after: 1 frame read at once, peak RSS 1180MB, 32s, 48 tiles, no error.
The 2.6× on the clock is mostly the harness: its `getFile` copies 22.6MB per
frame, so serialising the open serialises that copy too. A real folder pays a
disk read and an open in sequence instead.
- 200 frames of 8.5MB JPEG, same harness: peak 1713MB, 4 at a time, 200 tiles,
no error — the JPEG path is untouched by this commit.
ponytail: reading a RAW could skip LibRaw entirely — the camera's preview is a
plain JPEG and could be cut out of the head of the file this page has already
read. Probed on four samples: RW2 carries a 1920×1280 preview at 54KB, NEF one at
6016×4016 of 1.08MB, but DNG has only 720×480 inside its first 4MB and RAF keeps
almost none, and picking the wrong segment risks a thumbnail for a tile. Not
worth it until a RAW that is neither DNG nor RAF is the common case. The JPEG
path still peaks high (1713MB over 200 frames) and that is `createImageBitmap`
decoding every 5472×3648 frame whole, at four lanes — an app that reads fewer at
once trades time for the same headroom, if a page that large is ever the crash
again.
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.