The export menu measured the photo off the 1600px preview copy, so a 2400px
photo was believed to be 1600px across: the hint named the wrong size, the
model was asked to upscale a photo that already had more pixels than the
target, and a guest's 2048 ceiling was skipped because 1600 never crossed it.
A committed crop made it worse — the crop's longest edge was taken from the
wider side of the crop rect rather than the side the frame actually keeps, so
a 2400x1800 photo with the default 0.8 frame was called 1280px and ran the
model over 80 tiles (158.7s) to reach 2K.
The photo's own dimensions are now read off the original bytes, and the crop's
long edge is the same axis-aware fraction the stage already uses. The export
asks the model only when the photo itself is short of the requested size, or
when the crop would have to be stretched past 1.5x to get there; otherwise it
resamples — down, or a hair up to make up for the crop — which is what a photo
that already holds the pixels deserves.
Measured, wasm path, 2400x1800: no crop at 2K went 2.7s/2400px (wrong size) to
3.4s/2048px, the default 0.8 crop went 158.7s/80 tiles to 3.5s/no model, and a
1:1 crop went 2.5s/1800px to 4.0s/2048px. A 1200x900 photo cropped to 1:1 and
exported at 2K still runs the model (2048 from a 900px crop, 40.3s), and the
superres suite is unchanged: 640x480 to 2K/4K/custom still comes out exact,
with the model's 16.6 edge energy against bilinear's 4.8.
Every member gets /photos — their own uploads, counted against a 12-photo
cap, each card showing the tagline and the technical line the studio would
print. The studio gains SAVE PHOTO n/12 in the top bar: it renders the full
resolution look, stores the strip (tag/title/meta) with the upload so the
landing reel frames it the same way, and refuses past the cap.
EXPORT now burns that strip into the file: the amber #TAG over the photo's
top-left plus a dark caption band below carrying the recipe name and the
ISO / grain / warmth line. The live preview stays clean, and the saved
upload stays clean too — the reel draws its own frame from the stored
labels, so a burned band would tag the tag twice.
Admins manage any photo through DELETE /api/photos/:id; members only their
own. The users table's photo counts stay in step with the folder.
- /admin User account rows gain BLOCK/UNBLOCK, REMOVE/RESTORE and DELETE.
Blocked = cannot sign in (sessions swept), removed = hidden from the strip
and cannot sign in, both reversible; DELETE drops the account with its
photos and recipes and unlinks the files. An allowlisted account is never
a target, so an admin cannot moderate or delete itself.
- Photo uploads move from a 3MB API cap / 4m nginx cap to 12MB / 16m, and
the browser shrinks an oversized still before sending it (2048px JPEG,
avatars 512px) so the declared type still matches the sniffed bytes.
- The studio SAVE leaves the top bar and sits under the CREATE RECIPES tab,
labelled SAVE RECIPES.
The phone's RecipeCreateModal becomes a rail tab with the same rows, seeding
from the look on screen and clamping the same way. SAVE RECIPE applies the new
look, lists it under RECIPES and, when signed in, stores it on the account; a
guest's copy stays in memory and goes away with the page. Signed-in users can
also export the recipe as the app's encrypted .recipe file (shared/utils
/recipeShare.ts vendored byte-identical from the RN project).
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:
cd docker && cp .env.example .env && docker compose up -d --build
Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.
The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.
Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.
Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.