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>
5.2 KiB
Develop nodes
One file per operation. Adding a node to the pipeline is adding a file to this directory — there is no list to extend, no shader to edit, and no UI change.
../build.rs compiles each declaration into Rust implementing
Operation, generated into OUT_DIR. The result is
indistinguishable downstream from a hand-written operation: the same
&'static OpDescriptor, the same fused-shader composition, the same sidecar
round-trip.
What you get for free
A node dropped in here arrives with:
- Controls. The develop panel builds them from the parameters' declared kinds (FR-DEV-3c). It never learns the node's name.
- A place in the chain, from
order:. - Persistence. Parameters are ordinary scalars, so the sidecar writes them with everything else.
- Its tests, compiled from
tests:and run withcargo test. - Neutral-means-absent. At its defaults the node contributes no code, no uniform, and no branch to the generated shader.
The shape of a file
id: exposure # must match the filename
label: op.exposure # a localisation key, never a display string
order: 20 # where it sits in the chain
doc: | # becomes the generated module's documentation
Exposure — a linear gain, expressed in stops.
placement: | # why it sits where it does; appears above the
A correction to capture. # entry in the generated chain
params:
exposure:
label: param.exposure
kind: stops
min: -5
max: 5
doc: | # optional prose, kept with the descriptor
±5 stops.
uniforms:
gain: exp2(exposure) # shorthand, or a mapping with `value:` and `doc:`
helpers: [luminance] # names from _helpers.yaml
define: # helpers this node alone needs
my_curve: |
fn my_curve(x: f32) -> f32 { return x; }
wgsl: | # `c` is linear RGB in and out
c = c * gain;
tests:
- name: one_stop_is_a_doubling
why: The definition of a stop.
set: { exposure: 1 }
expect: { gain: 2.0 }
Parameter kinds
kind |
Shape | Extra keys |
|---|---|---|
amount |
−100…100, neutral at 0 — the familiar photographic control | — |
stops |
exposure-like, in stops | min, max |
fraction |
0…1 | default |
switch |
a toggle, neutral off | — |
scalar |
anything else | min, max, default, unit, scale, precision |
Reach for amount first. Writing its range out by hand in each node is how one
of them comes to disagree with the rest.
Uniform expressions
Arithmetic (+ - * /), parentheses, numbers, this node's parameters, and:
exp2 log2 exp sqrt abs floor ceil round pow min max
clamp mix.
Compiled to Rust, so an unknown name or a wrong arity is a build error naming the file and the key. Deliberately small: a node is a description, and letting it name arbitrary Rust would make this a second, worse place to write code.
Activity
By default a node is active exactly when some parameter has moved off its
default, which is the honest rule and the right one for nearly everything. A
node whose neutral is something else declares active: as an expression —
non-zero means active.
Tests
| Key | Asserts |
|---|---|
set |
parameter values to apply first; build fails if out of range |
expect |
uniform values, within 1e-5 |
expect_range |
a uniform lies in [low, high] |
expect_active |
whether the node reaches the shader |
expect_wgsl |
substrings the fragment must contain |
expect_helper_wgsl |
substrings a named helper must contain |
why: becomes a comment in the generated test. Use it — a test named for a
property should say what breaks without it.
Hand-written nodes
Some operations are not four facts, and forcing them into this schema would
produce a worse language aimed at one caller. Those stay in
../src/ops/ and declare only their position here:
id: tone_curve
order: 60
rust: ToneCurve
why_rust: |
Its neutral is a relationship between five points, not a set of values.
They still belong in this directory, because the pipeline's order is the one thing a reader comes here to learn, and an order written half in YAML and half in Rust would be worse than either alone.
Currently hand-written: tone_curve (a curve widget over five interpolated
points), colour_mixer (thirty-six faceted parameters from twelve computed hue
bands). vignetting is hand-written too but is not in the develop chain — it
carries lens-profile coefficients that are not parameters. distortion and
aberration are Warps rather than operations: they rewrite coordinates
before sampling rather than transforming a colour after it.
Errors
The build script reports failures by naming the key you got wrong, and exits
rather than panicking, so the message is the message and not a backtrace. It
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, an expression naming
something that is not a parameter, and a declared node whose name collides with
a file in ../src/ops/.