# TRACES: FR-DEV-12 id: colour_grading label: op.colour_grading order: 105 attributes: [colour] doc: | Colour grading — a hue and a strength for the shadows, the midtones and the highlights, and one more cast over the whole frame. The distinction from [`colour_mixer`](colour_mixer.yaml) is the whole reason this exists. The mixer reaches for a hue that is *already in the picture*: it will turn the greens that are there, and it can do nothing at all where there are none. This reaches for a tonal *range* and casts colour into it whether any was there or not — which makes it the only control that can warm an already-neutral highlight, and the only one that can tone a monochrome conversion, since a picture with no hue left in it gives the mixer nothing to find. Split toning is the case worth naming: cool shadows against warm highlights, which is what a print toned in two baths did and what most of the looks sold since are built on. placement: | After every colour correction, the mixer included. Grading is the closing statement rather than a correction, so it should land on the colours the photographer has already settled rather than be argued with by a control further down the chain. Before the detail stage, which is where every neighbourhood operation runs whatever this file says. params: shadow_hue: label: param.shadow_hue kind: scalar min: 0 max: 360 default: 0 unit: none scale: linear precision: 0 doc: | Degrees around the hue wheel, red at zero — the same units the straighten control reports, and for the same reason: a photographer reading "210" knows where on the wheel that is, where a normalised 0..1 has to be translated first. The two ends of the travel are the same colour. That is a fact about hue rather than a defect, and it is what a wheel makes obvious and a slider cannot — one of the reasons `presentation:` below asks for one. shadow_strength: label: param.shadow_strength kind: scalar min: 0 max: 100 default: 0 unit: percent scale: linear precision: 0 doc: | How far from neutral, which is the wheel's radius. It does not go negative: a negative radius is the hue opposite, which the hue control already says, and two ways to spell one colour is how a preset comes back looking like its own complement. midtone_hue: label: param.midtone_hue kind: scalar min: 0 max: 360 default: 0 unit: none scale: linear precision: 0 midtone_strength: label: param.midtone_strength kind: scalar min: 0 max: 100 default: 0 unit: percent scale: linear precision: 0 doc: | The midtones are where skin lives, so this is the range that goes wrong first and the one most often left at zero. It is offered anyway because a grade that can only reach the ends of the scale cannot answer a cast that sits in the middle of it. highlight_hue: label: param.highlight_hue kind: scalar min: 0 max: 360 default: 0 unit: none scale: linear precision: 0 highlight_strength: label: param.highlight_strength kind: scalar min: 0 max: 100 default: 0 unit: percent scale: linear precision: 0 global_hue: label: param.global_hue kind: scalar min: 0 max: 360 default: 0 unit: none scale: linear precision: 0 global_strength: label: param.global_strength kind: scalar min: 0 max: 100 default: 0 unit: percent scale: linear precision: 0 doc: | The cast that carries no tonal weight — it applies equally at every brightness. Without it, a photographer wanting one colour everywhere and a second in one range has to set all three ranges to the first hue, and can then no longer move any one of them without disturbing the other two. # The neutral is *no strength anywhere*, not "nothing has been touched". # # A hue with no strength behind it is a direction with no distance: the # picture is identical, and every uniform below is zero. Under the default # rule, nudging a hue while the strength sat at zero would make the node # active, and it would then cost a uniform block, a helper and a block in the # fused shader for a change nobody can see. Strengths cannot go negative, so # their sum is zero exactly when every one of them is. active: shadow_strength + midtone_strength + highlight_strength + global_strength uniforms: shadow_angle: value: shadow_hue * 0.017453292 doc: | Degrees to radians, here rather than in the shader. The wheel is a slider on the photographer's side and a cosine on the GPU's, and the conversion belongs at the seam between them — the fragment then has one meaning for an angle rather than two. shadow_amount: value: shadow_strength / 100 * 0.5 doc: | Full strength is half a stop of shift on the leading channel, the same ceiling white balance holds itself to and for the same reason: a colour control that can blow a channel on its own is a trap. Four of these can stack, so the honest worst case is a stop, which takes both a maximum global cast and a maximum range on top of it. midtone_angle: midtone_hue * 0.017453292 midtone_amount: midtone_strength / 100 * 0.5 highlight_angle: highlight_hue * 0.017453292 highlight_amount: highlight_strength / 100 * 0.5 global_angle: global_hue * 0.017453292 global_amount: global_strength / 100 * 0.5 helpers: [luminance, tone_position] define: hue_cast: | // A per-channel gain carrying a hue, centred on no change at all. // // The three channels are cosines 120 degrees apart, which is the hue wheel // written directly as an RGB direction — a round trip through HSV would // buy nothing here and would have to decide what to do with a colour that // has no hue. Their sum is zero at every angle, so exp2 turns them into // three gains whose product is exactly one: the cast tilts the balance // without moving the overall level. That property is what keeps grading // from doubling as an exposure control, which is the failure that has the // photographer chasing brightness with a colour slider. fn hue_cast(angle: f32, amount: f32) -> vec3 { let tilt = vec3(cos(angle), cos(angle - 2.0943951), cos(angle + 2.0943951)); return exp2(tilt * amount); } wgsl: | let pos = tone_position(luminance(c)); // Three weights that partition the tonal scale: at every luminance they sum // to exactly one. The two ends use the same 0.5 midpoint as // highlights_shadows, so "shadows" means the same range of the picture in // both places, and the midtones are defined as whatever the ends leave. // // The partition is what makes an equal setting on all three identical to // the global cast. Weights that overlapped would make the join between two // ranges stronger than either of them, so a split tone would darken or // colour its own midtones as a side effect of the two settings meeting — // an interaction with no control over it. let lo_w = 1.0 - smoothstep(0.0, 0.5, pos); let hi_w = smoothstep(0.5, 1.0, pos); let mid_w = 1.0 - lo_w - hi_w; // The ranges compose by multiplication rather than by mixing, because a // product of luminance-neutral triples is another one — so three casts and // a global still leave the tonal relationships the tone controls // established. The global cast takes no weight: it is the whole frame. c = c * hue_cast(shadow_angle, shadow_amount * lo_w) * hue_cast(midtone_angle, midtone_amount * mid_w) * hue_cast(highlight_angle, highlight_amount * hi_w) * hue_cast(global_angle, global_amount); presentation: # Four wheels, each a hue and a distance from the centre. Named in pairs # because that is the order a wheel wants them; a frontend that draws none # of these renders eight ordinary sliders and the edit is unchanged, which # is the state this ships in. # # That fallback is why each parameter carries its range in its own name # instead of leaning on the wheel to say which one it belongs to. A flat # list is what the panel produces today, and four sliders all called "Hue" # would be four controls nobody can tell apart. widgets: [colour_wheel] demand: two_dimensional: true # A hue a few degrees off is a slightly different warm, not a wrong # answer, so this is usable with a fingertip. Saying otherwise would take # the wheel away from touch to protect an accuracy nobody needs from it. precise_pointing: false params: - shadow_hue - shadow_strength - midtone_hue - midtone_strength - highlight_hue - highlight_strength - global_hue - global_strength tests: - name: it_starts_neutral why: | Neutral means absent: at defaults the node must contribute no code, no uniform and no branch to the fused shader. Every angle and amount being zero is the arithmetic half of that; `expect_active` is the half that keeps it out of the shader at all. expect: shadow_angle: 0.0 shadow_amount: 0.0 midtone_amount: 0.0 highlight_amount: 0.0 global_amount: 0.0 expect_active: false - name: a_hue_with_no_strength_is_still_neutral why: | What `active:` buys. A hue is a direction and a strength is the distance travelled along it, so a hue moved on its own changes nothing — but the default rule ("some parameter has moved") would call the node active and make every fused shader carry it for nothing. set: { shadow_hue: 240, global_hue: 40 } expect_active: false - name: strength_alone_reaches_the_shader why: | The complement, and the reason the neutral cannot simply be "nothing touched": red is hue zero, so a grade toward red never moves a hue slider off its default and would otherwise never be applied. set: { shadow_strength: 20 } expect_active: true - name: hue_is_converted_to_radians why: | The shader's cosines take radians and this uniform is the only place the conversion happens. Degrees arriving unconverted would be an angle 57 times too large — it would wrap the wheel several times and land on a colour with no relation to the one under the pointer, which reads as the control being broken rather than as a missing constant. set: { shadow_hue: 180 } expect: { shadow_angle: 3.1415926 } - name: full_strength_is_half_a_stop why: | The ceiling on one range's contribution, matching white balance's. A grade that could take a channel to clipping by itself would make the strength slider unusable over its top third. set: { highlight_strength: 100 } expect: { highlight_amount: 0.5 } - name: strength_maps_linearly_onto_the_shift why: | Half the slider must be half the shift. A curve here would make the wheel's radius mean something different at each distance from the centre, and a wheel is read as a distance. set: { midtone_strength: 50 } expect: { midtone_amount: 0.25 } - name: the_three_ranges_partition_the_tones why: | The midtone weight is defined as what the other two leave, so the three sum to one at every luminance. Computed independently they would overlap at the joins, and a split tone would then colour its own midtones as a side effect of the shadow and highlight settings meeting. expect_wgsl: ["let mid_w = 1.0 - lo_w - hi_w;"] - name: the_global_cast_takes_no_tonal_weight why: | It is the one that means "everywhere". Weighted like the others it would land mostly on the midtones, since that weight is the largest across the range an ordinary photograph occupies, and "global" would quietly become a fourth midtone control. expect_wgsl: ["hue_cast(global_angle, global_amount)"] - name: a_cast_does_not_change_the_level why: | The cosines sum to zero at every angle, so the three gains multiply to one and a cast tilts the balance without lifting or dropping the picture. Built any other way — a clamp, an added tint, an HSV round trip — a strong grade would double as an exposure change, and the photographer would correct it with a control that cannot reach it. expect_helper_wgsl: hue_cast: ["return exp2(tilt * amount);"]