id: blacks_whites label: op.blacks_whites order: 50 attributes: [tone] doc: | Blacks and whites — the endpoints, where the image clips. Narrow where [`highlights_shadows`](highlights_shadows.yaml) is broad: whites act only in the top quarter, blacks only in the bottom quarter. That difference in reach is the whole distinction between the two pairs. placement: | After the broad recovery controls: the endpoints are placed once the tonal range they bound has been settled. params: blacks: label: param.blacks kind: amount whites: label: param.whites kind: amount uniforms: white_amount: whites / 100 black_amount: value: blacks / 100 * 0.02 doc: | A small linear offset. Scene-referred black sits near zero, so the useful range here is far smaller than a stop — 0.02 is already a visible lift on a dark frame. helpers: [luminance, tone_position, apply_tone_gain] wgsl: | let luma = luminance(c); let pos = tone_position(luma); // Narrow weights concentrated at each end — this is what separates these // controls from highlights/shadows, which are broad. Whites act only in the // top quarter, blacks only in the bottom quarter. let white_w = smoothstep(0.75, 1.0, pos); let black_w = 1.0 - smoothstep(0.0, 0.25, pos); // Whites scale the top end multiplicatively, moving the clipping point. let white_gain = exp2(white_amount * white_w); c = apply_tone_gain(c, white_gain); // Blacks shift the floor. This one is deliberately *additive*: the point of // a blacks control is to set where the image reaches zero, and a multiply // can never bring a non-zero value to zero nor lift a true black off it. c = c + vec3(black_amount * black_w); // The subtractive direction can push below zero, which is not light. c = max(c, vec3(0.0)); tests: - name: it_starts_neutral expect_active: false - name: one_parameter_is_enough_to_activate set: { whites: 20 } expect_active: true - name: the_blacks_offset_stays_small why: | Scene-referred black is near zero; a full-stop control here would be unusable, moving the image to grey at a fraction of its travel. set: { blacks: 100 } expect_range: { black_amount: [0.0, 0.05] } - name: blacks_are_additive_not_multiplicative why: | A multiply can never bring a non-zero value to zero nor lift a true black off it, which is precisely what this control is for. expect_wgsl: ["c = c + vec3(black_amount * black_w);"] - name: the_result_cannot_go_below_zero why: A negative radiance is not light, and it poisons everything downstream. expect_wgsl: ["c = max(c, vec3(0.0));"] - name: these_endpoints_are_narrower_than_the_recovery_controls why: | The one property distinguishing this node from highlights_shadows. If these weights widened to match, the two would be the same control twice. expect_wgsl: ["smoothstep(0.75, 1.0, pos)", "smoothstep(0.0, 0.25, pos)"]