The landing card sells "35mm & 120 Film Grain — authentic grain structures plus
halation bloom, tuned per stock rather than one global overlay", and the engine
printed one field for everything: a width/1080 cell, one spread, no bleed.
`shared/utils/grainShader.ts` (new, the web fork of the phone's
src/utils/grainShader.ts) now carries the stock table — format, cell, spread,
octave mix, halation, halo radius, halo tint — and `grainStockFor(
recipe.baseFilter)` picks the one this recipe prints.
FORMAT. 35mm cells are the 1.0 reference the knob was tuned at (classic-negative
1.15, B&W high contrast 1.25); the 120 emulsions sit at 0.55-0.72 and open their
base octave (mix 0.55/0.30/0.15 -> 0.62/0.26/0.12), so the same knob prints a
finer, smoother texture on the bigger negative. Measured on a flat 128 grey at a
3200px preview (cells 3.41px vs 1.63px), GRAIN 10, luma residual against a 17px
box:
35mm CLASSIC NEGIPES r1 0.793 keeps 0.976
35mm CLASSIC CHRIPES r1 0.744 keeps 0.931
35mm B&W HIGH CONTRAST r1 0.812 keeps 1.002
120 PROVIPES r1 0.423 keeps 0.728
120 VELVIPES r1 0.313 keeps 0.672
120 ACRIPES r1 0.543 keeps 0.794
r1 is the lag-1 autocorrelation of the residual — how coarse the clumps are —
and "keeps" is the residual sd after a 2x box downscale over the sd before, i.e.
how much of its texture a print at half size holds on to. Every 35mm stock beats
every 120 stock on both, and VELVIPES (0.55 cell) is finer than PROVIPES (0.62)
inside 120, so the format is a look and not a label. Raw sd is NOT the measure:
the knob drives one alpha for every stock, so a stock's amount follows its cell
and mix rather than the order anyone assumed.
HALATION. A new pass 6b thresholds the print (T0 0.62, T1 0.92), tints what is
left the stock's halo colour — red, because red is the light the emulsion passes
and the backing returns — blurs it at the stock's own radius and screens it back
at `halation * grain/10 * 0.6`. Riding the GRAIN knob keeps today's contract:
OFF is still a clean frame, the OFF/WEAK/STRONG chips still mean 0/3/6, and a
sensor stock carries none at any amount. Measured R-B of the ring around a white
block on black, GRAIN 6 minus GRAIN 0 (mean, and the ring's reddest pixel):
CLASSIC NEGIPES 7.87 (peak 0 -> 14) VELVIPES 3.71 (0 -> 13)
CLASSIC CHRIPES 2.91 (0 -> 10) PROVIPES 2.01 (0 -> 7)
B&W HIGH CONTRAST 0.61 (0 -> 5) ACRIPES 0.24 (0 -> 3)
LC STREETLIFE CLASSIC 0.09 (0 -> 0)
which is the table's own halation column (0.45 > 0.30 > 0.25 > 0.18 > 0.15 >
0.12) in order: the colour negative halates hardest, the B&W emulsions barely,
Acros — no colour layer to bleed — least of all, and the sensor not at all. The
colour negative's own grade leaves its ring blue at GRAIN 0 (-5.96 there), so
the statistic is the change and not the absolute channel; in a crop of the block
the bloom itself is unmistakable at GRAIN 6 and 10 and absent at 0.
GRAIN_SEED moves here from exportEngine.ts so the roll is still one per page
load, and still shared by the preview, the compare copy and the file.
Checked: tsc --noEmit clean; grain-stock-test 53 PASS / 0 FAIL; sims-test 31/0,
fx-mono-test 15/0, grain-size-test 12/0, grain-analog-test 7/0, wm-font-test
green.
ponytail: halation rides the GRAIN knob instead of a control of its own, since
the card promises no more than "tuned per stock". Add a HALATION chip when the
phone grows one.
ponytail: `grainCell`'s 1px floor is the aliasing guard, and it also hides the
format ratio under a ~1600px preview. Nothing to add: the exported file is
always wide enough, and the harness renders at 3200 to see it.
The FRAME tab's fine rotation drew the photo through canvas.rotate() +
canvas.scale() with a plain drawImage, which CanvasKit samples with
nearest: the edge landed on the same pixel in every row, so a rotated
edge came out as 1px steps every 1/tan(angle) rows. Measured on the 30deg
export of a hard black/white edge: 42.3% of rows repeated the previous
row's edge position, the step across the edge was 252.9 of 255, and there
were no intermediate pixels at all.
Only the *Options/*Cubic call shapes take a sampling option, and
drawImageRectOptions exists in RN Skia too, so the shared renderer can use
it unchanged. The same export now moves the edge in every row (0.2% of
rows repeat, 0.35 intermediate pixels per row) and its edge step drops to
222. Cost: the filtered draw takes 0.35s against 0.24s for the 1600px
preview copy and 2.0s against 1.4s for a 12MP photo, once per render.
Preview and export share the function, so both change together.
The chip lands after ACRIPES and is a mono stock of its own, so it gets its own
baseFilter ('mono-high-contrast') rather than borrowing Acros': the PHOTO STYLE
chips are keyed by baseFilter, and the two greys must sit side by side.
The look is the B&W MIX plus a push at both ends. The mix rides the matrix — a
non-BT.709 row set (0.38/0.56/0.06, identical rows, sum 1.00) so a red roof
reads bright, a blue sky deep, and the separation is contrast before any curve.
The push rides FILM_TONE (shadow -0.32, highlight +0.26) so the ends move
without touching the midtones, and the midtone slope is SIM_CONTRAST_BIAS (4
contrast units) next to the sim's own exposure bias. The knobs stay at neutral:
a sim is colour and tone only.
Both B&W stocks being mono is now asked once, through isMonochromeBase, so the
colour-only stages (saturation, white balance, R/B fine-tune, chrome, hue
mixer) and the MONO strip label can never half-apply to one of them.
The name table is a reference for what each sim has to look like, not a
renaming order: the ten PHOTO STYLE chips go back to PROVIPES, VELVIPES,
CLASSIC CHRIPES, CLASSIC VIVIDIPES, CLASSIC NEGIPES, ASTIPES, ETERNIPES,
ACRIPES, LC STREETLIFE CLASSIC and LC STREETLIFE VIVID. The comment above
FILM_SIMS now says so outright — label on the left, the stock's colour and tone
it must match on the right, and neither side moves the other.
The grading is untouched: a sim is still colour and tone only, its `adjustments`
stay neutral, and LC STREETLIFE VIVID keeps its +2 exposure as SIM_EXPOSURE_BIAS
in colorUtils rather than as a knob.
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).
HUE, SAT and LUM leave the colour row: behind an IMAGE divider they are
hslHue/hslSat/hslLum, seeded into the shader's band accumulator at full
weight for every hue, while the eight band chips keep picking which colour
the panel on the photo edits. The image lightness term stays ungated so a
frame drained to grey by -SAT still answers +LUM.
FRAME loses its NO FRAME chip: pressing the frame already on the photo
takes it off.
ROTATE's quarter turns stop lighting the moment the fine angle leaves 0,
so the strip shows which of the two is steering the photo.
Classic Chrome's blue row, verbatim, with the red and green rows taken from
VELVIPES: skies keep the muted teal/cyan lean while everything that is not
blue reads loud. The shadow crush rides along from FILM_TONE, since it belongs
to the stock rather than to a row. CLASSIC CHRIPES stays untouched beside it.
LIGHT already spends its slope budget on the tone knees, so the two end
points were doing nothing a tone knob could not. On WB they act on each
channel's own distance from the end: the darker channel of a shadow and
the brighter channel of a highlight move most, which neutralises a cast
at the toe and the shoulder. Both shuffles stay cubic in the channel
value, so every channel's curve is still monotonic (>= 0.46).
WHITE (whites) and BLACK (blacks) get the same slider rows as HIGHLIGHT
and SHADOW, so the tone curve's two ends are editable on the stage, not
only typed into the CREATE form.
EXPORT no longer burns the caption strip: the pixels stay the photo's own
and the look travels as metadata — ImageDescription (0x010e) for the tag,
UserComment (0x9286, ASCII header) for the recipe JSON.
SAVE PHOTO now stores the look with the frame (photos.recipe) and the
uploader's consent for the community film strip (photos.consent, PATCH
/api/photos/:id for the owner). The landing reel skips non-consented frames,
and a new MY PHOTOS tab lists the account's saves, reopens one with the
settings it was stored with, and carries the two consent switches.
The CREATE RECIPES form had grown into one long scroll. The six categorical
groups (SIMULATION, DYNAMIC RANGE, GRAIN EFFECT, COLOR CHROME EFFECT, COLOR
CHROME EFFECT BLUE, WHITE BALANCE) are now native <details> folds — the browser
keeps the open flag, so no state and no library.
The two tone-curve ENDS join the numeric grid: EXPOSURE (the matrix gain the IQ
tab already drives), EV (the old EXPOSURE COMP. row, renamed to the phone's
word), WHITE and BLACK. WHITE/BLACK are new ColorAdjustments fields, applied in
TONE_SKSL as cubic end-weights rather than another smoothstep knee — the HL/SH
knees already spend the slope budget, and the cubic keeps the curve monotonic
for every combination (derivative >= 0.46), so a brighter input can still never
come out darker. Both are optional, so stored recipes keep working.
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 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.