id: vibrance label: op.vibrance order: 80 attributes: [colour] doc: | Vibrance — saturation weighted toward the muted colours. Where [`saturation`](saturation.yaml) scales every colour's distance from grey equally, vibrance scales it *more for muted colours than for already saturated ones*, and protects skin tones. The difference matters: pushing saturation on a portrait turns faces orange long before the background improves, which is precisely the problem vibrance was invented to solve. placement: | Before saturation, so the broad control has the last word if both are used. params: vibrance: label: param.vibrance kind: amount uniforms: amount: value: vibrance / 100 * 1.3 doc: | Scaled so that a value delivers the strength it names. Measured, not chosen: fitted on 45 of the photographer's earlier exports whose only colour setting was a vibrance of about +24, against their raws. helpers: [luminance] wgsl: | let luma = luminance(c); // How saturated a colour *looks*, so measured on display-encoded values. // In scene-linear light an ordinary tan reads as 0.78 saturated and the // falloff below would leave it a twentieth of the effect; encoded, it reads // as 0.5, which is what the eye sees. let e = pow(max(c, vec3(0.0)), vec3(1.0 / 2.2)); let e_hi = max(e.r, max(e.g, e.b)); let e_lo = min(e.r, min(e.g, e.b)); let sat = select(0.0, (e_hi - e_lo) / e_hi, e_hi > 0.00001); // The vibrance curve: full effect on grey, tapering to nothing on colours // that are already saturated. Squaring the falloff keeps the mid-range // responsive while still protecting the extremes. let falloff = (1.0 - sat) * (1.0 - sat); // Skin protection, for skin: hues between about 10 and 50 degrees (red // leading, green between red and blue) that are not strongly saturated. // Red-over-green-over-blue alone is every warm colour in a photograph — // wood, sand, brick, sunlit grass — and halving all of them is most of why // vibrance used to do so little. let span = max(e_hi - e_lo, 0.00001); let skin_hue = select(0.0, 60.0 * (e.g - e.b) / span, e.r >= e.g && e.g >= e.b); let in_band = smoothstep(4.0, 12.0, skin_hue) * (1.0 - smoothstep(42.0, 52.0, skin_hue)); let is_skin = in_band * (1.0 - smoothstep(0.45, 0.7, sat)) * f32(e.r >= e.g && e.g >= e.b); let skin_guard = 1.0 - is_skin * 0.5; let strength = amount * falloff * skin_guard; c = mix(vec3(luma), c, 1.0 + strength); c = max(c, vec3(0.0)); tests: - name: it_starts_neutral expect_active: false - name: the_amount_is_the_measured_scale why: | Fitted against the photographer's earlier exports, so a value delivers the strength it names. set: { vibrance: 100 } expect: { amount: 1.3 } - name: saturation_is_judged_as_displayed why: | Judged in scene-linear light, ordinary warm colours read as nearly saturated and get almost none of the effect. expect_wgsl: ["let e = pow(max(c, vec3(0.0)), vec3(1.0 / 2.2));"] - name: muted_colours_get_more_than_saturated_ones why: | The one property that distinguishes vibrance from saturation. Without the falloff term this node would be a duplicate of its neighbour. expect_wgsl: ["let falloff = (1.0 - sat) * (1.0 - sat);"] - name: skin_tones_are_protected why: | The reason vibrance exists. A portrait pushed on plain saturation goes orange long before the background improves. expect_wgsl: ["let skin_guard = 1.0 - is_skin * 0.5;"]