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.
213 lines
8.6 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|