diff --git a/docker/frontend/shared/types/index.ts b/docker/frontend/shared/types/index.ts index 720122e..28394f4 100644 --- a/docker/frontend/shared/types/index.ts +++ b/docker/frontend/shared/types/index.ts @@ -153,12 +153,13 @@ export interface GradientMask { exposure: number; contrast: number; saturation: number; - // The spec's section 4 knobs, the frame-wide ones the mask can now restrict to + // The spec's section 4 knobs, the frame-wide ones the mask can restrict to // its own area: HIGHLIGHT and SHADOW through its smoothstep soft masks, WHITE // and BLACK at the two ends of the histogram, and the two spatial moves — // CLARITY against the frame's own blurred reference and DEHAZE through the dark - // channel. Same -10..+10 as the app's own rulers; absent on a mask stored - // before they existed, and then the shader reads 0 for it. + // channel, both signed. The same -10..+10 as the app's own rulers, and the same + // moves; absent on a mask stored before they existed, and then the shader reads + // 0 for it. highlights?: number; shadows?: number; whites?: number; @@ -182,8 +183,9 @@ export interface ColorAdjustments { denoise: number; // -10 to +10 (+ = blur sigma; - = add film grain back) clarity: number; // -10 to +10 (mapped to matrix convolution / bloom) // FX tab > DEHAZE (raw_parameter_processing_gradient_mask_algorithms.md §3.2): - // 0 to 10, the Dark Channel Prior pass — the scattered light the haze puts in - // front of the subject, taken back out. Absent on an older recipe = 0. + // -10 to +10, the Dark Channel Prior pass — the scattered light the haze puts + // in front of the subject, taken back out above zero and put back below it. + // Absent on an older recipe = 0. dehaze?: number; grain: number; // 0 to 10 (mapped to noise turbulence opacity/scale) grainSize?: number; // 50 to 200 (percent of the stock's own grain cell; 100 = the stock's own) diff --git a/docker/frontend/shared/utils/gradientMask.ts b/docker/frontend/shared/utils/gradientMask.ts index f2a0b35..7205c01 100644 --- a/docker/frontend/shared/utils/gradientMask.ts +++ b/docker/frontend/shared/utils/gradientMask.ts @@ -1,5 +1,11 @@ import type { GradientMask } from '../types'; -import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA } from './toneShader'; +import { + CLARITY_GAIN, + DEHAZE_FLOOR_T, + DEHAZE_MAX_OMEGA, + DEHAZE_PATCH_STEP, + DEHAZE_PATCH_TAPS, +} from './toneShader'; // FX tab > LINEAR / RADIAL GRADIENT — Lightroom's two gradient masks, the local // adjustments that are a SHAPE rather than a whole-frame knob. @@ -27,7 +33,7 @@ import { CLARITY_GAIN, DEHAZE_FLOOR_T, DEHAZE_MAX_OMEGA } from './toneShader'; // 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 and the atmospheric light for. Every one of them rides the same +// 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; @@ -101,11 +107,12 @@ export function readMasks(masks: GradientMask[] | undefined): ReadMask[] { .filter((m) => (m.kind === 'linear' ? Math.hypot(m.ex - m.x, m.ey - m.y) > MASK_MIN : m.rx > 0 && m.ry > 0)); } -// The two spatial knobs need the frame's own blurred reference (and DEHAZE the -// atmospheric light) handed to the shader as a second child; the four tonal ones -// need nothing. One question, asked in one place, so the renderer builds the -// blur for exactly the masks that read it and the effect is cached for the same -// answer. +// The two spatial knobs read what the pass cannot build on its own: CLARITY the +// frame's own blurred reference, handed in as a second child, and both of them +// the frame's atmospheric light. One question, asked in one place, so the +// renderer builds the blur for exactly the masks with a spatial knob on them and +// the effect is cached for the same answer, and so the shader is built with the +// spatial block (DEHAZE's own dark channel lives in it). export function masksHaveSpatial(masks: GradientMask[]): boolean { return readMasks(masks).some((m) => m.clarity !== 0 || m.dehaze !== 0); } @@ -159,13 +166,14 @@ export function maskUniforms( // `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 +// `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 // shader was built without the second child, and then the two spatial knobs are // left out of the pass — the caller only builds that child for masks that ask -// for them (masksHaveSpatial). +// for them (masksHaveSpatial). `dark` is the patch's dark channel for DEHAZE, +// read in the block below (the frame's own pixels are only in reach there). const adjustFn = (spatial: boolean) => ` -half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx${spatial ? ', half3 blur, float3 air' : ''}) { +half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx, half dark${spatial ? ', half3 blur, float3 air' : ''}) { 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)); @@ -193,10 +201,15 @@ half3 maskAdjust(half3 c, float4 a, float4 tone, float4 fx${spatial ? ', half3 b ${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. + // DEHAZE (doc section 3.2), DEHAZE_SKSL's own formula on the mask's pixels: + // dark is the patch's dark channel, the MINIMUM of min(r,g,b)/A over a + // neighbourhood — read by the block below, which is where the frame's pixels + // are — so a patch with anything genuinely dark in it is not haze and is left + // alone (the patch average this used to read called every patch hazy). Signed + // like the frame-wide knob: below zero t rises above 1 and the same + // expression puts the scattered light back. half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05))); - half dark = min(min(blur.r / aa.r, blur.g / aa.g), blur.b / aa.b); - 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)); + 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 + DEHAZE_MAX_OMEGA})); c = clamp((c - aa) / t + aa, half3(0.0), half3(1.0)); } // CLARITY (doc section 3.1): the pixel against its own blurred surroundings. @@ -204,9 +217,10 @@ ${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame- // 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. + // The reference is the bilateral one (CLARITY_BLUR_SPAN, 6% of the frame) and + // the frame-wide negative CLARITY mixes toward that same reference with the + // same factor (CLARITY_BLEND_SKSL), so the two readings of the knob are one + // move on two different areas. 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) { @@ -240,7 +254,23 @@ const maskBlock = (i: number, spatial: boolean) => ` a = 1.0 - smoothstep(max(0.0, 1.0 - rads[${i}].w), 1.0, d); } if (a > 0.0) { - c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}]${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a)); +${spatial ? ` // The patch's dark channel for DEHAZE, one tap per DEHAZE_PATCH_STEP of + // the frame's width — the same fraction the frame-wide pass reads, so the + // mask's DEHAZE and the frame's are the same neighbourhood on any size of + // render. Skipped when the mask has no DEHAZE, so a CLARITY-only mask + // pays nothing for it. + half dark = half(1.0); + if (fx[${i}].y != 0.0) { + half3 aa = half3(half(max(air.x, 0.05)), half(max(air.y, 0.05)), half(max(air.z, 0.05))); + float stp = max(1.0, size.x * ${DEHAZE_PATCH_STEP}); + for (int j = -${DEHAZE_PATCH_TAPS}; j <= ${DEHAZE_PATCH_TAPS}; j++) { + for (int k = -${DEHAZE_PATCH_TAPS}; k <= ${DEHAZE_PATCH_TAPS}; k++) { + half3 p = clamp(img.eval(pos + float2(float(k), float(j)) * stp).rgb, half3(0.0), half3(1.0)); + dark = min(dark, min(min(p.r / aa.r, p.g / aa.g), p.b / aa.b)); + } + } + }` : ' half dark = half(0.0);'} + c.rgb = mix(c.rgb, maskAdjust(c.rgb, adj[${i}], tone[${i}], fx[${i}], dark${spatial ? ', blurred.eval(pos).rgb, air.xyz' : ''}), half(a)); } } `; diff --git a/docker/frontend/shared/utils/paramDefs.ts b/docker/frontend/shared/utils/paramDefs.ts index 6ad04a1..c076600 100644 --- a/docker/frontend/shared/utils/paramDefs.ts +++ b/docker/frontend/shared/utils/paramDefs.ts @@ -181,16 +181,17 @@ export const PARAM_DEFS: { }, { // The haze the frame's own pixels carry (dark channel prior, toneShader's - // DEHAZE_SKSL): no negative side, because there is no way to put scattered - // light back that is honest about where it went. Its chip sits with the - // other spatial knob it shares a blurred reference with. + // DEHAZE_SKSL): positive takes the scattered light out, negative puts it + // back — the same expression, with the transmission pushed above 1, which + // is the knob a photo shot through mist wants from this side. Its chip sits + // with the other spatial knob, CLARITY. key: 'dehaze', label: 'DEHAZE', - min: 0, + min: -10, max: 10, step: 1, defaultValue: 0, - display: String, + display: sign, get: (a) => a.dehaze ?? 0, set: (v) => ({ dehaze: v }), }, diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index bf808a3..a4150c9 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -362,12 +362,17 @@ vec4 main(vec2 xy) { } `; -// CLARITY (positive), pass 3 of the doc's architecture: the frame against its -// own blurred reference. `orig + (orig - B) * strength` — the local contrast the -// doc asks for, clamped because a file cannot hold more than white. Runs on the -// ENCODED pixels like every other grade here (only EXPOSURE_SKSL is linear -// light, see colorUtils.exposureStops) — the doc's formula is written for linear -// light, and moving the whole renderer there is a bigger change than this pass. +// CLARITY, pass 3 of the doc's architecture: the frame against its own blurred +// reference — `orig + (orig - B) * strength` above zero, the mix back toward B +// below it. One reference, one pass, both directions of one knob: a NEGATIVE +// CLARITY is the positive one's soften, not a second kind of blur picked for the +// sign (that mist had another radius than the reference the positive side reads, +// so -10 and +10 were two different neighbourhoods and a MASK's CLARITY could +// not be the frame's own move). Clamped because a file cannot hold more than +// white. Runs on the ENCODED pixels like every other grade here (only +// EXPOSURE_SKSL is linear light, see colorUtils.exposureStops) — the doc's +// formula is written for linear light, and moving the whole renderer there is a +// bigger change than this pass. export const CLARITY_BLEND_SKSL = ` uniform shader original; uniform shader blurred; @@ -375,48 +380,87 @@ uniform float strength; vec4 main(vec2 xy) { vec4 c = original.eval(xy); vec3 b = blurred.eval(xy).rgb; - return vec4(clamp(c.rgb + (c.rgb - b) * strength, 0.0, 1.0), c.a); + vec3 d = c.rgb - b; + vec3 out_rgb = strength >= 0.0 ? c.rgb + d * strength : mix(c.rgb, b, clamp(-strength, 0.0, 1.0)); + return vec4(clamp(out_rgb, 0.0, 1.0), c.a); } `; // Strength that keeps CLARITY 10 where the 3x3 kernel had it: that kernel was // `c*(1+4a) - a*sum` with a = 0.8, i.e. `c + 3.2*(c - mean4)`, so the same 3.2 -// lands the same local contrast through the wider bilateral reference. +// lands the same local contrast through the wider bilateral reference. The gain +// is for the POSITIVE side only: below zero the knob reads as its own fraction +// of the reference (0..1, the same units MASK's CLARITY uses on it). export const CLARITY_GAIN = 3.2; // DEHAZE — raw_parameter_processing_gradient_mask_algorithms.md, section 3.2. // Haze is scattered light: it lifts the DARKEST channel of every patch, which is -// the Dark Channel Prior. `haze` is the frame's own bilateral reference (the -// patch average the blur already computes), `air` the atmospheric light the -// caller estimated from the frame, and the transmission is what is left of the -// dark channel once the haze is taken out of it, floored so a flat sky cannot -// divide by zero. Then `J = (I - A)/t + A` removes the scattered light and the -// division is also the contrast stretch the doc asks for afterwards. -export const DEHAZE_SKSL = ` -uniform shader img; -uniform shader haze; -uniform float3 air; -uniform float amount; -uniform float floorT; -vec4 main(vec2 xy) { - vec3 c = clamp(img.eval(xy).rgb, 0.0, 1.0); - vec3 b = clamp(haze.eval(xy).rgb, 0.0, 1.0); - vec3 a = max(air, vec3(0.05)); - float dark = min(min(b.r / a.r, b.g / a.g), b.b / a.b); - float t = clamp(1.0 - amount * clamp(dark, 0.0, 1.0), floorT, 1.0); - return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0); -} -`; - +// the Dark Channel Prior. The dark channel is the MINIMUM of min(r,g,b)/A over +// the patch, and that minimum is the whole prior: a patch holding anything +// genuinely dark — a shadow, a black frame line — reads 0 and is left alone, +// while only a patch with no dark pixel in it at all is haze and gets corrected. +// The patch AVERAGE this pass used to read instead (the bilateral reference) +// called every patch hazy, so the positive end ground the frame down instead of +// taking haze out. `air` is the atmospheric light the caller estimated from the +// frame, `step` one tap of the patch in the caller's own pixels — a fraction of +// the frame's width, so the preview and the file look at the same neighbourhood +// (DEHAZE_PATCH_STEP). +// +// `amount` is signed. Positive pushes the transmission below 1 and +// `J = (I - A)/t + A` takes the scattered light out; negative pushes it above 1 +// and the same expression scatters light back in, which is what a negative +// DEHAZE is for. The floor keeps a flat sky from dividing by zero, and the +// ceiling is the largest amount the knob can ask for either way. // Ray marching the doc's A estimate would need the histogram; the caller reads a // 32x32 copy of the frame instead and takes its brightest dark-channel pixel — // the same 0.1% answer, in one readback (see exportEngine's atmosphericLight). export const DEHAZE_FLOOR_T = 0.1; export const DEHAZE_MAX_OMEGA = 0.95; -export function dehazeUniformArray(air: [number, number, number], amount: number): number[] { +// The dark channel's patch, and the two ends of the transmission t. The patch is +// `taps` samples out at `step` each — two taps at 0.625% of the frame's width is +// a 2.5%-wide neighbourhood, the DCP's own 15-pixel patch on a 600-pixel frame +// and the same fraction of a 4000-pixel export. Five by five samples rather than +// fifteen by fifteen because the doc's 225 reads per pixel is what +// CLARITY_BLUR_SKSL above already refused, and the prior only needs a patch the +// haze is flat over. +export const DEHAZE_PATCH_TAPS = 2; +export const DEHAZE_PATCH_STEP = 0.00625; + +export const DEHAZE_SKSL = ` +uniform shader img; +uniform float3 air; +uniform float amount; +uniform float floorT; +uniform float stepPx; +vec4 main(vec2 xy) { + vec3 c = clamp(img.eval(xy).rgb, 0.0, 1.0); + vec3 a = max(air, vec3(0.05)); + float dark = 1.0; + for (int j = -${DEHAZE_PATCH_TAPS}; j <= ${DEHAZE_PATCH_TAPS}; j++) { + for (int i = -${DEHAZE_PATCH_TAPS}; i <= ${DEHAZE_PATCH_TAPS}; i++) { + vec3 p = clamp(img.eval(xy + vec2(float(i), float(j)) * stepPx).rgb, 0.0, 1.0); + dark = min(dark, min(min(p.r / a.r, p.g / a.g), p.b / a.b)); + } + } + float t = clamp(1.0 - amount * clamp(dark, 0.0, 1.0), floorT, 1.0 + ${DEHAZE_MAX_OMEGA}); + return vec4(clamp((c - a) / t + a, 0.0, 1.0), 1.0); +} +`; + +export function dehazeUniformArray( + air: [number, number, number], + amount: number, + stepPx: number +): number[] { 'worklet'; - return [air[0], air[1], air[2], amount * DEHAZE_MAX_OMEGA, DEHAZE_FLOOR_T]; + return [air[0], air[1], air[2], amount * DEHAZE_MAX_OMEGA, DEHAZE_FLOOR_T, stepPx]; +} + +// One tap of that patch in the pixels of a frame this wide. +export function dehazePatchStep(width: number): number { + 'worklet'; + return Math.max(1, width * DEHAZE_PATCH_STEP); } export interface ToneUniforms { diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index ecaac6b..c8ee643 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2406,8 +2406,7 @@ export function Workspace() { // 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. + // frame. All six are the same -10..+10 row, so one factory builds them. const maskKnobRow = ( key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze', label: string @@ -2417,7 +2416,7 @@ export function Workspace() { key: `mask-${key}`, label, value: v, - min: key === 'dehaze' ? 0 : -10, + min: -10, max: 10, step: 1, display: v > 0 ? `+${v}` : String(v), diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index f9acc48..4737412 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -33,6 +33,7 @@ import { CLARITY_GAIN, DEHAZE_SKSL, dehazeUniformArray, + dehazePatchStep, getToneUniforms, toneIsActive, toneUniformArray, @@ -776,36 +777,42 @@ export async function renderPhoto(input: RenderInput): Promise 0 && dehazeEffect) { + if (dehazeAmount !== 0 && dehazeEffect) { replaceThrough(canvas, surface, width, height, (snap) => { const a = airOf(); - const reference = spatialReference(surface, width, height); - if (!a || !reference) return null; - own(reference); - const shader = dehazeEffect.makeShaderWithChildren(dehazeUniformArray(a, dehazeAmount), [ - own(imageShaderChild(snap)), - own(imageShaderChild(reference)), - ]); + if (!a) return null; + const shader = dehazeEffect.makeShaderWithChildren( + dehazeUniformArray(a, dehazeAmount, dehazePatchStep(width)), + [own(imageShaderChild(snap))] + ); return shader ? own(shader) : null; }); } - if (adjustments.clarity > 0 && clarityBlendEffect) { + // CLARITY is one move in two directions: the frame against its own blurred + // reference above zero, the mix back toward that same reference below it — + // which is exactly what a mask's CLARITY does with the same child, so the + // frame-wide knob and the masked one are the same neighbourhood and the same + // strength (the negative side used to be a mist blur of its own radius). + const clarityKnob = adjustments.clarity ?? 0; + if (clarityKnob !== 0 && clarityBlendEffect) { replaceThrough(canvas, surface, width, height, (snap) => { const reference = spatialReference(surface, width, height); if (!reference) return null; own(reference); const shader = clarityBlendEffect.makeShaderWithChildren( - [(adjustments.clarity / 10) * CLARITY_GAIN], + [(clarityKnob / 10) * (clarityKnob > 0 ? CLARITY_GAIN : 1)], [own(imageShaderChild(snap)), own(imageShaderChild(reference))] ); return shader ? own(shader) : null; }); - } else if (adjustments.clarity < 0) { - const mistSigma = Math.abs(adjustments.clarity / 10) * 4; - drawBlurred(canvas, surface, width, height, mistSigma); } // Negative SHARPENING takes edge enhancement back out — after the conv, so // it cannot blur away what the CLARITY pass just added.