The fused pass hands a fragment a colour and no coordinate. That is what buys one dispatch for a whole edit, and it is also a wall: sharpening, noise reduction, clarity, texture, dehaze and spot removal are each defined by what the neighbours are doing, and FR-DEV-3 and FR-DEV-8 ask for all six. None of them could be written at any price. So there is now a detail stage. An operation implements `Operation` for its parameters exactly as before — the panel, the sidecar, the history and the presets all work unchanged — and additionally returns `Affects::Detail` and a `DetailStage` yielding one pass per dispatch. `Affects` grows the third variant `docs/requirements.md:250` designed and nothing had cut. Where the stage sits is a colour-science decision, not an arrangement of convenience. It runs after every point operation and every mask layer, so an amount chosen against a tone curve survives the curve moving; in linear sRGB after the camera matrix, because camera RGB has no luminance to sharpen against; and before the output transform and the clip, because FR-DEV-2 allows one quantisation and a highlight clipped before a convolution grows a dark ring. The fused pass therefore ends one of two ways, and when a detail stage follows it hands on unclipped f16 and the last detail pass encodes. At render resolution rather than on the source, which is the whole of FR-DSP-1: a pass before the framing prologue would cost 24 MP to draw a 2 MP preview. `RenderScale` is what makes that survivable — a radius is stored as a fraction of the frame's shorter edge, exactly as a mask feather already is, or as a count of source pixels, and converted per render. It also reports when a radius is smaller than a proxy pixel rather than drawing a plausible lie; zooming to 1:1 makes the preview exact with no second path. `Invalidation` gives FR-DEV-3d something to mean. Moving a detail parameter leaves the colour key alone, so `AdjustPass` keeps the linear intermediate and skips the fused dispatch: dragging a sharpening slider costs a convolution. Moving exposure does re-run the detail passes, because they read what the colour pass wrote, and there is no arrangement of keys that avoids it while keeping sharpening after tone. Validated by a separable box blur that is not a develop operation, behind the `detail-probe` feature and absent from a shipping build. An abstraction with no consumer is a guess; a box blur's answer is known in closed form, so the tests assert every byte of the ramp rather than that the edge got softer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
attributes: — what the operation is about
Required, one or more of tone, colour, detail, optics, geometry,
effect. The build fails without it, and that is deliberate: an operation with
no attribute is invisible to an interface that groups by them, and a control
that silently does not exist is a worse failure than a build that stops.
Say what the operation is, not where you want it drawn. The core declares
capabilities; the frontend composes (ARCH §4.3a). tone means "this is about
lightness", not "put this in the second tab" — the interface is free to render
attributes as tabs, as headings, or not at all, and that choice may differ
between a tablet and a desktop without this file changing.
Plural is normal. The tone curve is [tone, colour] because an RGB curve
is tonal and the per-channel curves are chromatic; it appears wherever the
frontend decides that means. Reach for a second attribute when an operation
genuinely answers two questions, not to make it easier to find.
The vocabulary is closed. A typo is a build error rather than a new category containing exactly one operation — which looks identical to a deliberate new category until somebody notices the group with one control in it.
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:. - A place in the panel, from
attributes:. The interface groups by these and names no operation, so a node arrives in the right group by saying what it is rather than by anyone editing a list inui/. - 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
attributes: [tone] # what it is about; one or more, and required
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;
presentation: # optional; omit for one control per parameter
widgets: [tone_curve] # a hint, most preferred first
params: [exposure] # what that widget owns
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 | — |
enum |
one of a short, fixed list | variants |
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.
An enum names its choices as localisation keys, most-neutral first:
params:
method:
label: param.method
kind: enum
variants: [param.method.fast, param.method.exact]
The value is the chosen index, carried as an f32 like every other
parameter — so a uniform expression can use it (method is 0 or 1), the
sidecar stores it unchanged, and nothing along the way needed a second kind of
value. The default is always the first variant, so reset means what it means
everywhere else; if your neutral choice is not first, reorder the list rather
than reaching for a default:.
Two variants is the minimum. A one-entry list is a control the user cannot change, and the build rejects it.
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.
Presentation
Optional, and absent from nearly every node: one control per parameter is the right answer for a list of unrelated sliders, which is what most operations are. Declare it when several parameters form one conceptual control.
presentation:
widgets: [colour_wheel] # most preferred first
demand:
two_dimensional: true # dragged in x and y at once
precise_pointing: false # needs accuracy finer than a fingertip
params: [hue, strength] # what the widget owns, in its order
Widgets: tone_curve, colour_wheel, crop_overlay, gradient_handle,
brush_mask, white_point.
It is a hint and only a hint. The frontend walks widgets and takes the
first it both implements and can afford; if it implements none of them, or
cannot meet the demand, it renders the parameters as ordinary sliders and the
edit still works. So it is safe to name a widget nothing draws yet — the node
degrades to sliders rather than breaking. That fallback is also why every
parameter must stay an ordinary scalar: it is what there is to fall back to.
demand says what the widget inherently needs, never what the screen has.
There is deliberately no way to write a pixel width, a breakpoint or a platform
name here — those are the frontend's to decide, and a node that reasoned about
them would eventually be wrong about a display it never saw (ARCH §4.3a).
Parameters named in params: are claimed by the widget and not drawn
separately, so a typo would take a parameter out of both. The build checks each
name against the node's own params: and fails if it does not match.
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.
Nodes that read their neighbours
wgsl: above is handed c, a colour, and no coordinate. That is what makes
the fused dispatch possible and it is also a wall: sharpening, noise reduction,
clarity, texture, dehaze and spot removal are all defined by what the
neighbouring pixels are doing, and none of them can be written as a function
of c at any price.
They go in the detail stage, which runs after the fused pass, in linear
light, at render resolution, before the output transform — see
../src/detail.rs for why each of those is a decision
rather than a convenience. A node of this kind:
- is declared here with
rust:, like any other hand-written node, because a kernel is not four facts and stretching this schema to cover one would produce a worse language than Rust; - implements
Operationas usual — descriptor, parameters,is_active— so the panel, the sidecar, the history and the presets all work unchanged; - returns
Affects::Detailfromaffects()andSome(self)fromdetail(); - implements
DetailStage::passes, returning oneDetailPassper dispatch, each with a WGSL body, its uniforms, and its kernel radius in render pixels, which the tile scheduler needs and nothing can infer.
The order: still belongs here, and still orders the node — among the other
detail nodes. Detail runs as a group after every point operation, so an order:
that interleaves one with exposure would be a lie the chain cannot tell.
The one thing to get right is the unit of a radius. Never store pixels: a
length is either a fraction of the frame's shorter edge (RenderScale:: frame_fraction — clarity, texture, the unit a mask feather already uses) or a
count of source pixels (RenderScale::source_pixels — capture sharpening,
luminance NR). passes() is given the scale and converts on the CPU. A radius
in raw pixels is a different photograph on screen and in the exported file.
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/.