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:
@@ -108,6 +108,125 @@ const BAND_BLOCK = hslBandGaps()
|
||||
// inside the one before it.
|
||||
export const TONE_ANCHOR = 0.25;
|
||||
|
||||
// The tone and exposure maths, in ONE copy, because two passes ask it: the
|
||||
// whole-frame passes here and a gradient mask, which moves the same knobs on the
|
||||
// shape the user drew. What "HIGHLIGHT" or "EXPOSURE" means must not depend on
|
||||
// where the shape is, and it did: the frame moved the ramp's knots while a mask
|
||||
// ran a smoothstep luma lift with an arbitrary 0.55..1.35 chroma clamp, and the
|
||||
// frame's exposure was a linear-light stop while a mask's was a stop on
|
||||
// sRGB-encoded values. That divergence is what the scratchpad compat doc §3.3
|
||||
// warns the Android port about. `dr` is the whole frame's DYNAMIC RANGE; a mask
|
||||
// has no such knob and hands in 0, which is what DR's terms are worth when it is
|
||||
// off on the frame too.
|
||||
export const TONE_MATH_SKSL = `
|
||||
// Fraction of the way from e0 to e1, clamped — the position on one straight
|
||||
// segment of the tone ramp.
|
||||
float lin(float e0, float e1, float x) {
|
||||
return clamp((x - e0) / (e1 - e0), 0.0, 1.0);
|
||||
}
|
||||
// The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and
|
||||
// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp, and each knot
|
||||
// is held inside the one before it so the five can never cross. 0.50 is fixed:
|
||||
// it is the one point all four sliders leave alone, which is what keeps a
|
||||
// mid-grey a mid-grey while the ends move around it. Straight between the knots,
|
||||
// so every knob on zero is exactly the identity (see the note at the head of
|
||||
// this file).
|
||||
// DR moves the same knots instead of adding its own masked terms on top: it
|
||||
// lifts the toe and rolls the head exactly as before at t = 0 and t = 1 —
|
||||
// 0.12 and 0.18 at full strength — and half of each at the knots next to them,
|
||||
// but because it is a knot move the ordering clamp holds it too. Added as a
|
||||
// separate term it could not: with BLACK and SHADOW both at -1 the ramp is flat
|
||||
// between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through that
|
||||
// stretch, which is a fold at 0.238.
|
||||
//
|
||||
// Lightness takes the curve; the colour rides the difference. The pixel moves to
|
||||
// its new luma and carries its own chroma with it — the three channel
|
||||
// differences are scaled by ONE number, so the hue cannot move and a grey cannot
|
||||
// pick up a cast (a neutral has no difference to carry, and lands on o exactly).
|
||||
//
|
||||
// The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading of
|
||||
// the same sentence, and it is what this pass used to do. It is exact — until
|
||||
// the result stops fitting. Past 1.0 a channel clips, the differences stop being
|
||||
// scaled together, and the hue goes with them: measured on the scratchpad probe
|
||||
// (hl-variants.mjs), a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100,
|
||||
// and a warm white at 37° at 57.4°. Under L = 0.5 the same ratio also multiplies
|
||||
// whatever cast a near-black pixel had — x30 on a shadow with a hair of warmth,
|
||||
// which is colour noise amplified, the reason the old arbitrary 0.55..1.35 clamp
|
||||
// was there.
|
||||
//
|
||||
// So the scale is the chroma's own (1.0) and the only thing that pulls it back
|
||||
// is the cube: a pixel with no room left gives up saturation instead of hue, and
|
||||
// one that the curve has actually driven to 1.0 arrives at white.
|
||||
//
|
||||
// ONE move of the light, and everything in this file that changes how bright a
|
||||
// pixel is goes through it: a tone knob, a mask's tone knob, and the exposure
|
||||
// knob on both. The luma lands on o and the channel differences ride along at
|
||||
// one shared scale k, so a knob named "change the brightness" changes the
|
||||
// brightness and nothing else — what a per-channel multiply cannot promise once
|
||||
// a channel reaches the ceiling, where the three clip by different amounts and
|
||||
// the hue goes with them.
|
||||
vec3 lightMove(vec3 c, float t, float o) {
|
||||
float k = 1.0;
|
||||
float hiC = max(max(c.r, c.g), c.b);
|
||||
float loC = min(min(c.r, c.g), c.b);
|
||||
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
|
||||
if (loC < t) k = min(k, o / (t - loC));
|
||||
return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0);
|
||||
}
|
||||
vec3 toneRamp(vec3 c, float t, float bl, float sh, float hl, float wh, float dr) {
|
||||
float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18;
|
||||
float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4);
|
||||
float a1 = clamp(0.25 + ${TONE_ANCHOR} * sh + dr * 0.06, 0.0, 0.5);
|
||||
float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1);
|
||||
float o = mix(a0, a1, lin(0.00, 0.25, t));
|
||||
o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, t)), step(0.25, t));
|
||||
o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t));
|
||||
o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t));
|
||||
return lightMove(c, t, clamp(o, 0.0, 1.0));
|
||||
}
|
||||
// The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the
|
||||
// same constants colorUtils.planckianLinear uses on the WB side, and the reason
|
||||
// a stop is a stop here. EXPOSURE needs it — 2^ev is a multiplier on LIGHT — and
|
||||
// so does anything else that has to reach the linear domain.
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
|
||||
}
|
||||
vec3 toEncoded(vec3 c) {
|
||||
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
|
||||
}
|
||||
// One EXPOSURE knob, wherever it is: the frame's own pass and a mask's knob. It
|
||||
// linearises, moves the LIGHT by 2^ev — a stop is a multiplier on light, and on
|
||||
// an sRGB-encoded value +1 EV would take a mid-grey 0.5 straight to a blown 1.0
|
||||
// where a real stop gives 0.73 (measured: 0.6858 through here, and the plain
|
||||
// per-channel multiply puts the same 0.6858 on a grey, so a neutral is the knob
|
||||
// it always was) — and re-encodes.
|
||||
//
|
||||
// The linear domain decides WHERE the luma is going; the move is then made by
|
||||
// lightMove on the encoded values, where the tone ramp also works. That split is
|
||||
// measured, not chosen for symmetry: carrying the chroma in the LINEAR domain
|
||||
// drifts the hue of the encoded pixel by up to 12° (a saturated red at -1 EV
|
||||
// came back at 12.1°, a skin tone at +1 EV at 11.5° — the encoding is
|
||||
// per-channel, so equal ratios in linear are not equal ratios on screen),
|
||||
// against 0.00° this way. The knob whose whole promise is brightness must not be
|
||||
// the one that also moves a hue: a channel that would have clipped gives up
|
||||
// saturation instead, and a pixel the move has driven all the way to 1.0 is white
|
||||
// in all three channels at once.
|
||||
vec3 exposureMove(vec3 rgb, float ev) {
|
||||
vec3 c = clamp(rgb, 0.0, 1.0);
|
||||
float t = clamp(dot(c, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
|
||||
// The linear domain says where the luma is going; the value that lands there
|
||||
// is applied as a RATIO on the pixel's own encoded luma, not pointed at
|
||||
// directly. toEncoded(luma_lin * 2^ev) is the target, and on a grey it IS the
|
||||
// pixel's new luma (a stop on a neutral is the stop it always was) — but the
|
||||
// transfer does not commute with the luma weights, so on a colour the two
|
||||
// differ by a couple of code values, and the knob on 0 EV would brighten the
|
||||
// frame instead of leaving it alone. As a ratio it is exactly 1 at 0 EV.
|
||||
float lin = max(dot(toLinear(c), vec3(0.2126, 0.7152, 0.0722)), 1e-6);
|
||||
float stop = toEncoded(vec3(min(1.0, lin * exp2(ev)))).r / toEncoded(vec3(lin)).r;
|
||||
return lightMove(c, t, clamp(t * stop, 0.0, 1.0));
|
||||
}
|
||||
`;
|
||||
|
||||
export const TONE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float dr;
|
||||
@@ -175,11 +294,7 @@ float bandW(float hue, float anchor, float gapL, float gapR) {
|
||||
float d = mod(hue - anchor + 180.0, 360.0) - 180.0;
|
||||
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
|
||||
}
|
||||
// Fraction of the way from e0 to e1, clamped — the position on one straight
|
||||
// segment of the tone ramp.
|
||||
float lin(float e0, float e1, float x) {
|
||||
return clamp((x - e0) / (e1 - e0), 0.0, 1.0);
|
||||
}
|
||||
${TONE_MATH_SKSL}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
@@ -197,54 +312,11 @@ vec4 main(vec2 xy) {
|
||||
float shMask = clamp(1.0 - smoothstep(0.25, 0.50, t) - blMask, 0.0, 1.0);
|
||||
float whMask = smoothstep(0.75, 1.00, t);
|
||||
float hlMask = clamp(smoothstep(0.50, 0.75, t) - whMask, 0.0, 1.0);
|
||||
// The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and
|
||||
// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp, and each
|
||||
// knot is held inside the one before it so the five can never cross. 0.50 is
|
||||
// fixed: it is the one point all four sliders leave alone, which is what
|
||||
// keeps a mid-grey a mid-grey while the ends move around it. Straight between
|
||||
// the knots, so every knob on zero is exactly the identity (see the note at
|
||||
// the head of this file).
|
||||
// DR moves the same knots instead of adding its own masked terms on top: it
|
||||
// lifts the toe and rolls the head exactly as before at t = 0 and t = 1 —
|
||||
// 0.12 and 0.18 at full strength — and half of each at the knots next to
|
||||
// them, but because it is a knot move the ordering clamp holds it too. Added
|
||||
// as a separate term it could not: with BLACK and SHADOW both at -1 the ramp
|
||||
// is flat between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through
|
||||
// that stretch, which is a fold at 0.238.
|
||||
float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18;
|
||||
float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4);
|
||||
float a1 = clamp(0.25 + ${TONE_ANCHOR} * sh + dr * 0.06, 0.0, 0.5);
|
||||
float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1);
|
||||
float o = mix(a0, a1, lin(0.00, 0.25, t));
|
||||
o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, t)), step(0.25, t));
|
||||
o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t));
|
||||
o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t));
|
||||
o = clamp(o, 0.0, 1.0);
|
||||
// Lightness takes the curve; the colour rides the difference. The pixel moves
|
||||
// to its new luma and carries its own chroma with it — the three channel
|
||||
// differences are scaled by ONE number, so the hue cannot move and a grey
|
||||
// cannot pick up a cast (a neutral has no difference to carry, and lands on
|
||||
// o exactly).
|
||||
//
|
||||
// The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading
|
||||
// of the same sentence, and it is what this pass used to do. It is exact —
|
||||
// until the result stops fitting. Past 1.0 a channel clips, the differences
|
||||
// stop being scaled together, and the hue goes with them: measured on the
|
||||
// scratchpad probe (hl-variants.mjs), a skin tone at 24.0° came back at 48.0°
|
||||
// at HIGHLIGHT +100, and a warm white at 37° at 57.4°. Under L = 0.5 the same
|
||||
// ratio also multiplies whatever cast a near-black pixel had — x30 on a
|
||||
// shadow with a hair of warmth, which is colour noise amplified, the reason
|
||||
// the old arbitrary 0.55..1.35 clamp was there.
|
||||
//
|
||||
// So the scale is the chroma's own (1.0) and the only thing that pulls it back
|
||||
// is the cube: a pixel with no room left gives up saturation instead of hue,
|
||||
// and one that the curve has actually driven to 1.0 arrives at white.
|
||||
float k = 1.0;
|
||||
float hiC = max(max(rgb.r, rgb.g), rgb.b);
|
||||
float loC = min(min(rgb.r, rgb.g), rgb.b);
|
||||
if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));
|
||||
if (loC < t) k = min(k, o / (t - loC));
|
||||
rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0);
|
||||
// The four tonal-range knobs, on the shared ramp: see TONE_MATH_SKSL — the
|
||||
// same knots, the same hue-preserving rebuild, the same move a gradient mask
|
||||
// makes with the same four sliders. DR is the whole frame's, so it is spent
|
||||
// here and nowhere else.
|
||||
rgb = toneRamp(rgb, t, bl, sh, hl, wh, dr);
|
||||
// Split tone (stock look): the shadows and the highlights may each carry
|
||||
// their own tint, so the two ends of the curve can drift opposite ways
|
||||
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
|
||||
@@ -316,30 +388,22 @@ ${BAND_BLOCK} hsl.x = fract(hsl.x + acc.x * (30.0 / 360.0));
|
||||
//
|
||||
// A stop is a multiplier on LIGHT, and the old EV row multiplied sRGB-ENCODED
|
||||
// values: +1 EV took a mid-grey 0.5 straight to a blown 1.0 where a real stop
|
||||
// gives 0.73. Here the pixel is linearised, scaled by 2^ev, and re-encoded —
|
||||
// which is what a camera does when the shutter stays open twice as long: every
|
||||
// value keeps its ratio, the highlights roll instead of flattening, and the
|
||||
// HIGHLIGHT knob still has something to pull back afterwards.
|
||||
// gives 0.73. exposureMove linearises, moves the light by 2^ev, and re-encodes —
|
||||
// and it spends that stop on the LUMA, not on the three channels one at a time,
|
||||
// so this knob only ever changes how bright a pixel is: a channel that would
|
||||
// have clipped gives up saturation instead of dragging the hue (a gradient
|
||||
// mask's own EXPOSURE runs through the same function, on the mask's pixels).
|
||||
//
|
||||
// It sits between the graded image and the tone shader (see exportEngine step 3),
|
||||
// so `ev` carries the EXPOSURE knob, the stock's own bias and the EV knob added
|
||||
// up in stops — the caller hands in one number.
|
||||
//
|
||||
// The transfer pair is the accurate one (0.04045/12.92 + 2.4, and its inverse):
|
||||
// the same constants colorUtils.planckianLinear uses on the WB side.
|
||||
export const EXPOSURE_SKSL = `
|
||||
uniform shader src;
|
||||
uniform float ev;
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
|
||||
}
|
||||
vec3 toEncoded(vec3 c) {
|
||||
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
|
||||
}
|
||||
${TONE_MATH_SKSL}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
return vec4(clamp(toEncoded(toLinear(rgb) * exp2(ev)), 0.0, 1.0), c.a);
|
||||
return vec4(exposureMove(clamp(c.rgb, 0.0, 1.0), ev), c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user