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>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
DEHAZE_MAX_OMEGA,
|
||||
DEHAZE_PATCH_STEP,
|
||||
DEHAZE_PATCH_TAPS,
|
||||
TONE_MATH_SKSL,
|
||||
} from './toneShader';
|
||||
|
||||
// FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local
|
||||
@@ -31,17 +32,18 @@ import {
|
||||
//
|
||||
// The spec's section 4 — "the system needs to restrict all the above effects to
|
||||
// operate only within the mask's area" — is the rest of the block below: the
|
||||
// smoothstep soft masks that carry HIGHLIGHT and SHADOW, the two ends WHITE and
|
||||
// BLACK move, and the two spatial ones (CLARITY against the frame's own blur,
|
||||
// DEHAZE through the dark channel) that the caller hands in the blurred
|
||||
// reference for and the atmospheric light for. Every one of them rides the same
|
||||
// alpha the shape produces and lands through the same `mix`, so a mask at half
|
||||
// strength is half of the move.
|
||||
// four tonal-range knobs (the frame-wide ramp, moved on the mask's own pixels),
|
||||
// and the two spatial ones (CLARITY against the frame's own blur, DEHAZE through
|
||||
// the dark channel) that the caller hands in the blurred reference for and the
|
||||
// atmospheric light for. Every one of them rides the same alpha the shape
|
||||
// produces and lands through the same `mix`, so a mask at half strength is half
|
||||
// of the move.
|
||||
export const MASK_KIND = { linear: 0, radial: 1 } as const;
|
||||
// Exposure is stored as the EV itself — the spec's own -5..+5 — because that is
|
||||
// what `pow(2.0, e)` reads, and a stop is a stop whatever the app's slider units
|
||||
// are elsewhere. The other two are -10..+10 like every other knob, and are turned
|
||||
// into the spec's -1..+1 on the way to the shader.
|
||||
// what `exp2(e)` spends on the light (toneShader.EXPOSURE_SKSL), and a stop is a
|
||||
// stop whatever the app's slider units are elsewhere. The other two are -10..+10
|
||||
// like every other knob, and are turned into the spec's -1..+1 on the way to the
|
||||
// shader.
|
||||
export const MASK_EXPOSURE_MAX = 5;
|
||||
// How much of the semi-axis a fresh ellipse fades over. Half: the edge is soft
|
||||
// enough to be a gradient mask rather than a cut-out, and every pixel of the
|
||||
@@ -171,19 +173,18 @@ export function maskUniforms(
|
||||
return u;
|
||||
}
|
||||
|
||||
// The colour inside a mask, in the spec's own order and, for the knobs the app
|
||||
// also has frame-wide, in the app's own formulas: exposure first (a power of
|
||||
// two, so a stop is a stop), then contrast about the middle, then saturation as
|
||||
// a mix away from the pixel's own REC-709 luma. Then the spec's section 2 and 3
|
||||
// on top — HIGHLIGHT and SHADOW through the two smoothstep soft masks its own
|
||||
// formula names, WHITE and BLACK as the per-channel point moves TONE_SKSL
|
||||
// makes, and the two spatial ones against the blurred reference the caller hands
|
||||
// in. Every one of them means inside the mask what it means on the whole frame
|
||||
// (toneShader.ts is the reference the four tonal ones are written from), because
|
||||
// the same name on the same knob should not be two different moves. The result
|
||||
// is clamped to the range a file can hold — the spec's own guard, and it is
|
||||
// `mix`ed back over the base by the mask's alpha, so a mask at half strength is
|
||||
// half of the move rather than the whole of it.
|
||||
// The colour inside a mask, in the spec's own order and, for every knob the app
|
||||
// also has frame-wide, in the app's own formulas: EXPOSURE first (one stop of
|
||||
// LIGHT, through exposureMove), then contrast about the middle, then saturation
|
||||
// as a mix away from the pixel's own REC-709 luma. Then the four tonal-range
|
||||
// knobs and the two spatial ones — the four through the frame-wide ramp
|
||||
// (TONE_MATH_SKSL), CLARITY and DEHAZE against the blurred reference the caller
|
||||
// hands in. Every one of them means inside the mask what it means on the whole
|
||||
// frame, because the same name on the same knob must not be two different moves;
|
||||
// toneShader.ts is the one copy all of them are read from. The result is clamped
|
||||
// to the range a file can hold — the spec's own guard, and it is `mix`ed back
|
||||
// over the base by the mask's alpha, so a mask at half strength is half of the
|
||||
// move rather than the whole of it.
|
||||
//
|
||||
// `blur` is the frame's bilateral reference (the same one CLARITY uses
|
||||
// frame-wide) and `air` the atmospheric light; both are the constants 0 when the
|
||||
@@ -193,7 +194,11 @@ export function maskUniforms(
|
||||
// read in the block below (the frame's own pixels are only in reach there).
|
||||
const adjustFn = (spatial: boolean) => `
|
||||
half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) {
|
||||
c = c * half(pow(2.0, a.x));
|
||||
// EXPOSURE through the frame-wide function, on the mask's pixels: one stop of
|
||||
// LIGHT (a linear-light move, so 2^ev is what it multiplies) and a move of the
|
||||
// luma rather than of the three channels, so the knob brightens a colour
|
||||
// instead of shifting its hue when a channel reaches the ceiling.
|
||||
c = half3(exposureMove(vec3(c), a.x));
|
||||
// The mask's own white balance, the frame-wide WB gain on the mask's pixels: a
|
||||
// gain on each channel, hoisted to the JS side because the Kelvin fit is a
|
||||
// curve (colorUtils.whiteBalanceGain). 5500K / 0 hands over (1,1,1), so a mask
|
||||
@@ -201,25 +206,14 @@ half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark$
|
||||
c = clamp(c * wb, half3(0.0), half3(1.0));
|
||||
c = (c - half(0.5)) * half(1.0 + a.y) + half(0.5);
|
||||
half l = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
||||
// The tonal four, in TONE_SKSL's own formulas: a mask's HIGHLIGHT is meant to
|
||||
// be the same move as the whole-frame HIGHLIGHT on a smaller area, not a
|
||||
// second opinion about what the name means. HIGHLIGHT and SHADOW are additive
|
||||
// shifts of the luma, HIGHLIGHT weighted by the headroom left (1 - l) so it
|
||||
// cannot drag a blown white to grey, and the colour difference rides along at
|
||||
// a damped gain (TONE_SKSL's own cg) so a lift or a pull cannot collapse a
|
||||
// colour. WHITE and BLACK are per-channel point moves, cubic in each channel's
|
||||
// own distance from the end it owns: the toe and the shoulder move, the
|
||||
// midtones do not, and a white that is lowered stays white.
|
||||
// The spec's soft masks, computed in float and narrowed: smoothstep on half is
|
||||
// one more type the shader does not have to guess at.
|
||||
// The tonal four, through the frame-wide ramp (TONE_MATH_SKSL): a mask's
|
||||
// HIGHLIGHT is the same move as the whole-frame HIGHLIGHT on a smaller area,
|
||||
// not a second opinion about what the name means — the four knots, their
|
||||
// ordering clamp, the 0.50 midpoint no knob reaches, and the luma-preserving
|
||||
// rebuild are all the frame's. DR is the one knob the ramp also carries that a
|
||||
// mask does not have, so it is spent as 0 here.
|
||||
float lf = clamp(float(l), 0.0, 1.0);
|
||||
float mh = smoothstep(0.50, 1.00, lf);
|
||||
float ms = 1.0 - smoothstep(0.00, 0.55, lf);
|
||||
half lifted = half(clamp(lf + tone.x * mh * (1.0 - lf) + tone.y * 0.34 * ms, 0.0, 1.0));
|
||||
half cg = clamp(lifted / max(l, half(0.0004)), half(0.55), half(1.35));
|
||||
c = clamp(half3(lifted) + (c - half3(l)) * cg, half3(0.0), half3(1.0));
|
||||
half3 dk = half3(1.0) - c;
|
||||
c = clamp(c + half(tone.w * 0.18) * dk * dk * dk + half(tone.z * 0.18) * c * c * c, half3(0.0), half3(1.0));
|
||||
c = half3(toneRamp(vec3(c), lf, tone.w, tone.y, tone.x, tone.z, 0.0));
|
||||
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
||||
c = mix(half3(nl), c, half(1.0 + a.z));
|
||||
${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason:
|
||||
@@ -320,6 +314,7 @@ uniform float4 fx[${count}];
|
||||
uniform float4 wb[${count}];
|
||||
uniform float4 size;
|
||||
${spatial ? 'uniform shader blurred;\nuniform float4 air;' : ''}
|
||||
${TONE_MATH_SKSL}
|
||||
${adjustFn(spatial)}
|
||||
half4 main(float2 pos) {
|
||||
half4 c = img.eval(pos);${Array.from({ length: count }, (_, i) => maskBlock(i, spatial)).join('')}
|
||||
|
||||
Reference in New Issue
Block a user