//! The develop operations. //! //! # Adding one //! //! Write `ops/.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/.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 ); } } } }