Files
DarkRoom/core/dr-pipeline/src/ops/mod.rs
T
dtourolle 92afaebd34 Make the view transform an operation the photographer can set
FR-DEV-3j gives the view transform two controls, contrast and the white
point in stops above middle grey, persisted and held per mask layer like
any other setting. A scene-referred pipeline whose white point cannot be
moved hands the photographer a shoulder they cannot place.

`view_transform` is a hand-written node in `Stage::View`, a new stage
the composer emits last and emits whatever the node's state: a neutral
operation is otherwise left out of the shader, but a photograph with no
view transform is a scan. "Active" keeps meaning "moved from the
defaults", so an untouched photograph writes nothing for it and
`every_node_starts_neutral` still holds. A caller whose chain holds no
view operation gets a default one.

The composer's loop becomes an ordered list of steps — camera nodes, the
matrix, scene nodes, layer-only nodes, the view — so each is emitted in
exactly one place. The base curve could not be a node because it
belonged to the camera; the ops README records why that argument went
with it (D19). The panel shows it in the Light group as "Tone Mapping".

Tests that counted the blocks of a neutral graph now count one, the
view transform, and the two chain-wide tests that load a film expect the
view transform to be absent, since a stock replaces it.
2026-09-27 16:52:54 -04:00

213 lines
8.6 KiB
Rust

//! The develop operations.
//!
//! # Adding one
//!
//! 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; [`capture_sharpen`] is a convolution, and the schema describes a
//! fragment handed a colour with no way back to a coordinate;
//! [`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.
//!
//! # The neighbourhood nodes
//!
//! [`capture_sharpen`] and [`noise_reduction`] read the pixels around the one
//! they write, so they run in [`crate::detail`]'s stage after the fused pass
//! rather than as fragments within it. They are ordinary
//! [`Operation`](crate::Operation)s in every other respect — descriptor,
//! parameters, sidecar, history — which is what lets the panel, the presets
//! and the undo stack carry them with no special case.
//!
//! [`noise_reduction`] shows why the declarative schema cannot express one at
//! all: a declared node's `wgsl:` is handed a colour with no way back to a
//! coordinate. A kernel decides at each render how many dispatches to emit,
//! and converts a radius stated in sensor pixels into the render pixels this
//! frame is actually being drawn at.
//!
//! 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/`.
//!
//! [`vignetting`] is the exception, and the reason the split is drawn at
//! coordinates rather than at "lens correction": it applies a gain to the
//! pixel already fetched, so it is an ordinary node in `ops/` like any other.
//! What it needs that a colour fragment is not otherwise given is the pixel's
//! distance from the optical axis, which the sampler publishes as `radius`
//! — corner-normalised there, because the profile coefficients are fitted
//! against a corner radius of 1 and the prologue's `p` is not.
// 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 capture_sharpen;
pub mod colour_mixer;
pub mod curve;
pub mod dehaze;
pub mod distortion;
pub mod film_sim;
pub mod local_contrast;
pub mod noise_reduction;
pub mod view_transform;
pub mod vignetting;
pub use aberration::Aberration;
pub use capture_sharpen::CaptureSharpen;
pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve;
pub use dehaze::Dehaze;
pub use distortion::Distortion;
pub use film_sim::{FilmSim, FilmTables, PaperTables};
// Clarity and texture are one implementation at two scales; see the module's
// documentation for why that is two nodes and not one.
pub use local_contrast::{Clarity, Texture};
pub use noise_reduction::NoiseReduction;
pub use view_transform::ViewTransform;
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
);
}
}
}
}