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>
262 lines
11 KiB
Markdown
262 lines
11 KiB
Markdown
# 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`](../src/operation.rs), 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 in `ui/`.
|
||
- **Persistence.** Parameters are ordinary scalars, so the sidecar writes them
|
||
with everything else.
|
||
- **Its tests**, compiled from `tests:` and run with `cargo 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
|
||
|
||
```yaml
|
||
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:
|
||
|
||
```yaml
|
||
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.
|
||
|
||
```yaml
|
||
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/`](../src/ops/) and declare only their position here:
|
||
|
||
```yaml
|
||
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 `Warp`s 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`](../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 `Operation` as usual — descriptor, parameters, `is_active` — so
|
||
the panel, the sidecar, the history and the presets all work unchanged;
|
||
- returns `Affects::Detail` from `affects()` and `Some(self)` from `detail()`;
|
||
- implements `DetailStage::passes`, returning one `DetailPass` per 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/`.
|