diff --git a/docker/frontend/shared/utils/gradientMask.ts b/docker/frontend/shared/utils/gradientMask.ts index 34a09ac..f2a0b35 100644 --- a/docker/frontend/shared/utils/gradientMask.ts +++ b/docker/frontend/shared/utils/gradientMask.ts @@ -145,16 +145,19 @@ export function maskUniforms( return u; } -// The colour inside a mask, in the spec's own order and its 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 (`M_shadow = 1 - smoothstep(0, -// 0.5, L)`, `M_highlight = smoothstep(0.5, 1, L)`), WHITE and BLACK as a gain on -// the end each one owns, and the two spatial ones against the blurred reference -// the caller hands in. 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 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. // // `blur` is the frame's bilateral reference (the same one CLARITY and DEHAZE use // frame-wide) and `air` the atmospheric light; both are the constants 0 when the @@ -166,23 +169,29 @@ half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx${spatial ? ', half3 b c = c * half(pow(2.0, a.x)); 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 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, 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. float lf = clamp(float(l), 0.0, 1.0); - float ms = 1.0 - smoothstep(0.0, 0.5, lf); - float mh = smoothstep(0.5, 1.0, lf); - // Half a stop of tone at the knob's own ceiling, the weight the frame-wide - // HIGHLIGHT/SHADOW pass uses; + lifts, - pulls back, and the two masks cannot - // both be 1 at the same pixel. - c = clamp(c + half3(half(0.5 * (tone.y * ms - tone.x * mh))), half3(0.0), half3(1.0)); - c = c * half(1.0 + 0.5 * tone.z * mh); - c = c * half(1.0 + 0.5 * tone.w * ms); + 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)); 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 ? ` if (fx.x != 0.0) { - // CLARITY (doc section 3.1): the pixel against its own blurred surroundings. - c = clamp(c + half3(half(fx.x * ${CLARITY_GAIN.toFixed(1)})) * (c - blur), half3(0.0), half3(1.0)); - } +${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason: + // sharpening haze only makes it read as detail. if (fx.y != 0.0) { // DEHAZE (doc section 3.2): the dark channel of the patch is the haze. half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05))); @@ -190,6 +199,19 @@ ${spatial ? ` if (fx.x != 0.0) { half t = clamp(half(1.0 - fx.y * ${DEHAZE_MAX_OMEGA} * clamp(dark, half(0.0), half(1.0))), half(${DEHAZE_FLOOR_T}), half(1.0)); c = clamp((c - aa) / t + aa, half3(0.0), half3(1.0)); } + // CLARITY (doc section 3.1): the pixel against its own blurred surroundings. + // Positive sharpens. Negative SOFTENS toward that same reference — a masked + // CLARITY -10 is a soften of the mask's own detail, which is the property the + // knob is named for, and not the inverted unsharp it used to be (that only + // sank the mask's whites and left its contrast where it was). + // ponytail: the reference is the bilateral one (CLARITY_BLUR_SPAN, 6% of the + // frame), not the frame-wide mist blur — the mask pass is not handed a second + // blurred child. Add one when a negative CLARITY wants a wider soften. + if (fx.x > 0.0) { + c = clamp(c + half3(half(fx.x * ${CLARITY_GAIN.toFixed(1)})) * (c - blur), half3(0.0), half3(1.0)); + } else if (fx.x < 0.0) { + c = mix(c, blur, half(clamp(-fx.x, 0.0, 1.0))); + } ` : ''} return clamp(c, half3(0.0), half3(1.0)); } `; diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index a961d05..ecaac6b 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2401,11 +2401,13 @@ export function Workspace() { // mask adds the feather it fades over — the percentage of its own axis the // alpha holds full before it dies at the rim. // The spec's section 4 knobs, one row each: HIGHLIGHT and SHADOW through the - // soft masks the shader builds from the pixel's own luma, WHITE and BLACK as a - // gain on the end each one owns, and the two spatial ones — CLARITY against the - // frame's blurred reference, DEHAZE through the dark channel. All six are the - // same -10..+10 row, so one factory builds them; DEHAZE is the one knob with - // nothing to do on the negative side. + // soft masks the shader builds from the pixel's own luma, WHITE and BLACK as + // the per-channel point moves the frame-wide rows make (the toe and the + // shoulder, not a gain on the whole pixel), and the two spatial ones — CLARITY + // against the frame's blurred reference, DEHAZE through the dark channel. Each + // one means inside the mask what the row of the same name means on the whole + // frame. All six are the same -10..+10 row, so one factory builds them; DEHAZE + // is the one knob with nothing to do on the negative side. const maskKnobRow = ( key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze', label: string