LIBRARY read the whole catalogue back on every batch the scan wrote: `reload()` is a `getAll` of the `photos` store, every row and every thumbnail in it, and on a catalogue of 150,000 frames one read was measured at 2036ms, twenty of them at 315s with the worst at 58s, the heap behind them going from 44MB to 89MB — beside a LibRaw open of a quarter of a gigabyte and four lanes of RAW bytes. On a folder of RAW that is the crash, and on any long roll it is the strip taking the roll's own time to move. The read-back now runs on a clock: at most once every five seconds of a scan, and once more when the reading ends, which is when the last of it is due. A batch lands every second or so, and reading it back costs every frame in the catalogue — so a scan that read it back each time spent the roll doing nothing else. The strip still fills while the scan runs; it fills in steps. The walk's own position went to the origin private file system. It is one path per frame the walk has found and not read — about twenty-eight characters each, measured over a folder of three thousand, which is the 85,626-character write — and local storage on this origin takes 5,000,000 characters and then throws, measured the same way. That is a hundred and seventy-five thousand frames: past it the first write of a long roll fails, the position is nowhere, and the next visit walks the whole roll from the top. That is the reading that goes back to the start, and OPFS takes the same text as a file. It goes down at most once every two seconds, for the same reason the catalogue is read back on a clock: the string is a few megabytes of paths. The folder is walked again for being raised no more than once every thirty seconds. A hundred thousand frames is a hundred thousand stats on the disk, and this screen is raised every time the reader comes back from the studio. And the column no longer says there is no folder before it has looked. The note waited on `folders` alone, which is empty for as long as the screen is coming up: on the catalogue of 150,000 it said so for the whole of the load and showed the folder after. It now waits on `loaded`, and says nothing until there is something to say. Checked on the running bundle: a 3000-frame scan reads the catalogue back 13 times where the code read it back once per batch, writes the position 21 times, and leaves the peak heap at 116MB with no long tasks. A reading stopped at 483 of 4000 leaves `walk/ROLL.json` at 101,107 characters naming 3,550 unread frames, with no local storage key beside it, and the visit after a reload carries on at 585 rather than at the top. LIBRARY on 150,000 frames shows no "No folder yet" at any point and settles on "150000 photos". The wall, the strip, the grid, the deep link and the phone's recipes all pass their checks.
RecipesCam web — self-contained stack
A Docker-hosted web build of RecipesCam. Everything it needs is in this folder: move it to another machine, run two commands, and the app is up. It does not need the React Native project around it.
cp .env.example .env
docker compose up -d --build
# → http://localhost:8090
What runs where
| Service | Image | Role |
|---|---|---|
frontend |
nginx:1.27-alpine (built by frontend/Dockerfile) |
Static SPA + /api/ reverse proxy |
api |
node:22-slim (built by backend/Dockerfile) |
Accounts + saved recipes, SQLite on ./data |
frontend resolves api through Docker's embedded DNS and proxies /api/* to
it — that is why the API container is named api and why it is not published on
the host. Only ${WEB_PORT:-8090} is exposed.
Photos never leave the browser. The CanvasKit render pipeline (grade, frame, watermarks, JPEG encode) runs in the visitor's tab; the API only stores recipes as JSON.
Layout
docker-compose.yml the stack
.env.example WEB_PORT
data/ SQLite (created on first run, gitignored)
backend/ Fastify + better-sqlite3 API, own Dockerfile
frontend/ Vite + React + CanvasKit SPA, own Dockerfile + nginx.conf
shared/ vendored copies of the app's types + utils (see below)
src/engine/ skiaShim.ts (CanvasKit) + exportEngine.ts (render pipeline)
+ session.ts (localStorage/IndexedDB studio persistence)
Vendored files
frontend/shared/{types/index.ts,utils/*.ts} are byte-identical copies of
src/types/index.ts and ten src/utils/*.ts files from the React Native
project (@shopify/react-native-skia is aliased to src/engine/skiaShim.ts in
vite.config.ts + tsconfig.json, so those files compile unchanged):
cinemaShader colorUtils defaultRecipes exifWrite frameUtils jpegDpi
paramDefs recipeShare skiaImage toneShader.
The landing page needs no CDN: frontend/public/assets/fonts/*.woff2 are the
seven self-hosted faces behind the three font groups the Themes menu offers
(Plus Jakarta Sans / Inter / JetBrains Mono, Fraunces / Be Vietnam Pro /
Courier Prime, Be Vietnam Pro / Space Mono — all SIL OFL, pulled from Google
Fonts, vietnamese + latin + latin-ext subsets), and
frontend/public/assets/samples/s*.jpg are the six placeholder negatives the
film strip, preset tester and QR card show (swap them for real graded stills
whenever we have them). Its one foreign request is the QR image from
api.qrserver.com, which degrades to an empty slot offline.
When the app changes one of them, copy it back in — the renderer is only "parity" for as long as these stay in sync:
cd <repo>/docker/frontend/shared/utils
cp <repo>/src/utils/<name>.ts .
Operations
docker compose logs -f api # API log
docker compose restart api # after backend/src changes (rebuild: --build)
docker compose down # stop; ./data survives
Backup is the ./data folder — that is the whole database.
Checks
curl -s http://localhost:8090/api/health # {"ok":true}
curl -sI http://localhost:8090/ # 200, index.html
Then open the UI, drop a photo in, and confirm the preview shows the picture and
EXPORT downloads a JPEG that opens. The preview going solid black while the
export still reports a plausible size is the one failure mode worth knowing: it
means the CanvasKit GPU surfaces lost their shared GrDirectContext (see
frontend/src/engine/skiaShim.ts), and with no GPU the raster fallback renders
the same pipeline correctly, just slower.