The ten PHOTO STYLE sims now carry nothing but their stock's own grade, and
each is named for the stock it stands for: PROVIA, VELVIA, CLASSIC CHROME,
CLASSIC VIVID (Velvia spliced with Classic Chrome at the blue row), CLASSIC
NEGATIVE, ASTIA, ETERNA, ACROS, LC STREETLIFE CLASSIC, LC STREETLIFE VIVID.
Grain, clarity, saturation and light moves were dropped from their
`adjustments`, so a sim is a clean starting point and the general knobs read
their defaults while the look still lands on the pixels.
LC STREETLIFE VIVID keeps the one brightness step its stock needs, but as
SIM_EXPOSURE_BIAS in colorUtils rather than as an adjustment: it is folded in
where the Exposure slider applies, so the picture gets the lift and the
parameter stays at 0.
Also in this checkpoint: the watermark/GPS boxes and their colour pickers, the
WATERMARK chip column, the real admin stats, and the fix that stopped presets
from doubling and a frame from refusing to come off when a photo was reopened
(/file is the finished render, /base the editable pixels).
A session followed the browser, not the account: log in, open a frame, log
out, come back as a guest — the same photo stood on the stage, because the
studio's own store (localStorage knobs + the photo in IndexedDB) outlived the
cookie with nothing to clear it.
clearSession() now drops both, and the three log-out buttons call it. The
studio's own button reloads after the delete has committed — a reload mid-
delete aborts the transaction, so the promise resolves on tx.oncomplete, not
on the request. The account's frames are untouched: they reopen from MY
PHOTOS.
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.
A SkPaint runs its shader BEFORE its colourFilter, so setting the exposure
matrix on the same paint as the tone shader landed the gain after the tone
pass: HIGHLIGHT -10 rolled a bright pixel back to 0.78, +EXPOSURE then
multiplied it by 1.2 and +0.15 and it clamped back to 1.0 — the HIGHLIGHT
slider looked dead the moment exposure went up.
The matrix now renders into its own image and the tone/cinema chain samples
that. Measured on the real engine (HIGHLIGHT -10 first, then EXPOSURE +10):
top end stays 0.780 (was 1.000), midtone 0.502 -> 0.722.
Layout
- the panel is a cascade of columns: the rail's tabs, the tab's chips, the
open chip's sub-chips, then the ruler. A child column no longer hides the
column it came from (TEMP -> COLOR TEMP keeps TEMP visible); chips stack one
per row instead of wrapping
- FRAME's WATERMARK opens its own column, so the frame chips stay put
- CREATE RECIPES gets the wide column its two-up form needs
WB colour swatches
- the ruler draws a colour box under the slider that follows the value:
COLOR TEMP is the Kelvin colour (Tanner Helland), TINT runs green -10 ->
neutral 0 -> magenta +10
HDF EFFECT
- knee 0.55..0.85 -> 0.45..0.75, blur 0.004+0.015n -> 0.006+0.024n of the
width, screen alpha 0.15+0.35n -> 0.28+0.52n: a wide halo on the highlights
instead of a hairline glow. Web copy of toneShader only — the phone keeps
its own tuning.
Tabs
- rail order is PRESETS, FAVORITED, WB, LIGHT, FX, FRAME, CREATE RECIPES
The working photo and every knob the workspace holds now survive a reload,
for guests as much as for signed-in users:
- engine/session.ts: the knobs go to localStorage (rc.studio.v1) as small
JSON; the photo goes to IndexedDB, because a 12MP JPEG does not fit in
localStorage. Both fail soft (private mode, quota) — the studio still works,
it just forgets.
- App.tsx: state seeds from the stored snapshot synchronously, so the first
paint already holds the user's settings; the boot effect pulls the photo
back and adopts it with keepGeo, so the restored params are not clobbered
by the photo's own EXIF.
Recipes a guest creates with SAVE RECIPE stay session-only, as asked — they
are still gone on reload (create-test asserts it).
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.