Files
RecipesCam/docker/frontend/shared
3dtours 6d60d452e0 One move of the light for the whole app: a mask's EXPOSURE and its four tone knobs stop being a second opinion
A gradient mask had its own tone formula and its own exposure, and both
disagreed with the frame's. Before any of this was tidied, the mask ran a
smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp while the frame
moved the knots of a four-zone ramp, and the mask's exposure was a stop on
sRGB-encoded values while the frame's was a stop on light. Two names, four
moves, and the same slider meant different things depending on whether the
pixels were inside the shape you drew — the divergence §3.3 of the Android
port's compat doc warns about.

The maths is one string now (TONE_MATH_SKSL, interpolated by both passes): the
ramp the four knots build, the hue-preserving rebuild behind it, the transfer
pair, and exposureMove. A mask calls the same functions the frame calls.

The rebuild carries the chroma instead of re-scaling it. Lightness takes the
curve and the colour rides the difference — the channel differences move by ONE
shared scale k, pulled back only where the cube has no room left. The doc's
ratio (R_new = R_old * Luma_new / Luma_old) was the old reading and it is exact
only while nothing clips: a channel past 1.0 stops being scaled with its
neighbours and the hue goes with it. Measured on a flat patch frame, a skin
tone at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at
57.4°, and under L = 0.5 the same ratio multiplied a near-black pixel's cast by
x30 — colour noise amplified, which is why the 0.55..1.35 clamp was there. The
scale is the chroma's own now: over 135 knob combinations on seven colours and
five greys, the ramp moves the luma and the hue does not move at all (Δ < 1e-9°).

EXPOSURE gets the same treatment, which is what the second half of the request
was: the linear domain decides where the luma is going, and the pixel is
rebuilt onto it through the same lightMove. The old pass multiplied the three
channels in linear light, so +1 EV clipped them by three different amounts:
measured on the scratchpad probe, 29.2° of hue drift on a skin tone at +1 EV
and 33.3° at +2, against 0.00° here. The stop is applied as a ratio on the
pixel's own encoded luma rather than pointed straight at the encoded linear
target, which is what makes the knob exactly the identity at 0 EV — the
transfer does not commute with the luma weights, so pointing at it brightened a
colour by a couple of code values even at zero. A grey is the knob it always
was: 128 through +1 EV is 176, the same number the linear per-channel multiply
put there, so nothing a user has dialled in moves.

Verified: `npx tsc --noEmit` clean, `npm run build` clean. highlight-knee-check
now runs EXPOSURE_SKSL for real — compiled with CanvasKit and four pixels
pushed through it, agreeing with the twin to a code value on a grey at +1 EV
(176), a skin tone at +1 EV and +2 EV, and a shadow at -2 EV; it also pins the
hue, the cube, the identity at 0 EV and the black pixel that has no light to
move. mask-wb-check compiles the mask pass and pushes the same stops through
it: 128 through +1 EV is 176, through -1 EV is 92, +2 EV lands the channel on
the ceiling at 255 and holds the hue within 3°. auto-tone-check,
preview-match-check, white-level-check, raw-develop-check, half-check and
roll-walk-check all pass. Live on the built bundle in a 1440x950 browser: the
LIGHT panel's EXPOSURE +1 EV takes the mid grey of a flat patch frame from
0.502 to 0.690 (a stop on light gives 0.686) and moves no patch's hue at -1 EV
(Δ 0.00°), and a linear gradient mask's EXPOSURE +1 EV and HIGHLIGHT +100 move
the pixels inside the mask (luma 185.9 -> 211.1 and 185.9 -> 197.5) while the
corner outside it does not move at all (220.2 -> 220.2), with no console error.

Co-authored-by: PenguinHarness <noreply@penguin.local>
2026-09-29 18:04:50 +07:00
..