Declare a develop operation in YAML, and generate the rest

An operation was, in the overwhelming majority of cases, four facts: what
its parameters are, what uniforms they compute, what WGSL those uniforms
drive, and where it sits in the chain. Written in Rust those four facts
arrived wrapped in ninety lines of trait implementation — a match on
parameter id to a struct field, another match back, an is_active comparing
each field to its default, a Vec<Uniform> built by hand. All mechanical,
and each one a place to make a silent mistake: a param() arm returning the
wrong field reads perfectly and breaks the sidecar round-trip.

So the four facts are the file now. core/dr-pipeline/ops/<id>.yaml is a
node, build.rs compiles it into the same Operation impl as before, and the
result lands in OUT_DIR — the same reasoning as style.yaml -> theme.slint,
including why it does not land beside the sources it would look exactly
like. Nothing downstream can tell a declared node from a hand-written one:
same &'static OpDescriptor, same fused-shader composition, same sidecar.

Nine nodes moved: exposure, white_balance, contrast, highlights_shadows,
blacks_whites, brilliance, vibrance, saturation, and the shared WGSL
helper registry. Their prose came with them, and so did their tests —
set/expect/expect_active/expect_wgsl in the declaration compile to real
#[test]s, so a node file carries its own proof rather than leaving it
behind in a file that no longer exists.

Two stayed in Rust and say so with `rust:`. The tone curve's neutral is a
relationship between five interpolated points rather than a set of values;
the colour mixer generates thirty-six faceted parameters from twelve
computed hue bands. A schema stretched to cover either would be a worse
language than Rust aimed at one caller. They still declare their position
here, because the chain's *order* is the one thing a reader comes to this
directory to learn, and an order written half in YAML and half in Rust
would be worse than either alone. default_chain() is generated from it.

Uniforms are derived by a small expression language — exp2(exposure),
blacks / 100 * 0.02 — compiled to Rust rather than interpreted, so an
unknown name or a wrong arity is a build error naming the file and the key
and the arithmetic costs nothing at runtime. The build script refuses a
duplicate order, a filename disagreeing with its id, a default outside its
own range, a test value the graph would clamp before the node saw it, a
helper that does not define the function it names, and a declared node
colliding with a file in src/ops.

Verified by adding a scratch node and removing it again: one file, no
other edit, and it joined the chain at its declared order with its test
running. 237 tests pass in dr-pipeline, clippy and fmt clean.

.yaml joins the traceability tool's scanned suffixes, because a node's
Rust now lives in OUT_DIR where a tag could never be linked from the
report. Coverage 47.7% -> 48.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 21:08:16 +02:00
co-authored by Claude Opus 5
parent 65e6a96a65
commit 7c57f490fe
15 changed files with 483 additions and 1455 deletions
-397
View File
@@ -1,397 +0,0 @@
//! Colour operations: vibrance, saturation, and brilliance.
//!
//! # Vibrance versus saturation
//!
//! Saturation 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.
//!
//! # Brilliance
//!
//! Apple's control, and a genuinely different idea from either: it lifts
//! shadows and pulls highlights *simultaneously*, applying the opposite
//! correction at each end of the range while leaving mid-tones alone. The
//! result reads as "more light in the scene" rather than "less contrast",
//! because local relationships survive where a plain contrast reduction
//! flattens them.
//!
//! It overlaps with highlights/shadows deliberately — one control doing both
//! in a fixed relationship is easier to reach for than two controls needing
//! to be balanced against each other.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::tone::TONE_HELPERS;
// ---------------------------------------------------------------------------
// Saturation
// ---------------------------------------------------------------------------
pub const SATURATION_ID: OpId = OpId("saturation");
pub const SATURATION: ParamId = ParamId("saturation");
static SAT_DESCRIPTOR: OpDescriptor = OpDescriptor {
id: SATURATION_ID,
label: LocalizedKey("op.saturation"),
params: &[ParamDescriptor::amount("saturation", "param.saturation")],
};
#[derive(Debug, Default, Clone)]
pub struct Saturation {
amount: f32,
}
impl Saturation {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for Saturation {
fn descriptor(&self) -> &'static OpDescriptor {
&SAT_DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
SATURATION => self.amount = value,
_ => log::warn!("saturation: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
SATURATION => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
fn wgsl_body(&self) -> String {
"\
// Interpolate away from the luminance-preserving grey. A factor of 0 is
// monochrome, 1 is unchanged, above 1 is more saturated.
let luma = luminance(c);
c = mix(vec3<f32>(luma), c, factor);
c = max(c, vec3<f32>(0.0));"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
// -100 reaches exactly monochrome; +100 doubles the distance from
// grey. The floor at zero matters: a negative factor would push a
// colour past grey into its complement, inverting hues.
vec![Uniform {
name: "factor",
value: (1.0 + self.amount / 100.0).max(0.0),
}]
}
fn helpers(&self) -> &'static [Helper] {
TONE_HELPERS
}
}
// ---------------------------------------------------------------------------
// Vibrance
// ---------------------------------------------------------------------------
pub const VIBRANCE_ID: OpId = OpId("vibrance");
pub const VIBRANCE: ParamId = ParamId("vibrance");
static VIB_DESCRIPTOR: OpDescriptor = OpDescriptor {
id: VIBRANCE_ID,
label: LocalizedKey("op.vibrance"),
params: &[ParamDescriptor::amount("vibrance", "param.vibrance")],
};
#[derive(Debug, Default, Clone)]
pub struct Vibrance {
amount: f32,
}
impl Vibrance {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for Vibrance {
fn descriptor(&self) -> &'static OpDescriptor {
&VIB_DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
VIBRANCE => self.amount = value,
_ => log::warn!("vibrance: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
VIBRANCE => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
fn wgsl_body(&self) -> String {
"\
let luma = luminance(c);
let sat = colour_saturation(c);
// 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. Skin sits in a narrow band of hue where red leads green
// leads blue; pushing it is what makes vibrance look wrong on portraits.
// Detected by channel ordering rather than a hue angle, which costs a
// conversion and buys nothing here.
let is_skin = f32(c.r > c.g && c.g > c.b);
let skin_guard = 1.0 - is_skin * 0.5;
let strength = amount * falloff * skin_guard;
c = mix(vec3<f32>(luma), c, 1.0 + strength);
c = max(c, vec3<f32>(0.0));"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "amount",
value: self.amount / 100.0,
}]
}
fn helpers(&self) -> &'static [Helper] {
// Needs both luminance (from tone) and the saturation measure.
// Duplicates across the two lists are deduplicated by the composer.
COLOUR_AND_TONE
}
}
/// The helper set vibrance needs: luminance plus the saturation measure.
static COLOUR_AND_TONE: &[Helper] = crate::ops::helpers::COLOUR;
// ---------------------------------------------------------------------------
// Brilliance
// ---------------------------------------------------------------------------
pub const BRILLIANCE_ID: OpId = OpId("brilliance");
pub const BRILLIANCE: ParamId = ParamId("brilliance");
static BRIL_DESCRIPTOR: OpDescriptor = OpDescriptor {
id: BRILLIANCE_ID,
label: LocalizedKey("op.brilliance"),
params: &[ParamDescriptor::amount("brilliance", "param.brilliance")],
};
#[derive(Debug, Default, Clone)]
pub struct Brilliance {
amount: f32,
}
impl Brilliance {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for Brilliance {
fn descriptor(&self) -> &'static OpDescriptor {
&BRIL_DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
BRILLIANCE => self.amount = value,
_ => log::warn!("brilliance: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
BRILLIANCE => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
fn wgsl_body(&self) -> String {
"\
let luma = luminance(c);
let pos = tone_position(luma);
// Opposite corrections at the two ends: shadows up, highlights down, both
// tapering to nothing at the mid-point. This is what separates brilliance
// from a contrast control — mid-tones keep their local relationships, so
// the image gains apparent light rather than losing structure.
let lift = (1.0 - smoothstep(0.0, 0.5, pos)) * amount;
let pull = smoothstep(0.5, 1.0, pos) * amount;
// A mild saturation compensation. Flattening the tonal range washes colour
// out; without this, brilliance looks faded at useful settings.
let gain = exp2(lift - pull);
c = c * gain;
let luma_after = luminance(c);
c = mix(vec3<f32>(luma_after), c, 1.0 + max(amount, 0.0) * 0.15);
c = max(c, vec3<f32>(0.0));"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "amount",
// Half a stop at each end at full travel — the two ends move
// apart by a stop in total, which is a strong but not
// destructive flattening.
value: self.amount / 100.0 * 0.5,
}]
}
fn helpers(&self) -> &'static [Helper] {
TONE_HELPERS
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operation::compose;
#[test]
fn all_three_start_neutral() {
assert!(!Saturation::new().is_active());
assert!(!Vibrance::new().is_active());
assert!(!Brilliance::new().is_active());
}
#[test]
fn full_negative_saturation_reaches_monochrome() {
// The property that makes -100 meaningful: it must land exactly on
// grey, not merely near it.
let mut s = Saturation::new();
s.set_param(SATURATION, -100.0);
assert_eq!(s.uniforms()[0].value, 0.0);
}
#[test]
fn positive_saturation_increases_the_factor() {
let mut s = Saturation::new();
s.set_param(SATURATION, 100.0);
assert!((s.uniforms()[0].value - 2.0).abs() < 1e-6);
}
#[test]
fn the_saturation_factor_never_goes_negative() {
// A negative factor would invert hues — a colour past monochrome
// becomes its complement, which is never wanted here.
let mut s = Saturation::new();
s.set_param(SATURATION, -200.0);
assert!(s.uniforms()[0].value >= 0.0);
}
#[test]
fn vibrance_protects_skin_in_its_fragment() {
// The distinguishing behaviour; if the guard is dropped, portraits
// go orange and the control is indistinguishable from saturation.
let mut v = Vibrance::new();
v.set_param(VIBRANCE, 50.0);
let body = v.wgsl_body();
assert!(body.contains("skin_guard"), "skin protection must survive");
assert!(
body.contains("falloff"),
"the roll-off is what makes it vibrance"
);
}
#[test]
fn vibrance_and_saturation_are_distinct_operations() {
// They must not share an id, or the composer would emit one and the
// UI would show one control for two behaviours.
assert_ne!(VIBRANCE_ID, SATURATION_ID);
}
#[test]
fn brilliance_moves_the_ends_in_opposite_directions() {
let mut b = Brilliance::new();
b.set_param(BRILLIANCE, 100.0);
let body = b.wgsl_body();
assert!(body.contains("lift"), "shadows must rise");
assert!(body.contains("pull"), "highlights must fall");
assert!(
body.contains("lift - pull"),
"the two must oppose, or this is just an exposure control"
);
}
#[test]
fn brilliance_travel_is_bounded() {
let mut b = Brilliance::new();
b.set_param(BRILLIANCE, 100.0);
let v = b.uniforms()[0].value;
assert!((0.0..=0.6).contains(&v), "amount {v} is too aggressive");
}
#[test]
fn helpers_are_shared_across_every_colour_operation() {
// Vibrance declares its own helper list; if its luminance source
// drifted from tone's, the composer would emit whichever came first
// and the two operations would disagree about luminance.
let ops: Vec<Box<dyn Operation>> = vec![
Box::new({
let mut o = Vibrance::new();
o.set_param(VIBRANCE, 40.0);
o
}),
Box::new({
let mut o = Saturation::new();
o.set_param(SATURATION, 20.0);
o
}),
Box::new({
let mut o = Brilliance::new();
o.set_param(BRILLIANCE, 30.0);
o
}),
];
let shader = compose(&ops);
assert_eq!(
shader.source.matches("fn luminance(").count(),
1,
"luminance must be declared exactly once"
);
assert_eq!(shader.source.matches("fn colour_saturation(").count(), 1);
}
#[test]
fn tone_and_colour_agree_on_luminance() {
// Both sets reference the shared definition. If someone reintroduces
// a local copy, the composer would emit whichever operation came
// first and the two would compute luminance differently.
let from_tone = TONE_HELPERS
.iter()
.find(|h| h.name == "luminance")
.expect("tone declares luminance");
let from_colour = COLOUR_AND_TONE
.iter()
.find(|h| h.name == "luminance")
.expect("colour declares luminance");
assert_eq!(from_tone.source, from_colour.source);
}
}
-213
View File
@@ -1,213 +0,0 @@
//! Contrast — an S-curve about a fixed mid-point.
//!
//! Pushes tones away from middle grey (positive) or toward it (negative),
//! pivoting where the eye reads "neither light nor dark". In linear light
//! that point is 0.18, not 0.5: a scene-referred value of 0.5 is roughly a
//! stop and a half above middle grey, and pivoting there would darken almost
//! every photograph.
//!
//! The curve is applied in a perceptual domain rather than directly to linear
//! values. Applied linearly, an S-curve crushes shadows far harder than it
//! lifts highlights, because linear light devotes most of its range to the
//! brightest stop.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
pub const ID: OpId = OpId("contrast");
pub const CONTRAST: ParamId = ParamId("contrast");
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.contrast"),
params: &[ParamDescriptor::amount("contrast", "param.contrast")],
};
/// The helpers this operation needs, including its own S-curve.
static CONTRAST_HELPERS: &[Helper] = &[
helpers::LUMINANCE,
helpers::APPLY_TONE_GAIN,
Helper {
name: "contrast_curve",
source: "\
// A symmetric S-curve on a 0..1 perceptual position.
//
// `amount` above zero steepens, below zero flattens. The smoothstep form is
// used for the steepening direction because it has zero gradient at both
// ends, so the curve cannot invert however hard it is pushed — the failure
// that makes naive gain-about-a-pivot unusable past moderate settings.
fn contrast_curve(x: f32, amount: f32) -> f32 {
let clamped = clamp(x, 0.0, 1.0);
if (amount >= 0.0) {
// Blend toward a smoothstep, which is the S.
let s = clamped * clamped * (3.0 - 2.0 * clamped);
return mix(clamped, s, amount);
}
// Flattening: pull toward the mid-point. At amount = -1 every tone
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
return mix(clamped, 0.5, -amount);
}",
},
];
#[derive(Debug, Default, Clone)]
pub struct Contrast {
amount: f32,
}
impl Contrast {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for Contrast {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
CONTRAST => self.amount = value,
_ => log::warn!("contrast: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
CONTRAST => self.amount,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.amount != 0.0
}
fn wgsl_body(&self) -> String {
"\
let luma = luminance(c);
if (luma > 0.0001) {
// Work on luminance and rescale the colour by the ratio, rather than
// curving each channel independently. Per-channel contrast shifts hue
// wherever the channels differ — the classic symptom being skies going
// cyan as contrast rises.
//
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The
// curve operates on luma/(2*0.18) so that middle grey lands at the
// curve's own 0.5 pivot.
let pos = clamp(luma / 0.36, 0.0, 1.0);
let curved = contrast_curve(pos, amount);
// Not `target`: that is a WGSL reserved keyword, and using it produces a
// parse error in generated code rather than anywhere a reader would look.
let curved_luma = curved * 0.36;
c = apply_tone_gain(c, curved_luma / luma);
}
c = max(c, vec3<f32>(0.0));"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "amount",
value: self.amount / 100.0,
}]
}
fn helpers(&self) -> &'static [Helper] {
CONTRAST_HELPERS
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::operation::compose;
#[test]
fn neutral_does_nothing() {
let c = Contrast::new();
assert!(!c.is_active());
assert_eq!(c.uniforms()[0].value, 0.0);
}
#[test]
fn the_amount_is_normalised_to_unit_range() {
// The shader's curve expects -1..1; the descriptor speaks -100..100.
let mut c = Contrast::new();
c.set_param(CONTRAST, 100.0);
assert!((c.uniforms()[0].value - 1.0).abs() < 1e-6);
c.set_param(CONTRAST, -100.0);
assert!((c.uniforms()[0].value + 1.0).abs() < 1e-6);
}
#[test]
fn contrast_works_on_luminance_not_per_channel() {
// Curving each channel separately shifts hue; the ratio form is what
// keeps a blue sky blue as contrast rises.
let mut c = Contrast::new();
c.set_param(CONTRAST, 50.0);
let body = c.wgsl_body();
assert!(body.contains("luminance(c)"));
assert!(
body.contains("apply_tone_gain"),
"the colour must be scaled by a ratio, not curved per channel"
);
}
#[test]
fn the_pivot_is_middle_grey_not_half() {
// Pivoting at 0.5 in linear light would darken nearly every image:
// scene-referred 0.5 is well above what the eye calls mid-tone.
let c = Contrast::new();
assert!(
c.wgsl_body().contains("0.36"),
"the curve must pivot about middle grey (0.18, doubled to place \
it at the curve's own midpoint)"
);
}
#[test]
fn the_curve_cannot_invert() {
// A gain-about-a-pivot form produces a non-monotonic curve past
// moderate settings, which inverts tones. smoothstep cannot.
let helper = CONTRAST_HELPERS
.iter()
.find(|h| h.name == "contrast_curve")
.expect("declares its curve");
assert!(helper.source.contains("3.0 - 2.0 * clamped"));
}
#[test]
fn it_composes_with_the_other_tonal_operations() {
// Contrast, highlights/shadows and brilliance all want `luminance`;
// the composer must emit it once.
let ops: Vec<Box<dyn Operation>> = vec![
Box::new({
let mut o = Contrast::new();
o.set_param(CONTRAST, 40.0);
o
}),
Box::new({
let mut o = crate::ops::HighlightsShadows::new();
o.set_param(crate::ops::tone::HIGHLIGHTS, -30.0);
o
}),
];
let shader = compose(&ops);
assert_eq!(shader.source.matches("fn luminance(").count(), 1);
assert_eq!(shader.source.matches("fn apply_tone_gain(").count(), 1);
assert_eq!(shader.source.matches("fn contrast_curve(").count(), 1);
}
#[test]
fn a_division_by_luminance_is_guarded() {
// A black pixel has zero luminance; dividing by it would produce NaN
// and propagate through everything downstream.
assert!(
Contrast::new().wgsl_body().contains("luma > 0.0001"),
"the ratio must be guarded against black pixels"
);
}
}
-116
View File
@@ -1,116 +0,0 @@
//! Exposure — a linear gain, expressed in stops.
//!
//! The simplest operation in the pipeline and the one that most justifies
//! working in linear light: a stop is a doubling, so exposure is a single
//! multiply. Applied to gamma-encoded data it would be neither a doubling nor
//! reversible, which is why this stage sits where it does (ARCH §5.2).
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Operation, Uniform};
pub const ID: OpId = OpId("exposure");
pub const EXPOSURE: ParamId = ParamId("exposure");
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.exposure"),
// ±5 stops. Wider than most edits need, but recovering a badly
// underexposed frame is a real use and raw data often supports it.
params: &[ParamDescriptor::stops(
"exposure",
"param.exposure",
-5.0,
5.0,
)],
};
#[derive(Debug, Default, Clone)]
pub struct Exposure {
stops: f32,
}
impl Exposure {
pub fn new() -> Self {
Self::default()
}
/// The linear gain for the current setting.
fn gain(&self) -> f32 {
f32::exp2(self.stops)
}
}
impl Operation for Exposure {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
EXPOSURE => self.stops = value,
_ => log::warn!("exposure: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
EXPOSURE => self.stops,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.stops != 0.0
}
fn wgsl_body(&self) -> String {
"c = c * gain;".into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "gain",
value: self.gain(),
}]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neutral_does_nothing() {
let e = Exposure::new();
assert!(!e.is_active());
assert_eq!(e.gain(), 1.0);
}
#[test]
fn one_stop_is_a_doubling() {
// The definition of a stop. If this is wrong, every exposure
// adjustment is subtly off and no test of "looks right" would catch
// it.
let mut e = Exposure::new();
e.set_param(EXPOSURE, 1.0);
assert!((e.gain() - 2.0).abs() < 1e-6);
e.set_param(EXPOSURE, -1.0);
assert!((e.gain() - 0.5).abs() < 1e-6);
}
#[test]
fn stops_compose_additively() {
// +2 stops must equal +1 applied twice.
let mut e = Exposure::new();
e.set_param(EXPOSURE, 2.0);
assert!((e.gain() - 4.0).abs() < 1e-5);
}
#[test]
fn the_range_covers_a_badly_exposed_frame() {
let d = &DESCRIPTOR.params[0];
assert_eq!(d.clamp(-9.0), -5.0);
assert_eq!(d.clamp(9.0), 5.0);
}
}
-117
View File
@@ -1,117 +0,0 @@
//! WGSL helper functions shared between operations.
//!
//! **Single source of truth.** Several operations need the same helpers, and
//! each declares a `&'static [Helper]` naming the ones it uses. The composer
//! deduplicates by *name*, so if two lists carried different source for the
//! same name it would silently emit whichever came first — and two operations
//! would compute, say, luminance differently depending on graph order. That
//! is a genuinely hard bug to see, so the sources are defined exactly once
//! here and referenced everywhere else.
use crate::operation::Helper;
/// Rec. 709 luminance.
pub const LUMINANCE: Helper = Helper {
name: "luminance",
source: "\
// Rec. 709 luminance, the weighting that matches sRGB primaries.
//
// Applied to camera-space values it is an approximation — the true weights
// depend on the camera matrix — but using it here keeps the tonal operations
// working on sensor-native data, where highlight headroom still exists.
fn luminance(c: vec3<f32>) -> f32 {
return dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
}",
};
/// Linear luminance mapped to a perceptual 0..1 position.
pub const TONE_POSITION: Helper = Helper {
name: "tone_position",
source: "\
// Map linear luminance onto a perceptual 0..1 position.
//
// Tonal controls must feel evenly spaced to the eye, and linear light is
// not: middle grey sits at 0.18, so a linear weight would call almost
// everything a shadow. The cube root approximates lightness cheaply and
// behaves well near zero, where a log would diverge.
fn tone_position(luma: f32) -> f32 {
return clamp(pow(max(luma, 0.0), 1.0 / 3.0), 0.0, 1.0);
}",
};
/// Hue-preserving gain.
pub const APPLY_TONE_GAIN: Helper = Helper {
name: "apply_tone_gain",
source: "\
// Scale a colour by a gain while preserving its hue.
//
// Multiplying the three channels equally keeps chromaticity fixed, so
// lifting shadows does not desaturate them the way an additive lift would.
fn apply_tone_gain(c: vec3<f32>, gain: f32) -> vec3<f32> {
return c * gain;
}",
};
/// Distance from grey, as HSV chroma.
pub const COLOUR_SATURATION: Helper = Helper {
name: "colour_saturation",
source: "\
// How far a colour sits from grey, in 0..1.
//
// The max-minus-min definition (HSV chroma) rather than a standard
// deviation: it matches what the eye reads as 'colourfulness' and it is what
// makes vibrance's roll-off land where users expect.
fn colour_saturation(c: vec3<f32>) -> f32 {
let hi = max(c.r, max(c.g, c.b));
let lo = min(c.r, min(c.g, c.b));
if (hi <= 0.0) {
return 0.0;
}
return (hi - lo) / hi;
}",
};
/// The set the tonal operations need.
pub static TONE: &[Helper] = &[LUMINANCE, TONE_POSITION, APPLY_TONE_GAIN];
/// The set the colour operations need.
pub static COLOUR: &[Helper] = &[LUMINANCE, TONE_POSITION, COLOUR_SATURATION];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_helper_defines_the_function_it_names() {
// A mismatch between the dedup key and the function actually emitted
// would produce either a duplicate definition or a missing one.
for h in TONE.iter().chain(COLOUR.iter()) {
assert!(
h.source.contains(&format!("fn {}(", h.name)),
"helper {} does not define fn {}",
h.name,
h.name
);
}
}
#[test]
fn helpers_shared_between_sets_are_the_same_value() {
// The drift this module exists to prevent: same name, different
// source, and the composer silently picks one.
let tone_luma = TONE.iter().find(|h| h.name == "luminance").unwrap();
let colour_luma = COLOUR.iter().find(|h| h.name == "luminance").unwrap();
assert_eq!(tone_luma.source, colour_luma.source);
}
#[test]
fn no_set_lists_the_same_helper_twice() {
for set in [TONE, COLOUR] {
let mut names: Vec<&str> = set.iter().map(|h| h.name).collect();
let before = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(before, names.len(), "a helper set lists a duplicate");
}
}
}
+157 -19
View File
@@ -1,35 +1,173 @@
//! The develop operations.
//!
//! Each operation is a self-contained file. Adding one means writing that file
//! and adding it to [`crate::graph::EditGraph::default_chain`] — no central
//! shader to edit, no UI change (FR-DEV-3c).
//! # Adding one
//!
//! Most implement [`crate::operation::Operation`], a function from colour to
//! colour. The optical corrections ([`distortion`]) implement
//! [`crate::lens::Warp`] instead, because they rewrite *coordinates* before
//! the source is sampled rather than transforming a colour after it. Both
//! publish the same [`crate::descriptor::OpDescriptor`], so the UI builds
//! controls for them identically and never learns the difference.
//! Write `ops/<id>.yaml` and rebuild. That is the whole procedure: the node
//! appears in the chain at its declared `order`, the develop panel grows the
//! controls its parameters describe (FR-DEV-3c), the sidecar persists them
//! because they are ordinary parameters, and its declared tests run with
//! everything else.
//!
//! There is no list to extend here, no shader to edit, and no UI change.
//! `build.rs` compiles each declaration into a module implementing
//! [`crate::operation::Operation`], and [`chain`] is generated from the
//! `order:` each node carries.
//!
//! # The two kinds of node
//!
//! **Declared** nodes are the majority: parameters, uniform expressions over
//! those parameters, and a WGSL fragment. Nothing about them is Rust.
//!
//! **Hand-written** nodes are the exceptions, and they are exceptions for a
//! reason rather than for want of migrating. The tone curve interpolates
//! between five points and its neutral is a *relationship* between them; the
//! colour mixer generates thirty-six faceted parameters from twelve computed
//! hue bands; [`vignetting`] carries lens-profile coefficients that are not
//! parameters at all. A schema stretched to cover those would be a worse
//! language than Rust, aimed at one caller each.
//!
//! Both publish the same [`crate::descriptor::OpDescriptor`], so nothing
//! downstream can tell them apart. A hand-written node still declares its
//! place in the chain in `ops/<id>.yaml` with `rust:`, so the directory
//! remains the one place the pipeline's order is written down.
//!
//! # The optical corrections
//!
//! [`distortion`] and [`aberration`] implement [`crate::lens::Warp`] rather
//! than `Operation`, because they rewrite *coordinates* before the source is
//! sampled rather than transforming a colour after it. They are not part of
//! the develop chain and do not appear in `ops/`.
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
// places it in the chain; these are the implementations that entry points at.
pub mod aberration;
pub mod colour;
pub mod colour_mixer;
pub mod contrast;
pub mod curve;
pub mod distortion;
pub mod exposure;
pub mod helpers;
pub mod tone;
pub mod vignetting;
pub mod white_balance;
pub use aberration::Aberration;
pub use colour::{Brilliance, Saturation, Vibrance};
pub use colour_mixer::ColourMixer;
pub use contrast::Contrast;
pub use curve::ToneCurve;
pub use distortion::Distortion;
pub use exposure::Exposure;
pub use tone::{BlacksWhites, HighlightsShadows};
pub use vignetting::Vignetting;
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
// `build.rs` from `ops/*.yaml` — see that file for why it does not land here
// beside the sources it looks exactly like.
include!(concat!(env!("OUT_DIR"), "/nodes.rs"));
// Re-exported so a caller writes `ops::Exposure` as it did when these were
// hand-written files, and so the chain reads the same either way.
pub use blacks_whites::BlacksWhites;
pub use brilliance::Brilliance;
pub use contrast::Contrast;
pub use exposure::Exposure;
pub use highlights_shadows::HighlightsShadows;
pub use saturation::Saturation;
pub use vibrance::Vibrance;
pub use white_balance::WhiteBalance;
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn the_chain_is_what_the_declarations_say_it_is() {
// The only check available on a `rust:` node: `build.rs` cannot read
// the Rust type's descriptor, so it emits the declared id and this
// asserts the type agrees. A `rust:` entry whose id drifts from its
// implementation would otherwise reorder the pipeline silently.
let built: Vec<&str> = chain().iter().map(|o| o.descriptor().id.0).collect();
assert_eq!(built, DECLARED_IDS);
}
#[test]
fn every_helper_defines_the_function_it_names() {
// A mismatch between the dedup key and the function actually emitted
// would produce either a duplicate definition or a missing one.
// `build.rs` rejects this at the declaration; this asserts the
// generated registry kept the property.
for h in helpers::ALL {
assert!(
h.source.contains(&format!("fn {}(", h.name)),
"helper {} does not define fn {}",
h.name,
h.name
);
}
}
#[test]
fn no_two_helpers_share_a_name() {
// The drift the single-source-of-truth rule exists to prevent: same
// name, different source, and the composer silently picks one.
let mut names = BTreeSet::new();
for h in helpers::ALL {
assert!(
names.insert(h.name),
"two helpers are both called {}",
h.name
);
}
}
#[test]
fn a_node_only_requests_helpers_that_exist() {
// Follows from the build-time check, but asserted end to end: a
// fragment calling a function no helper defines compiles here and
// fails in the shader, which is the expensive place to find it.
let known: BTreeSet<&str> = helpers::ALL.iter().map(|h| h.name).collect();
for op in chain() {
for h in op.helpers() {
assert!(
known.contains(h.name) || h.source.contains(&format!("fn {}(", h.name)),
"{} requests helper {}, which is neither shared nor \
defined by the node",
op.descriptor().id,
h.name
);
}
}
}
#[test]
fn every_node_starts_neutral() {
// An unedited image must be the image. A node whose defaults are not
// its neutral would apply itself to every photograph on open.
for op in chain() {
assert!(
!op.is_active(),
"{} is active at its defaults",
op.descriptor().id
);
}
}
#[test]
fn every_declared_parameter_round_trips() {
// The generated `set_param`/`param` pair is mechanical, which is
// exactly why it is worth checking: a wrong field in one arm reads
// perfectly and silently breaks the sidecar.
for mut op in chain() {
let descriptor = op.descriptor();
for p in descriptor.params {
let crate::descriptor::ParamKind::Scalar { min, max, .. } = p.kind else {
continue;
};
// A value inside the range and away from the default, so a
// stuck field cannot pass by returning the default.
let target = (p.default + (max - p.default) * 0.5).clamp(min, max);
op.set_param(p.id, target);
assert_eq!(
op.param(p.id),
target,
"{}.{} did not round-trip",
descriptor.id,
p.id
);
}
}
}
}
-312
View File
@@ -1,312 +0,0 @@
//! Tonal range operations: highlights/shadows and blacks/whites.
//!
//! Both work by building a smooth weight over the luminance range and
//! applying a gain where that weight is high. The distinction between the two
//! pairs is *where* they act and *how sharply*:
//!
//! - **Highlights and shadows** are broad and overlapping, recovering detail
//! across the upper and lower thirds. They are the controls used to tame a
//! contrasty scene.
//! - **Blacks and whites** act at the very ends, setting where the image
//! clips. They are the controls used to place the endpoints.
//!
//! Weights are built from smoothstep rather than a hard threshold: a sharp
//! boundary produces visible banding on a gradient — a sky is the worst case,
//! and it is also the most common subject for these controls.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
/// The helpers both tonal operations need.
///
/// Sources live in [`crate::ops::helpers`] — see that module for why they are
/// defined exactly once.
pub use crate::ops::helpers::TONE as TONE_HELPERS;
// ---------------------------------------------------------------------------
// Highlights and shadows
// ---------------------------------------------------------------------------
pub const HIGHLIGHTS_SHADOWS_ID: OpId = OpId("highlights_shadows");
pub const HIGHLIGHTS: ParamId = ParamId("highlights");
pub const SHADOWS: ParamId = ParamId("shadows");
static HS_DESCRIPTOR: OpDescriptor = OpDescriptor {
id: HIGHLIGHTS_SHADOWS_ID,
label: LocalizedKey("op.highlights_shadows"),
params: &[
// Negative recovers highlights, the overwhelmingly common direction,
// matching the convention every other developer uses.
ParamDescriptor::amount("highlights", "param.highlights"),
ParamDescriptor::amount("shadows", "param.shadows"),
],
};
#[derive(Debug, Default, Clone)]
pub struct HighlightsShadows {
highlights: f32,
shadows: f32,
}
impl HighlightsShadows {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for HighlightsShadows {
fn descriptor(&self) -> &'static OpDescriptor {
&HS_DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
HIGHLIGHTS => self.highlights = value,
SHADOWS => self.shadows = value,
_ => log::warn!("highlights_shadows: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
HIGHLIGHTS => self.highlights,
SHADOWS => self.shadows,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.highlights != 0.0 || self.shadows != 0.0
}
fn wgsl_body(&self) -> String {
"\
let luma = luminance(c);
let pos = tone_position(luma);
// Broad, overlapping weights. Highlights ramp in over the upper half,
// shadows out over the lower half, so a mid-tone is barely touched by
// either and the two controls blend rather than fighting at the join.
let hi_w = smoothstep(0.5, 1.0, pos);
let lo_w = 1.0 - smoothstep(0.0, 0.5, pos);
// Each control contributes up to a stop of gain at full deflection.
// exp2 keeps the effect symmetric: -100 and +100 are inverse.
let hi_gain = exp2(hi_amount * hi_w);
let lo_gain = exp2(lo_amount * lo_w);
c = apply_tone_gain(c, hi_gain * lo_gain);"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![
Uniform {
name: "hi_amount",
// A full stop at the extreme; enough to recover a bright sky
// without inverting the tonal relationship.
value: self.highlights / 100.0,
},
Uniform {
name: "lo_amount",
value: self.shadows / 100.0,
},
]
}
fn helpers(&self) -> &'static [Helper] {
TONE_HELPERS
}
}
// ---------------------------------------------------------------------------
// Blacks and whites
// ---------------------------------------------------------------------------
pub const BLACKS_WHITES_ID: OpId = OpId("blacks_whites");
pub const BLACKS: ParamId = ParamId("blacks");
pub const WHITES: ParamId = ParamId("whites");
static BW_DESCRIPTOR: OpDescriptor = OpDescriptor {
id: BLACKS_WHITES_ID,
label: LocalizedKey("op.blacks_whites"),
params: &[
ParamDescriptor::amount("blacks", "param.blacks"),
ParamDescriptor::amount("whites", "param.whites"),
],
};
#[derive(Debug, Default, Clone)]
pub struct BlacksWhites {
blacks: f32,
whites: f32,
}
impl BlacksWhites {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for BlacksWhites {
fn descriptor(&self) -> &'static OpDescriptor {
&BW_DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
BLACKS => self.blacks = value,
WHITES => self.whites = value,
_ => log::warn!("blacks_whites: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
BLACKS => self.blacks,
WHITES => self.whites,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.blacks != 0.0 || self.whites != 0.0
}
fn wgsl_body(&self) -> String {
"\
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<f32>(black_amount * black_w);
// The subtractive direction can push below zero, which is not light.
c = max(c, vec3<f32>(0.0));"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![
Uniform {
name: "white_amount",
value: self.whites / 100.0,
},
Uniform {
name: "black_amount",
// 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.
value: self.blacks / 100.0 * 0.02,
},
]
}
fn helpers(&self) -> &'static [Helper] {
TONE_HELPERS
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_operations_start_neutral() {
assert!(!HighlightsShadows::new().is_active());
assert!(!BlacksWhites::new().is_active());
}
#[test]
fn one_parameter_is_enough_to_activate() {
let mut hs = HighlightsShadows::new();
hs.set_param(HIGHLIGHTS, -50.0);
assert!(hs.is_active());
let mut bw = BlacksWhites::new();
bw.set_param(WHITES, 20.0);
assert!(bw.is_active());
}
#[test]
fn highlight_recovery_is_the_negative_direction() {
// The convention users expect: dragging left recovers.
let mut hs = HighlightsShadows::new();
hs.set_param(HIGHLIGHTS, -100.0);
let u = hs.uniforms();
assert!(
u[0].value < 0.0,
"negative highlights must produce a gain below 1"
);
assert!((u[0].value + 1.0).abs() < 1e-6, "full travel is one stop");
}
#[test]
fn tonal_amounts_are_symmetric() {
let mut up = HighlightsShadows::new();
up.set_param(SHADOWS, 100.0);
let mut down = HighlightsShadows::new();
down.set_param(SHADOWS, -100.0);
// exp2 of equal and opposite exponents multiplies to 1.
assert!((up.uniforms()[1].value + down.uniforms()[1].value).abs() < 1e-6);
}
#[test]
fn the_blacks_offset_stays_small() {
// 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.
let mut bw = BlacksWhites::new();
bw.set_param(BLACKS, 100.0);
let offset = bw
.uniforms()
.iter()
.find(|u| u.name == "black_amount")
.expect("black_amount")
.value;
assert!(
(0.0..=0.05).contains(&offset),
"offset {offset} is too large for scene-referred data"
);
}
#[test]
fn the_two_operations_share_helpers_without_duplicating_them() {
// Both request TONE_HELPERS; the composer must emit each once.
let ops: Vec<Box<dyn Operation>> = vec![
Box::new({
let mut o = HighlightsShadows::new();
o.set_param(HIGHLIGHTS, -30.0);
o
}),
Box::new({
let mut o = BlacksWhites::new();
o.set_param(BLACKS, 30.0);
o
}),
];
let shader = crate::operation::compose(&ops);
assert_eq!(shader.source.matches("fn luminance(").count(), 1);
assert_eq!(shader.source.matches("fn tone_position(").count(), 1);
}
#[test]
fn unknown_parameters_are_ignored_rather_than_panicking() {
// A sidecar written by a newer version may name a parameter this
// build does not have; the image must still open.
let mut hs = HighlightsShadows::new();
hs.set_param(ParamId("from_the_future"), 50.0);
assert!(!hs.is_active());
}
}
-192
View File
@@ -1,192 +0,0 @@
//! White balance — temperature and tint, relative to as-shot.
//!
//! Expressed as an offset from what the camera chose rather than an absolute
//! kelvin value. Neutral means "as shot", so the control starts where the
//! image already is and a reset returns there. An absolute scale would make
//! the neutral position depend on the file, which is exactly the confusion
//! Lightroom's temperature slider creates on non-raw files.
//!
//! The as-shot multipliers themselves are applied here too, folded into the
//! same multiply — they come from the uniform block rather than the fragment,
//! because every image has them even when this operation is neutral.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
use crate::operation::{Operation, Uniform};
pub const ID: OpId = OpId("white_balance");
pub const TEMPERATURE: ParamId = ParamId("temperature");
pub const TINT: ParamId = ParamId("tint");
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.white_balance"),
params: &[
// Warmer is positive, matching every other raw developer: dragging
// right makes the image warmer, even though that means *lowering*
// the colour temperature being corrected for.
ParamDescriptor::scalar(
"temperature",
"param.temperature",
-100.0,
100.0,
0.0,
Unit::None,
Scale::Linear,
0,
),
ParamDescriptor::amount("tint", "param.tint"),
],
};
#[derive(Debug, Default, Clone)]
pub struct WhiteBalance {
temperature: f32,
tint: f32,
}
impl WhiteBalance {
pub fn new() -> Self {
Self::default()
}
/// Per-channel multipliers for the current settings.
///
/// Temperature trades red against blue; tint trades green against
/// magenta. Both are scaled so the full range is a strong but not
/// destructive correction, and green is held near unity so the control
/// does not double as an exposure slider.
fn multipliers(&self) -> [f32; 3] {
// ±0.5 in log2 at the extremes — half a stop of channel shift, which
// covers ordinary illuminant error without letting the slider blow a
// channel on its own.
let t = self.temperature / 100.0 * 0.5;
let g = self.tint / 100.0 * 0.5;
[
f32::exp2(t),
f32::exp2(-g),
// Blue moves opposite red, so a neutral grey stays grey as the
// control moves.
f32::exp2(-t),
]
}
}
impl Operation for WhiteBalance {
fn descriptor(&self) -> &'static OpDescriptor {
&DESCRIPTOR
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
TEMPERATURE => self.temperature = value,
TINT => self.tint = value,
_ => log::warn!("white_balance: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
TEMPERATURE => self.temperature,
TINT => self.tint,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
self.temperature != 0.0 || self.tint != 0.0
}
fn wgsl_body(&self) -> String {
// as_shot_wb comes from the base uniform block: it applies to every
// image regardless of whether this operation is active, so the adjust
// pass folds it in separately. Here we apply only the user's offset.
"c = c * vec3<f32>(mul_r, mul_g, mul_b);".into()
}
fn uniforms(&self) -> Vec<Uniform> {
let m = self.multipliers();
vec![
Uniform {
name: "mul_r",
value: m[0],
},
Uniform {
name: "mul_g",
value: m[1],
},
Uniform {
name: "mul_b",
value: m[2],
},
]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neutral_is_as_shot() {
let wb = WhiteBalance::new();
assert!(!wb.is_active(), "a fresh control must not alter the image");
assert_eq!(wb.multipliers(), [1.0, 1.0, 1.0]);
}
#[test]
fn warming_raises_red_and_lowers_blue() {
let mut wb = WhiteBalance::new();
wb.set_param(TEMPERATURE, 100.0);
let m = wb.multipliers();
assert!(m[0] > 1.0, "red should rise, got {}", m[0]);
assert!(m[2] < 1.0, "blue should fall, got {}", m[2]);
}
#[test]
fn cooling_is_the_inverse_of_warming() {
let mut warm = WhiteBalance::new();
warm.set_param(TEMPERATURE, 60.0);
let mut cool = WhiteBalance::new();
cool.set_param(TEMPERATURE, -60.0);
let (w, c) = (warm.multipliers(), cool.multipliers());
// Warming by n then cooling by n must return to neutral.
assert!((w[0] * c[0] - 1.0).abs() < 1e-5);
assert!((w[2] * c[2] - 1.0).abs() < 1e-5);
}
#[test]
fn temperature_leaves_green_alone() {
// Otherwise the control doubles as an exposure slider, because green
// carries most of the luminance.
let mut wb = WhiteBalance::new();
wb.set_param(TEMPERATURE, 100.0);
assert_eq!(wb.multipliers()[1], 1.0);
}
#[test]
fn tint_moves_green_against_magenta() {
let mut wb = WhiteBalance::new();
wb.set_param(TINT, 100.0);
let m = wb.multipliers();
assert!(m[1] < 1.0, "positive tint reduces green (toward magenta)");
assert_eq!(m[0], 1.0, "tint must not touch red");
assert_eq!(m[2], 1.0, "tint must not touch blue");
}
#[test]
fn the_extremes_stay_within_half_a_stop() {
// A white balance control that can blow a channel by itself is a
// trap; correction belongs in a range where highlights survive.
let mut wb = WhiteBalance::new();
wb.set_param(TEMPERATURE, 100.0);
wb.set_param(TINT, 100.0);
for m in wb.multipliers() {
assert!(
(0.70..=1.42).contains(&m),
"multiplier {m} exceeds half a stop"
);
}
}
}