From 0c3b8cb1c4c3fef2e9b190ad9c7e9426984094a1 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 27 Aug 2026 19:02:25 +0200 Subject: [PATCH 1/2] Hand out descriptors a declaration could produce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime is the whole reason a build-time node is free and a run-time node is impossible: only a compile-time literal can satisfy it, so no amount of reading `ops/*.yaml` at startup could ever produce a descriptor the rest of the application would accept. FR-PLG-2 says a bundled operation and a third-party plugin are the same kind of thing, differing only in where the file was found — and a lifetime outsiders cannot meet is exactly the second, weaker format that requirement forbids. So a descriptor is now owned and handed out as `Arc`, with `Vec` where it held `&'static` slices. `Arc` rather than a `&self`-borrowed reference because the callers want to *keep* it: the develop panel collects descriptors and then mutates the graph, and a borrow would tie the descriptor's lifetime to a borrow of the operation it came from, which is the one thing `&'static` was doing right. The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is compared in `match` arms against generated constants, is a map key in the sidecar and history, and reaches Slint model rows; an `Arc` there would cost a refcount on every one of those and would take `match id { EXPOSURE => .. }` away from the generated code. They gain an interner instead, which is honest about its lifetime rather than pretending to one — the set of ids is bounded by deduplication and is process-lifetime by construction, because the sidecar on disk names its parameters and an id has to stay resolvable for as long as any edit naming it can be opened. No behaviour changes. Every descriptor that was a `static` is a `LazyLock` initialiser now, `Operation::helpers` borrows from `self` instead of being `'static` so a future run-time node can own its list, and `Warp` and `Framing` follow `Operation` so there is one shape rather than two. The one place a descriptor is read per frame is `compose_full`, which takes `descriptor().id` to prefix each active operation's uniforms, and `dr-ui` composes on every frame it draws. That is a dozen atomic increments beside a composition that is already building several kilobytes of WGSL on the same call; it is noted at the trait method rather than left for a profiler to find. Co-Authored-By: Claude Opus 5 --- core/dr-pipeline/build.rs | 43 ++--- core/dr-pipeline/src/descriptor.rs | 171 ++++++++++++++++---- core/dr-pipeline/src/detail.rs | 8 +- core/dr-pipeline/src/detail/probe.rs | 51 +++--- core/dr-pipeline/src/framing.rs | 105 ++++++------ core/dr-pipeline/src/graph.rs | 22 +-- core/dr-pipeline/src/lens.rs | 69 +++++--- core/dr-pipeline/src/lib.rs | 8 +- core/dr-pipeline/src/mask.rs | 25 ++- core/dr-pipeline/src/operation.rs | 151 ++++++++++++----- core/dr-pipeline/src/ops/aberration.rs | 69 ++++---- core/dr-pipeline/src/ops/capture_sharpen.rs | 87 +++++----- core/dr-pipeline/src/ops/colour_mixer.rs | 58 +++---- core/dr-pipeline/src/ops/curve.rs | 64 ++++---- core/dr-pipeline/src/ops/distortion.rs | 45 +++--- core/dr-pipeline/src/ops/film_sim.rs | 53 +++--- core/dr-pipeline/src/ops/local_contrast.rs | 40 +++-- core/dr-pipeline/src/ops/mod.rs | 2 +- core/dr-pipeline/src/ops/noise_reduction.rs | 71 ++++---- core/dr-pipeline/src/ops/vignetting.rs | 35 ++-- core/dr-pipeline/src/sidecar.rs | 13 +- docs/traceability.md | 48 +++--- ui/dr-ui/src/develop.rs | 24 +-- 23 files changed, 772 insertions(+), 490 deletions(-) diff --git a/core/dr-pipeline/build.rs b/core/dr-pipeline/build.rs index e0e1a51..5ca58ab 100644 --- a/core/dr-pipeline/build.rs +++ b/core/dr-pipeline/build.rs @@ -18,7 +18,7 @@ //! //! It does not change the runtime model. The generated code implements the //! same [`Operation`](../src/operation.rs) trait, publishes the same -//! `&'static OpDescriptor`, and composes through the same fused-shader path. +//! `Arc`, and composes through the same fused-shader path. //! Nothing downstream — not `dr-gpu`, not the develop panel — can tell a //! declared node from a hand-written one, which is what allows the two to sit //! side by side in one chain. @@ -788,7 +788,7 @@ fn param_ctor( .collect::, _>>()?; ( format!( - "ParamDescriptor::choice({:?}, {:?}, &[{}])", + "ParamDescriptor::choice({:?}, {:?}, vec![{}])", id, label, keys.join(", ") @@ -1495,7 +1495,8 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { \x20 Scale, Unit, WidgetDemand, WidgetKind,\n\ \x20 };\n\ \x20 #[allow(unused_imports)]\n\ - \x20 use crate::operation::{Helper, Operation, Uniform};\n\n", + \x20 use crate::operation::{Helper, Operation, Uniform};\n\ + \x20 use std::sync::{Arc, LazyLock};\n\n", ); // Ids as consts, so a caller names a parameter through the type system @@ -1511,20 +1512,26 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { } // Descriptor. - let _ = writeln!( - out, - "\n static DESCRIPTOR: OpDescriptor = OpDescriptor {{\n\ - \x20 id: ID,\n\ - \x20 label: LocalizedKey({label:?}),\n\ - \x20 params: &[" + // + // Built once, behind a `LazyLock`, and handed out as an `Arc` clone. It + // was a plain `static` until descriptors became owned (FR-PLG-2): an + // `OpDescriptor` now holds `Vec`s, and an `Arc` is not const-constructible + // in any case. The cost is one lock check the first time an operation is + // asked what it is, and an atomic increment thereafter. + out.push_str( + "\n static DESCRIPTOR: LazyLock> = LazyLock::new(|| {\n\ + \x20 Arc::new(OpDescriptor {\n", ); + let _ = writeln!(out, " id: ID,"); + let _ = writeln!(out, " label: LocalizedKey({label:?}),"); + out.push_str(" params: vec![\n"); for p in params { if let Some(doc) = &p.doc { - out.push_str(&comment(doc, "//", 12)); + out.push_str(&comment(doc, "//", 16)); } - let _ = writeln!(out, " {},", p.ctor); + let _ = writeln!(out, " {},", p.ctor); } - out.push_str(" ],\n"); + out.push_str(" ],\n"); // What the operation is about. The panel groups by these and names no // operation, which is what keeps FR-DEV-3a true as the set grows. @@ -1539,8 +1546,8 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { format!("Attribute::{head}{}", c.as_str()) }) .collect(); - let _ = writeln!(out, " attributes: &[{}],", attrs.join(", ")); - out.push_str(" };\n"); + let _ = writeln!(out, " attributes: vec![{}],", attrs.join(", ")); + out.push_str(" })\n });\n"); // Helpers: node-local definitions first, then the assembled list. for h in local_helpers { @@ -1583,7 +1590,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { // The trait. let _ = writeln!(out, "\n impl Operation for {ty} {{"); out.push_str( - " fn descriptor(&self) -> &'static OpDescriptor {\n &DESCRIPTOR\n }\n\n", + " fn descriptor(&self) -> Arc {\n DESCRIPTOR.clone()\n }\n\n", ); out.push_str( @@ -1650,7 +1657,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { if !helper_refs.is_empty() { out.push_str( - "\n fn helpers(&self) -> &'static [Helper] {\n HELPERS\n }\n", + "\n fn helpers(&self) -> &[Helper] {\n HELPERS\n }\n", ); } @@ -1661,12 +1668,12 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { out, "\n fn presentation(&self) -> Option {{\n\ \x20 Some(Presentation {{\n\ - \x20 widgets: &[{}],\n\ + \x20 widgets: vec![{}],\n\ \x20 demand: WidgetDemand {{\n\ \x20 two_dimensional: {},\n\ \x20 precise_pointing: {},\n\ \x20 }},\n\ - \x20 params: &[{}],\n\ + \x20 params: vec![{}],\n\ \x20 }})\n\ \x20 }}", p.widgets.join(", "), diff --git a/core/dr-pipeline/src/descriptor.rs b/core/dr-pipeline/src/descriptor.rs index 6e18352..5f8a080 100644 --- a/core/dr-pipeline/src/descriptor.rs +++ b/core/dr-pipeline/src/descriptor.rs @@ -8,12 +8,75 @@ //! Labels are keys, not strings: resolving them needs a localiser, and //! `core/` must not depend on one (NFR-A11Y-1). +use std::collections::HashSet; use std::fmt; +use std::sync::{LazyLock, Mutex}; + +/// TRACES: FR-PLG-2 +/// Give a string read at run time the `'static` lifetime the identifier types +/// carry. +/// +/// # Why the identifiers stayed `&'static str` when the descriptors did not +/// +/// [`OpDescriptor`] became owned so a declaration read at *load* time can +/// produce one (FR-PLG-2). The three identifier newtypes below deliberately +/// did not follow it. +/// +/// An id is not content; it is a key. [`ParamId`] is `Copy`, is compared in +/// `match` arms against the constants `build.rs` generates, is a map key in +/// the sidecar and in history, and is threaded through `dr-ui` into Slint +/// model rows. An `Arc` there would put a refcount on every one of those +/// and would take `match id { EXPOSURE => .. }` away from the generated code — +/// which is precisely the inspectability of the built-in chain that keeping +/// the generated path was for. +/// +/// So ids are interned instead, and interning is honest about its lifetime +/// rather than pretending to one. The set of interned ids is: +/// +/// - **Bounded.** One entry per *distinct* string, deduplicated on the way in. +/// Parsing the same declaration a thousand times adds nothing after the +/// first. +/// - **Process-lifetime by construction.** A loaded declaration's vocabulary +/// is never withdrawn. Nothing unloads a plugin, and nothing could: the +/// sidecar on disk stores parameters by `(op_id, param_id)`, so an id has to +/// stay resolvable for as long as any edit naming it can be opened. +/// +/// A leak whose bound is "the distinct ids this process has ever seen" is a +/// different thing from one that grows with use, and this is the first. +pub fn intern(s: &str) -> &'static str { + static POOL: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + + // A poisoned pool is still a correct pool: every entry in it is a + // `&'static str` that was interned successfully, and a panic elsewhere + // while the lock was held cannot have made one invalid. Refusing to + // intern here would turn an unrelated panic into an application that can + // no longer read a declaration. + let mut pool = POOL.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(found) = pool.get(s) { + return found; + } + let leaked: &'static str = Box::leak(s.to_owned().into_boxed_str()); + pool.insert(leaked); + leaked +} /// Identifies a parameter within an operation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ParamId(pub &'static str); +impl ParamId { + /// The same id, from a name read out of a declaration at load time. + /// + /// Equal to `ParamId("exposure")` when the name is `"exposure"`: the + /// derived `PartialEq` compares the `str` contents, not the pointer, which + /// is what lets an interned id match a generated `match` arm. See + /// [`intern`]. + pub fn interned(name: &str) -> Self { + Self(intern(name)) + } +} + impl fmt::Display for ParamId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.0) @@ -24,6 +87,13 @@ impl fmt::Display for ParamId { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct OpId(pub &'static str); +impl OpId { + /// The same id, from a declaration read at load time. See [`intern`]. + pub fn interned(name: &str) -> Self { + Self(intern(name)) + } +} + impl fmt::Display for OpId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.0) @@ -34,6 +104,13 @@ impl fmt::Display for OpId { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LocalizedKey(pub &'static str); +impl LocalizedKey { + /// The same key, from a declaration read at load time. See [`intern`]. + pub fn interned(key: &str) -> Self { + Self(intern(key)) + } +} + /// What a slider's travel means. /// /// Photographic controls are rarely linear in their underlying quantity: @@ -170,7 +247,11 @@ pub enum ParamKind { Enum { /// In index order. The label is a localisation key, resolved by the /// frontend — `core/` must not depend on a localiser (NFR-A11Y-1). - variants: &'static [LocalizedKey], + /// + /// Owned rather than `&'static`, for the reason [`OpDescriptor`] + /// gives: a declaration parsed at load time has nowhere to put a + /// `'static` slice. + variants: Vec, }, } @@ -204,7 +285,7 @@ pub struct Presentation { /// pair of sliders, and an operation that can say so gets a good control /// on a workstation and a usable one on a phone without the core knowing /// which it is talking to. - pub widgets: &'static [WidgetKind], + pub widgets: Vec, /// What the preferred widget needs in order to be worth drawing. /// /// Applies to the list as a whole rather than per entry: a frontend that @@ -215,7 +296,7 @@ pub struct Presentation { /// /// Parameters absent from this list are presented normally, so an /// operation can pair a curve with an ordinary strength slider. - pub params: &'static [ParamId], + pub params: Vec, } impl Presentation { @@ -328,11 +409,13 @@ impl ParamDescriptor { /// holds the way it does for every other kind: index 0 is the neutral /// choice, and an operation whose default is not its first variant has /// listed them in the wrong order. - pub const fn choice( - id: &'static str, - label: &'static str, - variants: &'static [LocalizedKey], - ) -> Self { + // + // Not `const`, unlike its four siblings, and the reason is the `Vec` in + // [`ParamKind::Enum`]: a heap allocation cannot happen in a const context. + // Nothing is lost — every descriptor now lives inside a `LazyLock` + // initialiser rather than a `static`, because `descriptor()` hands out an + // `Arc` and an `Arc` is not const-constructible either. + pub fn choice(id: &'static str, label: &'static str, variants: Vec) -> Self { Self { id: ParamId(id), label: LocalizedKey(label), @@ -366,13 +449,15 @@ impl ParamDescriptor { /// A general scalar with an explicit range and default. // - // Eight arguments, and a builder would be the usual answer — but this has - // to be `const` so descriptors can be `static`, which rules out the - // `&mut self` builder the pattern usually takes. (A `self`-by-value step - // *is* const-callable — `faceted` below is one — but eight of them would - // be eight methods to say what one call already says.) The two common - // shapes have their own constructors above; this is the escape hatch for - // the rest. + // Eight arguments, and a builder would be the usual answer — but eight + // `&mut self` steps would be eight methods to say what one call already + // says, and each one would be a place for a caller to forget a field. The + // two common shapes have their own constructors above; this is the escape + // hatch for the rest. + // + // Still `const` although no descriptor is a `static` any more: it costs + // nothing, and it keeps the five constructors uniform where only `choice` + // genuinely cannot be. #[allow(clippy::too_many_arguments)] pub const fn scalar( id: &'static str, @@ -404,10 +489,13 @@ impl ParamDescriptor { /// A method rather than a sixth constructor, because a facet is orthogonal /// to the shape of the value: a faceted parameter is still an amount, or /// still a scalar in stops, and pairing every constructor with a faceted - /// twin would double the list above to say one thing. Taking `self` by - /// value is what keeps it usable in the `static` descriptors — a `&mut - /// self` builder is what cannot be `const`. - pub const fn faceted(mut self, facet: Facet) -> Self { + /// twin would double the list above to say one thing. + // + // No longer `const`: a `ParamDescriptor` can now carry a `Vec` (an enum's + // variants), which gives the type drop glue, and assigning over a field of + // such a type is not something a const function may do. Nothing is lost — + // every descriptor is built inside a `LazyLock` initialiser now. + pub fn faceted(mut self, facet: Facet) -> Self { self.facet = Some(facet); self } @@ -418,10 +506,10 @@ impl ParamDescriptor { /// bounds, or a sidecar written by a newer version with a wider range, /// must not produce out-of-range uniforms. pub fn clamp(&self, value: f32) -> f32 { - match self.kind { + match &self.kind { ParamKind::Scalar { min, max, .. } => { if value.is_finite() { - value.clamp(min, max) + value.clamp(*min, *max) } else { // A NaN from a corrupt sidecar would otherwise poison the // uniform block and blank the image. @@ -544,20 +632,45 @@ impl Attribute { } } -/// The static description of an operation. +/// TRACES: FR-PLG-2 +/// The description of an operation. +/// +/// # Owned, not `&'static` +/// +/// This used to be a `static` with `&'static [ParamDescriptor]` inside it, and +/// [`crate::Operation::descriptor`] used to hand out a reference to it. That +/// shape made a build-time node free and a run-time node **impossible**: a +/// declaration parsed at startup has nothing to borrow from, so no amount of +/// interpreting `ops/*.yaml` at load time could ever produce a descriptor the +/// rest of the application would accept. FR-PLG-2 says a bundled operation and +/// a third-party plugin are the same kind of thing, differing only in where +/// the file was found — and a lifetime that only a compile-time literal can +/// satisfy is exactly a second, weaker format for outsiders. +/// +/// So the descriptor owns its contents and is handed out as an +/// `Arc`. The `Arc` rather than a `&self`-borrowed reference +/// because the callers want to *keep* it: the develop panel collects +/// descriptors and then mutates the graph, and a borrow would tie the +/// descriptor's lifetime to a borrow of the operation it came from — which is +/// the one thing `&'static` was doing right. +/// +/// The cost is a refcount per read, on a path that reads descriptors when a +/// panel is built rather than per pixel. See `Operation::descriptor` for the +/// one place that is read per composition and why it does not matter. #[derive(Debug, Clone, PartialEq)] pub struct OpDescriptor { pub id: OpId, pub label: LocalizedKey, - pub params: &'static [ParamDescriptor], + pub params: Vec, /// What this operation is about (ARCH §4.3a). /// - /// **Never empty**, and `build.rs` refuses to generate an operation that - /// declares none. An operation with no attribute would be invisible to a - /// frontend that filters by them, and a control that silently does not - /// exist is a worse failure than a build that stops — particularly when - /// the cause would be a missing line in a YAML file nobody looked at. - pub attributes: &'static [Attribute], + /// **Never empty**, and both the build-time and the load-time reader + /// refuse an operation that declares none. An operation with no attribute + /// would be invisible to a frontend that filters by them, and a control + /// that silently does not exist is a worse failure than a build that stops + /// — particularly when the cause would be a missing line in a YAML file + /// nobody looked at. + pub attributes: Vec, } impl OpDescriptor { diff --git a/core/dr-pipeline/src/detail.rs b/core/dr-pipeline/src/detail.rs index c07dfda..8026d52 100644 --- a/core/dr-pipeline/src/detail.rs +++ b/core/dr-pipeline/src/detail.rs @@ -519,7 +519,13 @@ pub fn compose_detail_with( ) -> ComposedDetail { // Every pass of every active detail operation, flattened, carrying the // operation it came from for the uniform prefix and the helper set. - let mut planned: Vec<(&'static str, &'static [Helper], DetailPass, usize)> = Vec::new(); + // + // The helper slice borrows from the operation rather than being `'static`: + // `Operation::helpers` hands out a slice owned by the operation now, so + // that a node built from a declaration at load time can own its list + // (FR-PLG-2). The borrow lasts as long as `ops`, which outlives this + // function's body. + let mut planned: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::new(); for (index, pass) in spots.iter().enumerate() { planned.push(( crate::spot::SPOT_ID, diff --git a/core/dr-pipeline/src/detail/probe.rs b/core/dr-pipeline/src/detail/probe.rs index 361fb4b..f5c6c73 100644 --- a/core/dr-pipeline/src/detail/probe.rs +++ b/core/dr-pipeline/src/detail/probe.rs @@ -29,6 +29,7 @@ //! was built for: a horizontal pass then a vertical one is mathematically a 2D //! box average, so if the ping-pong is wired backwards or a pass reads its own //! output the result is visibly not a box blur rather than subtly wrong. +use std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit, @@ -36,28 +37,30 @@ use crate::descriptor::{ use crate::detail::{DetailPass, DetailStage, RenderScale}; use crate::operation::{Affects, Operation, Uniform}; -static DESCRIPTOR: OpDescriptor = OpDescriptor { - id: OpId("detail_probe"), - label: LocalizedKey("op.detail_probe"), - params: &[ParamDescriptor { - id: ParamId("radius"), - label: LocalizedKey("param.detail_probe.radius"), - // A fraction of the frame's shorter edge, which is the unit - // `RenderScale::frame_fraction` converts and the unit a mask feather - // is already stored in. Stating it in pixels is the mistake this - // whole stage is arranged to make impossible. - kind: ParamKind::Scalar { - min: 0.0, - max: 0.25, - scale: Scale::Linear, - unit: Unit::None, - precision: 4, - }, - default: 0.0, - facet: None, - }], - attributes: &[Attribute::Detail], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: OpId("detail_probe"), + label: LocalizedKey("op.detail_probe"), + params: vec![ParamDescriptor { + id: ParamId("radius"), + label: LocalizedKey("param.detail_probe.radius"), + // A fraction of the frame's shorter edge, which is the unit + // `RenderScale::frame_fraction` converts and the unit a mask feather + // is already stored in. Stating it in pixels is the mistake this + // whole stage is arranged to make impossible. + kind: ParamKind::Scalar { + min: 0.0, + max: 0.25, + scale: Scale::Linear, + unit: Unit::None, + precision: 4, + }, + default: 0.0, + facet: None, + }], + attributes: vec![Attribute::Detail], + }) +}); /// A separable box blur whose radius is a fraction of the frame's shorter edge. #[derive(Debug, Clone, Copy, Default)] @@ -85,8 +88,8 @@ impl BoxBlur { } impl Operation for BoxBlur { - fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } fn set_param(&mut self, _id: ParamId, value: f32) { diff --git a/core/dr-pipeline/src/framing.rs b/core/dr-pipeline/src/framing.rs index a92cf89..20a58a3 100644 --- a/core/dr-pipeline/src/framing.rs +++ b/core/dr-pipeline/src/framing.rs @@ -40,6 +40,7 @@ use std::f32::consts::PI; use std::fmt::Write as _; +use std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, @@ -73,50 +74,52 @@ static FRAMING_PARAMS: [ParamId; 8] = [ CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V, ]; -static DESCRIPTOR: OpDescriptor = OpDescriptor { - // The shape of the frame, and the only operation that changes the - // output's dimensions. - attributes: &[Attribute::Geometry], - id: ID, - label: LocalizedKey("op.framing"), - params: &[ - // Straightening. Degrees rather than a normalised amount because a - // photographer reading "-1.4°" off a horizon knows what it means. - ParamDescriptor::scalar( - "angle", - "param.angle", - -MAX_STRAIGHTEN, - MAX_STRAIGHTEN, - 0.0, - Unit::None, - Scale::Linear, - 2, - ), - // Quarter turns, 0..3. Separate from `angle` because these are exact - // and lossless, and because reorienting a frame is a different - // gesture from nudging a horizon. - ParamDescriptor::scalar( - "rotation", - "param.rotation", - 0.0, - 3.0, - 0.0, - Unit::None, - Scale::Linear, - 0, - ), - ParamDescriptor::switch("flip_h", "param.flip_h"), - ParamDescriptor::switch("flip_v", "param.flip_v"), - // The crop rect, in fractions of the source. Normalised rather than - // in pixels so a crop survives being applied to a proxy, a full - // resolution render, or an export at another size — the same reason - // the viewport renders at display resolution (FR-DSP-1). - ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0), - ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0), - ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0), - ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0), - ], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + // The shape of the frame, and the only operation that changes the + // output's dimensions. + attributes: vec![Attribute::Geometry], + id: ID, + label: LocalizedKey("op.framing"), + params: vec![ + // Straightening. Degrees rather than a normalised amount because a + // photographer reading "-1.4°" off a horizon knows what it means. + ParamDescriptor::scalar( + "angle", + "param.angle", + -MAX_STRAIGHTEN, + MAX_STRAIGHTEN, + 0.0, + Unit::None, + Scale::Linear, + 2, + ), + // Quarter turns, 0..3. Separate from `angle` because these are exact + // and lossless, and because reorienting a frame is a different + // gesture from nudging a horizon. + ParamDescriptor::scalar( + "rotation", + "param.rotation", + 0.0, + 3.0, + 0.0, + Unit::None, + Scale::Linear, + 0, + ), + ParamDescriptor::switch("flip_h", "param.flip_h"), + ParamDescriptor::switch("flip_v", "param.flip_v"), + // The crop rect, in fractions of the source. Normalised rather than + // in pixels so a crop survives being applied to a proxy, a full + // resolution render, or an export at another size — the same reason + // the viewport renders at display resolution (FR-DSP-1). + ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0), + ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0), + ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0), + ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0), + ], + }) +}); /// A normalised crop rectangle, in fractions of the source image. #[derive(Debug, Clone, Copy, PartialEq)] @@ -252,8 +255,8 @@ impl Framing { Self::default() } - pub fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + pub fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } /// TRACES: FR-DEV-3a | FR-DEV-3b | FR-UI-7 @@ -280,7 +283,7 @@ impl Framing { /// into the generated panel underneath a crop control that already exists. pub fn presentation(&self) -> Option { Some(Presentation { - widgets: &[WidgetKind::CropOverlay], + widgets: vec![WidgetKind::CropOverlay], demand: WidgetDemand { // A crop rect is dragged by its corners; nothing about that // reduces to one axis at a time. @@ -290,7 +293,7 @@ impl Framing { // modality, so a thumb is as workable as a mouse. precise_pointing: false, }, - params: &FRAMING_PARAMS, + params: FRAMING_PARAMS.to_vec(), }) } @@ -1426,7 +1429,7 @@ mod tests { fn every_parameter_at_its_extremes_is_survivable() { // The whole descriptor driven to both ends, which is what a codegen // test does and what a corrupt sidecar can do. - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] { let mut f = Framing::new(); f.set_param(p.id, p.clamp(value)); @@ -1610,7 +1613,7 @@ mod tests { // The same contract the operations honour, checked against the // descriptor rather than a literal. let mut f = Framing::new(); - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { f.set_param(p.id, p.default); } assert!(!f.is_active(), "descriptor defaults must be neutral"); @@ -1618,7 +1621,7 @@ mod tests { #[test] fn every_default_is_within_its_declared_range() { - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id); } } diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 900f25f..209bbd3 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -51,8 +51,8 @@ pub struct OpCapability { /// other, so a new operation joins the right group by declaring what it /// is — which is the only thing its author is well placed to say. /// - /// Never empty; `build.rs` refuses an operation that declares none. - pub attributes: &'static [Attribute], + /// Never empty; both readers refuse an operation that declares none. + pub attributes: Vec, } /// TRACES: FR-DEV-3a | FR-DEV-3b @@ -243,7 +243,7 @@ impl EditGraph { /// because this is what the codegen tests count `---- ` shader blocks /// against, and framing generates a prologue rather than a colour block. /// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a). - pub fn descriptors(&self) -> Vec<&'static OpDescriptor> { + pub fn descriptors(&self) -> Vec> { self.ops.iter().map(|o| o.descriptor()).collect() } @@ -281,7 +281,7 @@ impl EditGraph { }) .collect(), presentation: op.presentation(), - attributes: desc.attributes, + attributes: desc.attributes.clone(), } }); @@ -310,7 +310,7 @@ impl EditGraph { // sliders for framing *without naming framing* — see // `Framing::presentation`. presentation: self.framing.presentation(), - attributes: desc.attributes, + attributes: desc.attributes.clone(), }; ops.chain(std::iter::once(framing)).collect() @@ -427,7 +427,11 @@ impl EditGraph { pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) { if op == crate::framing::ID { - let Some(desc) = self.framing.descriptor().param(param) else { + // Bound rather than chained: `descriptor()` hands back an owned + // `Arc` now, so a `param()` borrowed straight out of the call + // would outlive the temporary it came from. + let descriptor = self.framing.descriptor(); + let Some(desc) = descriptor.param(param) else { log::warn!("unknown parameter {param} on {op}; ignoring"); return; }; @@ -469,7 +473,7 @@ impl EditGraph { /// Reset every parameter of every operation, and the framing, to default. pub fn reset(&mut self) { for op in &mut self.ops { - for p in op.descriptor().params { + for p in &op.descriptor().params { op.set_param(p.id, p.default); } } @@ -625,7 +629,7 @@ impl EditGraph { // structure key deliberately omits because they do not recompile a // shader. Both matter to a cached *result*, so both are here. let mut geometry = mix(FNV_OFFSET, self.framing.structure_key()); - for p in self.framing.descriptor().params { + for p in &self.framing.descriptor().params { geometry = hash_bytes(geometry, p.id.0.as_bytes()); geometry = mix( geometry, @@ -944,7 +948,7 @@ mod tests { .capabilities() .iter() .flat_map(|c| { - c.params.iter().map(move |p| match p.kind { + c.params.iter().map(move |p| match &p.kind { ParamKind::Scalar { min, max, diff --git a/core/dr-pipeline/src/lens.rs b/core/dr-pipeline/src/lens.rs index f6c0b99..a1753f7 100644 --- a/core/dr-pipeline/src/lens.rs +++ b/core/dr-pipeline/src/lens.rs @@ -53,6 +53,7 @@ //! wearing the same lens, which defeats the point of a lens profile. use std::fmt::Write as _; +use std::sync::Arc; use crate::descriptor::{OpDescriptor, ParamId}; use crate::operation::{Helper, Uniform}; @@ -62,8 +63,10 @@ use crate::operation::{Helper, Uniform}; /// Object-safe for the same reason [`crate::operation::Operation`] is: the /// graph holds `Box` in order, so the geometry chain is data. pub trait Warp: Send + Sync { - /// Static description, driving UI generation exactly as for an operation. - fn descriptor(&self) -> &'static OpDescriptor; + /// This warp's description, driving UI generation exactly as for an + /// operation — including being owned rather than `&'static`, for the + /// reason [`crate::descriptor::OpDescriptor`] gives. + fn descriptor(&self) -> Arc; /// Set a parameter. Values arrive already clamped to the descriptor. fn set_param(&mut self, id: ParamId, value: f32); @@ -204,32 +207,38 @@ fn sanitise(id: &str) -> String { #[cfg(test)] mod tests { + use std::sync::LazyLock; + use super::*; use crate::descriptor::Attribute; use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor}; - static DESC_A: OpDescriptor = OpDescriptor { - id: OpId("warp_a"), - label: LocalizedKey("a"), - params: &[ParamDescriptor::amount("amount", "a.amount")], - attributes: &[Attribute::Tone], - }; - static DESC_B: OpDescriptor = OpDescriptor { - id: OpId("warp_b"), - label: LocalizedKey("b"), - params: &[ParamDescriptor::amount("amount", "b.amount")], - attributes: &[Attribute::Tone], - }; + static DESC_A: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: OpId("warp_a"), + label: LocalizedKey("a"), + params: vec![ParamDescriptor::amount("amount", "a.amount")], + attributes: vec![Attribute::Tone], + }) + }); + static DESC_B: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: OpId("warp_b"), + label: LocalizedKey("b"), + params: vec![ParamDescriptor::amount("amount", "b.amount")], + attributes: vec![Attribute::Tone], + }) + }); struct Fake { - desc: &'static OpDescriptor, + desc: Arc, amount: f32, splits: bool, } impl Warp for Fake { - fn descriptor(&self) -> &'static OpDescriptor { - self.desc + fn descriptor(&self) -> Arc { + self.desc.clone() } fn set_param(&mut self, _id: ParamId, value: f32) { self.amount = value; @@ -254,7 +263,7 @@ mod tests { } } - fn fake(desc: &'static OpDescriptor, amount: f32, splits: bool) -> Box { + fn fake(desc: Arc, amount: f32, splits: bool) -> Box { Box::new(Fake { desc, amount, @@ -266,7 +275,7 @@ mod tests { fn no_active_warp_composes_to_nothing() { // The property that keeps the common case free: an image with no lens // correction must not pay for a bilinear sample. - let composed = compose_warps(&[fake(&DESC_A, 0.0, false)]); + let composed = compose_warps(&[fake(DESC_A.clone(), 0.0, false)]); assert!(!composed.is_active()); assert!(composed.uniforms.is_empty()); assert!(!composed.splits_channels); @@ -274,7 +283,7 @@ mod tests { #[test] fn an_active_warp_appears_once() { - let composed = compose_warps(&[fake(&DESC_A, 2.0, false)]); + let composed = compose_warps(&[fake(DESC_A.clone(), 2.0, false)]); assert!(composed.is_active()); assert!(composed.body.contains("---- warp: warp_a ----")); assert!(composed.body.contains("u.warp_a_amount")); @@ -284,7 +293,10 @@ mod tests { fn uniforms_are_prefixed_so_warps_cannot_collide() { // Both fakes declare `amount`; without prefixing the generated struct // would carry a duplicate field and fail to compile. - let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)]); + let composed = compose_warps(&[ + fake(DESC_A.clone(), 1.0, false), + fake(DESC_B.clone(), 2.0, false), + ]); assert!(composed.uniform_fields.contains("warp_a_amount: f32")); assert!(composed.uniform_fields.contains("warp_b_amount: f32")); assert_eq!(composed.uniforms, vec![1.0, 2.0]); @@ -294,7 +306,10 @@ mod tests { fn channel_splitting_is_requested_by_any_active_warp() { // One CA warp among several must switch the whole stage to the // three-sample path. - let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, true)]); + let composed = compose_warps(&[ + fake(DESC_A.clone(), 1.0, false), + fake(DESC_B.clone(), 1.0, true), + ]); assert!(composed.splits_channels); } @@ -302,14 +317,20 @@ mod tests { fn an_inactive_splitting_warp_does_not_force_three_samples() { // CA present but at neutral must cost nothing — otherwise every image // with the panel visible pays triple bandwidth. - let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 0.0, true)]); + let composed = compose_warps(&[ + fake(DESC_A.clone(), 1.0, false), + fake(DESC_B.clone(), 0.0, true), + ]); assert!(composed.is_active()); assert!(!composed.splits_channels); } #[test] fn warps_compose_in_order() { - let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]); + let composed = compose_warps(&[ + fake(DESC_A.clone(), 1.0, false), + fake(DESC_B.clone(), 1.0, false), + ]); let a = composed.body.find("warp_a").expect("a present"); let b = composed.body.find("warp_b").expect("b present"); assert!(a < b, "warps must chain in graph order"); diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index 4b64e36..e59af4f 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -81,13 +81,13 @@ mod tests { let mut g = EditGraph::default_chain(); for desc in g.descriptors() { for (i, p) in desc.params.iter().enumerate() { - let v = match p.kind { + let v = match &p.kind { ParamKind::Scalar { min, max, .. } => { // A fraction that differs per parameter, so no two // move in lockstep. let fraction = 0.15 + 0.05 * (i % 4) as f32; let step = (max - min) * fraction; - if p.default + step <= max { + if p.default + step <= *max { p.default + step } else { p.default - step @@ -217,7 +217,7 @@ mod tests { // A default outside its own range would mean a fresh image opens // with a value the UI cannot represent. for desc in EditGraph::default_chain().descriptors() { - for p in desc.params { + for p in &desc.params { assert_eq!( p.clamp(p.default), p.default, @@ -236,7 +236,7 @@ mod tests { // operation must read its own default as neutral. let g = EditGraph::default_chain(); for desc in g.descriptors() { - for p in desc.params { + for p in &desc.params { assert_eq!( g.param(desc.id, p.id), Some(p.default), diff --git a/core/dr-pipeline/src/mask.rs b/core/dr-pipeline/src/mask.rs index 47e0067..5ed23da 100644 --- a/core/dr-pipeline/src/mask.rs +++ b/core/dr-pipeline/src/mask.rs @@ -36,6 +36,7 @@ //! see there for what happens when it does not match. use std::fmt::Write as _; +use std::sync::Arc; use crate::descriptor::{OpDescriptor, ParamId}; use crate::operation::Operation; @@ -692,7 +693,7 @@ impl Clone for MaskLayer { fn clone(&self) -> Self { let mut ops = layer_chain(); for (dst, src) in ops.iter_mut().zip(&self.ops) { - for p in src.descriptor().params { + for p in &src.descriptor().params { dst.set_param(p.id, src.param(p.id)); } } @@ -947,7 +948,7 @@ impl MaskLayer { } } - pub fn descriptors(&self) -> Vec<&'static OpDescriptor> { + pub fn descriptors(&self) -> Vec> { self.ops.iter().map(|o| o.descriptor()).collect() } @@ -995,7 +996,7 @@ impl MaskLayer { }) .collect(), presentation: op.presentation(), - attributes: desc.attributes, + attributes: desc.attributes.clone(), } }) .collect() @@ -1007,7 +1008,7 @@ impl MaskLayer { /// arrive at — so "start this layer's edit again" must not throw it away. pub fn reset_adjustments(&mut self) { for op in &mut self.ops { - for p in op.descriptor().params { + for p in &op.descriptor().params { op.set_param(p.id, p.default); } } @@ -1026,10 +1027,18 @@ impl MaskLayer { /// Every non-default parameter, for the sidecar. pub fn params(&self) -> impl Iterator + '_ { self.ops.iter().flat_map(|o| { - let id = o.descriptor().id.0; - o.descriptor().params.iter().filter_map(move |p| { - let v = o.param(p.id); - (v != p.default).then_some((id, p.id.0, v)) + let desc = o.descriptor(); + let id = desc.id.0; + // Collected rather than borrowed from `desc`: a descriptor is an + // `Arc` handed over by value now (FR-PLG-2), so it would be + // dropped at the end of this closure and the lazy iterator would + // outlive it. The ids and defaults are all this needs, and there + // are a handful of them. + let params: Vec<(ParamId, f32)> = + desc.params.iter().map(|p| (p.id, p.default)).collect(); + params.into_iter().filter_map(move |(param, default)| { + let v = o.param(param); + (v != default).then_some((id, param.0, v)) }) }) } diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index 35978bc..cebcf2f 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -21,6 +21,7 @@ //! generator emits readable, commented output — see [`compose`]. use std::fmt::Write as _; +use std::sync::Arc; use dr_types::{ColourSpace, Transfer}; @@ -171,7 +172,7 @@ impl Invalidation { pub(crate) fn hash_op(h: u64, op: &dyn Operation) -> u64 { let desc = op.descriptor(); let mut h = hash_bytes(h, desc.id.0.as_bytes()); - for p in desc.params { + for p in &desc.params { h = hash_bytes(h, p.id.0.as_bytes()); h = mix(h, u64::from(canonical_bits(op.param(p.id)))); } @@ -213,6 +214,12 @@ pub(crate) const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; pub struct Uniform { /// Field name as it appears in WGSL. Prefixed with the op id by the /// composer, so two operations may both declare `amount`. + /// + /// `&'static str` for the reason [`crate::descriptor::intern`] gives about + /// ids: a uniform name is a small, deduplicated, process-lifetime piece of + /// vocabulary, and a declared operation interns its names once when it is + /// parsed rather than allocating them on every `uniforms()` call — which + /// happens per composition, and composition happens per frame. pub name: &'static str, pub value: f32, } @@ -222,8 +229,32 @@ pub struct Uniform { /// Object-safe: the pipeline holds `Box` in graph order, so /// order is data rather than code (ARCH §3.4). pub trait Operation: Send + Sync { - /// Static description, driving UI generation (FR-DEV-3a). - fn descriptor(&self) -> &'static OpDescriptor; + /// TRACES: FR-DEV-3a | FR-PLG-2 + /// This operation's description, driving UI generation (FR-DEV-3a). + /// + /// **Shared and owned rather than `&'static`.** See [`OpDescriptor`] for + /// why — in short, a `&'static` descriptor is one a compile-time literal + /// can produce and a load-time declaration cannot, which would make a + /// plugin a second-class kind of operation for a reason that is purely an + /// artefact of how the built-ins happen to be written. + /// + /// # What this costs, and where + /// + /// One `Arc` clone and drop per call. Descriptors are read when a panel is + /// built (`EditGraph::capabilities`), when a sidecar is written or read, + /// and when the history names what changed — none of which is a per-frame + /// path. + /// + /// There is **one** exception, and it is worth stating plainly rather than + /// letting somebody discover it with a profiler: [`compose_full`] reads + /// `descriptor().id` once per *active* operation to prefix its uniforms, + /// and `dr-ui` composes on every frame it draws. That is a handful of + /// atomic increments — a dozen or so, against a composition that is + /// already building several kilobytes of WGSL text from scratch on the + /// same call. If composition ever stops being a per-frame operation, this + /// stops being a question at all; while it is one, the refcount is not + /// what makes it expensive. + fn descriptor(&self) -> Arc; /// Set a parameter. Values arrive already clamped to the descriptor. fn set_param(&mut self, id: ParamId, value: f32); @@ -321,7 +352,13 @@ pub trait Operation: Send + Sync { /// Emitted once per *distinct* function name even if several operations /// request it, so shared helpers (luminance, soft clipping) are declared /// exactly once. - fn helpers(&self) -> &'static [Helper] { + /// + /// Borrowed from `self` rather than `'static`, for the reason + /// [`Self::descriptor`] is owned: a generated operation returns a + /// `&'static [Helper]` and coerces, while an operation built from a + /// declaration at load time owns its list. The [`Helper`] *strings* + /// themselves stay `&'static` — they are interned, like the ids. + fn helpers(&self) -> &[Helper] { &[] } @@ -1184,31 +1221,37 @@ pub(crate) fn sanitise(id: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::sync::LazyLock; + use crate::descriptor::Attribute; use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor}; - static DESC_A: OpDescriptor = OpDescriptor { - id: OpId("op_a"), - label: LocalizedKey("a"), - params: &[ParamDescriptor::amount("amount", "a.amount")], - attributes: &[Attribute::Tone], - }; - static DESC_B: OpDescriptor = OpDescriptor { - id: OpId("op_b"), - label: LocalizedKey("b"), - params: &[ParamDescriptor::amount("amount", "b.amount")], - attributes: &[Attribute::Tone], - }; + static DESC_A: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: OpId("op_a"), + label: LocalizedKey("a"), + params: vec![ParamDescriptor::amount("amount", "a.amount")], + attributes: vec![Attribute::Tone], + }) + }); + static DESC_B: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: OpId("op_b"), + label: LocalizedKey("b"), + params: vec![ParamDescriptor::amount("amount", "b.amount")], + attributes: vec![Attribute::Tone], + }) + }); struct Fake { - desc: &'static OpDescriptor, + desc: Arc, amount: f32, helper: Option, } impl Operation for Fake { - fn descriptor(&self) -> &'static OpDescriptor { - self.desc + fn descriptor(&self) -> Arc { + self.desc.clone() } fn set_param(&mut self, _id: ParamId, value: f32) { self.amount = value; @@ -1241,7 +1284,7 @@ mod tests { source: "fn luma(c: vec3) -> f32 { return c.g; }", }]; - fn fake(desc: &'static OpDescriptor, amount: f32, helper: bool) -> Box { + fn fake(desc: Arc, amount: f32, helper: bool) -> Box { Box::new(Fake { desc, amount, @@ -1253,7 +1296,7 @@ mod tests { fn an_inactive_operation_contributes_nothing() { // The point of composing rather than branching: an op at neutral // must not appear in the source at all. - let ops = vec![fake(&DESC_A, 0.0, false)]; + let ops = vec![fake(DESC_A.clone(), 0.0, false)]; let shader = compose(&ops); assert!( !shader.source.contains("op_a"), @@ -1274,7 +1317,7 @@ mod tests { #[test] fn an_active_operation_appears_once() { - let ops = vec![fake(&DESC_A, 2.0, false)]; + let ops = vec![fake(DESC_A.clone(), 2.0, false)]; let shader = compose(&ops); assert!(shader.source.contains("---- op_a ----")); assert!(shader.source.contains("u.op_a_amount")); @@ -1285,7 +1328,10 @@ mod tests { // Both fakes declare a uniform called `amount`. Without prefixing, // the generated struct would have a duplicate field and fail to // compile — the failure mode that makes naive concatenation fragile. - let ops = vec![fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)]; + let ops = vec![ + fake(DESC_A.clone(), 1.0, false), + fake(DESC_B.clone(), 2.0, false), + ]; let shader = compose(&ops); assert!(shader.source.contains("op_a_amount: f32")); assert!(shader.source.contains("op_b_amount: f32")); @@ -1295,7 +1341,10 @@ mod tests { #[test] fn uniform_values_follow_declaration_order() { - let ops = vec![fake(&DESC_A, 1.5, false), fake(&DESC_B, 2.5, false)]; + let ops = vec![ + fake(DESC_A.clone(), 1.5, false), + fake(DESC_B.clone(), 2.5, false), + ]; let shader = compose(&ops); assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5); assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5); @@ -1305,7 +1354,10 @@ mod tests { fn a_shared_helper_is_emitted_once() { // Two operations wanting the same helper must not produce a // duplicate function definition. - let ops = vec![fake(&DESC_A, 1.0, true), fake(&DESC_B, 1.0, true)]; + let ops = vec![ + fake(DESC_A.clone(), 1.0, true), + fake(DESC_B.clone(), 1.0, true), + ]; let shader = compose(&ops); assert_eq!( shader.source.matches("fn luma(").count(), @@ -1319,7 +1371,17 @@ mod tests { // WGSL rejects a uniform struct whose size is not a multiple of 16. for n in 0..6 { let ops: Vec> = (0..n) - .map(|i| fake(if i % 2 == 0 { &DESC_A } else { &DESC_B }, 1.0, false)) + .map(|i| { + fake( + if i % 2 == 0 { + DESC_A.clone() + } else { + DESC_B.clone() + }, + 1.0, + false, + ) + }) .collect(); let shader = compose(&ops); assert_eq!( @@ -1335,11 +1397,14 @@ mod tests { fn structure_hash_ignores_values_but_tracks_the_op_set() { // The property the shader cache depends on: moving a slider must not // trigger a recompile, but enabling an operation must. - let a1 = compose(&[fake(&DESC_A, 1.0, false)]).structure_hash; - let a2 = compose(&[fake(&DESC_A, 9.0, false)]).structure_hash; + let a1 = compose(&[fake(DESC_A.clone(), 1.0, false)]).structure_hash; + let a2 = compose(&[fake(DESC_A.clone(), 9.0, false)]).structure_hash; assert_eq!(a1, a2, "a value change must reuse the compiled pipeline"); - let both = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]); + let both = compose(&[ + fake(DESC_A.clone(), 1.0, false), + fake(DESC_B.clone(), 1.0, false), + ]); assert_ne!(a1, both.structure_hash, "a different op-set must recompile"); } @@ -1347,8 +1412,14 @@ mod tests { fn structure_hash_is_order_sensitive() { // Operation order is data (ARCH §3.4); two orders are different // shaders and must not share a cache entry. - let ab = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]); - let ba = compose(&[fake(&DESC_B, 1.0, false), fake(&DESC_A, 1.0, false)]); + let ab = compose(&[ + fake(DESC_A.clone(), 1.0, false), + fake(DESC_B.clone(), 1.0, false), + ]); + let ba = compose(&[ + fake(DESC_B.clone(), 1.0, false), + fake(DESC_A.clone(), 1.0, false), + ]); assert_ne!(ab.structure_hash, ba.structure_hash); } @@ -1412,7 +1483,7 @@ mod tests { // Exposure and the tonal controls act on white-balanced values; if // the multiply came afterwards, every operation would be reasoning // about a green-cast image. - let ops = vec![fake(&DESC_A, 2.0, false)]; + let ops = vec![fake(DESC_A.clone(), 2.0, false)]; let source = compose(&ops).source; let wb = source.find("u.as_shot_wb").expect("wb applied"); let op = source.find("---- op_a ----").expect("op present"); @@ -1472,7 +1543,7 @@ mod tests { // The other half, and the one that would fail silently: a bug that // suppressed the tail unconditionally renders every ordinary edit // flat and uncorrected, which reads as a broken camera profile. - let source = compose(&[fake(&DESC_A, 2.0, false)]).source; + let source = compose(&[fake(DESC_A.clone(), 2.0, false)]).source; assert!(source.contains("base_curve_last.z > 0.5")); assert!(source.contains("Camera space -> linear sRGB")); } @@ -1484,7 +1555,7 @@ mod tests { // stock loaded is the default state of every photograph in the // catalogue, and it must not disturb the camera's own rendering. let film: Box = Box::new(crate::ops::FilmSim::new()); - let source = compose(&[film, fake(&DESC_A, 2.0, false)]).source; + let source = compose(&[film, fake(DESC_A.clone(), 2.0, false)]).source; assert!(source.contains("base_curve_last.z > 0.5")); assert!(source.contains("Camera space -> linear sRGB")); } @@ -1493,7 +1564,7 @@ mod tests { fn the_camera_matrix_is_applied_after_the_operations() { // Adjustments are meaningful in sensor-native space, where highlight // headroom still exists; converting first would clip it away. - let ops = vec![fake(&DESC_A, 2.0, false)]; + let ops = vec![fake(DESC_A.clone(), 2.0, false)]; let source = compose(&ops).source; let op = source.find("---- op_a ----").expect("op present"); let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied"); @@ -1514,7 +1585,7 @@ mod tests { // Before the matrix: the curve was tuned against this body's own // primaries. Applied after the conversion it would be a Canon // rendering acting on sRGB values, which is a different curve. - let ops = vec![fake(&DESC_A, 2.0, false)]; + let ops = vec![fake(DESC_A.clone(), 2.0, false)]; let source = compose(&ops).source; let op = source.find("---- op_a ----").expect("op present"); let curve = source @@ -1592,7 +1663,7 @@ mod tests { // it must not pick up an identity matrix multiply for the sake of // generality. Asserted against the source rather than against timing, // which would not fail reliably. - let ops = vec![fake(&DESC_A, 1.0, false)]; + let ops = vec![fake(DESC_A.clone(), 1.0, false)]; let srgb = compose_to(&ops, ColourSpace::Srgb).source; assert!( !srgb.contains("Linear sRGB -> linear sRGB"), @@ -1607,7 +1678,7 @@ mod tests { // in linear sRGB, the primaries conversion carries it into the wider // space, and only then is it clipped — clipping first would discard // exactly the colours the wider space was chosen to keep. - let source = compose_to(&[fake(&DESC_A, 1.0, false)], ColourSpace::DisplayP3).source; + let source = compose_to(&[fake(DESC_A.clone(), 1.0, false)], ColourSpace::DisplayP3).source; let camera = source.find("u.cam_to_srgb_0").expect("camera matrix"); let convert = source .find("Linear sRGB -> linear Display P3") @@ -1659,7 +1730,7 @@ mod tests { // an export that came out sRGB and claimed to be Display P3. let mut seen: Vec = Vec::new(); for space in ColourSpace::ALL { - let h = compose_to(&[fake(&DESC_A, 1.0, false)], space).structure_hash; + let h = compose_to(&[fake(DESC_A.clone(), 1.0, false)], space).structure_hash; assert!(!seen.contains(&h), "{space:?} collides with another space"); seen.push(h); } @@ -1669,7 +1740,7 @@ mod tests { fn generated_source_carries_a_do_not_edit_banner() { // Someone will eventually find this in a debugger and try to fix it // in place. - let shader = compose(&[fake(&DESC_A, 1.0, false)]); + let shader = compose(&[fake(DESC_A.clone(), 1.0, false)]); assert!(shader.source.starts_with("// GENERATED")); } diff --git a/core/dr-pipeline/src/ops/aberration.rs b/core/dr-pipeline/src/ops/aberration.rs index 1cf7bdb..0d6117e 100644 --- a/core/dr-pipeline/src/ops/aberration.rs +++ b/core/dr-pipeline/src/ops/aberration.rs @@ -31,6 +31,7 @@ //! untouched means a mis-set correction shifts the channels that contribute //! least to perceived sharpness. Scaling all three about a virtual reference //! would soften the image even when the correction is right. +use std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit, @@ -49,36 +50,38 @@ pub const BLUE: ParamId = ParamId("blue"); /// resolution left to tune by eye at 100%. const MAX_SCALE: f32 = 0.005; -static DESCRIPTOR: OpDescriptor = OpDescriptor { - attributes: &[Attribute::Optics], - id: ID, - label: LocalizedKey("op.aberration"), - params: &[ - // Two independent controls rather than one: the red and blue - // displacements are caused by different ends of the spectrum and are - // not symmetric, so a single "fringing" slider could not remove both. - ParamDescriptor::scalar( - "red", - "param.aberration.red", - -100.0, - 100.0, - 0.0, - Unit::None, - Scale::Linear, - 0, - ), - ParamDescriptor::scalar( - "blue", - "param.aberration.blue", - -100.0, - 100.0, - 0.0, - Unit::None, - Scale::Linear, - 0, - ), - ], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + attributes: vec![Attribute::Optics], + id: ID, + label: LocalizedKey("op.aberration"), + params: vec![ + // Two independent controls rather than one: the red and blue + // displacements are caused by different ends of the spectrum and are + // not symmetric, so a single "fringing" slider could not remove both. + ParamDescriptor::scalar( + "red", + "param.aberration.red", + -100.0, + 100.0, + 0.0, + Unit::None, + Scale::Linear, + 0, + ), + ParamDescriptor::scalar( + "blue", + "param.aberration.blue", + -100.0, + 100.0, + 0.0, + Unit::None, + Scale::Linear, + 0, + ), + ], + }) +}); #[derive(Debug, Default, Clone)] pub struct Aberration { @@ -113,8 +116,8 @@ impl Aberration { } impl Warp for Aberration { - fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { @@ -300,7 +303,7 @@ mod tests { #[test] fn every_default_is_neutral() { let mut a = Aberration::new(); - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { a.set_param(p.id, p.default); } assert!(!a.is_active(), "descriptor defaults must be neutral"); diff --git a/core/dr-pipeline/src/ops/capture_sharpen.rs b/core/dr-pipeline/src/ops/capture_sharpen.rs index 20e4909..6bda4d2 100644 --- a/core/dr-pipeline/src/ops/capture_sharpen.rs +++ b/core/dr-pipeline/src/ops/capture_sharpen.rs @@ -124,6 +124,7 @@ //! would be a guess dressed as a number; `resolves` is the line the stage //! already draws, and drawing it in two places differently is worse than a //! visible step. +use std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit, @@ -163,46 +164,48 @@ const MAX_KERNEL: f32 = 48.0; /// every editor's capture sharpening starts. const DEFAULT_RADIUS: f32 = 1.0; -static DESCRIPTOR: OpDescriptor = OpDescriptor { - id: ID, - label: LocalizedKey("op.capture_sharpen"), - params: &[ - // Amount carries the neutral, which is why it is first: the operation - // is off when this is zero regardless of the other two, so a reset is - // one control and the panel's ordering matches the way it is used. - ParamDescriptor::amount("amount", "param.amount"), - // In **source pixels** — see the module documentation. Half a photosite - // is the smallest radius that means anything on a Bayer sensor, and - // three is already past the point where an unsharp mask is sharpening - // rather than adding local contrast; a photographer wanting the latter - // wants clarity, which is a different operation with a different unit. - ParamDescriptor::scalar( - "radius", - "param.radius", - 0.5, - 3.0, - DEFAULT_RADIUS, - Unit::None, - Scale::Linear, - 2, - ), - // A fraction, but declared as a scalar rather than through - // `ParamDescriptor::fraction` for its precision alone: four decimal - // places on a control whose whole useful travel is a dozen steps - // reads as noise, and invites fiddling with digits that do nothing. - ParamDescriptor::scalar( - "threshold", - "param.threshold", - 0.0, - 1.0, - 0.0, - Unit::None, - Scale::Linear, - 2, - ), - ], - attributes: &[Attribute::Detail], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: ID, + label: LocalizedKey("op.capture_sharpen"), + params: vec![ + // Amount carries the neutral, which is why it is first: the operation + // is off when this is zero regardless of the other two, so a reset is + // one control and the panel's ordering matches the way it is used. + ParamDescriptor::amount("amount", "param.amount"), + // In **source pixels** — see the module documentation. Half a photosite + // is the smallest radius that means anything on a Bayer sensor, and + // three is already past the point where an unsharp mask is sharpening + // rather than adding local contrast; a photographer wanting the latter + // wants clarity, which is a different operation with a different unit. + ParamDescriptor::scalar( + "radius", + "param.radius", + 0.5, + 3.0, + DEFAULT_RADIUS, + Unit::None, + Scale::Linear, + 2, + ), + // A fraction, but declared as a scalar rather than through + // `ParamDescriptor::fraction` for its precision alone: four decimal + // places on a control whose whole useful travel is a dozen steps + // reads as noise, and invites fiddling with digits that do nothing. + ParamDescriptor::scalar( + "threshold", + "param.threshold", + 0.0, + 1.0, + 0.0, + Unit::None, + Scale::Linear, + 2, + ), + ], + attributes: vec![Attribute::Detail], + }) +}); /// TRACES: FR-DEV-3 /// Capture sharpening: a separable unsharp mask with a contrast threshold. @@ -275,8 +278,8 @@ impl CaptureSharpen { } impl Operation for CaptureSharpen { - fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { diff --git a/core/dr-pipeline/src/ops/colour_mixer.rs b/core/dr-pipeline/src/ops/colour_mixer.rs index 60abe72..c2205b2 100644 --- a/core/dr-pipeline/src/ops/colour_mixer.rs +++ b/core/dr-pipeline/src/ops/colour_mixer.rs @@ -29,6 +29,7 @@ //! the band acts at full strength right up to a hard edge; and with two bands //! adjusted, each one's share depends on what the other is set to, so turning //! up one colour's saturation quietly weakened its neighbour's hue shift. +use std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, @@ -123,8 +124,9 @@ impl Channel { } // Parameter descriptors, one per band per channel. Written out rather than -// generated because `ParamDescriptor` must be `const` to live in a `static`, -// and a const loop cannot build a slice. The macro keeps it honest. +// looped because `concat!` needs literals: every id is built from its band's +// key, and a runtime loop has no way to spell `orange_sat`. The macro keeps it +// honest. // // **Every one of them is faceted**, and that is what makes the operation // legible in a panel. Thirty-six parameters presented as a flat list are @@ -137,7 +139,7 @@ impl Channel { // §4.3a); this only says the parameter acts on the band centred there. macro_rules! band_params { ($(($key:literal, $hue:literal)),* $(,)?) => { - &[ + vec![ $( ParamDescriptor::amount( concat!($key, "_hue"), @@ -175,25 +177,27 @@ macro_rules! band_params { // the same reason as those: `concat!` needs literals, so the keys and hues // cannot be read out of `BANDS` here. `facets_match_their_bands` below is // what keeps them from drifting. -static DESCRIPTOR: OpDescriptor = OpDescriptor { - attributes: &[Attribute::Colour], - id: ID, - label: LocalizedKey("op.colour_mixer"), - params: band_params![ - ("red", 0.0), - ("orange", 30.0), - ("yellow", 60.0), - ("chartreuse", 90.0), - ("green", 120.0), - ("spring", 150.0), - ("cyan", 180.0), - ("azure", 210.0), - ("blue", 240.0), - ("violet", 270.0), - ("magenta", 300.0), - ("rose", 330.0), - ], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + attributes: vec![Attribute::Colour], + id: ID, + label: LocalizedKey("op.colour_mixer"), + params: band_params![ + ("red", 0.0), + ("orange", 30.0), + ("yellow", 60.0), + ("chartreuse", 90.0), + ("green", 120.0), + ("spring", 150.0), + ("cyan", 180.0), + ("azure", 210.0), + ("blue", 240.0), + ("violet", 270.0), + ("magenta", 300.0), + ("rose", 330.0), + ], + }) +}); static MIXER_HELPERS: &[Helper] = &[ helpers::LUMINANCE, @@ -306,8 +310,8 @@ impl ColourMixer { } impl Operation for ColourMixer { - fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { @@ -505,7 +509,7 @@ mod tests { fn every_descriptor_id_resolves_to_a_band_and_channel() { // The link between the descriptor list and the value array. A // mismatch would make a slider silently adjust nothing. - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { assert!( ColourMixer::index_of(p.id).is_some(), "{} does not map to a band", @@ -547,7 +551,7 @@ mod tests { // and a hue mistyped there would put a row's swatch on a colour the // band does not act on — a control that lies about what it edits, // which is worse than one with no swatch at all. - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { let facet = p.facet.expect("every mixer parameter is faceted"); let (band_key, _) = p.id.0.rsplit_once('_').expect("id is band_channel"); let band = BANDS @@ -577,7 +581,7 @@ mod tests { // the aspect keyed per band, grouping by it would produce thirty-six // groups of one and nothing would have been gained. let mut per_aspect = std::collections::BTreeMap::new(); - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { let facet = p.facet.expect("faceted"); *per_aspect.entry(facet.aspect.0).or_insert(0) += 1; } diff --git a/core/dr-pipeline/src/ops/curve.rs b/core/dr-pipeline/src/ops/curve.rs index ce845bd..03c201e 100644 --- a/core/dr-pipeline/src/ops/curve.rs +++ b/core/dr-pipeline/src/ops/curve.rs @@ -79,6 +79,7 @@ //! whole composition scheme rests on (ARCH §5.6). use std::fmt::Write as _; +use std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, @@ -341,11 +342,12 @@ const fn facet_of(aspect: &'static str, channel: Channel) -> Facet { /// One channel's ten descriptors, defaulted onto the identity diagonal. /// -/// Written out per point rather than looped because a `ParamDescriptor` has to -/// be `const` to live in a `static`, and a const loop cannot build a slice. +/// Written out per point rather than looped because `concat!` needs literals: +/// the parameter ids are built from the channel's prefix, and a runtime loop +/// has no way to spell `r_p0_x`. macro_rules! channel_params { ($(($prefix:literal, $channel:expr)),* $(,)?) => { - &[$( + vec![$( coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0) .faceted(facet_of("param.curve.p0_x", $channel)), coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0) @@ -370,26 +372,28 @@ macro_rules! channel_params { }; } -static DESCRIPTOR: OpDescriptor = OpDescriptor { - // Both, and this is the case the plural exists for: the master curve is - // tonal and the per-channel curves are chromatic. Filing it under one - // would hide it from half the people looking for it. - attributes: &[Attribute::Tone, Attribute::Colour], - id: ID, - label: LocalizedKey("op.tone_curve"), - // Defaults lie on y = x, so a fresh curve is the identity and the - // operation reports itself inactive — on every channel. - // - // The master's ten come first, and stay first: a frontend addresses a - // point by its offset from the first parameter of the run it is drawing, - // and this is also the order one falling back to sliders reads them in. - params: channel_params![ - ("", Channel::Master), - ("r_", Channel::Red), - ("g_", Channel::Green), - ("b_", Channel::Blue), - ], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + // Both, and this is the case the plural exists for: the master curve is + // tonal and the per-channel curves are chromatic. Filing it under one + // would hide it from half the people looking for it. + attributes: vec![Attribute::Tone, Attribute::Colour], + id: ID, + label: LocalizedKey("op.tone_curve"), + // Defaults lie on y = x, so a fresh curve is the identity and the + // operation reports itself inactive — on every channel. + // + // The master's ten come first, and stay first: a frontend addresses a + // point by its offset from the first parameter of the run it is drawing, + // and this is also the order one falling back to sliders reads them in. + params: channel_params![ + ("", Channel::Master), + ("r_", Channel::Red), + ("g_", Channel::Green), + ("b_", Channel::Blue), + ], + }) +}); /// One span of a monotone cubic Hermite spline. Shared by all four curves. const CURVE_SPAN: Helper = Helper { @@ -699,8 +703,8 @@ impl ToneCurve { } impl Operation for ToneCurve { - fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { @@ -727,7 +731,7 @@ impl Operation for ToneCurve { Some(Presentation { // One entry: there is no second way to draw a tone curve that is // better than the sliders the frontend falls back to anyway. - widgets: &[WidgetKind::ToneCurve], + widgets: vec![WidgetKind::ToneCurve], demand: WidgetDemand { // A point is dragged in x and y together — that is what a // curve *is*, and a frontend that can only move one axis at a @@ -739,7 +743,7 @@ impl Operation for ToneCurve { // leave the other thirty stranded as sliders beneath the plot; // which of the four it draws at a time is its own affair, and the // facets are what let it decide without naming a channel. - params: &CURVE_PARAMS, + params: CURVE_PARAMS.to_vec(), }) } @@ -910,7 +914,7 @@ mod tests { // Opening an unedited image must show the image. let c = ToneCurve::new(); assert!(!c.is_active()); - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { assert_eq!(c.param(p.id), p.default); } } @@ -927,7 +931,7 @@ mod tests { #[test] fn every_parameter_id_maps_to_a_point() { - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { assert!( ToneCurve::index_of(p.id).is_some(), "{} does not map to a point", @@ -1196,7 +1200,7 @@ mod tests { ); assert_eq!(presentation.choose(|_| false), None); assert_eq!(presentation.params.len(), DESCRIPTOR.params.len()); - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { assert!( presentation.params.contains(&p.id), "{} is not owned by the widget", diff --git a/core/dr-pipeline/src/ops/distortion.rs b/core/dr-pipeline/src/ops/distortion.rs index d5fe6b7..c327e1c 100644 --- a/core/dr-pipeline/src/ops/distortion.rs +++ b/core/dr-pipeline/src/ops/distortion.rs @@ -25,6 +25,7 @@ //! set three correlated coefficients, and hand-correcting a lens with no //! profile is a "make the horizon straight" task, which one term does well. //! The full triple is reachable by loading a profile. +use std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit, @@ -35,25 +36,27 @@ use crate::operation::{Helper, Uniform}; pub const ID: OpId = OpId("distortion"); pub const AMOUNT: ParamId = ParamId("amount"); -static DESCRIPTOR: OpDescriptor = OpDescriptor { - attributes: &[Attribute::Optics], - id: ID, - label: LocalizedKey("op.distortion"), - // ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected - // fisheye at one end and strong pincushion at the other; beyond it the - // inverse mapping stops being single-valued near the corners and the - // correction folds the image over itself. - params: &[ParamDescriptor::scalar( - "amount", - "param.distortion.amount", - -100.0, - 100.0, - 0.0, - Unit::None, - Scale::Linear, - 0, - )], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + attributes: vec![Attribute::Optics], + id: ID, + label: LocalizedKey("op.distortion"), + // ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected + // fisheye at one end and strong pincushion at the other; beyond it the + // inverse mapping stops being single-valued near the corners and the + // correction folds the image over itself. + params: vec![ParamDescriptor::scalar( + "amount", + "param.distortion.amount", + -100.0, + 100.0, + 0.0, + Unit::None, + Scale::Linear, + 0, + )], + }) +}); /// The cubic coefficient at full slider travel. const MAX_COEFF: f32 = 0.25; @@ -108,8 +111,8 @@ impl Distortion { } impl Warp for Distortion { - fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { diff --git a/core/dr-pipeline/src/ops/film_sim.rs b/core/dr-pipeline/src/ops/film_sim.rs index 49b8a16..19a3ac0 100644 --- a/core/dr-pipeline/src/ops/film_sim.rs +++ b/core/dr-pipeline/src/ops/film_sim.rs @@ -29,6 +29,7 @@ //! Declared as a plain struct here rather than imported, so that dr-pipeline //! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does //! with `Pa`. +use std::sync::{Arc, LazyLock}; use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; use crate::operation::{Operation, Uniform}; @@ -73,29 +74,31 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [ ["m20", "m21", "m22"], ]; -static DESCRIPTOR: OpDescriptor = OpDescriptor { - // Tone and colour both, and not `Effect`: a stock is not something applied - // on top of a photograph, it is what the photograph was made on. - attributes: &[Attribute::Tone, Attribute::Colour], - id: ID, - label: LocalizedKey("op.film_sim"), - params: &[ - ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0), - ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0), - // TRACES: FR-DEV-3f - // Development, in stops of push. Bounded by what the manufacturers - // actually published: Double-X's measured axis spans about -1 to +2, - // and beyond a range like that a curve would have to be invented. - ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0), - // TRACES: FR-DEV-3f - // Which frame this was taken on — the half of the enlargement a - // photograph cannot supply. A crystal is a fixed size in micrometres, - // so how grainy a picture looks is film size against output size, and - // the same emulsion on 4x5 renders about three times smoother than on - // 35mm at the same print. - ParamDescriptor::choice("format", "param.film_sim.format", &FORMATS), - ], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + // Tone and colour both, and not `Effect`: a stock is not something applied + // on top of a photograph, it is what the photograph was made on. + attributes: vec![Attribute::Tone, Attribute::Colour], + id: ID, + label: LocalizedKey("op.film_sim"), + params: vec![ + ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0), + ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0), + // TRACES: FR-DEV-3f + // Development, in stops of push. Bounded by what the manufacturers + // actually published: Double-X's measured axis spans about -1 to +2, + // and beyond a range like that a curve would have to be invented. + ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0), + // TRACES: FR-DEV-3f + // Which frame this was taken on — the half of the enlargement a + // photograph cannot supply. A crystal is a fixed size in micrometres, + // so how grainy a picture looks is film size against output size, and + // the same emulsion on 4x5 renders about three times smoother than on + // 35mm at the same print. + ParamDescriptor::choice("format", "param.film_sim.format", FORMATS.to_vec()), + ], + }) +}); /// A stock reduced to what a shader runs, as `dr-film` bakes it. /// @@ -190,8 +193,8 @@ impl FilmSim { } impl Operation for FilmSim { - fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { diff --git a/core/dr-pipeline/src/ops/local_contrast.rs b/core/dr-pipeline/src/ops/local_contrast.rs index fe25ab5..b030a9d 100644 --- a/core/dr-pipeline/src/ops/local_contrast.rs +++ b/core/dr-pipeline/src/ops/local_contrast.rs @@ -153,6 +153,7 @@ //! this file. use std::marker::PhantomData; +use std::sync::{Arc, LazyLock}; use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; use crate::detail::{DetailPass, DetailStage, RenderScale}; @@ -181,7 +182,12 @@ const TRUNCATION: f32 = 2.0; /// between clarity and texture can be read side by side, which is the one /// thing a reader comes to this file to do. pub struct Recipe { - descriptor: &'static OpDescriptor, + /// The static this operation's descriptor is built in. A `LazyLock` + /// rather than a reference to a descriptor, because a descriptor is an + /// owned value handed out as an `Arc` now (FR-PLG-2), and a `const` + /// recipe cannot hold an `Arc` — only a reference to the static that + /// makes one. + descriptor: &'static LazyLock>, helpers: &'static [Helper], /// The Gaussian's σ, as a fraction of the frame's shorter edge. sigma: f32, @@ -253,19 +259,23 @@ impl Band for Fine { }; } -static CLARITY_DESCRIPTOR: OpDescriptor = OpDescriptor { - id: CLARITY, - label: LocalizedKey("op.clarity"), - params: &[ParamDescriptor::amount("amount", "param.clarity.amount")], - attributes: &[Attribute::Detail], -}; +static CLARITY_DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: CLARITY, + label: LocalizedKey("op.clarity"), + params: vec![ParamDescriptor::amount("amount", "param.clarity.amount")], + attributes: vec![Attribute::Detail], + }) +}); -static TEXTURE_DESCRIPTOR: OpDescriptor = OpDescriptor { - id: TEXTURE, - label: LocalizedKey("op.texture"), - params: &[ParamDescriptor::amount("amount", "param.texture.amount")], - attributes: &[Attribute::Detail], -}; +static TEXTURE_DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: TEXTURE, + label: LocalizedKey("op.texture"), + params: vec![ParamDescriptor::amount("amount", "param.texture.amount")], + attributes: vec![Attribute::Detail], + }) +}); /// Luminance as a position on a logarithmic scale, floored. /// @@ -394,8 +404,8 @@ impl LocalContrast { } impl Operation for LocalContrast { - fn descriptor(&self) -> &'static OpDescriptor { - B::RECIPE.descriptor + fn descriptor(&self) -> Arc { + Arc::clone(B::RECIPE.descriptor) } fn set_param(&mut self, _id: ParamId, value: f32) { diff --git a/core/dr-pipeline/src/ops/mod.rs b/core/dr-pipeline/src/ops/mod.rs index ef9283c..15b169a 100644 --- a/core/dr-pipeline/src/ops/mod.rs +++ b/core/dr-pipeline/src/ops/mod.rs @@ -179,7 +179,7 @@ mod tests { // perfectly and silently breaks the sidecar. for mut op in chain() { let descriptor = op.descriptor(); - for p in descriptor.params { + for p in &descriptor.params { let crate::descriptor::ParamKind::Scalar { min, max, .. } = p.kind else { continue; }; diff --git a/core/dr-pipeline/src/ops/noise_reduction.rs b/core/dr-pipeline/src/ops/noise_reduction.rs index b57a828..e35afcb 100644 --- a/core/dr-pipeline/src/ops/noise_reduction.rs +++ b/core/dr-pipeline/src/ops/noise_reduction.rs @@ -147,6 +147,7 @@ //! lie. The chroma radius, ten times larger, still resolves — which is also //! true of the fault it treats, since a blotch twenty pixels across survives //! being halved. +use std::sync::{Arc, LazyLock}; use crate::descriptor::{ Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit, @@ -158,38 +159,40 @@ pub const ID: OpId = OpId("noise_reduction"); pub const LUMINANCE: ParamId = ParamId("luminance"); pub const CHROMA: ParamId = ParamId("chroma"); -static DESCRIPTOR: OpDescriptor = OpDescriptor { - id: ID, - label: LocalizedKey("op.noise_reduction"), - attributes: &[Attribute::Detail], - // Zero to a hundred rather than the symmetric `amount` shape the tonal - // controls use. There is no meaningful negative: "minus fifty noise - // reduction" would be adding grain, which is a look rather than a repair - // and belongs to a different operation carrying `Attribute::Effect`. A - // control whose left half does nothing is worse than one that stops. - params: &[ - ParamDescriptor::scalar( - "luminance", - "param.noise_reduction.luminance", - 0.0, - 100.0, - 0.0, - Unit::None, - Scale::Linear, - 0, - ), - ParamDescriptor::scalar( - "chroma", - "param.noise_reduction.chroma", - 0.0, - 100.0, - 0.0, - Unit::None, - Scale::Linear, - 0, - ), - ], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + id: ID, + label: LocalizedKey("op.noise_reduction"), + attributes: vec![Attribute::Detail], + // Zero to a hundred rather than the symmetric `amount` shape the tonal + // controls use. There is no meaningful negative: "minus fifty noise + // reduction" would be adding grain, which is a look rather than a repair + // and belongs to a different operation carrying `Attribute::Effect`. A + // control whose left half does nothing is worse than one that stops. + params: vec![ + ParamDescriptor::scalar( + "luminance", + "param.noise_reduction.luminance", + 0.0, + 100.0, + 0.0, + Unit::None, + Scale::Linear, + 0, + ), + ParamDescriptor::scalar( + "chroma", + "param.noise_reduction.chroma", + 0.0, + 100.0, + 0.0, + Unit::None, + Scale::Linear, + 0, + ), + ], + }) +}); /// The luminance radius at the lowest and the highest amount, in **source** /// pixels. @@ -365,8 +368,8 @@ fn inv_spatial(kernel: u32) -> f32 { } impl Operation for NoiseReduction { - fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { diff --git a/core/dr-pipeline/src/ops/vignetting.rs b/core/dr-pipeline/src/ops/vignetting.rs index 3bf7724..7c6ffd7 100644 --- a/core/dr-pipeline/src/ops/vignetting.rs +++ b/core/dr-pipeline/src/ops/vignetting.rs @@ -32,6 +32,7 @@ //! division is the whole reason this operation must run before the tonal //! stages: a corner recovered by two stops has to be recovered while the //! highlight headroom to hold it still exists (ARCH §5.2). +use std::sync::{Arc, LazyLock}; use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; use crate::operation::{Helper, Operation, Uniform}; @@ -45,19 +46,21 @@ pub const AMOUNT: ParamId = ParamId("amount"); /// fast prime wide open — the case that actually needs correcting. const MAX_K1: f32 = -0.5; -static DESCRIPTOR: OpDescriptor = OpDescriptor { - // Optics rather than effect: this carries lens-profile coefficients - // and corrects what the lens did. A *creative* vignette is a different - // operation that does not exist yet, and would be `Effect`. - attributes: &[Attribute::Optics], - id: ID, - label: LocalizedKey("op.vignetting"), - // Bidirectional deliberately. Negative values *add* falloff, which is a - // legitimate creative choice as well as a correction, and a control that - // only removed vignetting would need a second one beside it to put any - // back. - params: &[ParamDescriptor::amount("amount", "param.vignetting.amount")], -}; +static DESCRIPTOR: LazyLock> = LazyLock::new(|| { + Arc::new(OpDescriptor { + // Optics rather than effect: this carries lens-profile coefficients + // and corrects what the lens did. A *creative* vignette is a different + // operation that does not exist yet, and would be `Effect`. + attributes: vec![Attribute::Optics], + id: ID, + label: LocalizedKey("op.vignetting"), + // Bidirectional deliberately. Negative values *add* falloff, which is a + // legitimate creative choice as well as a correction, and a control that + // only removed vignetting would need a second one beside it to put any + // back. + params: vec![ParamDescriptor::amount("amount", "param.vignetting.amount")], + }) +}); /// The `pa` polynomial coefficients, as Lensfun stores them. #[derive(Debug, Clone, Copy, PartialEq)] @@ -107,8 +110,8 @@ impl Vignetting { } impl Operation for Vignetting { - fn descriptor(&self) -> &'static OpDescriptor { - &DESCRIPTOR + fn descriptor(&self) -> Arc { + DESCRIPTOR.clone() } fn set_param(&mut self, id: ParamId, value: f32) { @@ -371,7 +374,7 @@ mod tests { #[test] fn every_default_is_neutral() { let mut v = Vignetting::new(); - for p in DESCRIPTOR.params { + for p in &DESCRIPTOR.params { v.set_param(p.id, p.default); } assert!(!v.is_active()); diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index c36ad2e..93e2372 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -1297,8 +1297,17 @@ impl PartialMask { .ops .iter() .find(|o| o.descriptor().id.0 == op) - .and_then(|o| o.descriptor().params.iter().find(|p| p.id.0 == param)) - .map(|p| p.id) + // The descriptor is bound inside the closure rather than + // chained through: it is an owned `Arc` now, so a + // `ParamDescriptor` borrowed out of it would not outlive the + // expression. The `ParamId` is `Copy`, so it does. + .and_then(|o| { + o.descriptor() + .params + .iter() + .find(|p| p.id.0 == param) + .map(|p| p.id) + }) else { log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring"); continue; diff --git a/docs/traceability.md b/docs/traceability.md index ddc7ab2..64cf961 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 254 | -| TRACES tags found | 732 | +| Source files scanned | 257 | +| TRACES tags found | 746 | | Requirements defined | 177 | -| Requirements covered | 98 | -| **Coverage** | **55.4%** (98/177) | +| Requirements covered | 100 | +| **Coverage** | **56.5%** (100/177) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 78 | 122 | +| FR | 80 | 122 | | NFR | 18 | 49 | | R | 2 | 6 | @@ -46,7 +46,7 @@ _None._ | FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1001`](../core/dr-catalog/src/schema.rs#L1001), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3110`](../ui/dr-ui/src/library.rs#L3110), [`ui/dr-ui/src/library_ui.rs:6271`](../ui/dr-ui/src/library_ui.rs#L6271), [`ui/dr-ui/src/library_ui.rs:6282`](../ui/dr-ui/src/library_ui.rs#L6282), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6295`](../ui/dr-ui/src/library_ui.rs#L6295), [`ui/dr-ui/src/library_ui.rs:6310`](../ui/dr-ui/src/library_ui.rs#L6310), [`ui/dr-ui/src/library_ui.rs:6319`](../ui/dr-ui/src/library_ui.rs#L6319), [`ui/dr-ui/ui/app.slint:485`](../ui/dr-ui/ui/app.slint#L485), [`ui/dr-ui/ui/app.slint:655`](../ui/dr-ui/ui/app.slint#L655), [`ui/dr-ui/ui/library.slint:1219`](../ui/dr-ui/ui/library.slint#L1219), [`ui/dr-ui/ui/library.slint:1222`](../ui/dr-ui/ui/library.slint#L1222), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:850`](../ui/dr-ui/ui/library.slint#L850), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902) | | FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:315`](../ui/dr-ui/src/library_ui.rs#L315), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5193`](../ui/dr-ui/src/library_ui.rs#L5193), [`ui/dr-ui/src/library_ui.rs:6411`](../ui/dr-ui/src/library_ui.rs#L6411), [`ui/dr-ui/ui/app.slint:488`](../ui/dr-ui/ui/app.slint#L488), [`ui/dr-ui/ui/app.slint:655`](../ui/dr-ui/ui/app.slint#L655), [`ui/dr-ui/ui/app.slint:894`](../ui/dr-ui/ui/app.slint#L894), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1288`](../ui/dr-ui/ui/library.slint#L1288), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/settings.slint:128`](../ui/dr-ui/ui/settings.slint#L128) | | FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3482`](../ui/dr-ui/src/library_ui.rs#L3482), [`ui/dr-ui/src/library_ui.rs:4180`](../ui/dr-ui/src/library_ui.rs#L4180), [`ui/dr-ui/ui/app.slint:650`](../ui/dr-ui/ui/app.slint#L650), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2528`](../ui/dr-ui/ui/library.slint#L2528), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) | -| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1627`](../core/dr-pipeline/src/sidecar.rs#L1627), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:3316`](../ui/dr-ui/src/develop.rs#L3316), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1365`](../ui/dr-ui/src/lib.rs#L1365), [`ui/dr-ui/src/lib.rs:1738`](../ui/dr-ui/src/lib.rs#L1738), [`ui/dr-ui/src/lib.rs:1874`](../ui/dr-ui/src/lib.rs#L1874), [`ui/dr-ui/src/lib.rs:480`](../ui/dr-ui/src/lib.rs#L480), [`ui/dr-ui/src/lib.rs:905`](../ui/dr-ui/src/lib.rs#L905), [`ui/dr-ui/src/library.rs:1551`](../ui/dr-ui/src/library.rs#L1551), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4882`](../ui/dr-ui/src/library_ui.rs#L4882), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:3316`](../ui/dr-ui/src/develop.rs#L3316), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1365`](../ui/dr-ui/src/lib.rs#L1365), [`ui/dr-ui/src/lib.rs:1738`](../ui/dr-ui/src/lib.rs#L1738), [`ui/dr-ui/src/lib.rs:1874`](../ui/dr-ui/src/lib.rs#L1874), [`ui/dr-ui/src/lib.rs:480`](../ui/dr-ui/src/lib.rs#L480), [`ui/dr-ui/src/lib.rs:905`](../ui/dr-ui/src/lib.rs#L905), [`ui/dr-ui/src/library.rs:1551`](../ui/dr-ui/src/library.rs#L1551), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4882`](../ui/dr-ui/src/library_ui.rs#L4882), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2812`](../ui/dr-ui/src/develop.rs#L2812), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1511`](../ui/dr-ui/src/library.rs#L1511), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:220`](../ui/dr-ui/src/library.rs#L220), [`ui/dr-ui/src/library.rs:3463`](../ui/dr-ui/src/library.rs#L3463), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library_ui.rs:1564`](../ui/dr-ui/src/library_ui.rs#L1564), [`ui/dr-ui/src/library_ui.rs:1590`](../ui/dr-ui/src/library_ui.rs#L1590), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1700`](../ui/dr-ui/src/library_ui.rs#L1700), [`ui/dr-ui/src/library_ui.rs:226`](../ui/dr-ui/src/library_ui.rs#L226), [`ui/dr-ui/src/library_ui.rs:2316`](../ui/dr-ui/src/library_ui.rs#L2316), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:2754`](../ui/dr-ui/src/library_ui.rs#L2754), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:3257`](../ui/dr-ui/src/library_ui.rs#L3257), [`ui/dr-ui/src/library_ui.rs:3345`](../ui/dr-ui/src/library_ui.rs#L3345), [`ui/dr-ui/src/library_ui.rs:3533`](../ui/dr-ui/src/library_ui.rs#L3533), [`ui/dr-ui/src/library_ui.rs:3651`](../ui/dr-ui/src/library_ui.rs#L3651), [`ui/dr-ui/src/library_ui.rs:440`](../ui/dr-ui/src/library_ui.rs#L440), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5238`](../ui/dr-ui/src/library_ui.rs#L5238), [`ui/dr-ui/src/library_ui.rs:5353`](../ui/dr-ui/src/library_ui.rs#L5353), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | | FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1776`](../ui/dr-ui/src/develop.rs#L1776), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1846`](../ui/dr-ui/src/lib.rs#L1846), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | @@ -56,25 +56,25 @@ _None._ | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) | | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:343`](../core/dr-catalog/src/schema.rs#L343), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/ui/settings.slint:385`](../ui/dr-ui/ui/settings.slint#L385), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) | -| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:362`](../core/dr-pipeline/src/framing.rs#L362), [`core/dr-pipeline/src/framing.rs:617`](../core/dr-pipeline/src/framing.rs#L617), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:573`](../core/dr-pipeline/src/graph.rs#L573), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:479`](../core/dr-pipeline/src/operation.rs#L479), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1627`](../core/dr-pipeline/src/sidecar.rs#L1627), [`core/dr-pipeline/src/sidecar.rs:1687`](../core/dr-pipeline/src/sidecar.rs#L1687), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1280`](../ui/dr-ui/src/develop.rs#L1280), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1758`](../ui/dr-ui/src/develop.rs#L1758), [`ui/dr-ui/src/develop.rs:1776`](../ui/dr-ui/src/develop.rs#L1776), [`ui/dr-ui/src/develop.rs:1790`](../ui/dr-ui/src/develop.rs#L1790), [`ui/dr-ui/src/develop.rs:1812`](../ui/dr-ui/src/develop.rs#L1812), [`ui/dr-ui/src/develop.rs:1958`](../ui/dr-ui/src/develop.rs#L1958), [`ui/dr-ui/src/develop.rs:2056`](../ui/dr-ui/src/develop.rs#L2056), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3851`](../ui/dr-ui/src/develop.rs#L3851), [`ui/dr-ui/src/develop.rs:3905`](../ui/dr-ui/src/develop.rs#L3905), [`ui/dr-ui/src/develop.rs:3949`](../ui/dr-ui/src/develop.rs#L3949), [`ui/dr-ui/src/develop.rs:3999`](../ui/dr-ui/src/develop.rs#L3999), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1422`](../ui/dr-ui/src/lib.rs#L1422), [`ui/dr-ui/src/lib.rs:2124`](../ui/dr-ui/src/lib.rs#L2124), [`ui/dr-ui/src/lib.rs:301`](../ui/dr-ui/src/lib.rs#L301), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:2100`](../ui/dr-ui/ui/app.slint#L2100), [`ui/dr-ui/ui/app.slint:981`](../ui/dr-ui/ui/app.slint#L981) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1177`](../ui/dr-ui/src/develop.rs#L1177), [`ui/dr-ui/src/lib.rs:595`](../ui/dr-ui/src/lib.rs#L595) | -| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:4578`](../ui/dr-ui/src/develop.rs#L4578) | -| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:612`](../core/dr-pipeline/src/graph.rs#L612), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | -| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1424`](../core/dr-pipeline/src/operation.rs#L1424), [`core/dr-pipeline/src/operation.rs:1505`](../core/dr-pipeline/src/operation.rs#L1505), [`core/dr-pipeline/src/operation.rs:1530`](../core/dr-pipeline/src/operation.rs#L1530), [`core/dr-pipeline/src/operation.rs:1545`](../core/dr-pipeline/src/operation.rs#L1545), [`core/dr-pipeline/src/operation.rs:1569`](../core/dr-pipeline/src/operation.rs#L1569), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:413`](../core/dr-pipeline/src/operation.rs#L413), [`core/dr-pipeline/src/operation.rs:563`](../core/dr-pipeline/src/operation.rs#L563) | -| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1028`](../core/dr-pipeline/src/operation.rs#L1028), [`core/dr-pipeline/src/operation.rs:1057`](../core/dr-pipeline/src/operation.rs#L1057), [`core/dr-pipeline/src/operation.rs:1424`](../core/dr-pipeline/src/operation.rs#L1424), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:126`](../core/dr-pipeline/src/ops/film_sim.rs#L126), [`core/dr-pipeline/src/ops/film_sim.rs:150`](../core/dr-pipeline/src/ops/film_sim.rs#L150), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:328`](../core/dr-pipeline/src/ops/film_sim.rs#L328), [`core/dr-pipeline/src/ops/film_sim.rs:42`](../core/dr-pipeline/src/ops/film_sim.rs#L42), [`core/dr-pipeline/src/ops/film_sim.rs:85`](../core/dr-pipeline/src/ops/film_sim.rs#L85), [`core/dr-pipeline/src/ops/film_sim.rs:90`](../core/dr-pipeline/src/ops/film_sim.rs#L90), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1948`](../core/dr-pipeline/src/sidecar.rs#L1948), [`core/dr-pipeline/src/sidecar.rs:2024`](../core/dr-pipeline/src/sidecar.rs#L2024), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2827`](../ui/dr-ui/src/develop.rs#L2827), [`ui/dr-ui/src/develop.rs:2844`](../ui/dr-ui/src/develop.rs#L2844), [`ui/dr-ui/src/develop.rs:2856`](../ui/dr-ui/src/develop.rs#L2856), [`ui/dr-ui/src/develop.rs:2894`](../ui/dr-ui/src/develop.rs#L2894), [`ui/dr-ui/src/develop.rs:2903`](../ui/dr-ui/src/develop.rs#L2903), [`ui/dr-ui/src/develop.rs:3006`](../ui/dr-ui/src/develop.rs#L3006), [`ui/dr-ui/src/develop.rs:3324`](../ui/dr-ui/src/develop.rs#L3324), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/lib.rs:2098`](../ui/dr-ui/src/lib.rs#L2098), [`ui/dr-ui/src/lib.rs:528`](../ui/dr-ui/src/lib.rs#L528), [`ui/dr-ui/src/lib.rs:586`](../ui/dr-ui/src/lib.rs#L586), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:959`](../ui/dr-ui/ui/adjust.slint#L959), [`ui/dr-ui/ui/app.slint:2712`](../ui/dr-ui/ui/app.slint#L2712), [`ui/dr-ui/ui/app.slint:775`](../ui/dr-ui/ui/app.slint#L775) | -| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-pipeline/src/framing.rs:362`](../core/dr-pipeline/src/framing.rs#L362), [`core/dr-pipeline/src/framing.rs:924`](../core/dr-pipeline/src/framing.rs#L924), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | +| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1280`](../ui/dr-ui/src/develop.rs#L1280), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1758`](../ui/dr-ui/src/develop.rs#L1758), [`ui/dr-ui/src/develop.rs:1776`](../ui/dr-ui/src/develop.rs#L1776), [`ui/dr-ui/src/develop.rs:1790`](../ui/dr-ui/src/develop.rs#L1790), [`ui/dr-ui/src/develop.rs:1812`](../ui/dr-ui/src/develop.rs#L1812), [`ui/dr-ui/src/develop.rs:1958`](../ui/dr-ui/src/develop.rs#L1958), [`ui/dr-ui/src/develop.rs:2056`](../ui/dr-ui/src/develop.rs#L2056), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3851`](../ui/dr-ui/src/develop.rs#L3851), [`ui/dr-ui/src/develop.rs:3905`](../ui/dr-ui/src/develop.rs#L3905), [`ui/dr-ui/src/develop.rs:3949`](../ui/dr-ui/src/develop.rs#L3949), [`ui/dr-ui/src/develop.rs:3999`](../ui/dr-ui/src/develop.rs#L3999), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1422`](../ui/dr-ui/src/lib.rs#L1422), [`ui/dr-ui/src/lib.rs:2124`](../ui/dr-ui/src/lib.rs#L2124), [`ui/dr-ui/src/lib.rs:301`](../ui/dr-ui/src/lib.rs#L301), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:2100`](../ui/dr-ui/ui/app.slint#L2100), [`ui/dr-ui/ui/app.slint:981`](../ui/dr-ui/ui/app.slint#L981) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:1814`](../core/dr-pipeline/build.rs#L1814), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1177`](../ui/dr-ui/src/develop.rs#L1177), [`ui/dr-ui/src/lib.rs:595`](../ui/dr-ui/src/lib.rs#L595) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1814`](../core/dr-pipeline/build.rs#L1814), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4578`](../ui/dr-ui/src/develop.rs#L4578) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | +| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2827`](../ui/dr-ui/src/develop.rs#L2827), [`ui/dr-ui/src/develop.rs:2844`](../ui/dr-ui/src/develop.rs#L2844), [`ui/dr-ui/src/develop.rs:2856`](../ui/dr-ui/src/develop.rs#L2856), [`ui/dr-ui/src/develop.rs:2894`](../ui/dr-ui/src/develop.rs#L2894), [`ui/dr-ui/src/develop.rs:2903`](../ui/dr-ui/src/develop.rs#L2903), [`ui/dr-ui/src/develop.rs:3006`](../ui/dr-ui/src/develop.rs#L3006), [`ui/dr-ui/src/develop.rs:3324`](../ui/dr-ui/src/develop.rs#L3324), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/lib.rs:2098`](../ui/dr-ui/src/lib.rs#L2098), [`ui/dr-ui/src/lib.rs:528`](../ui/dr-ui/src/lib.rs#L528), [`ui/dr-ui/src/lib.rs:586`](../ui/dr-ui/src/lib.rs#L586), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:959`](../ui/dr-ui/ui/adjust.slint#L959), [`ui/dr-ui/ui/app.slint:2712`](../ui/dr-ui/ui/app.slint#L2712), [`ui/dr-ui/ui/app.slint:775`](../ui/dr-ui/ui/app.slint#L775) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:205`](../core/dr-pipeline/src/framing.rs#L205), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:927`](../core/dr-pipeline/src/framing.rs#L927), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | | FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2856`](../ui/dr-ui/src/develop.rs#L2856), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3369`](../ui/dr-ui/src/develop.rs#L3369), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3394`](../ui/dr-ui/src/develop.rs#L3394), [`ui/dr-ui/src/develop.rs:3410`](../ui/dr-ui/src/develop.rs#L3410), [`ui/dr-ui/src/develop.rs:3442`](../ui/dr-ui/src/develop.rs#L3442), [`ui/dr-ui/src/develop.rs:3446`](../ui/dr-ui/src/develop.rs#L3446), [`ui/dr-ui/src/develop.rs:3465`](../ui/dr-ui/src/develop.rs#L3465), [`ui/dr-ui/src/develop.rs:3481`](../ui/dr-ui/src/develop.rs#L3481), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1386`](../ui/dr-ui/src/lib.rs#L1386), [`ui/dr-ui/src/lib.rs:1400`](../ui/dr-ui/src/lib.rs#L1400), [`ui/dr-ui/src/lib.rs:1408`](../ui/dr-ui/src/lib.rs#L1408), [`ui/dr-ui/src/lib.rs:2294`](../ui/dr-ui/src/lib.rs#L2294), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | | FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3281`](../ui/dr-ui/src/develop.rs#L3281), [`ui/dr-ui/src/develop.rs:3302`](../ui/dr-ui/src/develop.rs#L3302), [`ui/dr-ui/src/lib.rs:1365`](../ui/dr-ui/src/lib.rs#L1365), [`ui/dr-ui/src/library.rs:1551`](../ui/dr-ui/src/library.rs#L1551), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:547`](../ui/dr-ui/src/settings_ui.rs#L547), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1315`](../ui/dr-ui/ui/library.slint#L1315), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:913`](../ui/dr-ui/ui/library.slint#L913), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | | FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3410`](../ui/dr-ui/src/develop.rs#L3410), [`ui/dr-ui/src/develop.rs:3442`](../ui/dr-ui/src/develop.rs#L3442), [`ui/dr-ui/src/lib.rs:1408`](../ui/dr-ui/src/lib.rs#L1408), [`ui/dr-ui/src/lib.rs:2294`](../ui/dr-ui/src/lib.rs#L2294), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:681`](../core/dr-pipeline/src/graph.rs#L681), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:517`](../core/dr-pipeline/src/operation.rs#L517), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2127`](../ui/dr-ui/src/develop.rs#L2127), [`ui/dr-ui/src/develop.rs:2165`](../ui/dr-ui/src/develop.rs#L2165), [`ui/dr-ui/src/develop.rs:2230`](../ui/dr-ui/src/develop.rs#L2230), [`ui/dr-ui/src/develop.rs:2313`](../ui/dr-ui/src/develop.rs#L2313), [`ui/dr-ui/src/develop.rs:2327`](../ui/dr-ui/src/develop.rs#L2327), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1429`](../ui/dr-ui/src/lib.rs#L1429), [`ui/dr-ui/src/lib.rs:2391`](../ui/dr-ui/src/lib.rs#L2391), [`ui/dr-ui/src/lib.rs:306`](../ui/dr-ui/src/lib.rs#L306), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:676`](../ui/dr-ui/ui/adjust.slint#L676), [`ui/dr-ui/ui/app.slint:1835`](../ui/dr-ui/ui/app.slint#L1835), [`ui/dr-ui/ui/app.slint:2352`](../ui/dr-ui/ui/app.slint#L2352), [`ui/dr-ui/ui/app.slint:2618`](../ui/dr-ui/ui/app.slint#L2618), [`ui/dr-ui/ui/app.slint:332`](../ui/dr-ui/ui/app.slint#L332), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:543`](../core/dr-pipeline/src/graph.rs#L543), [`core/dr-pipeline/src/graph.rs:573`](../core/dr-pipeline/src/graph.rs#L573), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:654`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L654), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:662`](../core/dr-pipeline/src/ops/local_contrast.rs#L662), [`core/dr-pipeline/src/ops/noise_reduction.rs:695`](../core/dr-pipeline/src/ops/noise_reduction.rs#L695), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2627`](../ui/dr-ui/src/develop.rs#L2627), [`ui/dr-ui/src/develop.rs:3640`](../ui/dr-ui/src/develop.rs#L3640), [`ui/dr-ui/src/develop.rs:4123`](../ui/dr-ui/src/develop.rs#L4123), [`ui/dr-ui/src/develop.rs:4157`](../ui/dr-ui/src/develop.rs#L4157), [`ui/dr-ui/src/lib.rs:70`](../ui/dr-ui/src/lib.rs#L70), [`ui/dr-ui/src/lib.rs:736`](../ui/dr-ui/src/lib.rs#L736), [`ui/dr-ui/src/lib.rs:795`](../ui/dr-ui/src/lib.rs#L795) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2127`](../ui/dr-ui/src/develop.rs#L2127), [`ui/dr-ui/src/develop.rs:2165`](../ui/dr-ui/src/develop.rs#L2165), [`ui/dr-ui/src/develop.rs:2230`](../ui/dr-ui/src/develop.rs#L2230), [`ui/dr-ui/src/develop.rs:2313`](../ui/dr-ui/src/develop.rs#L2313), [`ui/dr-ui/src/develop.rs:2327`](../ui/dr-ui/src/develop.rs#L2327), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1429`](../ui/dr-ui/src/lib.rs#L1429), [`ui/dr-ui/src/lib.rs:2391`](../ui/dr-ui/src/lib.rs#L2391), [`ui/dr-ui/src/lib.rs:306`](../ui/dr-ui/src/lib.rs#L306), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:676`](../ui/dr-ui/ui/adjust.slint#L676), [`ui/dr-ui/ui/app.slint:1835`](../ui/dr-ui/ui/app.slint#L1835), [`ui/dr-ui/ui/app.slint:2352`](../ui/dr-ui/ui/app.slint#L2352), [`ui/dr-ui/ui/app.slint:2618`](../ui/dr-ui/ui/app.slint#L2618), [`ui/dr-ui/ui/app.slint:332`](../ui/dr-ui/ui/app.slint#L332), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2627`](../ui/dr-ui/src/develop.rs#L2627), [`ui/dr-ui/src/develop.rs:3640`](../ui/dr-ui/src/develop.rs#L3640), [`ui/dr-ui/src/develop.rs:4123`](../ui/dr-ui/src/develop.rs#L4123), [`ui/dr-ui/src/develop.rs:4157`](../ui/dr-ui/src/develop.rs#L4157), [`ui/dr-ui/src/lib.rs:70`](../ui/dr-ui/src/lib.rs#L70), [`ui/dr-ui/src/lib.rs:736`](../ui/dr-ui/src/lib.rs#L736), [`ui/dr-ui/src/lib.rs:795`](../ui/dr-ui/src/lib.rs#L795) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | | FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2693`](../ui/dr-ui/src/develop.rs#L2693), [`ui/dr-ui/src/develop.rs:5225`](../ui/dr-ui/src/develop.rs#L5225), [`ui/dr-ui/src/develop.rs:5257`](../ui/dr-ui/src/develop.rs#L5257), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1485`](../ui/dr-ui/src/lib.rs#L1485), [`ui/dr-ui/src/lib.rs:296`](../ui/dr-ui/src/lib.rs#L296), [`ui/dr-ui/ui/app.slint:292`](../ui/dr-ui/ui/app.slint#L292), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:533`](../core/dr-pipeline/src/graph.rs#L533), [`core/dr-pipeline/src/graph.rs:587`](../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | @@ -97,10 +97,12 @@ _None._ | FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1104`](../ui/dr-ui/src/lib.rs#L1104), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1104`](../ui/dr-ui/src/lib.rs#L1104) | | FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1738`](../ui/dr-ui/src/lib.rs#L1738), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2024`](../core/dr-pipeline/src/sidecar.rs#L2024), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:845`](../ui/dr-ui/src/lib.rs#L845), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | +| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232) | +| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:414`](../core/dr-pipeline/src/declared/decl.rs#L414), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:186`](../ui/dr-ui/src/lib.rs#L186) | @@ -110,7 +112,7 @@ _None._ | FR-UI-3 | [`ui/dr-ui/src/develop.rs:2056`](../ui/dr-ui/src/develop.rs#L2056), [`ui/dr-ui/src/develop.rs:2165`](../ui/dr-ui/src/develop.rs#L2165), [`ui/dr-ui/src/library_ui.rs:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2100`](../ui/dr-ui/ui/app.slint#L2100), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | | FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`ui/dr-ui/src/library_ui.rs:4430`](../ui/dr-ui/src/library_ui.rs#L4430), [`ui/dr-ui/src/library_ui.rs:4533`](../ui/dr-ui/src/library_ui.rs#L4533), [`ui/dr-ui/src/library_ui.rs:4561`](../ui/dr-ui/src/library_ui.rs#L4561), [`ui/dr-ui/src/library_ui.rs:5273`](../ui/dr-ui/src/library_ui.rs#L5273), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772), [`ui/dr-ui/ui/app.slint:650`](../ui/dr-ui/ui/app.slint#L650), [`ui/dr-ui/ui/app.slint:681`](../ui/dr-ui/ui/app.slint#L681), [`ui/dr-ui/ui/library.slint:1196`](../ui/dr-ui/ui/library.slint#L1196), [`ui/dr-ui/ui/library.slint:1203`](../ui/dr-ui/ui/library.slint#L1203), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2278`](../ui/dr-ui/ui/library.slint#L2278), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) | | FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2356`](../ui/dr-ui/src/lib.rs#L2356), [`ui/dr-ui/src/lib.rs:2763`](../ui/dr-ui/src/lib.rs#L2763), [`ui/dr-ui/src/lib.rs:2948`](../ui/dr-ui/src/lib.rs#L2948), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:313`](../ui/dr-ui/ui/app.slint#L313), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | -| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) | +| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1) | | NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:2074`](../ui/dr-ui/src/lib.rs#L2074), [`ui/dr-ui/ui/app.slint:1128`](../ui/dr-ui/ui/app.slint#L1128), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) | @@ -134,7 +136,7 @@ _None._ ## Not yet tagged -79 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +77 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -162,11 +164,9 @@ _None._ - FR-PLG-11 - FR-PLG-12 - FR-PLG-1a -- FR-PLG-2 - FR-PLG-2a - FR-PLG-2b - FR-PLG-2c -- FR-PLG-2d - FR-PLG-3 - FR-PLG-3a - FR-PLG-4 diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 90a46f9..2d4530b 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -1196,7 +1196,7 @@ fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option = Vec::new(); - for id in presentation.params { + for id in &presentation.params { // The widget addresses points by offset from the first of its run, so // a run has to be contiguous in the capability list. let at = op.params.iter().position(|p| p.id == *id)?; @@ -4480,15 +4480,15 @@ mod tests { presentation: Some(Presentation { // Prefers a gradient handle; this frontend has none, so it // falls back to the next entry, which the canvas does host. - widgets: &[WidgetKind::GradientHandle, WidgetKind::CropOverlay], + widgets: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay], demand: WidgetDemand { two_dimensional: true, precise_pointing: false, }, - params: &[ParamId("a"), ParamId("b")], + params: vec![ParamId("a"), ParamId("b")], }), params: vec![param("a"), param("b")], - attributes: &[dr_pipeline::Attribute::Tone], + attributes: vec![dr_pipeline::Attribute::Tone], }; assert!(rows_from(&[on_canvas]).is_empty()); @@ -4612,7 +4612,7 @@ mod tests { id: ParamId("method"), label: LocalizedKey("param.invented.method"), kind: ParamKind::Enum { - variants: &[ + variants: vec![ LocalizedKey("param.invented.method.fast"), LocalizedKey("param.invented.method.exact"), ], @@ -4622,7 +4622,7 @@ mod tests { facet: None, }, ], - attributes: &[dr_pipeline::Attribute::Tone], + attributes: vec![dr_pipeline::Attribute::Tone], }; let rows = rows_from(&[invented]); @@ -4660,12 +4660,12 @@ mod tests { label: LocalizedKey("op.grading"), active: false, presentation: Some(Presentation { - widgets: &[WidgetKind::ColourWheel], + widgets: vec![WidgetKind::ColourWheel], demand: WidgetDemand { two_dimensional: true, precise_pointing: false, }, - params: &[ParamId("hue"), ParamId("strength")], + params: vec![ParamId("hue"), ParamId("strength")], }), params: vec![ ParamCapability { @@ -4697,7 +4697,7 @@ mod tests { facet: None, }, ], - attributes: &[dr_pipeline::Attribute::Tone], + attributes: vec![dr_pipeline::Attribute::Tone], }; assert!(!supported(WidgetKind::ColourWheel), "precondition"); @@ -5146,15 +5146,15 @@ mod tests { label: LocalizedKey("op.invented_curve"), active: false, presentation: Some(Presentation { - widgets: &[WidgetKind::ToneCurve], + widgets: vec![WidgetKind::ToneCurve], demand: WidgetDemand { two_dimensional: true, precise_pointing: true, }, - params: &IDS, + params: IDS.to_vec(), }), params: IDS.iter().map(|id| param(*id)).collect(), - attributes: &[dr_pipeline::Attribute::Tone], + attributes: vec![dr_pipeline::Attribute::Tone], }; let presentation = plain.presentation.as_ref().expect("declares a widget"); From 0cd3ef1b3f6469d2fba1ac59e2996475e7e05d2b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 27 Aug 2026 19:08:34 +0200 Subject: [PATCH 2/2] Run a node declaration without compiling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the declarative nodes landed — it was simply resolved at build time. Nothing about a declaration requires the compiler: everything it produces is data plus a WGSL string, and the composer already assembles WGSL at run time from whatever operations are active. So this is not a new mechanism. It is the existing one, loaded later (FR-PLG-2). `DeclaredOp` implements `Operation` from an owned `Declaration` — one interpreter over many declarations, where `build.rs` emits generated code per node. The generated path stays, as FR-PLG-2 says it should: a generated `match` is faster than an interpreted one, the built-ins' declared `tests:` have to run under `cargo test`, and generated source is inspectable in a way an interpreter's state is not. **The reader is now one file, read by both.** `src/declared/decl.rs` and `src/declared/expr.rs` are `#[path]`-included by `build.rs` as well as being modules of the crate, and they produce a neutral `Declaration` that names no Rust type. The build script's job is reduced to *rendering* that declaration as Rust; `DeclaredOp` converts the same declaration into descriptors and `Expr::eval` walks the same tree the renderer writes out. There is one grammar, one set of validations and one set of error messages, so "a plugin is the same kind of thing as a built-in" is structural rather than aspirational. What remains genuinely written twice is the pair of backends — an arithmetic node rendered as Rust here and evaluated there — and that is what the parity test stands between. `tests/declared_parity.rs` parses every built-in declaration at run time and asserts the composed WGSL is byte-for-byte what the generated implementation produces, with the uniform block bit-for-bit identical, at both ends of every parameter's range and at four interior points; then again over the whole develop chain with the declared nodes swapped in, which is what covers uniform slot ordering and helper de-duplication between operations. A third test asserts the declared and hand-written nodes partition `ops/` between them, so coverage cannot shrink silently. Bit-for-bit rather than within a tolerance, because a tolerance is where a real divergence hides. The one thing that had to be got right for that to hold is number literals: `expr::as_f32` rounds a decimal exactly once, through the same shortest-round-trip text the compiler is handed, rather than rounding an `f64` a second time. Not in scope, and deliberately untagged: load-time WGSL validation (FR-PLG-11), id namespacing, a plugin directory read at startup, and pass nodes (FR-PLG-2a). Those are separate work, and tagging them from here would be the overstatement the spec's own §7 warns about. Co-Authored-By: Claude Opus 5 --- core/dr-pipeline/Cargo.toml | 6 + core/dr-pipeline/build.rs | 1472 +++------------------ core/dr-pipeline/ops/README.md | 16 +- core/dr-pipeline/src/declared/decl.rs | 1175 ++++++++++++++++ core/dr-pipeline/src/declared/expr.rs | 447 +++++++ core/dr-pipeline/src/declared/mod.rs | 487 +++++++ core/dr-pipeline/src/lib.rs | 2 + core/dr-pipeline/tests/declared_parity.rs | 435 ++++++ docs/traceability.md | 12 +- 9 files changed, 2766 insertions(+), 1286 deletions(-) create mode 100644 core/dr-pipeline/src/declared/decl.rs create mode 100644 core/dr-pipeline/src/declared/expr.rs create mode 100644 core/dr-pipeline/src/declared/mod.rs create mode 100644 core/dr-pipeline/tests/declared_parity.rs diff --git a/core/dr-pipeline/Cargo.toml b/core/dr-pipeline/Cargo.toml index 3ac1714..a411e98 100644 --- a/core/dr-pipeline/Cargo.toml +++ b/core/dr-pipeline/Cargo.toml @@ -12,6 +12,12 @@ license.workspace = true dr-types.workspace = true log.workspace = true +# A dependency of the library, not only of the build script, since FR-PLG-2: +# `src/declared/` reads the same declaration format at *load* time, so that an +# operation found in a file at startup is the same kind of thing as one found +# at compile time. The reader itself is one file shared by both. +serde_norway.workspace = true + # Nodes are declared in `ops/*.yaml` and compiled to Rust by `build.rs` # (ARCH §5.7). The same reasoning as `ui/dr-ui`'s style.yaml: the declaration # is the source of truth, the Rust is generated into OUT_DIR where it cannot diff --git a/core/dr-pipeline/build.rs b/core/dr-pipeline/build.rs index 5ca58ab..263ea46 100644 --- a/core/dr-pipeline/build.rs +++ b/core/dr-pipeline/build.rs @@ -23,6 +23,21 @@ //! declared node from a hand-written one, which is what allows the two to sit //! side by side in one chain. //! +//! # And it is no longer the only reader +//! +//! The parsing half of this script lives in `src/declared/`, included here by +//! `#[path]` and compiled into the crate as well (FR-PLG-2). This script's own +//! job is now purely *rendering*: it takes the [`decl::Declaration`] the shared +//! reader produced and writes Rust from it, while at run time +//! [`crate::declared::DeclaredOp`](../src/declared/mod.rs) takes the same +//! declaration and interprets it. +//! +//! That is what makes "a plugin is the same kind of thing as a built-in" +//! checkable rather than merely intended: there is one grammar, one set of +//! error messages, and one place where a node's meaning is decided. +//! `tests/declared_parity.rs` then asserts the two backends agree byte for +//! byte on every node in `ops/`. +//! //! A node that needs more than the four facts stays in Rust and declares //! itself with `rust:` instead (see `ops/tone_curve.yaml`). The escape hatch //! is deliberate: a schema stretched to cover the tone curve's interpolator @@ -40,18 +55,44 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; use std::path::{Path, PathBuf}; -use serde_norway::{Mapping, Value}; +// The shared reader, compiled into this build script as well as into the +// crate. See its own module documentation, and `#[path]` rather than a copy +// because a copy is precisely what FR-PLG-2 forbids: a plugin and a built-in +// have to be read by the same code or they are not the same kind of thing. +// +// Nothing in either file may name `crate::` items — they are compiled here as +// modules of a build script, where those paths do not exist. +// +// `dead_code` because the run-time half of the shared reader — `Expr::eval`, +// the descriptor conversions' inputs — has no caller here. Silencing it at the +// module rather than at each item keeps the shared files free of attributes +// that only mean something in one of their two compilations. +#[allow(dead_code)] +#[path = "src/declared/decl.rs"] +mod decl; +#[allow(dead_code)] +#[path = "src/declared/expr.rs"] +mod expr; + +use decl::{Declaration, Node, ParamDef, SharedHelpers, TestDef}; +use expr::Expr; /// The directory holding node declarations, relative to the manifest. const OPS_DIR: &str = "ops"; /// Declarations whose name begins with this are not nodes. const NON_NODE_PREFIX: &str = "_"; -const HELPERS_FILE: &str = "_helpers.yaml"; +const HELPERS_FILE: &str = decl::HELPERS_FILE; const GENERATED: &str = "nodes.rs"; fn main() { println!("cargo:rerun-if-changed={OPS_DIR}"); println!("cargo:rerun-if-changed=build.rs"); + // The reader is an input to this build script now, so a change to it has + // to regenerate `nodes.rs` — otherwise a fix to the grammar would take + // effect at load time and not at build time, which is the one divergence + // this whole arrangement exists to prevent. + println!("cargo:rerun-if-changed=src/declared/decl.rs"); + println!("cargo:rerun-if-changed=src/declared/expr.rs"); let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); @@ -78,179 +119,21 @@ fn fail(message: String) -> ! { std::process::exit(1); } -// --------------------------------------------------------------------------- -// The declaration model -// --------------------------------------------------------------------------- - -/// A WGSL helper function, either shared or declared by one node. -struct HelperDef { - name: String, - doc: Option, - wgsl: String, -} - -/// One parameter of a declared node. -struct ParamDef { - id: String, - doc: Option, - /// The `ParamDescriptor` constructor call, already rendered. - ctor: String, - /// Needed to generate `is_active` and to range-check test values. - default: f64, - min: f64, - max: f64, -} - -/// A uniform the node's fragment reads, and the expression computing it. -struct UniformDef { - name: String, - doc: Option, - /// The Rust expression, compiled from the declared one. - rust: String, -} - -/// A node's `presentation:` block, ready to render as a `Presentation`. -struct PresentationDef { - /// Rendered `WidgetKind` paths, most preferred first. - widgets: Vec, - /// The owned parameters' generated `ParamId` constants, in widget order. - params: Vec, - two_dimensional: bool, - precise_pointing: bool, -} - -/// One generated `#[test]`. -struct TestDef { - name: String, - why: Option, - set: Vec<(String, f64)>, - expect: Vec<(String, f64)>, - expect_range: Vec<(String, f64, f64)>, - expect_active: Option, - expect_wgsl: Vec, - expect_helper_wgsl: Vec<(String, Vec)>, -} - -/// The attributes an operation declares, validated against the vocabulary. -/// -/// **Required, and non-empty.** An operation with no attribute is invisible to -/// a frontend that filters by them, and a control that silently does not exist -/// is a far worse failure than a build that stops — especially when the cause -/// is one missing line in a YAML file nobody had reason to open. Failing here -/// costs whoever adds an operation ten seconds; failing at runtime costs a -/// photographer a control they cannot find and cannot know is missing. -/// -/// The vocabulary is closed on purpose. A typo would otherwise invent a -/// category containing exactly one operation, which is indistinguishable from -/// a deliberate new one until somebody notices the tab with a single control -/// in it. -fn read_attributes(root: &Mapping) -> Result, String> { - const KNOWN: [&str; 6] = ["tone", "colour", "detail", "optics", "geometry", "effect"]; - - let value = root.get("attributes").ok_or_else(|| { - format!( - "missing `attributes:`; every operation must say what it is about, \ - one or more of {KNOWN:?}. It is what lets the panel group \ - operations without naming any of them (ARCH §4.3a)." - ) - })?; - - let list = value - .as_sequence() - .ok_or("`attributes:` must be a list, even with one entry")?; - - let mut out = Vec::new(); - for entry in list { - let name = as_str(entry, "attributes")?; - if !KNOWN.contains(&name) { - return Err(format!( - "unknown attribute {name:?}; expected one of {KNOWN:?}" - )); - } - if out.contains(&name.to_string()) { - return Err(format!("attribute {name:?} is listed twice")); - } - out.push(name.to_string()); - } - - if out.is_empty() { - return Err("`attributes:` is empty; an operation with no attribute \ - would not appear in a panel that groups by them" - .into()); - } - Ok(out) -} - -/// A node: either declared in full, or a pointer to a hand-written type. -enum Node { - Declared { - id: String, - label: String, - order: i64, - /// What the operation is about (ARCH §4.3a). Never empty — see - /// `read_attributes`. - attributes: Vec, - doc: Option, - placement: Option, - params: Vec, - uniforms: Vec, - /// Names of shared helpers, in declaration order. - shared_helpers: Vec, - /// Helpers this node defines for itself. - local_helpers: Vec, - wgsl: String, - /// `None` means the default rule: active when any parameter has moved. - active: Option, - tests: Vec, - /// `None` — the usual case — means one control per parameter. - /// - /// Boxed so the rare node that declares one does not widen every - /// `Node` value by the size of a presentation it does not have. - presentation: Option>, - }, - Rust { - id: String, - order: i64, - /// The type in `crate::ops` implementing `Operation`. - ty: String, - why_rust: Option, - placement: Option, - }, -} - -impl Node { - fn id(&self) -> &str { - match self { - Node::Declared { id, .. } | Node::Rust { id, .. } => id, - } - } - - fn order(&self) -> i64 { - match self { - Node::Declared { order, .. } | Node::Rust { order, .. } => *order, - } - } - - fn placement(&self) -> Option<&str> { - match self { - Node::Declared { placement, .. } | Node::Rust { placement, .. } => placement.as_deref(), - } - } -} - // --------------------------------------------------------------------------- // Driver // --------------------------------------------------------------------------- fn generate(ops_dir: &Path, src_ops: &Path) -> Result { - let shared = read_helpers(&ops_dir.join(HELPERS_FILE))?; - let shared_names: BTreeSet<&str> = shared.helpers.iter().map(|h| h.name.as_str()).collect(); + let helpers_path = ops_dir.join(HELPERS_FILE); + let shared = decl::read_helpers(&read_file(&helpers_path)?)?; + let shared_names = shared.names(); let mut nodes = Vec::new(); for path in node_files(ops_dir)? { let name = file_stem(&path); - let node = read_node(&path, &shared_names) - .map_err(|e| format!("{}/{}.yaml: {e}", OPS_DIR, name))?; + let ctx = format!("{OPS_DIR}/{name}.yaml"); + let node = decl::read_node(&read_file(&path)?, &ctx, &shared_names) + .map_err(|e| format!("{ctx}: {e}"))?; // The filename and the id must agree. They are two names for one // thing, and a node found by one and referred to by the other is a @@ -300,6 +183,10 @@ fn node_files(dir: &Path) -> Result, String> { Ok(files) } +fn read_file(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display())) +} + fn file_stem(path: &Path) -> String { path.file_stem() .map(|s| s.to_string_lossy().into_owned()) @@ -337,9 +224,10 @@ fn check_unique(nodes: &[Node]) -> Result<(), String> { /// file to delete. fn check_no_shadowing(nodes: &[Node], src_ops: &Path) -> Result<(), String> { for node in nodes { - let Node::Declared { id, .. } = node else { + let Node::Declared(declared) = node else { continue; }; + let id = &declared.id; let shadowed = src_ops.join(format!("{id}.rs")); if shadowed.exists() { return Err(format!( @@ -354,776 +242,59 @@ fn check_no_shadowing(nodes: &[Node], src_ops: &Path) -> Result<(), String> { } // --------------------------------------------------------------------------- -// Reading: shared helpers -// --------------------------------------------------------------------------- - -struct SharedHelpers { - doc: Option, - helpers: Vec, -} - -fn read_helpers(path: &Path) -> Result { - let doc = read_yaml(path)?; - let root = as_mapping(&doc, HELPERS_FILE)?; - - let module_doc = opt_prose(root, "doc", HELPERS_FILE)?; - let helpers = root - .get("helpers") - .ok_or_else(|| format!("{HELPERS_FILE}: missing `helpers:` map"))?; - let helpers = as_mapping(helpers, "helpers")?; - - let mut out = Vec::new(); - for (name, spec) in helpers { - let name = as_str(name, "a helper name")?.to_string(); - let ctx = format!("helpers.{name}"); - let spec = as_mapping(spec, &ctx)?; - let wgsl = spec - .get("wgsl") - .ok_or_else(|| format!("{HELPERS_FILE}: `{ctx}` has no `wgsl:`"))?; - let wgsl = as_str(wgsl, &format!("{ctx}.wgsl"))?.trim_end().to_string(); - - // The composer deduplicates by name, so a helper whose declared name - // is not the function it defines would be emitted under one name and - // called under another. - if !wgsl.contains(&format!("fn {name}(")) { - return Err(format!( - "{HELPERS_FILE}: `{ctx}` does not define `fn {name}(`. The key \ - is the name the composer deduplicates on, so it has to be the \ - function actually declared." - )); - } - out.push(HelperDef { - doc: opt_prose(spec, "doc", &ctx)?, - name, - wgsl, - }); - } - - if out.is_empty() { - return Err(format!("{HELPERS_FILE}: `helpers:` is empty")); - } - Ok(SharedHelpers { - doc: module_doc, - helpers: out, - }) -} - -// --------------------------------------------------------------------------- -// Reading: a node -// --------------------------------------------------------------------------- - -fn read_node(path: &Path, shared: &BTreeSet<&str>) -> Result { - let doc = read_yaml(path)?; - let root = as_mapping(&doc, "the document")?; - - let id = as_str(root.get("id").ok_or("missing `id:`")?, "id")?.to_string(); - check_ident(&id, "id")?; - - let order = root - .get("order") - .ok_or("missing `order:`; it is what places this node in the chain")? - .as_i64() - .ok_or("`order` must be a whole number")?; - - let placement = opt_prose(root, "placement", "placement")?; - - // A `rust:` node describes where a hand-written type sits, and nothing - // else — its descriptor comes from the type. Mixing the two forms would - // mean two sources for one node's parameters. - if let Some(ty) = root.get("rust") { - let ty = as_str(ty, "rust")?.to_string(); - check_type_name(&ty)?; - for key in ["params", "uniforms", "wgsl", "helpers", "define", "label"] { - if root.contains_key(key) { - return Err(format!( - "`{key}` is meaningless on a `rust:` node — {ty} publishes \ - its own descriptor. Remove one or the other." - )); - } - } - return Ok(Node::Rust { - id, - order, - ty, - why_rust: opt_prose(root, "why_rust", "why_rust")?, - placement, - }); - } - - let label = as_str(root.get("label").ok_or("missing `label:`")?, "label")?.to_string(); - let attributes = read_attributes(root)?; - - let params = read_params(root)?; - let param_names: BTreeSet<&str> = params.iter().map(|p| p.id.as_str()).collect(); - - let local_helpers = read_local_helpers(root)?; - let shared_helpers = read_shared_refs(root, shared, &local_helpers)?; - - let uniforms = read_uniforms(root, ¶m_names)?; - - let wgsl = as_str(root.get("wgsl").ok_or("missing `wgsl:`")?, "wgsl")? - .trim_end() - .to_string(); - if wgsl.trim().is_empty() { - return Err("`wgsl:` is empty; a node that changes nothing is not a node".into()); - } - - let active = match root.get("active") { - None => None, - Some(v) => { - let expr = as_str(v, "active")?; - Some(compile_expr(expr, ¶m_names).map_err(|e| format!("`active`: {e}"))?) - } - }; - - let uniform_names: BTreeSet<&str> = uniforms.iter().map(|u| u.name.as_str()).collect(); - let helper_names: BTreeSet<&str> = shared_helpers - .iter() - .map(String::as_str) - .chain(local_helpers.iter().map(|h| h.name.as_str())) - .collect(); - let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?; - let presentation = read_presentation(root, ¶m_names)?; - - Ok(Node::Declared { - id, - label, - order, - doc: opt_prose(root, "doc", "doc")?, - attributes, - placement, - params, - uniforms, - shared_helpers, - local_helpers, - wgsl, - active, - tests, - presentation, - }) -} - -/// The widgets a node would like, in descending order of preference. -/// -/// Optional, and absent on nearly every node — one control per parameter is -/// the right answer for a list of unrelated sliders, which is what most -/// operations are. Declaring this says that several parameters form *one* -/// conceptual control. -/// -/// It is a hint and nothing more (ARCH §4.3a). A frontend implementing none of -/// the named widgets renders the parameters as ordinary sliders and the edit -/// still works, which is why the list can safely name widgets that do not -/// exist yet. -fn read_presentation( - root: &Mapping, - params: &BTreeSet<&str>, -) -> Result>, String> { - let Some(value) = root.get("presentation") else { - return Ok(None); - }; - let m = as_mapping(value, "presentation")?; - - let widgets = m - .get("widgets") - .and_then(Value::as_sequence) - .ok_or("`presentation` needs a `widgets:` list, most preferred first")?; - if widgets.is_empty() { - return Err("`presentation.widgets` is empty; omit `presentation:` instead".into()); - } - let widgets = widgets - .iter() - .enumerate() - .map(|(i, w)| { - let name = as_str(w, &format!("presentation.widgets[{i}]"))?; - // Spelled in the YAML the way the enum spells it, so a node - // declaration and `descriptor.rs` cannot drift into two - // vocabularies for one idea. - match name { - "tone_curve" => Ok("WidgetKind::ToneCurve"), - "colour_wheel" => Ok("WidgetKind::ColourWheel"), - "crop_overlay" => Ok("WidgetKind::CropOverlay"), - "gradient_handle" => Ok("WidgetKind::GradientHandle"), - "brush_mask" => Ok("WidgetKind::BrushMask"), - "white_point" => Ok("WidgetKind::WhitePoint"), - other => Err(format!( - "`presentation.widgets[{i}]` is `{other}`; expected tone_curve, \ - colour_wheel, crop_overlay, gradient_handle, brush_mask or white_point" - )), - } - }) - .collect::, _>>()?; - - // The parameters the widget owns, in the order it expects them. Checked - // against the node's own list, because a typo here would silently leave a - // parameter out of the widget *and* out of the panel — the widget claims - // it, and the generic path skips what the widget claimed. - let owned = m - .get("params") - .and_then(Value::as_sequence) - .ok_or("`presentation` needs a `params:` list naming what the widget owns")?; - let owned = owned - .iter() - .enumerate() - .map(|(i, p)| { - let name = as_str(p, &format!("presentation.params[{i}]"))?; - if !params.contains(name) { - return Err(format!( - "`presentation.params[{i}]` is `{name}`, which this node does not declare" - )); - } - Ok(name.to_uppercase()) - }) - .collect::, _>>()?; - - let demand = match m.get("demand") { - None => (false, false), - Some(d) => { - let d = as_mapping(d, "presentation.demand")?; - let flag = |key: &str| -> Result { - match d.get(key) { - None => Ok(false), - Some(v) => v.as_bool().ok_or_else(|| { - format!("`presentation.demand.{key}` must be true or false") - }), - } - }; - // Deliberately only these two. ARCH §4.3a forbids a demand - // carrying pixels, breakpoints or a platform name — those are the - // frontend's to decide — so there is no key here to write one in. - (flag("two_dimensional")?, flag("precise_pointing")?) - } - }; - - Ok(Some(Box::new(PresentationDef { - widgets: widgets.into_iter().map(str::to_string).collect(), - params: owned, - two_dimensional: demand.0, - precise_pointing: demand.1, - }))) -} - -fn read_params(root: &Mapping) -> Result, String> { - let params = root - .get("params") - .ok_or("missing `params:`; an operation with no parameters has nothing to control")?; - let params = as_mapping(params, "params")?; - - let mut out = Vec::new(); - for (id, spec) in params { - let id = as_str(id, "a parameter name")?.to_string(); - check_ident(&id, &format!("params.{id}"))?; - let ctx = format!("params.{id}"); - let spec = as_mapping(spec, &ctx)?; - - let label = as_str( - spec.get("label") - .ok_or_else(|| format!("`{ctx}` has no `label:`"))?, - &format!("{ctx}.label"), - )? - .to_string(); - - let kind = as_str( - spec.get("kind") - .ok_or_else(|| format!("`{ctx}` has no `kind:`"))?, - &format!("{ctx}.kind"), - )?; - - let (ctor, default, min, max) = param_ctor(&id, &label, kind, spec, &ctx)?; - out.push(ParamDef { - id, - doc: opt_prose(spec, "doc", &ctx)?, - ctor, - default, - min, - max, - }); - } - - if out.is_empty() { - return Err("`params:` is empty".into()); - } - Ok(out) -} - -/// Render the `ParamDescriptor` constructor for a declared parameter kind. -/// -/// The kinds are the constructors `descriptor.rs` already offers, named rather -/// than spelled out: `amount` is the −100…+100 shape nearly every photographic -/// control takes, and writing its range in every node would invite one of them -/// to drift. -fn param_ctor( - id: &str, - label: &str, - kind: &str, - spec: &Mapping, - ctx: &str, -) -> Result<(String, f64, f64, f64), String> { - let need = |key: &str| -> Result { - spec.get(key) - .and_then(Value::as_f64) - .ok_or_else(|| format!("`{ctx}` is `kind: {kind}` and needs a numeric `{key}:`")) - }; - let opt = |key: &str, fallback: f64| -> f64 { - spec.get(key).and_then(Value::as_f64).unwrap_or(fallback) - }; - - Ok(match kind { - "stops" => { - let (min, max) = (need("min")?, need("max")?); - ( - format!( - "ParamDescriptor::stops({:?}, {:?}, {}, {})", - id, - label, - rust_f32(min), - rust_f32(max) - ), - 0.0, - min, - max, - ) - } - "amount" => ( - format!("ParamDescriptor::amount({id:?}, {label:?})"), - 0.0, - -100.0, - 100.0, - ), - "switch" => ( - format!("ParamDescriptor::switch({id:?}, {label:?})"), - 0.0, - 0.0, - 1.0, - ), - "fraction" => { - let default = opt("default", 0.0); - ( - format!( - "ParamDescriptor::fraction({:?}, {:?}, {})", - id, - label, - rust_f32(default) - ), - default, - 0.0, - 1.0, - ) - } - "scalar" => { - let (min, max) = (need("min")?, need("max")?); - let default = opt("default", 0.0); - let unit = match spec.get("unit").and_then(Value::as_str).unwrap_or("none") { - "none" => "Unit::None", - "stops" => "Unit::Stops", - "kelvin" => "Unit::Kelvin", - "percent" => "Unit::Percent", - other => { - return Err(format!( - "`{ctx}.unit` is `{other}`; expected none, stops, kelvin or percent" - )) - } - }; - let scale = match spec - .get("scale") - .and_then(Value::as_str) - .unwrap_or("linear") - { - "linear" => "Scale::Linear", - "perceptual" => "Scale::Perceptual", - other => { - return Err(format!( - "`{ctx}.scale` is `{other}`; expected linear or perceptual" - )) - } - }; - let precision = spec - .get("precision") - .and_then(Value::as_u64) - .ok_or_else(|| format!("`{ctx}` is `kind: scalar` and needs `precision:`"))?; - ( - format!( - "ParamDescriptor::scalar({:?}, {:?}, {}, {}, {}, {}, {}, {})", - id, - label, - rust_f32(min), - rust_f32(max), - rust_f32(default), - unit, - scale, - precision - ), - default, - min, - max, - ) - } - // A fixed list of named alternatives. The value is the chosen index, - // so the range is the list's own bounds and a node declaring one needs - // no `min:`/`max:` of its own. - // - // Variants are localisation keys, like every other label in a node — - // the core never holds a display string (NFR-A11Y-1). The default is - // always the first, so `reset` means the same thing here as everywhere - // else; a node whose neutral choice is not first has listed them in - // the wrong order. - "enum" => { - let variants = spec - .get("variants") - .and_then(Value::as_sequence) - .ok_or_else(|| format!("`{ctx}` is `kind: enum` and needs a `variants:` list"))?; - if variants.len() < 2 { - return Err(format!( - "`{ctx}.variants` lists {} choice(s); a control the user \ - cannot change is not a control", - variants.len() - )); - } - let keys = variants - .iter() - .enumerate() - .map(|(i, v)| { - as_str(v, &format!("{ctx}.variants[{i}]")) - .map(|k| format!("LocalizedKey({k:?})")) - }) - .collect::, _>>()?; - ( - format!( - "ParamDescriptor::choice({:?}, {:?}, vec![{}])", - id, - label, - keys.join(", ") - ), - 0.0, - 0.0, - (variants.len() - 1) as f64, - ) - } - other => { - return Err(format!( - "`{ctx}.kind` is `{other}`; expected stops, amount, switch, \ - fraction, scalar or enum" - )) - } - }) - .and_then(|(ctor, default, min, max): (String, f64, f64, f64)| { - // Caught at build time rather than surfacing as a control that opens - // where it cannot be dragged back to. - if min >= max { - return Err(format!( - "`{ctx}` has an empty range: min {min} >= max {max}" - )); - } - if default < min || default > max { - return Err(format!( - "`{ctx}` has default {default} outside its range {min}..{max}" - )); - } - Ok((ctor, default, min, max)) - }) -} - -fn read_local_helpers(root: &Mapping) -> Result, String> { - let Some(define) = root.get("define") else { - return Ok(Vec::new()); - }; - let define = as_mapping(define, "define")?; - - let mut out = Vec::new(); - for (name, spec) in define { - let name = as_str(name, "a helper name under `define`")?.to_string(); - let ctx = format!("define.{name}"); - // Shorthand: the value may be the WGSL directly, or a mapping with - // prose beside it. Most node-local helpers carry their explanation in - // the WGSL itself, so the shorthand is the common case. - let (doc, wgsl) = match spec { - Value::String(s) => (None, s.trim_end().to_string()), - other => { - let m = as_mapping(other, &ctx)?; - let wgsl = as_str( - m.get("wgsl") - .ok_or_else(|| format!("`{ctx}` has no `wgsl:`"))?, - &format!("{ctx}.wgsl"), - )? - .trim_end() - .to_string(); - (opt_prose(m, "doc", &ctx)?, wgsl) - } - }; - if !wgsl.contains(&format!("fn {name}(")) { - return Err(format!("`{ctx}` does not define `fn {name}(`")); - } - out.push(HelperDef { name, doc, wgsl }); - } - Ok(out) -} - -fn read_shared_refs( - root: &Mapping, - shared: &BTreeSet<&str>, - local: &[HelperDef], -) -> Result, String> { - let Some(list) = root.get("helpers") else { - return Ok(Vec::new()); - }; - let list = list - .as_sequence() - .ok_or("`helpers` must be a list of helper names")?; - - let mut out = Vec::new(); - let mut seen = BTreeSet::new(); - for item in list { - let name = as_str(item, "a helper name")?.to_string(); - if !shared.contains(name.as_str()) { - let known: Vec<&str> = shared.iter().copied().collect(); - return Err(format!( - "`helpers` names `{name}`, which {HELPERS_FILE} does not \ - define. Known helpers: {}", - known.join(", ") - )); - } - if local.iter().any(|h| h.name == name) { - return Err(format!( - "`{name}` is both requested from {HELPERS_FILE} and redefined \ - under `define`. The composer deduplicates by name, so one of \ - the two definitions would silently win." - )); - } - if !seen.insert(name.clone()) { - return Err(format!("`helpers` lists `{name}` twice")); - } - out.push(name); - } - Ok(out) -} - -fn read_uniforms(root: &Mapping, params: &BTreeSet<&str>) -> Result, String> { - let uniforms = root - .get("uniforms") - .ok_or("missing `uniforms:`; the fragment has nothing to read otherwise")?; - let uniforms = as_mapping(uniforms, "uniforms")?; - - let mut out = Vec::new(); - for (name, spec) in uniforms { - let name = as_str(name, "a uniform name")?.to_string(); - check_ident(&name, &format!("uniforms.{name}"))?; - let ctx = format!("uniforms.{name}"); - - // Shorthand: `gain: exp2(exposure)`, or a mapping carrying prose. - let (doc, expr) = match spec { - Value::String(s) => (None, s.clone()), - other => { - let m = as_mapping(other, &ctx)?; - let value = m - .get("value") - .ok_or_else(|| format!("`{ctx}` has neither a bare expression nor `value:`"))?; - ( - opt_prose(m, "doc", &ctx)?, - as_str(value, &format!("{ctx}.value"))?.to_string(), - ) - } - }; - - let rust = compile_expr(&expr, params).map_err(|e| format!("`{ctx}`: {e}"))?; - out.push(UniformDef { name, doc, rust }); - } - - if out.is_empty() { - return Err("`uniforms:` is empty".into()); - } - Ok(out) -} - -fn read_tests( - root: &Mapping, - params: &[ParamDef], - uniforms: &BTreeSet<&str>, - helpers: &BTreeSet<&str>, -) -> Result, String> { - let Some(list) = root.get("tests") else { - return Ok(Vec::new()); - }; - let list = list.as_sequence().ok_or("`tests` must be a list")?; - - let mut out = Vec::new(); - let mut seen = BTreeSet::new(); - for item in list { - let m = as_mapping(item, "a test")?; - let name = as_str( - m.get("name").ok_or("a test has no `name:`")?, - "tests[].name", - )? - .to_string(); - check_ident(&name, &format!("tests.{name}.name"))?; - if !seen.insert(name.clone()) { - return Err(format!("two tests are both called `{name}`")); - } - let ctx = format!("tests.{name}"); - - let mut set = Vec::new(); - if let Some(v) = m.get("set") { - for (k, val) in as_mapping(v, &format!("{ctx}.set"))? { - let key = as_str(k, &format!("{ctx}.set key"))?.to_string(); - let Some(param) = params.iter().find(|p| p.id == key) else { - return Err(format!( - "`{ctx}.set` names `{key}`, which is not a parameter" - )); - }; - let value = val - .as_f64() - .ok_or_else(|| format!("`{ctx}.set.{key}` must be a number"))?; - // Values reach `set_param` already clamped by the graph, so a - // test setting an out-of-range value would be asserting - // against something that cannot happen. - if value < param.min || value > param.max { - return Err(format!( - "`{ctx}.set.{key}` is {value}, outside the parameter's \ - range {}..{}. The graph clamps before an operation \ - sees a value, so this test could never run as written.", - param.min, param.max - )); - } - set.push((key, value)); - } - } - - let mut expect = Vec::new(); - if let Some(v) = m.get("expect") { - for (k, val) in as_mapping(v, &format!("{ctx}.expect"))? { - let key = as_str(k, &format!("{ctx}.expect key"))?.to_string(); - if !uniforms.contains(key.as_str()) { - return Err(format!( - "`{ctx}.expect` names `{key}`, which is not a uniform of this node" - )); - } - let value = val - .as_f64() - .ok_or_else(|| format!("`{ctx}.expect.{key}` must be a number"))?; - expect.push((key, value)); - } - } - - let mut expect_range = Vec::new(); - if let Some(v) = m.get("expect_range") { - for (k, val) in as_mapping(v, &format!("{ctx}.expect_range"))? { - let key = as_str(k, &format!("{ctx}.expect_range key"))?.to_string(); - if !uniforms.contains(key.as_str()) { - return Err(format!( - "`{ctx}.expect_range` names `{key}`, which is not a uniform" - )); - } - let pair = val - .as_sequence() - .filter(|s| s.len() == 2) - .ok_or_else(|| format!("`{ctx}.expect_range.{key}` must be [low, high]"))?; - let lo = pair[0] - .as_f64() - .ok_or_else(|| format!("`{ctx}.expect_range.{key}` low must be a number"))?; - let hi = pair[1] - .as_f64() - .ok_or_else(|| format!("`{ctx}.expect_range.{key}` high must be a number"))?; - expect_range.push((key, lo, hi)); - } - } - - let mut expect_wgsl = Vec::new(); - if let Some(v) = m.get("expect_wgsl") { - for item in v - .as_sequence() - .ok_or_else(|| format!("`{ctx}.expect_wgsl` must be a list of strings"))? - { - expect_wgsl.push(as_str(item, &format!("{ctx}.expect_wgsl[]"))?.to_string()); - } - } - - let mut expect_helper_wgsl = Vec::new(); - if let Some(v) = m.get("expect_helper_wgsl") { - for (k, val) in as_mapping(v, &format!("{ctx}.expect_helper_wgsl"))? { - let key = as_str(k, &format!("{ctx}.expect_helper_wgsl key"))?.to_string(); - if !helpers.contains(key.as_str()) { - return Err(format!( - "`{ctx}.expect_helper_wgsl` names `{key}`, which this node does not use" - )); - } - let mut needles = Vec::new(); - for item in val - .as_sequence() - .ok_or_else(|| format!("`{ctx}.expect_helper_wgsl.{key}` must be a list"))? - { - needles.push( - as_str(item, &format!("{ctx}.expect_helper_wgsl.{key}[]"))?.to_string(), - ); - } - expect_helper_wgsl.push((key, needles)); - } - } - - let expect_active = match m.get("expect_active") { - None => None, - Some(v) => Some( - v.as_bool() - .ok_or_else(|| format!("`{ctx}.expect_active` must be true or false"))?, - ), - }; - - if expect.is_empty() - && expect_range.is_empty() - && expect_wgsl.is_empty() - && expect_helper_wgsl.is_empty() - && expect_active.is_none() - { - return Err(format!("`{ctx}` asserts nothing")); - } - - out.push(TestDef { - name, - why: opt_prose(m, "why", &ctx)?, - set, - expect, - expect_range, - expect_active, - expect_wgsl, - expect_helper_wgsl, - }); - } - Ok(out) -} - -// --------------------------------------------------------------------------- -// The expression language +// The expression language, rendered as Rust // --------------------------------------------------------------------------- // -// Uniforms are derived from parameters — `exp2(exposure)`, `blacks / 100 * -// 0.02` — and that derivation is the one piece of a node that is genuinely -// computation rather than description. It is kept to arithmetic over the -// node's own parameters and a fixed set of maths functions: enough for every -// operation in the chain, and small enough that a reader of the YAML can see -// exactly what will happen. +// The grammar and the validation live in `src/declared/expr.rs`, shared with +// the crate. What is left here is the half that is specific to generating +// code: turning a validated [`Expr`] into a Rust `f32` expression, so that a +// built-in node's arithmetic costs nothing at run time. // -// Compiled to Rust rather than interpreted, so an unknown name or a wrong -// arity is a build error naming the file, and the arithmetic itself costs -// nothing at runtime. +// `Expr::eval` is the other half, and the two must agree bit for bit. Every +// arm below has a counterpart there, written to produce the same operations in +// the same order — including `mix`, which is spelled out in both because +// `a + (b - a) * t` and `a * (1 - t) + b * t` are different numbers in `f32`. -#[derive(Debug)] -enum Expr { - Num(f64), - Param(String), - Neg(Box), - Bin(char, Box, Box), - Call(String, Vec), +/// Compile a validated expression to a Rust `f32` expression. +fn compile_expr(expr: &Expr) -> String { + unwrap_parens(&render(expr)) } -/// Compile a declared expression to a Rust `f32` expression. -fn compile_expr(src: &str, params: &BTreeSet<&str>) -> Result { - let tokens = tokenise(src)?; - let mut parser = Parser { tokens, at: 0 }; - let expr = parser.expr()?; - if parser.at < parser.tokens.len() { - return Err(format!( - "unexpected `{}` after the end of the expression", - parser.tokens[parser.at] - )); +/// Render an expression as Rust source. +/// +/// Infallible: `expr::parse` has already established that every name resolves +/// and every call has the right arity, which is why the validation lives in +/// the shared reader rather than here — an unknown function had to be rejected +/// identically whether the declaration was read by this script or at load +/// time. +fn render(expr: &Expr) -> String { + match expr { + Expr::Num(n) => rust_f32(*n), + Expr::Param(name) => format!("self.{name}"), + // One pair of parentheses, never two: the inner expression brings its + // own, and `-((a * b))` is a clippy warning in code nobody can edit. + // The pair that remains is load-bearing — `-(a + b)` and `-a + b` are + // different numbers. + Expr::Neg(inner) => format!("-({})", unwrap_parens(&render(inner))), + Expr::Bin(op, l, r) => format!("({} {op} {})", render(l), render(r)), + Expr::Call(name, args) => { + let a: Vec = args.iter().map(|x| unwrap_parens(&render(x))).collect(); + match name.as_str() { + "exp2" | "log2" | "exp" | "sqrt" | "abs" | "floor" | "ceil" | "round" => { + format!("f32::{name}({})", a[0]) + } + "pow" => format!("f32::powf({}, {})", a[0], a[1]), + "min" | "max" => format!("f32::{name}({}, {})", a[0], a[1]), + "clamp" => format!("f32::clamp({}, {}, {})", a[0], a[1], a[2]), + // Spelled out rather than called: Rust has no `mix`, and the + // linear form is what WGSL's `mix` means. + "mix" => format!("({x} + ({y} - {x}) * {t})", x = a[0], y = a[1], t = a[2]), + // `expr::FUNCTIONS` is the closed list and `expr::parse` + // enforces it, so reaching here means the two fell out of step. + other => unreachable!("`{other}` passed validation but has no Rust rendering"), + } + } } - render(&expr, params).map(|s| unwrap_parens(&s)) } /// Drop one redundant pair of enclosing parentheses. @@ -1159,240 +330,6 @@ fn unwrap_parens(s: &str) -> String { s.to_string() } } - -#[derive(Debug, Clone, PartialEq)] -enum Tok { - Num(f64), - Ident(String), - Sym(char), -} - -impl std::fmt::Display for Tok { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Tok::Num(n) => write!(f, "{n}"), - Tok::Ident(s) => write!(f, "{s}"), - Tok::Sym(c) => write!(f, "{c}"), - } - } -} - -fn tokenise(src: &str) -> Result, String> { - let bytes: Vec = src.chars().collect(); - let mut out = Vec::new(); - let mut i = 0; - while i < bytes.len() { - let c = bytes[i]; - if c.is_whitespace() { - i += 1; - } else if c.is_ascii_digit() - || (c == '.' && bytes.get(i + 1).is_some_and(char::is_ascii_digit)) - { - let start = i; - while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') { - i += 1; - } - let text: String = bytes[start..i].iter().collect(); - let n = text - .parse::() - .map_err(|_| format!("`{text}` is not a number"))?; - out.push(Tok::Num(n)); - } else if c.is_ascii_alphabetic() || c == '_' { - let start = i; - while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') { - i += 1; - } - out.push(Tok::Ident(bytes[start..i].iter().collect())); - } else if "+-*/(),".contains(c) { - out.push(Tok::Sym(c)); - i += 1; - } else { - return Err(format!( - "`{c}` is not valid in an expression; the language is \ - arithmetic (+ - * /), parentheses, numbers, this node's \ - parameters, and the maths functions" - )); - } - } - if out.is_empty() { - return Err("the expression is empty".into()); - } - Ok(out) -} - -struct Parser { - tokens: Vec, - at: usize, -} - -impl Parser { - fn peek(&self) -> Option<&Tok> { - self.tokens.get(self.at) - } - - fn eat(&mut self, sym: char) -> bool { - if self.peek() == Some(&Tok::Sym(sym)) { - self.at += 1; - return true; - } - false - } - - fn expr(&mut self) -> Result { - let mut left = self.term()?; - loop { - if self.eat('+') { - left = Expr::Bin('+', Box::new(left), Box::new(self.term()?)); - } else if self.eat('-') { - left = Expr::Bin('-', Box::new(left), Box::new(self.term()?)); - } else { - return Ok(left); - } - } - } - - fn term(&mut self) -> Result { - let mut left = self.unary()?; - loop { - if self.eat('*') { - left = Expr::Bin('*', Box::new(left), Box::new(self.unary()?)); - } else if self.eat('/') { - left = Expr::Bin('/', Box::new(left), Box::new(self.unary()?)); - } else { - return Ok(left); - } - } - } - - fn unary(&mut self) -> Result { - if self.eat('-') { - return Ok(Expr::Neg(Box::new(self.unary()?))); - } - self.primary() - } - - fn primary(&mut self) -> Result { - match self.peek().cloned() { - Some(Tok::Num(n)) => { - self.at += 1; - Ok(Expr::Num(n)) - } - Some(Tok::Ident(name)) => { - self.at += 1; - if !self.eat('(') { - return Ok(Expr::Param(name)); - } - let mut args = Vec::new(); - if !self.eat(')') { - loop { - args.push(self.expr()?); - if self.eat(',') { - continue; - } - if self.eat(')') { - break; - } - return Err(format!("expected `,` or `)` in the call to `{name}`")); - } - } - Ok(Expr::Call(name, args)) - } - Some(Tok::Sym('(')) => { - self.at += 1; - let inner = self.expr()?; - if !self.eat(')') { - return Err("unclosed `(`".into()); - } - Ok(inner) - } - Some(t) => Err(format!("unexpected `{t}`")), - None => Err("the expression ends early".into()), - } - } -} - -/// The maths functions a node may call, and the Rust each becomes. -/// -/// A closed list rather than a passthrough to `f32`: a node is a description, -/// and letting it name arbitrary Rust would make the YAML a second, worse -/// place to write code. -fn render(expr: &Expr, params: &BTreeSet<&str>) -> Result { - Ok(match expr { - Expr::Num(n) => rust_f32(*n), - Expr::Param(name) => { - if !params.contains(name.as_str()) { - let known: Vec<&str> = params.iter().copied().collect(); - return Err(format!( - "`{name}` is not a parameter of this node. Its parameters \ - are: {}", - known.join(", ") - )); - } - format!("self.{name}") - } - // One pair of parentheses, never two: the inner expression brings its - // own, and `-((a * b))` is a clippy warning in code nobody can edit. - // The pair that remains is load-bearing — `-(a + b)` and `-a + b` are - // different numbers. - Expr::Neg(inner) => format!("-({})", unwrap_parens(&render(inner, params)?)), - Expr::Bin(op, l, r) => format!("({} {op} {})", render(l, params)?, render(r, params)?), - Expr::Call(name, args) => { - let rendered: Vec = args - .iter() - .map(|a| render(a, params).map(|s| unwrap_parens(&s))) - .collect::>()?; - let arity = |n: usize| -> Result<(), String> { - if rendered.len() != n { - return Err(format!( - "`{name}` takes {n} argument(s), given {}", - rendered.len() - )); - } - Ok(()) - }; - match name.as_str() { - "exp2" | "log2" | "exp" | "sqrt" | "abs" | "floor" | "ceil" | "round" => { - arity(1)?; - format!("f32::{name}({})", rendered[0]) - } - "pow" => { - arity(2)?; - format!("f32::powf({}, {})", rendered[0], rendered[1]) - } - "min" | "max" => { - arity(2)?; - format!("f32::{name}({}, {})", rendered[0], rendered[1]) - } - "clamp" => { - arity(3)?; - format!( - "f32::clamp({}, {}, {})", - rendered[0], rendered[1], rendered[2] - ) - } - "mix" => { - arity(3)?; - // Spelled out rather than called: Rust has no `mix`, and - // the linear form is what WGSL's `mix` means. - format!( - "({a} + ({b} - {a}) * {t})", - a = rendered[0], - b = rendered[1], - t = rendered[2] - ) - } - other => { - return Err(format!( - "`{other}` is not one of the maths functions a node may \ - call. Available: exp2, log2, exp, sqrt, abs, floor, \ - ceil, round, pow, min, max, clamp, mix" - )) - } - } - } - }) -} - // --------------------------------------------------------------------------- // Emission // --------------------------------------------------------------------------- @@ -1409,8 +346,8 @@ fn emit(shared: &SharedHelpers, nodes: &[Node]) -> Result { emit_helpers(&mut out, shared); for node in nodes { - if let Node::Declared { .. } = node { - emit_node(&mut out, node)?; + if let Node::Declared(declared) = node { + emit_node(&mut out, declared); } } emit_chain(&mut out, nodes); @@ -1436,7 +373,7 @@ fn emit_helpers(out: &mut String, shared: &SharedHelpers) { " pub const {}: Helper = Helper {{\n name: {:?},\n source: {},\n }};", h.name.to_uppercase(), h.name, - wgsl_literal(&h.wgsl, h.doc.as_deref()) + raw_string(&h.source()) ); } @@ -1455,8 +392,8 @@ fn emit_helpers(out: &mut String, shared: &SharedHelpers) { ); } -fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { - let Node::Declared { +fn emit_node(out: &mut String, node: &Declaration) { + let Declaration { id, label, attributes, @@ -1465,15 +402,11 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { uniforms, shared_helpers, local_helpers, - wgsl, active, tests, presentation, .. - } = node - else { - return Ok(()); - }; + } = node; let ty = pascal_case(id); @@ -1529,7 +462,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { if let Some(doc) = &p.doc { out.push_str(&comment(doc, "//", 16)); } - let _ = writeln!(out, " {},", p.ctor); + let _ = writeln!(out, " {},", param_ctor(p)); } out.push_str(" ],\n"); @@ -1538,7 +471,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { let attrs: Vec = attributes .iter() .map(|a| { - let mut c = a.chars(); + let mut c = a.name().chars(); let head = c .next() .expect("attribute names are non-empty") @@ -1556,7 +489,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { "\n const {}: Helper = Helper {{\n name: {:?},\n source: {},\n }};", local_helper_const(&h.name), h.name, - wgsl_literal(&h.wgsl, h.doc.as_deref()) + raw_string(&h.source()) ); } @@ -1624,7 +557,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { // default rule is the honest one: the operation is doing something exactly // when a parameter has moved off its default. let active_expr = match active { - Some(expr) => format!("({expr}) != 0.0"), + Some(expr) => format!("({}) != 0.0", compile_expr(expr)), None => params .iter() .map(|p| format!("self.{} != {}", p.id, rust_f32(p.default))) @@ -1639,7 +572,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { let _ = writeln!( out, " fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n", - wgsl_literal(wgsl, None) + raw_string(&node.wgsl_body()) ); out.push_str(" fn uniforms(&self) -> Vec {\n vec![\n"); @@ -1650,7 +583,8 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { let _ = writeln!( out, " Uniform {{ name: {:?}, value: {} }},", - u.name, u.rust + u.name, + compile_expr(&u.expr) ); } out.push_str(" ]\n }\n"); @@ -1676,10 +610,19 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { \x20 params: vec![{}],\n\ \x20 }})\n\ \x20 }}", - p.widgets.join(", "), + p.widgets + .iter() + .map(|w| w.rust()) + .collect::>() + .join(", "), p.two_dimensional, p.precise_pointing, - p.params.join(", ") + // The generated `ParamId` constants, which are the ids uppercased. + p.params + .iter() + .map(|name| name.to_uppercase()) + .collect::>() + .join(", ") ); } @@ -1687,7 +630,6 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { emit_tests(out, &ty, params, tests); out.push_str("}\n\n"); - Ok(()) } fn emit_tests(out: &mut String, ty: &str, params: &[ParamDef], tests: &[TestDef]) { @@ -1831,7 +773,8 @@ fn emit_chain(out: &mut String, nodes: &[Node]) { out.push_str(&comment(placement, "//", 8)); } match node { - Node::Declared { id, .. } => { + Node::Declared(declared) => { + let id = &declared.id; let _ = writeln!(out, " Box::new({id}::{}::new()),", pascal_case(id)); } Node::Rust { ty, why_rust, .. } => { @@ -1862,6 +805,66 @@ fn emit_chain(out: &mut String, nodes: &[Node]) { // Rendering helpers // --------------------------------------------------------------------------- +/// The `ParamDescriptor` constructor call for a declared parameter. +/// +/// The kinds are the constructors `descriptor.rs` already offers, named rather +/// than spelled out: `amount` is the −100…+100 shape nearly every photographic +/// control takes, and writing its range in every node would invite one of them +/// to drift. +/// +/// The load-time reader calls the *same* constructors from the same +/// [`decl::Kind`] — see `declared/mod.rs`'s `param_descriptor` — so the two +/// produce equal descriptors by construction rather than by coincidence. +fn param_ctor(p: &ParamDef) -> String { + let (id, label) = (&p.id, &p.label); + match &p.kind { + decl::Kind::Stops { min, max } => format!( + "ParamDescriptor::stops({id:?}, {label:?}, {}, {})", + rust_f32(*min), + rust_f32(*max) + ), + decl::Kind::Amount => format!("ParamDescriptor::amount({id:?}, {label:?})"), + decl::Kind::Switch => format!("ParamDescriptor::switch({id:?}, {label:?})"), + decl::Kind::Fraction { default } => format!( + "ParamDescriptor::fraction({id:?}, {label:?}, {})", + rust_f32(*default) + ), + decl::Kind::Scalar { + min, + max, + default, + unit, + scale, + precision, + } => format!( + "ParamDescriptor::scalar({id:?}, {label:?}, {}, {}, {}, {}, {}, {precision})", + rust_f32(*min), + rust_f32(*max), + rust_f32(*default), + match unit { + decl::Unit::None => "Unit::None", + decl::Unit::Stops => "Unit::Stops", + decl::Unit::Kelvin => "Unit::Kelvin", + decl::Unit::Percent => "Unit::Percent", + }, + match scale { + decl::Scale::Linear => "Scale::Linear", + decl::Scale::Perceptual => "Scale::Perceptual", + } + ), + decl::Kind::Enum { variants } => { + let keys: Vec = variants + .iter() + .map(|v| format!("LocalizedKey({v:?})")) + .collect(); + format!( + "ParamDescriptor::choice({id:?}, {label:?}, vec![{}])", + keys.join(", ") + ) + } + } +} + /// A WGSL block as a Rust raw string literal. /// /// Raw so the WGSL reads as itself in the generated file — escaped quotes and @@ -1872,22 +875,12 @@ fn emit_chain(out: &mut String, nodes: &[Node]) { /// a fragment as it places it into the generated shader, so a literal indented /// here to look tidy in `nodes.rs` would arrive in the WGSL indented twice. /// The hand-written operations had the same shape for the same reason. -fn wgsl_literal(wgsl: &str, doc: Option<&str>) -> String { - let mut body = String::new(); - // Prose from the declaration becomes a WGSL comment above the function, - // which is where it is useful — the generated shader is what gets read - // when a compile fails. - if let Some(doc) = doc { - for line in doc.trim_end().lines() { - if line.trim().is_empty() { - body.push_str("//\n"); - } else { - let _ = writeln!(body, "// {line}"); - } - } - } - body.push_str(wgsl.trim_matches('\n').trim_end()); - +/// +/// The *content* it wraps comes from the shared reader — `Declaration:: +/// wgsl_body` and `HelperDef::source` — because that content is what reaches +/// the composed shader, and a run-time node has to produce the same +/// characters. All this adds is the quoting. +fn raw_string(body: &str) -> String { let mut hashes = String::new(); while body.contains(&format!("\"{hashes}")) { hashes.push('#'); @@ -1939,82 +932,3 @@ fn comment(text: &str, marker: &str, indent: usize) -> String { } out } - -// --------------------------------------------------------------------------- -// YAML access -// --------------------------------------------------------------------------- - -fn read_yaml(path: &Path) -> Result { - let text = std::fs::read_to_string(path) - .map_err(|e| format!("cannot read {}: {e}", path.display()))?; - serde_norway::from_str(&text).map_err(|e| format!("{}: not valid YAML: {e}", path.display())) -} - -fn as_mapping<'a>(value: &'a Value, ctx: &str) -> Result<&'a Mapping, String> { - value - .as_mapping() - .ok_or_else(|| format!("`{ctx}` must be a mapping")) -} - -fn as_str<'a>(value: &'a Value, ctx: &str) -> Result<&'a str, String> { - value - .as_str() - .ok_or_else(|| format!("`{ctx}` must be a string")) -} - -fn opt_prose(map: &Mapping, key: &str, ctx: &str) -> Result, String> { - match map.get(key) { - None => Ok(None), - Some(v) => v - .as_str() - .map(|s| Some(s.trim_end().to_string())) - .ok_or_else(|| format!("`{ctx}.{key}` must be a string")), - } -} - -/// Names reaching generated Rust have to be identifiers, and must not be -/// keywords — `ParamId("type")` would generate a struct field called `type`. -fn check_ident(name: &str, ctx: &str) -> Result<(), String> { - if name.is_empty() { - return Err(format!("`{ctx}` is empty")); - } - let head_ok = name - .chars() - .next() - .is_some_and(|c| c.is_ascii_lowercase() || c == '_'); - let rest_ok = name - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'); - if !head_ok || !rest_ok { - return Err(format!( - "`{ctx}` is `{name}`; names must be lower_snake_case so they can \ - become Rust identifiers" - )); - } - const KEYWORDS: &[&str] = &[ - "as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", - "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", - "ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", - "use", "where", "while", "async", "await", "box", "final", "macro", "override", "priv", - "try", "typeof", "unsized", "virtual", "yield", - ]; - if KEYWORDS.contains(&name) { - return Err(format!("`{ctx}` is `{name}`, which is a Rust keyword")); - } - Ok(()) -} - -/// `rust:` names a type in `crate::ops`, so it is PascalCase rather than an -/// id. Checked only for shape — whether the type exists, and whether it -/// implements `Operation`, is for the compiler to say. -fn check_type_name(name: &str) -> Result<(), String> { - let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) - && name.chars().all(|c| c.is_ascii_alphanumeric()); - if !ok { - return Err(format!( - "`rust: {name}` must be the PascalCase name of a type in \ - `crate::ops`, such as `ToneCurve`" - )); - } - Ok(()) -} diff --git a/core/dr-pipeline/ops/README.md b/core/dr-pipeline/ops/README.md index b2e7ad7..ba1d5d2 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -6,9 +6,23 @@ 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 +`Arc`, the same fused-shader composition, the same sidecar round-trip. +**The same declaration also runs without being compiled** (FR-PLG-2). +[`DeclaredOp`](../src/declared/mod.rs) reads this format at *load* time and +implements `Operation` from it directly — one interpreter over many +declarations, where `build.rs` emits generated code per node. Both paths exist +on purpose: the built-ins stay compiled, because a generated `match` is faster +than an interpreted one and because the `tests:` blocks below have to run under +`cargo test`. + +The reading half is one file, [`src/declared/decl.rs`](../src/declared/decl.rs), +shared by both — so everything documented here means exactly one thing, and +`tests/declared_parity.rs` asserts the two backends compose byte-identical WGSL +for every node in this directory. Nothing in this document is specific to the +build-time path. + ## `attributes:` — what the operation is about Required, one or more of `tone`, `colour`, `detail`, `optics`, `geometry`, diff --git a/core/dr-pipeline/src/declared/decl.rs b/core/dr-pipeline/src/declared/decl.rs new file mode 100644 index 0000000..adb1039 --- /dev/null +++ b/core/dr-pipeline/src/declared/decl.rs @@ -0,0 +1,1175 @@ +//! TRACES: FR-PLG-2 | FR-PLG-2d +//! Reading a node declaration — the one reader, used at build time and at load +//! time. +//! +//! # Why this is not in `build.rs` +//! +//! It was, and everything below is that code. FR-PLG-2 requires the schema +//! documented in `ops/README.md` to be "readable at **load time** as well as +//! build time … with no change to what a declaration means", and the sharpest +//! way to guarantee "no change" is for there to be nothing that could change: +//! one grammar, one set of validations, one set of error messages. +//! +//! So this module produces a **neutral** [`Declaration`] — owned data that +//! names no Rust type and no crate item — and the two backends work from it: +//! +//! - `build.rs` `#[path]`-includes this file and renders a `Declaration` as +//! Rust source, so the operations that ship with the application stay +//! compiled and inspectable. +//! - [`super::DeclaredOp`] converts a `Declaration` into descriptors and +//! evaluates it, so an operation found in a file at startup is the same kind +//! of thing as one found at compile time. +//! +//! `tests/declared_parity.rs` then asserts the two agree byte for byte on +//! every node in `ops/`. +//! +//! **Nothing here may refer to the rest of the crate.** `build.rs` compiles it +//! as a standalone module of a different crate, so `crate::` paths would not +//! resolve. The mapping from these neutral types onto `crate::descriptor`'s is +//! in `declared/mod.rs`, which is compiled only as part of the library. + +use std::collections::BTreeSet; + +use serde_norway::{Mapping, Value}; + +use super::expr::{self, Expr}; + +/// The file shared WGSL helpers are declared in. +pub const HELPERS_FILE: &str = "_helpers.yaml"; + +// --------------------------------------------------------------------------- +// The declaration model +// --------------------------------------------------------------------------- + +/// A WGSL helper function, either shared or declared by one node. +pub struct HelperDef { + pub name: String, + pub doc: Option, + pub wgsl: String, +} + +impl HelperDef { + /// The helper's source exactly as it reaches the composed shader. + /// + /// Prose from the declaration becomes a WGSL comment above the function, + /// which is where it is useful — the generated shader is what gets read + /// when a compile fails. + /// + /// Shared between the backends because it is *content*, not rendering: + /// `build.rs` wraps this in a Rust raw-string literal and a declared + /// operation interns it, and the two have to produce the same characters + /// or the composed shader differs. + pub fn source(&self) -> String { + literal_body(&self.wgsl, self.doc.as_deref()) + } +} + +/// TRACES: FR-PLG-2d +/// What a parameter *is*, from the closed list of kinds a declaration may name. +/// +/// Closed on purpose, and the reason `ops/README.md` gives strengthens here: a +/// typo that invented a kind would produce a control nobody asked for, and a +/// control that silently does not exist is worse than a build that stops. +pub enum Kind { + Stops { + min: f64, + max: f64, + }, + Amount, + Switch, + Fraction { + default: f64, + }, + Scalar { + min: f64, + max: f64, + default: f64, + unit: Unit, + scale: Scale, + precision: u64, + }, + Enum { + variants: Vec, + }, +} + +/// The unit a value carries, mirroring `crate::descriptor::Unit`. +#[derive(Clone, Copy)] +pub enum Unit { + None, + Stops, + Kelvin, + Percent, +} + +/// What a slider's travel means, mirroring `crate::descriptor::Scale`. +#[derive(Clone, Copy)] +pub enum Scale { + Linear, + Perceptual, +} + +/// TRACES: FR-PLG-2d +/// What an operation is about, mirroring `crate::descriptor::Attribute`. +/// +/// A closed vocabulary. `declared/mod.rs` asserts that this list and the +/// crate's own agree, so the two spellings of one idea cannot drift. +#[derive(Clone, Copy, PartialEq)] +pub enum Attr { + Tone, + Colour, + Detail, + Optics, + Geometry, + Effect, +} + +impl Attr { + /// The name a declaration spells this with. + pub const fn name(self) -> &'static str { + match self { + Attr::Tone => "tone", + Attr::Colour => "colour", + Attr::Detail => "detail", + Attr::Optics => "optics", + Attr::Geometry => "geometry", + Attr::Effect => "effect", + } + } + + /// Every attribute, in the order `crate::descriptor::Attribute` declares + /// them. + pub const ALL: [Attr; 6] = [ + Attr::Tone, + Attr::Colour, + Attr::Detail, + Attr::Optics, + Attr::Geometry, + Attr::Effect, + ]; +} + +/// TRACES: FR-PLG-2d +/// A widget a node may ask for, mirroring `crate::descriptor::WidgetKind`. +/// +/// Spelled in the YAML the way the enum spells it, so a node declaration and +/// `descriptor.rs` cannot drift into two vocabularies for one idea. +#[derive(Clone, Copy, PartialEq)] +pub enum Widget { + ToneCurve, + ColourWheel, + CropOverlay, + GradientHandle, + BrushMask, + WhitePoint, +} + +impl Widget { + pub const fn name(self) -> &'static str { + match self { + Widget::ToneCurve => "tone_curve", + Widget::ColourWheel => "colour_wheel", + Widget::CropOverlay => "crop_overlay", + Widget::GradientHandle => "gradient_handle", + Widget::BrushMask => "brush_mask", + Widget::WhitePoint => "white_point", + } + } + + /// The `WidgetKind` variant this is, as Rust source. For `build.rs`. + pub const fn rust(self) -> &'static str { + match self { + Widget::ToneCurve => "WidgetKind::ToneCurve", + Widget::ColourWheel => "WidgetKind::ColourWheel", + Widget::CropOverlay => "WidgetKind::CropOverlay", + Widget::GradientHandle => "WidgetKind::GradientHandle", + Widget::BrushMask => "WidgetKind::BrushMask", + Widget::WhitePoint => "WidgetKind::WhitePoint", + } + } + + pub const ALL: [Widget; 6] = [ + Widget::ToneCurve, + Widget::ColourWheel, + Widget::CropOverlay, + Widget::GradientHandle, + Widget::BrushMask, + Widget::WhitePoint, + ]; +} + +/// One parameter of a declared node. +pub struct ParamDef { + pub id: String, + pub label: String, + pub doc: Option, + pub kind: Kind, + /// Needed to generate `is_active` and to range-check test values. + pub default: f64, + pub min: f64, + pub max: f64, +} + +/// A uniform the node's fragment reads, and the expression computing it. +pub struct UniformDef { + pub name: String, + pub doc: Option, + pub expr: Expr, +} + +/// A node's `presentation:` block. +pub struct PresentationDef { + /// Most preferred first. + pub widgets: Vec, + /// The owned parameters' ids, in widget order. + pub params: Vec, + pub two_dimensional: bool, + pub precise_pointing: bool, +} + +/// One declared test. +/// +/// Read and validated here so that a nonsense assertion is rejected the same +/// way whoever wrote it, though only `build.rs` emits anything from it: a +/// declared node's tests run under `cargo test` against the *generated* +/// implementation, which is where FR-PLG-2 says they belong. +pub struct TestDef { + pub name: String, + pub why: Option, + pub set: Vec<(String, f64)>, + pub expect: Vec<(String, f64)>, + pub expect_range: Vec<(String, f64, f64)>, + pub expect_active: Option, + pub expect_wgsl: Vec, + pub expect_helper_wgsl: Vec<(String, Vec)>, +} + +/// A node declared in full. +pub struct Declaration { + pub id: String, + pub label: String, + pub order: i64, + /// What the operation is about (ARCH §4.3a). Never empty — see + /// [`read_attributes`]. + pub attributes: Vec, + pub doc: Option, + pub placement: Option, + pub params: Vec, + pub uniforms: Vec, + /// Names of shared helpers, in declaration order. + pub shared_helpers: Vec, + /// Helpers this node defines for itself. + pub local_helpers: Vec, + pub wgsl: String, + /// `None` means the default rule: active when any parameter has moved. + pub active: Option, + pub tests: Vec, + /// `None` — the usual case — means one control per parameter. + /// + /// Boxed so the rare node that declares one does not widen every + /// declaration by the size of a presentation it does not have. + pub presentation: Option>, +} + +impl Declaration { + /// The fragment body exactly as it reaches [`crate::Operation::wgsl_body`]. + pub fn wgsl_body(&self) -> String { + literal_body(&self.wgsl, None) + } +} + +/// A node: either declared in full, or a pointer to a hand-written type. +/// +/// The declared arm is boxed because it is an order of magnitude larger than +/// the other: a `Declaration` carries every parameter, uniform, helper and +/// test a node has, while a `rust:` node is four strings. Both readers move +/// these by value out of the parser, and an unboxed enum would copy the larger +/// shape every time it moved either. +pub enum Node { + Declared(Box), + Rust { + id: String, + order: i64, + /// The type in `crate::ops` implementing `Operation`. + ty: String, + why_rust: Option, + placement: Option, + }, +} + +impl Node { + pub fn id(&self) -> &str { + match self { + Node::Declared(d) => &d.id, + Node::Rust { id, .. } => id, + } + } + + pub fn order(&self) -> i64 { + match self { + Node::Declared(d) => d.order, + Node::Rust { order, .. } => *order, + } + } + + pub fn placement(&self) -> Option<&str> { + match self { + Node::Declared(d) => d.placement.as_deref(), + Node::Rust { placement, .. } => placement.as_deref(), + } + } +} + +/// The WGSL a declaration contributes, with its prose folded in as comments. +/// +/// The single definition of "what text does this declaration produce", so that +/// the generated raw-string literal and the interpreted `String` cannot come +/// to hold different characters. +/// +/// The trailing `trim` pair is load-bearing rather than tidiness: it is what +/// `build.rs` used to do inline when emitting the literal, and the composed +/// shader would differ by a newline without it. +fn literal_body(wgsl: &str, doc: Option<&str>) -> String { + let mut body = String::new(); + if let Some(doc) = doc { + for line in doc.trim_end().lines() { + if line.trim().is_empty() { + body.push_str("//\n"); + } else { + body.push_str("// "); + body.push_str(line); + body.push('\n'); + } + } + } + body.push_str(wgsl.trim_matches('\n').trim_end()); + body +} + +// --------------------------------------------------------------------------- +// Reading: shared helpers +// --------------------------------------------------------------------------- + +pub struct SharedHelpers { + pub doc: Option, + pub helpers: Vec, +} + +impl SharedHelpers { + /// The names, for validating a node's `helpers:` list against. + pub fn names(&self) -> BTreeSet<&str> { + self.helpers.iter().map(|h| h.name.as_str()).collect() + } + + pub fn get(&self, name: &str) -> Option<&HelperDef> { + self.helpers.iter().find(|h| h.name == name) + } +} + +/// Parse `_helpers.yaml`. +pub fn read_helpers(text: &str) -> Result { + let doc = parse_yaml(text, HELPERS_FILE)?; + let root = as_mapping(&doc, HELPERS_FILE)?; + + let module_doc = opt_prose(root, "doc", HELPERS_FILE)?; + let helpers = root + .get("helpers") + .ok_or_else(|| format!("{HELPERS_FILE}: missing `helpers:` map"))?; + let helpers = as_mapping(helpers, "helpers")?; + + let mut out = Vec::new(); + for (name, spec) in helpers { + let name = as_str(name, "a helper name")?.to_string(); + let ctx = format!("helpers.{name}"); + let spec = as_mapping(spec, &ctx)?; + let wgsl = spec + .get("wgsl") + .ok_or_else(|| format!("{HELPERS_FILE}: `{ctx}` has no `wgsl:`"))?; + let wgsl = as_str(wgsl, &format!("{ctx}.wgsl"))?.trim_end().to_string(); + + // The composer deduplicates by name, so a helper whose declared name + // is not the function it defines would be emitted under one name and + // called under another. + if !wgsl.contains(&format!("fn {name}(")) { + return Err(format!( + "{HELPERS_FILE}: `{ctx}` does not define `fn {name}(`. The key \ + is the name the composer deduplicates on, so it has to be the \ + function actually declared." + )); + } + out.push(HelperDef { + doc: opt_prose(spec, "doc", &ctx)?, + name, + wgsl, + }); + } + + if out.is_empty() { + return Err(format!("{HELPERS_FILE}: `helpers:` is empty")); + } + Ok(SharedHelpers { + doc: module_doc, + helpers: out, + }) +} + +// --------------------------------------------------------------------------- +// Reading: a node +// --------------------------------------------------------------------------- + +/// TRACES: FR-PLG-2d +/// The attributes an operation declares, validated against the vocabulary. +/// +/// **Required, and non-empty.** An operation with no attribute is invisible to +/// a frontend that filters by them, and a control that silently does not exist +/// is a far worse failure than a build that stops — especially when the cause +/// is one missing line in a YAML file nobody had reason to open. Failing here +/// costs whoever adds an operation ten seconds; failing at runtime costs a +/// photographer a control they cannot find and cannot know is missing. +/// +/// The vocabulary is closed on purpose. A typo would otherwise invent a +/// category containing exactly one operation, which is indistinguishable from +/// a deliberate new one until somebody notices the tab with a single control +/// in it. +fn read_attributes(root: &Mapping) -> Result, String> { + let known: Vec<&str> = Attr::ALL.iter().map(|a| a.name()).collect(); + + let value = root.get("attributes").ok_or_else(|| { + format!( + "missing `attributes:`; every operation must say what it is about, \ + one or more of {known:?}. It is what lets the panel group \ + operations without naming any of them (ARCH §4.3a)." + ) + })?; + + let list = value + .as_sequence() + .ok_or("`attributes:` must be a list, even with one entry")?; + + let mut out: Vec = Vec::new(); + for entry in list { + let name = as_str(entry, "attributes")?; + let Some(attr) = Attr::ALL.iter().copied().find(|a| a.name() == name) else { + return Err(format!( + "unknown attribute {name:?}; expected one of {known:?}" + )); + }; + if out.contains(&attr) { + return Err(format!("attribute {name:?} is listed twice")); + } + out.push(attr); + } + + if out.is_empty() { + return Err("`attributes:` is empty; an operation with no attribute \ + would not appear in a panel that groups by them" + .into()); + } + Ok(out) +} + +/// Parse one `ops/.yaml`. +/// +/// `shared` is the set of helper names `_helpers.yaml` defines, so that a node +/// naming one that does not exist is rejected where it is written rather than +/// producing a shader that fails to compile. +pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result { + let doc = parse_yaml(text, ctx)?; + let root = as_mapping(&doc, "the document")?; + + let id = as_str(root.get("id").ok_or("missing `id:`")?, "id")?.to_string(); + check_ident(&id, "id")?; + + let order = root + .get("order") + .ok_or("missing `order:`; it is what places this node in the chain")? + .as_i64() + .ok_or("`order` must be a whole number")?; + + let placement = opt_prose(root, "placement", "placement")?; + + // A `rust:` node describes where a hand-written type sits, and nothing + // else — its descriptor comes from the type. Mixing the two forms would + // mean two sources for one node's parameters. + if let Some(ty) = root.get("rust") { + let ty = as_str(ty, "rust")?.to_string(); + check_type_name(&ty)?; + for key in ["params", "uniforms", "wgsl", "helpers", "define", "label"] { + if root.contains_key(key) { + return Err(format!( + "`{key}` is meaningless on a `rust:` node — {ty} publishes \ + its own descriptor. Remove one or the other." + )); + } + } + return Ok(Node::Rust { + id, + order, + ty, + why_rust: opt_prose(root, "why_rust", "why_rust")?, + placement, + }); + } + + let label = as_str(root.get("label").ok_or("missing `label:`")?, "label")?.to_string(); + let attributes = read_attributes(root)?; + + let params = read_params(root)?; + let param_names: BTreeSet<&str> = params.iter().map(|p| p.id.as_str()).collect(); + + let local_helpers = read_local_helpers(root)?; + let shared_helpers = read_shared_refs(root, shared, &local_helpers)?; + + let wgsl = as_str(root.get("wgsl").ok_or("missing `wgsl:`")?, "wgsl")? + .trim_end() + .to_string(); + if wgsl.trim().is_empty() { + return Err("`wgsl:` is empty; a node that changes nothing is not a node".into()); + } + + let uniforms = read_uniforms(root, ¶m_names)?; + + let active = match root.get("active") { + None => None, + Some(v) => { + let src = as_str(v, "active")?; + Some(expr::parse(src, ¶m_names).map_err(|e| format!("`active`: {e}"))?) + } + }; + + let uniform_names: BTreeSet<&str> = uniforms.iter().map(|u| u.name.as_str()).collect(); + let helper_names: BTreeSet<&str> = shared_helpers + .iter() + .map(String::as_str) + .chain(local_helpers.iter().map(|h| h.name.as_str())) + .collect(); + let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?; + let presentation = read_presentation(root, ¶m_names)?; + + Ok(Node::Declared(Box::new(Declaration { + id, + label, + order, + doc: opt_prose(root, "doc", "doc")?, + attributes, + placement, + params, + uniforms, + shared_helpers, + local_helpers, + wgsl, + active, + tests, + presentation, + }))) +} + +/// The widgets a node would like, in descending order of preference. +/// +/// Optional, and absent on nearly every node — one control per parameter is +/// the right answer for a list of unrelated sliders, which is what most +/// operations are. Declaring this says that several parameters form *one* +/// conceptual control. +/// +/// It is a hint and nothing more (ARCH §4.3a). A frontend implementing none of +/// the named widgets renders the parameters as ordinary sliders and the edit +/// still works, which is why the list can safely name widgets that do not +/// exist yet. +fn read_presentation( + root: &Mapping, + params: &BTreeSet<&str>, +) -> Result>, String> { + let Some(value) = root.get("presentation") else { + return Ok(None); + }; + let m = as_mapping(value, "presentation")?; + + let widgets = m + .get("widgets") + .and_then(Value::as_sequence) + .ok_or("`presentation` needs a `widgets:` list, most preferred first")?; + if widgets.is_empty() { + return Err("`presentation.widgets` is empty; omit `presentation:` instead".into()); + } + let widgets = widgets + .iter() + .enumerate() + .map(|(i, w)| { + let name = as_str(w, &format!("presentation.widgets[{i}]"))?; + Widget::ALL + .iter() + .copied() + .find(|k| k.name() == name) + .ok_or_else(|| { + format!( + "`presentation.widgets[{i}]` is `{name}`; expected tone_curve, \ + colour_wheel, crop_overlay, gradient_handle, brush_mask or white_point" + ) + }) + }) + .collect::, _>>()?; + + // The parameters the widget owns, in the order it expects them. Checked + // against the node's own list, because a typo here would silently leave a + // parameter out of the widget *and* out of the panel — the widget claims + // it, and the generic path skips what the widget claimed. + let owned = m + .get("params") + .and_then(Value::as_sequence) + .ok_or("`presentation` needs a `params:` list naming what the widget owns")?; + let owned = owned + .iter() + .enumerate() + .map(|(i, p)| { + let name = as_str(p, &format!("presentation.params[{i}]"))?; + if !params.contains(name) { + return Err(format!( + "`presentation.params[{i}]` is `{name}`, which this node does not declare" + )); + } + Ok(name.to_string()) + }) + .collect::, _>>()?; + + let demand = match m.get("demand") { + None => (false, false), + Some(d) => { + let d = as_mapping(d, "presentation.demand")?; + let flag = |key: &str| -> Result { + match d.get(key) { + None => Ok(false), + Some(v) => v.as_bool().ok_or_else(|| { + format!("`presentation.demand.{key}` must be true or false") + }), + } + }; + // Deliberately only these two. ARCH §4.3a forbids a demand + // carrying pixels, breakpoints or a platform name — those are the + // frontend's to decide — so there is no key here to write one in. + (flag("two_dimensional")?, flag("precise_pointing")?) + } + }; + + Ok(Some(Box::new(PresentationDef { + widgets, + params: owned, + two_dimensional: demand.0, + precise_pointing: demand.1, + }))) +} + +fn read_params(root: &Mapping) -> Result, String> { + let params = root + .get("params") + .ok_or("missing `params:`; an operation with no parameters has nothing to control")?; + let params = as_mapping(params, "params")?; + + let mut out = Vec::new(); + for (id, spec) in params { + let id = as_str(id, "a parameter name")?.to_string(); + check_ident(&id, &format!("params.{id}"))?; + let ctx = format!("params.{id}"); + let spec = as_mapping(spec, &ctx)?; + + let label = as_str( + spec.get("label") + .ok_or_else(|| format!("`{ctx}` has no `label:`"))?, + &format!("{ctx}.label"), + )? + .to_string(); + + let kind_name = as_str( + spec.get("kind") + .ok_or_else(|| format!("`{ctx}` has no `kind:`"))?, + &format!("{ctx}.kind"), + )?; + + let (kind, default, min, max) = read_kind(kind_name, spec, &ctx)?; + out.push(ParamDef { + id, + label, + doc: opt_prose(spec, "doc", &ctx)?, + kind, + default, + min, + max, + }); + } + + if out.is_empty() { + return Err("`params:` is empty".into()); + } + Ok(out) +} + +/// Read a declared parameter kind, with its default and its bounds. +/// +/// The kinds are the constructors `descriptor.rs` already offers, named rather +/// than spelled out: `amount` is the −100…+100 shape nearly every photographic +/// control takes, and writing its range in every node would invite one of them +/// to drift. +fn read_kind(kind: &str, spec: &Mapping, ctx: &str) -> Result<(Kind, f64, f64, f64), String> { + let need = |key: &str| -> Result { + spec.get(key) + .and_then(Value::as_f64) + .ok_or_else(|| format!("`{ctx}` is `kind: {kind}` and needs a numeric `{key}:`")) + }; + let opt = |key: &str, fallback: f64| -> f64 { + spec.get(key).and_then(Value::as_f64).unwrap_or(fallback) + }; + + let (kind, default, min, max) = match kind { + "stops" => { + let (min, max) = (need("min")?, need("max")?); + (Kind::Stops { min, max }, 0.0, min, max) + } + "amount" => (Kind::Amount, 0.0, -100.0, 100.0), + "switch" => (Kind::Switch, 0.0, 0.0, 1.0), + "fraction" => { + let default = opt("default", 0.0); + (Kind::Fraction { default }, default, 0.0, 1.0) + } + "scalar" => { + let (min, max) = (need("min")?, need("max")?); + let default = opt("default", 0.0); + let unit = match spec.get("unit").and_then(Value::as_str).unwrap_or("none") { + "none" => Unit::None, + "stops" => Unit::Stops, + "kelvin" => Unit::Kelvin, + "percent" => Unit::Percent, + other => { + return Err(format!( + "`{ctx}.unit` is `{other}`; expected none, stops, kelvin or percent" + )) + } + }; + let scale = match spec + .get("scale") + .and_then(Value::as_str) + .unwrap_or("linear") + { + "linear" => Scale::Linear, + "perceptual" => Scale::Perceptual, + other => { + return Err(format!( + "`{ctx}.scale` is `{other}`; expected linear or perceptual" + )) + } + }; + let precision = spec + .get("precision") + .and_then(Value::as_u64) + .ok_or_else(|| format!("`{ctx}` is `kind: scalar` and needs `precision:`"))?; + ( + Kind::Scalar { + min, + max, + default, + unit, + scale, + precision, + }, + default, + min, + max, + ) + } + // A fixed list of named alternatives. The value is the chosen index, + // so the range is the list's own bounds and a node declaring one needs + // no `min:`/`max:` of its own. + // + // Variants are localisation keys, like every other label in a node — + // the core never holds a display string (NFR-A11Y-1). The default is + // always the first, so `reset` means the same thing here as everywhere + // else; a node whose neutral choice is not first has listed them in + // the wrong order. + "enum" => { + let variants = spec + .get("variants") + .and_then(Value::as_sequence) + .ok_or_else(|| format!("`{ctx}` is `kind: enum` and needs a `variants:` list"))?; + if variants.len() < 2 { + return Err(format!( + "`{ctx}.variants` lists {} choice(s); a control the user \ + cannot change is not a control", + variants.len() + )); + } + let keys = variants + .iter() + .enumerate() + .map(|(i, v)| as_str(v, &format!("{ctx}.variants[{i}]")).map(str::to_string)) + .collect::, _>>()?; + let last = (keys.len() - 1) as f64; + (Kind::Enum { variants: keys }, 0.0, 0.0, last) + } + other => { + return Err(format!( + "`{ctx}.kind` is `{other}`; expected stops, amount, switch, \ + fraction, scalar or enum" + )) + } + }; + + // Caught at build time rather than surfacing as a control that opens where + // it cannot be dragged back to. + if min >= max { + return Err(format!( + "`{ctx}` has an empty range: min {min} >= max {max}" + )); + } + if default < min || default > max { + return Err(format!( + "`{ctx}` has default {default} outside its range {min}..{max}" + )); + } + Ok((kind, default, min, max)) +} + +fn read_local_helpers(root: &Mapping) -> Result, String> { + let Some(define) = root.get("define") else { + return Ok(Vec::new()); + }; + let define = as_mapping(define, "define")?; + + let mut out = Vec::new(); + for (name, spec) in define { + let name = as_str(name, "a helper name under `define`")?.to_string(); + let ctx = format!("define.{name}"); + // Shorthand: the value may be the WGSL directly, or a mapping with + // prose beside it. Most node-local helpers carry their explanation in + // the WGSL itself, so the shorthand is the common case. + let (doc, wgsl) = match spec { + Value::String(s) => (None, s.trim_end().to_string()), + other => { + let m = as_mapping(other, &ctx)?; + let wgsl = as_str( + m.get("wgsl") + .ok_or_else(|| format!("`{ctx}` has no `wgsl:`"))?, + &format!("{ctx}.wgsl"), + )? + .trim_end() + .to_string(); + (opt_prose(m, "doc", &ctx)?, wgsl) + } + }; + if !wgsl.contains(&format!("fn {name}(")) { + return Err(format!("`{ctx}` does not define `fn {name}(`")); + } + out.push(HelperDef { name, doc, wgsl }); + } + Ok(out) +} + +fn read_shared_refs( + root: &Mapping, + shared: &BTreeSet<&str>, + local: &[HelperDef], +) -> Result, String> { + let Some(list) = root.get("helpers") else { + return Ok(Vec::new()); + }; + let list = list + .as_sequence() + .ok_or("`helpers` must be a list of helper names")?; + + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + for item in list { + let name = as_str(item, "a helper name")?.to_string(); + if !shared.contains(name.as_str()) { + let known: Vec<&str> = shared.iter().copied().collect(); + return Err(format!( + "`helpers` names `{name}`, which {HELPERS_FILE} does not \ + define. Known helpers: {}", + known.join(", ") + )); + } + if local.iter().any(|h| h.name == name) { + return Err(format!( + "`{name}` is both requested from {HELPERS_FILE} and redefined \ + under `define`. The composer deduplicates by name, so one of \ + the two definitions would silently win." + )); + } + if !seen.insert(name.clone()) { + return Err(format!("`helpers` lists `{name}` twice")); + } + out.push(name); + } + Ok(out) +} + +fn read_uniforms(root: &Mapping, params: &BTreeSet<&str>) -> Result, String> { + let uniforms = root + .get("uniforms") + .ok_or("missing `uniforms:`; the fragment has nothing to read otherwise")?; + let uniforms = as_mapping(uniforms, "uniforms")?; + + let mut out = Vec::new(); + for (name, spec) in uniforms { + let name = as_str(name, "a uniform name")?.to_string(); + check_ident(&name, &format!("uniforms.{name}"))?; + let ctx = format!("uniforms.{name}"); + + // Shorthand: `gain: exp2(exposure)`, or a mapping carrying prose. + let (doc, src) = match spec { + Value::String(s) => (None, s.clone()), + other => { + let m = as_mapping(other, &ctx)?; + let value = m + .get("value") + .ok_or_else(|| format!("`{ctx}` has neither a bare expression nor `value:`"))?; + ( + opt_prose(m, "doc", &ctx)?, + as_str(value, &format!("{ctx}.value"))?.to_string(), + ) + } + }; + + let expr = expr::parse(&src, params).map_err(|e| format!("`{ctx}`: {e}"))?; + out.push(UniformDef { name, doc, expr }); + } + + if out.is_empty() { + return Err("`uniforms:` is empty".into()); + } + Ok(out) +} + +fn read_tests( + root: &Mapping, + params: &[ParamDef], + uniforms: &BTreeSet<&str>, + helpers: &BTreeSet<&str>, +) -> Result, String> { + let Some(list) = root.get("tests") else { + return Ok(Vec::new()); + }; + let list = list.as_sequence().ok_or("`tests` must be a list")?; + + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + for item in list { + let m = as_mapping(item, "a test")?; + let name = as_str( + m.get("name").ok_or("a test has no `name:`")?, + "tests[].name", + )? + .to_string(); + check_ident(&name, &format!("tests.{name}.name"))?; + if !seen.insert(name.clone()) { + return Err(format!("two tests are both called `{name}`")); + } + let ctx = format!("tests.{name}"); + + let mut set = Vec::new(); + if let Some(v) = m.get("set") { + for (k, val) in as_mapping(v, &format!("{ctx}.set"))? { + let key = as_str(k, &format!("{ctx}.set key"))?.to_string(); + let Some(param) = params.iter().find(|p| p.id == key) else { + return Err(format!( + "`{ctx}.set` names `{key}`, which is not a parameter" + )); + }; + let value = val + .as_f64() + .ok_or_else(|| format!("`{ctx}.set.{key}` must be a number"))?; + // Values reach `set_param` already clamped by the graph, so a + // test setting an out-of-range value would be asserting + // against something that cannot happen. + if value < param.min || value > param.max { + return Err(format!( + "`{ctx}.set.{key}` is {value}, outside the parameter's \ + range {}..{}. The graph clamps before an operation \ + sees a value, so this test could never run as written.", + param.min, param.max + )); + } + set.push((key, value)); + } + } + + let mut expect = Vec::new(); + if let Some(v) = m.get("expect") { + for (k, val) in as_mapping(v, &format!("{ctx}.expect"))? { + let key = as_str(k, &format!("{ctx}.expect key"))?.to_string(); + if !uniforms.contains(key.as_str()) { + return Err(format!( + "`{ctx}.expect` names `{key}`, which is not a uniform of this node" + )); + } + let value = val + .as_f64() + .ok_or_else(|| format!("`{ctx}.expect.{key}` must be a number"))?; + expect.push((key, value)); + } + } + + let mut expect_range = Vec::new(); + if let Some(v) = m.get("expect_range") { + for (k, val) in as_mapping(v, &format!("{ctx}.expect_range"))? { + let key = as_str(k, &format!("{ctx}.expect_range key"))?.to_string(); + if !uniforms.contains(key.as_str()) { + return Err(format!( + "`{ctx}.expect_range` names `{key}`, which is not a uniform" + )); + } + let pair = val + .as_sequence() + .filter(|s| s.len() == 2) + .ok_or_else(|| format!("`{ctx}.expect_range.{key}` must be [low, high]"))?; + let lo = pair[0] + .as_f64() + .ok_or_else(|| format!("`{ctx}.expect_range.{key}` low must be a number"))?; + let hi = pair[1] + .as_f64() + .ok_or_else(|| format!("`{ctx}.expect_range.{key}` high must be a number"))?; + expect_range.push((key, lo, hi)); + } + } + + let mut expect_wgsl = Vec::new(); + if let Some(v) = m.get("expect_wgsl") { + for item in v + .as_sequence() + .ok_or_else(|| format!("`{ctx}.expect_wgsl` must be a list of strings"))? + { + expect_wgsl.push(as_str(item, &format!("{ctx}.expect_wgsl[]"))?.to_string()); + } + } + + let mut expect_helper_wgsl = Vec::new(); + if let Some(v) = m.get("expect_helper_wgsl") { + for (k, val) in as_mapping(v, &format!("{ctx}.expect_helper_wgsl"))? { + let key = as_str(k, &format!("{ctx}.expect_helper_wgsl key"))?.to_string(); + if !helpers.contains(key.as_str()) { + return Err(format!( + "`{ctx}.expect_helper_wgsl` names `{key}`, which this node does not use" + )); + } + let mut needles = Vec::new(); + for item in val + .as_sequence() + .ok_or_else(|| format!("`{ctx}.expect_helper_wgsl.{key}` must be a list"))? + { + needles.push( + as_str(item, &format!("{ctx}.expect_helper_wgsl.{key}[]"))?.to_string(), + ); + } + expect_helper_wgsl.push((key, needles)); + } + } + + let expect_active = match m.get("expect_active") { + None => None, + Some(v) => Some( + v.as_bool() + .ok_or_else(|| format!("`{ctx}.expect_active` must be true or false"))?, + ), + }; + + if expect.is_empty() + && expect_range.is_empty() + && expect_wgsl.is_empty() + && expect_helper_wgsl.is_empty() + && expect_active.is_none() + { + return Err(format!("`{ctx}` asserts nothing")); + } + + out.push(TestDef { + name, + why: opt_prose(m, "why", &ctx)?, + set, + expect, + expect_range, + expect_active, + expect_wgsl, + expect_helper_wgsl, + }); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// YAML access +// --------------------------------------------------------------------------- + +fn parse_yaml(text: &str, ctx: &str) -> Result { + serde_norway::from_str(text).map_err(|e| format!("{ctx}: not valid YAML: {e}")) +} + +fn as_mapping<'a>(value: &'a Value, ctx: &str) -> Result<&'a Mapping, String> { + value + .as_mapping() + .ok_or_else(|| format!("`{ctx}` must be a mapping")) +} + +fn as_str<'a>(value: &'a Value, ctx: &str) -> Result<&'a str, String> { + value + .as_str() + .ok_or_else(|| format!("`{ctx}` must be a string")) +} + +fn opt_prose(map: &Mapping, key: &str, ctx: &str) -> Result, String> { + match map.get(key) { + None => Ok(None), + Some(v) => v + .as_str() + .map(|s| Some(s.trim_end().to_string())) + .ok_or_else(|| format!("`{ctx}.{key}` must be a string")), + } +} + +/// Names reaching generated Rust have to be identifiers, and must not be +/// keywords — `ParamId("type")` would generate a struct field called `type`. +/// +/// Enforced at load time as well as build time even though a declaration read +/// at run time never becomes Rust: the two paths must agree on what a valid +/// declaration *is*, or a plugin could be accepted by one and rejected by the +/// other, and the built-ins would stop being a fair test of the format. +pub fn check_ident(name: &str, ctx: &str) -> Result<(), String> { + if name.is_empty() { + return Err(format!("`{ctx}` is empty")); + } + let head_ok = name + .chars() + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c == '_'); + let rest_ok = name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'); + if !head_ok || !rest_ok { + return Err(format!( + "`{ctx}` is `{name}`; names must be lower_snake_case so they can \ + become Rust identifiers" + )); + } + const KEYWORDS: &[&str] = &[ + "as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", + "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", + "ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", + "use", "where", "while", "async", "await", "box", "final", "macro", "override", "priv", + "try", "typeof", "unsized", "virtual", "yield", + ]; + if KEYWORDS.contains(&name) { + return Err(format!("`{ctx}` is `{name}`, which is a Rust keyword")); + } + Ok(()) +} + +/// `rust:` names a type in `crate::ops`, so it is PascalCase rather than an +/// id. Checked only for shape — whether the type exists, and whether it +/// implements `Operation`, is for the compiler to say. +fn check_type_name(name: &str) -> Result<(), String> { + let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) + && name.chars().all(|c| c.is_ascii_alphanumeric()); + if !ok { + return Err(format!( + "`rust: {name}` must be the PascalCase name of a type in \ + `crate::ops`, such as `ToneCurve`" + )); + } + Ok(()) +} diff --git a/core/dr-pipeline/src/declared/expr.rs b/core/dr-pipeline/src/declared/expr.rs new file mode 100644 index 0000000..dd25268 --- /dev/null +++ b/core/dr-pipeline/src/declared/expr.rs @@ -0,0 +1,447 @@ +//! TRACES: FR-PLG-2 +//! The little expression language a node declaration derives its uniforms in. +//! +//! Uniforms are functions of parameters — `exp2(exposure)`, `blacks / 100 * +//! 0.02` — and that derivation is the one piece of a node that is genuinely +//! computation rather than description. The language is arithmetic over the +//! node's own parameters plus a fixed set of maths functions: enough for every +//! operation in the chain, and small enough that a reader of the YAML can see +//! exactly what will happen. +//! +//! # Why this file is compiled twice +//! +//! It is `#[path]`-included by `build.rs` as well as being a module of the +//! crate, and it deliberately depends on nothing but `std` so that it can be. +//! +//! There are two backends over one grammar. `build.rs` renders an [`Expr`] to +//! Rust source, so a built-in node's arithmetic costs nothing at run time and +//! an unknown name is a build error naming the file. [`Expr::eval`] evaluates +//! the same tree directly, which is what lets a declaration loaded at run time +//! produce uniforms without a compiler. +//! +//! **The two must agree bit for bit**, or a plugin is not the same kind of +//! thing as a built-in and `tests/declared_parity.rs` says so. Sharing the +//! tokeniser and the parser removes the larger half of the ways they could +//! drift; the remaining half is the pair of backends, and each entry in +//! [`FUNCTIONS`] below is written twice on purpose, once here and once in +//! `build.rs`, with the parity test standing between them. + +use std::collections::BTreeSet; + +/// A number in a declaration, as the `f32` the arithmetic will actually use. +/// +/// **The route through the decimal string is load-bearing, not clumsiness.** +/// `build.rs` renders a number as a Rust literal — `0.02f32` — and `rustc` +/// rounds that decimal text to the nearest `f32` exactly once. Writing +/// `n as f32` here would instead round the `f64` YAML parsed to the nearest +/// `f32`, which is a *second* rounding on top of the one that produced the +/// `f64`, and double rounding does not always land where single rounding does. +/// +/// So this reproduces what the compiler sees: `{:?}` is the shortest decimal +/// that round-trips the `f64`, which is exactly the literal `build.rs` emits, +/// and parsing it as `f32` is exactly what `rustc` does with it. +pub fn as_f32(n: f64) -> f32 { + // Infallible in practice: `{:?}` on a finite `f64` is always a parseable + // decimal, and the infinities and NaN it can also print all parse back. + // The fallback is the direct cast rather than a panic, because a bad + // number in a declaration is the reader's error to report, not this + // function's to crash on. + format!("{n:?}").parse::().unwrap_or(n as f32) +} + +/// The maths functions a declaration may call, and how many arguments each +/// takes. +/// +/// A closed list rather than a passthrough to `f32`: a node is a description, +/// and letting it name arbitrary Rust would make the YAML a second, worse +/// place to write code. Closed for the same reason `WidgetKind` and +/// `attributes` are closed (FR-PLG-2d) — a typo must be an error rather than a +/// silent new category of one. +/// +/// Shared by both backends so that the *set* of callable functions cannot +/// drift even though the two translations of each must be written separately. +pub const FUNCTIONS: &[(&str, usize)] = &[ + ("exp2", 1), + ("log2", 1), + ("exp", 1), + ("sqrt", 1), + ("abs", 1), + ("floor", 1), + ("ceil", 1), + ("round", 1), + ("pow", 2), + ("min", 2), + ("max", 2), + ("clamp", 3), + ("mix", 3), +]; + +/// A parsed expression over a node's parameters. +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + Num(f64), + Param(String), + Neg(Box), + Bin(char, Box, Box), + Call(String, Vec), +} + +/// Parse and validate an expression against the parameters a node declares. +/// +/// Validation happens here rather than in either backend, so that "this names +/// a parameter that does not exist" is one error message in one place and +/// cannot be reported at build time but missed at load time. +pub fn parse(src: &str, params: &BTreeSet<&str>) -> Result { + let tokens = tokenise(src)?; + let mut parser = Parser { tokens, at: 0 }; + let expr = parser.expr()?; + if parser.at < parser.tokens.len() { + return Err(format!( + "unexpected `{}` after the end of the expression", + parser.tokens[parser.at] + )); + } + check(&expr, params)?; + Ok(expr) +} + +/// Every name and arity in the tree resolves. +fn check(expr: &Expr, params: &BTreeSet<&str>) -> Result<(), String> { + match expr { + Expr::Num(_) => Ok(()), + Expr::Param(name) => { + if params.contains(name.as_str()) { + return Ok(()); + } + let known: Vec<&str> = params.iter().copied().collect(); + Err(format!( + "`{name}` is not a parameter of this node. Its parameters \ + are: {}", + known.join(", ") + )) + } + Expr::Neg(inner) => check(inner, params), + Expr::Bin(_, l, r) => { + check(l, params)?; + check(r, params) + } + Expr::Call(name, args) => { + let Some((_, arity)) = FUNCTIONS.iter().find(|(f, _)| *f == name) else { + let known: Vec<&str> = FUNCTIONS.iter().map(|(f, _)| *f).collect(); + return Err(format!( + "`{name}` is not one of the maths functions a node may \ + call. Available: {}", + known.join(", ") + )); + }; + if args.len() != *arity { + return Err(format!( + "`{name}` takes {arity} argument(s), given {}", + args.len() + )); + } + for a in args { + check(a, params)?; + } + Ok(()) + } + } +} + +impl Expr { + /// TRACES: FR-PLG-2 + /// Evaluate this expression for a set of parameter values. + /// + /// **Every step is an `f32` operation in the same order `build.rs` renders + /// it**, which is what makes the interpreted result bit-identical to the + /// compiled one rather than merely close. Rust's `f32` arithmetic is IEEE + /// 754 with no excess precision, so `(a * b) + c` here and `(a * b) + c` + /// in generated source are the same number down to the last bit — and the + /// parity test asserts exactly that rather than an epsilon, because a + /// tolerance is how a real divergence gets to hide. + /// + /// `param` is asked for a parameter's current value. It is a closure + /// rather than a map so the caller can serve the values out of whatever it + /// already has, which for [`super::DeclaredOp`] is a plain `Vec` + /// indexed in declaration order. + /// + /// Infallible: [`parse`] has already established that every name resolves + /// and every call has the right arity. An unknown parameter reaching here + /// would be a reader that let one through, so `param` decides what to do + /// about it rather than this returning a `Result` every caller would + /// unwrap. + pub fn eval(&self, param: &dyn Fn(&str) -> f32) -> f32 { + match self { + Expr::Num(n) => as_f32(*n), + Expr::Param(name) => param(name), + Expr::Neg(inner) => -inner.eval(param), + Expr::Bin(op, l, r) => { + let (l, r) = (l.eval(param), r.eval(param)); + match op { + '+' => l + r, + '-' => l - r, + '*' => l * r, + '/' => l / r, + // `tokenise` only ever produces these four as binary + // operators, and `Parser` only ever builds `Bin` from what + // `tokenise` produced. + _ => unreachable!("`{op}` is not a binary operator"), + } + } + Expr::Call(name, args) => { + let a = |i: usize| args[i].eval(param); + match name.as_str() { + "exp2" => f32::exp2(a(0)), + "log2" => f32::log2(a(0)), + "exp" => f32::exp(a(0)), + "sqrt" => f32::sqrt(a(0)), + "abs" => f32::abs(a(0)), + "floor" => f32::floor(a(0)), + "ceil" => f32::ceil(a(0)), + "round" => f32::round(a(0)), + "pow" => f32::powf(a(0), a(1)), + "min" => f32::min(a(0), a(1)), + "max" => f32::max(a(0), a(1)), + "clamp" => f32::clamp(a(0), a(1), a(2)), + // Spelled out rather than called, matching what `build.rs` + // renders: Rust has no `mix`, and this linear form is what + // WGSL's `mix` means. The association matters — `a + (b - + // a) * t` and `a * (1 - t) + b * t` are the same value in + // real arithmetic and different ones in `f32`. + "mix" => { + let (x, y, t) = (a(0), a(1), a(2)); + x + (y - x) * t + } + // `parse` rejects anything not in `FUNCTIONS`. + _ => unreachable!("`{name}` is not a declared maths function"), + } + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Tok { + Num(f64), + Ident(String), + Sym(char), +} + +impl std::fmt::Display for Tok { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Tok::Num(n) => write!(f, "{n}"), + Tok::Ident(s) => write!(f, "{s}"), + Tok::Sym(c) => write!(f, "{c}"), + } + } +} + +fn tokenise(src: &str) -> Result, String> { + let bytes: Vec = src.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if c.is_whitespace() { + i += 1; + } else if c.is_ascii_digit() + || (c == '.' && bytes.get(i + 1).is_some_and(char::is_ascii_digit)) + { + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') { + i += 1; + } + let text: String = bytes[start..i].iter().collect(); + let n = text + .parse::() + .map_err(|_| format!("`{text}` is not a number"))?; + out.push(Tok::Num(n)); + } else if c.is_ascii_alphabetic() || c == '_' { + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') { + i += 1; + } + out.push(Tok::Ident(bytes[start..i].iter().collect())); + } else if "+-*/(),".contains(c) { + out.push(Tok::Sym(c)); + i += 1; + } else { + return Err(format!( + "`{c}` is not valid in an expression; the language is \ + arithmetic (+ - * /), parentheses, numbers, this node's \ + parameters, and the maths functions" + )); + } + } + if out.is_empty() { + return Err("the expression is empty".into()); + } + Ok(out) +} + +struct Parser { + tokens: Vec, + at: usize, +} + +impl Parser { + fn peek(&self) -> Option<&Tok> { + self.tokens.get(self.at) + } + + fn eat(&mut self, sym: char) -> bool { + if self.peek() == Some(&Tok::Sym(sym)) { + self.at += 1; + return true; + } + false + } + + fn expr(&mut self) -> Result { + let mut left = self.term()?; + loop { + if self.eat('+') { + left = Expr::Bin('+', Box::new(left), Box::new(self.term()?)); + } else if self.eat('-') { + left = Expr::Bin('-', Box::new(left), Box::new(self.term()?)); + } else { + return Ok(left); + } + } + } + + fn term(&mut self) -> Result { + let mut left = self.unary()?; + loop { + if self.eat('*') { + left = Expr::Bin('*', Box::new(left), Box::new(self.unary()?)); + } else if self.eat('/') { + left = Expr::Bin('/', Box::new(left), Box::new(self.unary()?)); + } else { + return Ok(left); + } + } + } + + fn unary(&mut self) -> Result { + if self.eat('-') { + return Ok(Expr::Neg(Box::new(self.unary()?))); + } + self.primary() + } + + fn primary(&mut self) -> Result { + match self.peek().cloned() { + Some(Tok::Num(n)) => { + self.at += 1; + Ok(Expr::Num(n)) + } + Some(Tok::Ident(name)) => { + self.at += 1; + if !self.eat('(') { + return Ok(Expr::Param(name)); + } + let mut args = Vec::new(); + if !self.eat(')') { + loop { + args.push(self.expr()?); + if self.eat(',') { + continue; + } + if self.eat(')') { + break; + } + return Err(format!("expected `,` or `)` in the call to `{name}`")); + } + } + Ok(Expr::Call(name, args)) + } + Some(Tok::Sym('(')) => { + self.at += 1; + let inner = self.expr()?; + if !self.eat(')') { + return Err("unclosed `(`".into()); + } + Ok(inner) + } + Some(t) => Err(format!("unexpected `{t}`")), + None => Err("the expression ends early".into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params() -> BTreeSet<&'static str> { + ["a", "b"].into_iter().collect() + } + + fn eval(src: &str, a: f32, b: f32) -> f32 { + parse(src, ¶ms()) + .expect("parses") + .eval(&|name| match name { + "a" => a, + "b" => b, + other => panic!("no parameter {other}"), + }) + } + + #[test] + fn arithmetic_follows_the_usual_precedence() { + assert_eq!(eval("a + b * 2", 1.0, 3.0), 7.0); + assert_eq!(eval("(a + b) * 2", 1.0, 3.0), 8.0); + } + + #[test] + fn unary_minus_binds_tighter_than_addition() { + // `-(a + b)` and `-a + b` are different numbers, and the parser has to + // agree with the renderer about which one `-a + b` is. + assert_eq!(eval("-a + b", 1.0, 3.0), 2.0); + assert_eq!(eval("-(a + b)", 1.0, 3.0), -4.0); + } + + #[test] + fn a_number_is_rounded_once_the_way_the_compiler_rounds_a_literal() { + // The whole reason `as_f32` goes through the decimal string. If this + // ever regresses to `n as f32`, the values a declared node produces + // drift from the generated one in the last bit, and every parity + // assertion has to become a tolerance to keep passing — which is + // exactly the silent disagreement the test exists to catch. + assert_eq!(as_f32(0.02), 0.02f32); + assert_eq!(as_f32(0.1), 0.1f32); + // A third: eight significant digits, which is past what an `f32` + // resolves, so this is the case where a second rounding could land + // somewhere the compiler's single one does not. + assert_eq!(as_f32(1.0 / 3.0), 0.333_333_34_f32); + } + + #[test] + fn mix_is_the_linear_form_wgsl_means() { + assert_eq!(eval("mix(a, b, 0.25)", 0.0, 4.0), 1.0); + } + + #[test] + fn an_unknown_parameter_names_the_ones_that_exist() { + // The error is the whole value of validating in the parser: whoever + // wrote the typo needs the list, and needs it identically whether the + // declaration was read by the build script or at load time. + let err = parse("c * 2", ¶ms()).unwrap_err(); + assert!(err.contains("`c` is not a parameter"), "{err}"); + assert!(err.contains("a, b"), "{err}"); + } + + #[test] + fn an_unknown_function_is_rejected_rather_than_passed_through() { + let err = parse("tan(a)", ¶ms()).unwrap_err(); + assert!(err.contains("not one of the maths functions"), "{err}"); + } + + #[test] + fn a_wrong_arity_is_caught_where_it_is_written() { + let err = parse("pow(a)", ¶ms()).unwrap_err(); + assert!(err.contains("takes 2 argument(s), given 1"), "{err}"); + } +} diff --git a/core/dr-pipeline/src/declared/mod.rs b/core/dr-pipeline/src/declared/mod.rs new file mode 100644 index 0000000..f243b92 --- /dev/null +++ b/core/dr-pipeline/src/declared/mod.rs @@ -0,0 +1,487 @@ +//! TRACES: FR-PLG-2 | FR-PLG-2d +//! Running a node declaration without compiling it. +//! +//! # The format already existed +//! +//! `ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the +//! declarative nodes landed — it was simply resolved at build time: +//! +//! ```text +//! ops/exposure.yaml ──build.rs──▶ generated impl Operation ──▶ fused shader +//! ``` +//! +//! Nothing about that requires the declaration to be present when the compiler +//! runs. Everything a declaration produces is *data plus a WGSL string*, and +//! the composer already assembles WGSL at run time from whichever operations +//! are active. So this module is not a new mechanism; it is the existing one, +//! loaded later. +//! +//! [`DeclaredOp`] is **one interpreter over many declarations**, where +//! `build.rs` emits generated code per node. It implements [`Operation`] from +//! an owned [`Declaration`], which is only possible because descriptors became +//! owned — see [`crate::descriptor::OpDescriptor`] for why a `&'static` +//! descriptor made a run-time node impossible. +//! +//! # Both paths stay +//! +//! The generated path is not removed and should not be. FR-PLG-2 says so, and +//! the reasons are good ones: a generated `match` is faster than an +//! interpreted one, the built-ins' declared tests have to run under `cargo +//! test`, and generated source is *inspectable* in a way an interpreter's +//! internal state is not. +//! +//! What matters is that the two are **indistinguishable downstream**, and that +//! is a test rather than an intention: `tests/declared_parity.rs` parses every +//! built-in `ops/*.yaml` at run time and asserts the composed WGSL is +//! byte-for-byte identical to what the generated implementation produces, for +//! the same parameter values. If the two ever disagree, a plugin is not the +//! same kind of thing as a built-in and the premise of the whole plugin plan +//! has failed quietly. +//! +//! # What this is not, yet +//! +//! Not load-time WGSL validation (FR-PLG-11), not id namespacing (FR-PLG-2's +//! `author.name`), and not a plugin directory read at startup. Those are +//! separate work and are deliberately absent — a declaration reaching +//! [`DeclaredOp`] here is one that ships in this repository, so its WGSL has +//! already been compiled by the build and its id has already been checked for +//! collisions. + +pub mod decl; +pub mod expr; + +use std::sync::{Arc, LazyLock}; + +pub use decl::{Declaration, Node, SharedHelpers}; + +use crate::descriptor::{ + intern, Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, + Scale, Unit, WidgetDemand, WidgetKind, +}; +use crate::operation::{Helper, Operation, Uniform}; +use expr::{as_f32, Expr}; + +/// The shared WGSL helper library, as the built-in nodes see it. +/// +/// The very same `_helpers.yaml` `build.rs` reads, embedded rather than read +/// from disk: there is no file path an installed application could look it up +/// at, and embedding is what makes it impossible for the compiled helpers and +/// the interpreted ones to be two different files. +/// +/// Panics if the bundled file does not parse, which is a build-time fact about +/// this repository rather than anything a user can cause — `build.rs` reads the +/// same text on the same build and fails first. +pub fn builtin_helpers() -> &'static SharedHelpers { + static LIBRARY: LazyLock = LazyLock::new(|| { + decl::read_helpers(include_str!("../../ops/_helpers.yaml")) + .unwrap_or_else(|e| panic!("the bundled {}: {e}", decl::HELPERS_FILE)) + }); + &LIBRARY +} + +/// TRACES: FR-PLG-2 +/// An operation built from a declaration at run time. +/// +/// Holds everything the trait has to answer with, resolved once when the +/// declaration is read: the descriptor, the uniform expressions, the helper +/// list and the fragment text. Nothing is re-parsed per call, so the per-frame +/// cost of an interpreted node is the arithmetic in [`Expr::eval`] and nothing +/// else — the same arithmetic the generated node does, simply walked rather +/// than inlined. +#[derive(Debug, Clone)] +pub struct DeclaredOp { + descriptor: Arc, + /// Parameter ids in declaration order, parallel to `defaults` and + /// `values`. + /// + /// Three parallel `Vec`s rather than one of triples because the hot read + /// is `values` alone, and because `set_param` writes exactly one of them. + /// They are built together and never resized. + params: Vec, + defaults: Vec, + values: Vec, + uniforms: Vec, + /// The declared `active:` rule, or `None` for the default one. + active: Option, + wgsl: String, + helpers: Vec, + presentation: Option, + order: i64, +} + +/// One uniform: the name the fragment reads it by, and how to compute it. +#[derive(Debug, Clone)] +struct DeclaredUniform { + /// Interned when the declaration was read, not on every `uniforms()` call. + /// See [`crate::operation::Uniform::name`]. + name: &'static str, + expr: Expr, +} + +impl DeclaredOp { + /// Read one `ops/.yaml` and build the operation it declares. + /// + /// `ctx` names the source in error messages — a path, usually. + /// + /// A `rust:` node is an error here rather than a silent `None`: it names a + /// hand-written type in this crate, which is by definition not something a + /// declaration can produce, and a caller that got one back as "nothing to + /// do" would drop an operation out of the chain without saying so. + pub fn from_yaml(text: &str, ctx: &str, library: &SharedHelpers) -> Result { + let names = library.names(); + match decl::read_node(text, ctx, &names)? { + Node::Declared(d) => Self::new(&d, library), + Node::Rust { id, ty, .. } => Err(format!( + "`{id}` declares `rust: {ty}`, which names a hand-written type \ + rather than describing an operation. Only a full declaration \ + can be read at run time." + )), + } + } + + /// Build the operation a parsed declaration describes. + pub fn new(declaration: &Declaration, library: &SharedHelpers) -> Result { + let params: Vec = declaration + .params + .iter() + .map(|p| ParamId::interned(&p.id)) + .collect(); + let defaults: Vec = declaration + .params + .iter() + .map(|p| as_f32(p.default)) + .collect(); + + // Helpers in the order the composer will see them: the shared ones the + // node asked for, in the order it asked, then its own definitions. + // Order decides emission order in the generated shader, so it is part + // of the output rather than an implementation detail. + let mut helpers = + Vec::with_capacity(declaration.shared_helpers.len() + declaration.local_helpers.len()); + for name in &declaration.shared_helpers { + // `decl::read_node` has already rejected a name the library does + // not define, so this is a library that changed underneath a + // declaration rather than a declaration with a typo in it. + let helper = library.get(name).ok_or_else(|| { + format!( + "`{}` asks for the shared helper `{name}`, which this \ + helper library does not define", + declaration.id + ) + })?; + helpers.push(Helper { + name: intern(&helper.name), + source: intern(&helper.source()), + }); + } + for helper in &declaration.local_helpers { + helpers.push(Helper { + name: intern(&helper.name), + source: intern(&helper.source()), + }); + } + + Ok(Self { + descriptor: Arc::new(OpDescriptor { + id: OpId::interned(&declaration.id), + label: LocalizedKey::interned(&declaration.label), + params: declaration.params.iter().map(param_descriptor).collect(), + attributes: declaration + .attributes + .iter() + .copied() + .map(attribute) + .collect(), + }), + values: defaults.clone(), + params, + defaults, + uniforms: declaration + .uniforms + .iter() + .map(|u| DeclaredUniform { + name: intern(&u.name), + expr: u.expr.clone(), + }) + .collect(), + active: declaration.active.clone(), + wgsl: declaration.wgsl_body(), + helpers, + presentation: declaration.presentation.as_deref().map(presentation), + order: declaration.order, + }) + } + + /// Where this node sits in the chain, from its `order:`. + /// + /// Not part of [`Operation`] — the graph holds operations in a `Vec` and + /// order *is* that position (ARCH §3.4). Exposed so whoever assembles a + /// chain out of declarations can sort them, which is what `build.rs` does + /// at the other end. + pub fn order(&self) -> i64 { + self.order + } + + fn index_of(&self, id: ParamId) -> Option { + self.params.iter().position(|p| *p == id) + } + + /// One parameter's current value, by the name an expression calls it. + /// + /// Returns 0.0 for a name that is not a parameter, matching what the + /// generated `param()` does with an unknown id. It cannot happen — + /// [`expr::parse`] rejects a name that is not declared — but a silent zero + /// is a better failure here than a panic inside a render. + fn value_named(&self, name: &str) -> f32 { + self.params + .iter() + .position(|p| p.0 == name) + .map_or(0.0, |i| self.values[i]) + } +} + +impl Operation for DeclaredOp { + fn descriptor(&self) -> Arc { + self.descriptor.clone() + } + + fn set_param(&mut self, id: ParamId, value: f32) { + match self.index_of(id) { + Some(i) => self.values[i] = value, + // The same complaint the generated `set_param` makes, for the same + // reason: a parameter that does not exist is a sidecar or a UI + // naming something this build does not have, and dropping it + // silently is how an edit comes to be half-applied. + None => log::warn!("{}: unknown parameter {id}", self.descriptor.id), + } + } + + fn param(&self, id: ParamId) -> f32 { + self.index_of(id).map_or(0.0, |i| self.values[i]) + } + + fn is_active(&self) -> bool { + match &self.active { + Some(expr) => expr.eval(&|name| self.value_named(name)) != 0.0, + // The default rule, and the honest one: the operation is doing + // something exactly when a parameter has moved off its default. + // Short-circuiting in declaration order, which is what the + // generated `a != d || b != d` does. + None => self.values.iter().zip(&self.defaults).any(|(v, d)| v != d), + } + } + + fn wgsl_body(&self) -> String { + self.wgsl.clone() + } + + fn uniforms(&self) -> Vec { + self.uniforms + .iter() + .map(|u| Uniform { + name: u.name, + value: u.expr.eval(&|name| self.value_named(name)), + }) + .collect() + } + + fn helpers(&self) -> &[Helper] { + &self.helpers + } + + fn presentation(&self) -> Option { + self.presentation.clone() + } +} + +/// A declared parameter as the descriptor the panel reads. +/// +/// Every arm calls the constructor `build.rs` renders a call to, so the two +/// produce the same `ParamDescriptor` by construction rather than by +/// coincidence. +fn param_descriptor(p: &decl::ParamDef) -> ParamDescriptor { + let id = intern(&p.id); + let label = intern(&p.label); + match &p.kind { + decl::Kind::Stops { min, max } => { + ParamDescriptor::stops(id, label, as_f32(*min), as_f32(*max)) + } + decl::Kind::Amount => ParamDescriptor::amount(id, label), + decl::Kind::Switch => ParamDescriptor::switch(id, label), + decl::Kind::Fraction { default } => ParamDescriptor::fraction(id, label, as_f32(*default)), + decl::Kind::Scalar { + min, + max, + default, + unit, + scale, + precision, + } => ParamDescriptor::scalar( + id, + label, + as_f32(*min), + as_f32(*max), + as_f32(*default), + match unit { + decl::Unit::None => Unit::None, + decl::Unit::Stops => Unit::Stops, + decl::Unit::Kelvin => Unit::Kelvin, + decl::Unit::Percent => Unit::Percent, + }, + match scale { + decl::Scale::Linear => Scale::Linear, + decl::Scale::Perceptual => Scale::Perceptual, + }, + // `decl::read_kind` refuses a precision that does not fit, so this + // cast cannot lose anything. + *precision as u8, + ), + decl::Kind::Enum { variants } => ParamDescriptor::choice( + id, + label, + variants.iter().map(|v| LocalizedKey::interned(v)).collect(), + ), + } +} + +fn attribute(a: decl::Attr) -> Attribute { + match a { + decl::Attr::Tone => Attribute::Tone, + decl::Attr::Colour => Attribute::Colour, + decl::Attr::Detail => Attribute::Detail, + decl::Attr::Optics => Attribute::Optics, + decl::Attr::Geometry => Attribute::Geometry, + decl::Attr::Effect => Attribute::Effect, + } +} + +fn widget(w: decl::Widget) -> WidgetKind { + match w { + decl::Widget::ToneCurve => WidgetKind::ToneCurve, + decl::Widget::ColourWheel => WidgetKind::ColourWheel, + decl::Widget::CropOverlay => WidgetKind::CropOverlay, + decl::Widget::GradientHandle => WidgetKind::GradientHandle, + decl::Widget::BrushMask => WidgetKind::BrushMask, + decl::Widget::WhitePoint => WidgetKind::WhitePoint, + } +} + +fn presentation(p: &decl::PresentationDef) -> Presentation { + Presentation { + widgets: p.widgets.iter().copied().map(widget).collect(), + demand: WidgetDemand { + two_dimensional: p.two_dimensional, + precise_pointing: p.precise_pointing, + }, + params: p.params.iter().map(|n| ParamId::interned(n)).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// TRACES: FR-PLG-2d + /// The two spellings of the attribute vocabulary are one vocabulary. + /// + /// `decl::Attr` exists because the reader is compiled by `build.rs`, which + /// cannot see `crate::descriptor`. That is a duplicated closed list, and a + /// duplicated closed list is exactly the thing FR-PLG-2d warns about: an + /// attribute added to one and not the other would put an operation in a + /// category the panel does not know it has. + #[test] + fn the_attribute_vocabulary_is_the_same_on_both_sides() { + assert_eq!(decl::Attr::ALL.len(), Attribute::ALL.len()); + for (a, b) in decl::Attr::ALL.iter().zip(Attribute::ALL) { + // Same order, so an index into one indexes the other. + assert_eq!(attribute(*a), b); + // And the name a declaration writes resolves to the same variant. + assert_eq!(Attribute::from_name(a.name()), Some(b), "{}", a.name()); + } + } + + /// TRACES: FR-PLG-2d + /// Every `WidgetKind` is nameable from a declaration. + /// + /// A widget the core can ask for but a declaration cannot name is a widget + /// only a hand-written operation may have, which would make the two kinds + /// of node unequal in exactly the way FR-PLG-2 forbids. + #[test] + fn every_widget_kind_can_be_declared() { + assert_eq!(decl::Widget::ALL.len(), 6); + let named: Vec = decl::Widget::ALL.iter().copied().map(widget).collect(); + for kind in [ + WidgetKind::ToneCurve, + WidgetKind::ColourWheel, + WidgetKind::CropOverlay, + WidgetKind::GradientHandle, + WidgetKind::BrushMask, + WidgetKind::WhitePoint, + ] { + assert!(named.contains(&kind), "{kind:?} cannot be declared"); + } + } + + #[test] + fn the_bundled_helper_library_parses() { + // It is `include_str!`'d, so a syntax error in it is a panic at first + // use rather than a build failure. This is the first use. + assert!(!builtin_helpers().helpers.is_empty()); + } + + fn exposure() -> DeclaredOp { + DeclaredOp::from_yaml( + include_str!("../../ops/exposure.yaml"), + "ops/exposure.yaml", + builtin_helpers(), + ) + .expect("exposure declares an operation") + } + + #[test] + fn a_declaration_becomes_an_operation_with_its_declared_descriptor() { + let op = exposure(); + let d = op.descriptor(); + assert_eq!(d.id, OpId("exposure")); + assert_eq!(d.label, LocalizedKey("op.exposure")); + assert_eq!(d.params.len(), 1); + assert_eq!(d.params[0].id, ParamId("exposure")); + assert_eq!(d.attributes, vec![Attribute::Tone]); + } + + #[test] + fn a_declared_operation_is_neutral_until_a_parameter_moves() { + let mut op = exposure(); + assert!(!op.is_active()); + assert_eq!(op.uniforms()[0].value, 1.0); + + op.set_param(ParamId("exposure"), 1.0); + assert!(op.is_active()); + // A stop is a doubling — the same assertion `exposure.yaml`'s own + // declared test makes against the generated implementation. + assert_eq!(op.uniforms()[0].value, 2.0); + } + + #[test] + fn an_interned_id_matches_a_literal_one() { + // The property that lets a declared operation be addressed by the same + // `ParamId` constants the generated code matches on. If interning ever + // stopped deduplicating, this would still pass — `ParamId` compares + // string contents — but the point is that the two are interchangeable + // at every call site. + let mut op = exposure(); + op.set_param(ParamId::interned("exposure"), 2.0); + assert_eq!(op.param(ParamId("exposure")), 2.0); + } + + #[test] + fn a_rust_node_is_refused_rather_than_silently_dropped() { + let err = DeclaredOp::from_yaml( + include_str!("../../ops/tone_curve.yaml"), + "ops/tone_curve.yaml", + builtin_helpers(), + ) + .expect_err("a `rust:` node is not a declaration"); + assert!(err.contains("hand-written type"), "{err}"); + } +} diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index e59af4f..dbb86b6 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -31,6 +31,7 @@ //! single multiply and white balance a per-channel scale; on gamma-encoded //! data neither would be physically meaningful (ARCH §5.2). +pub mod declared; pub mod descriptor; pub mod detail; pub mod framing; @@ -45,6 +46,7 @@ pub mod sidecar; pub mod spot; pub mod state; +pub use declared::{Declaration, DeclaredOp}; pub use descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation, Scale, Unit, WidgetDemand, WidgetKind, diff --git a/core/dr-pipeline/tests/declared_parity.rs b/core/dr-pipeline/tests/declared_parity.rs new file mode 100644 index 0000000..3048e00 --- /dev/null +++ b/core/dr-pipeline/tests/declared_parity.rs @@ -0,0 +1,435 @@ +//! TRACES: FR-PLG-2 +//! The generated path and the interpreted path produce the same operation. +//! +//! # Why this test is the point +//! +//! FR-PLG-2 says a bundled operation and a third-party plugin are the same +//! kind of thing, differing only in where the file was found. Two +//! implementations sit behind that claim: `build.rs` compiles `ops/*.yaml` +//! into Rust, and [`DeclaredOp`] interprets the same declaration at run time. +//! +//! **If the two ever disagree, the claim fails quietly.** A plugin would be a +//! second-class kind of node — one whose colours come out fractionally +//! different, or whose fragment lands in the shader with a different comment, +//! or whose uniform arrives in a different slot — and nothing would say so. +//! The photographer would see a look they could not reproduce with a built-in +//! and would have no way to find out why. +//! +//! So this parses every built-in declaration at run time and asserts the +//! composed WGSL is **byte for byte** what the generated implementation +//! produces, with the uniform block bit for bit identical, at a spread of +//! parameter values. +//! +//! # Byte-for-byte, and bit-for-bit, on purpose +//! +//! Not "equivalent", not "within an epsilon". A tolerance is where a real +//! divergence hides: the arithmetic in a declaration is `f32` at both ends and +//! there is no reason for a single bit to differ, so any difference at all is +//! a bug in one of the two backends and should read as one. The one place +//! this bites is number literals, which is why `expr::as_f32` rounds a decimal +//! exactly once — see its own documentation. +//! +//! # What is not covered, and why that is honest +//! +//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`, +//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture` — names a +//! hand-written type and has no declaration to interpret. It is not skipped +//! silently: [`every_declared_node_is_checked`] asserts the two sets partition +//! `ops/` between them, so a node that stops being declared cannot quietly +//! drop out of this file's coverage. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use dr_pipeline::declared::{builtin_helpers, decl, DeclaredOp, Node}; +use dr_pipeline::descriptor::ParamKind; +use dr_pipeline::operation::{compose, ComposedShader, Operation}; +use dr_pipeline::ops; +use dr_pipeline::ParamId; + +/// The declarations this crate ships, read from disk rather than embedded. +/// +/// From disk deliberately: `build.rs` reads these very files, so reading the +/// same bytes is what makes the comparison a comparison of the two *readers* +/// rather than of two snapshots that were taken at different times. +fn ops_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("ops") +} + +/// Every `.yaml` in `ops/`, keyed by id, excluding `_helpers.yaml`. +fn declaration_files() -> BTreeMap { + let mut out = BTreeMap::new(); + for entry in std::fs::read_dir(ops_dir()).expect("ops/ is readable") { + let path = entry.expect("a directory entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("yaml") { + continue; + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .expect("a file name") + .to_string(); + if stem.starts_with('_') { + continue; + } + out.insert(stem, std::fs::read_to_string(&path).expect("readable")); + } + assert!(!out.is_empty(), "ops/ declares no nodes"); + out +} + +/// Parse one declaration the way both backends do. +fn read(id: &str, text: &str) -> Node { + let library = builtin_helpers(); + decl::read_node(text, &format!("ops/{id}.yaml"), &library.names()) + .unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}")) +} + +/// The generated implementation of one node, taken out of the default chain. +/// +/// Out of `chain()` rather than constructed by name, because that is how the +/// application gets one: if the two ever differed, the chain's version is the +/// one a photograph would be developed with. +fn generated(id: &str) -> Box { + let chain = ops::chain(); + let index = chain + .iter() + .position(|o| o.descriptor().id.0 == id) + .unwrap_or_else(|| panic!("`{id}` is not in the default chain")); + let mut chain = chain; + chain.remove(index) +} + +/// Values worth setting a parameter to, spanning its declared range. +/// +/// Both ends, because that is where a rounding difference in a `min:` or a +/// `max:` would show; the default, because that is the neutral every +/// `is_active` is written against; and two interior points that are not +/// round numbers, because a value like 37.5 exercises the arithmetic in a way +/// 0 and 100 do not. +fn probe_values(kind: &ParamKind, default: f32) -> Vec { + match kind { + ParamKind::Scalar { min, max, .. } => { + let span = max - min; + vec![ + default, + *min, + *max, + min + span * 0.375, + min + span * 0.8125, + // A value that is not representable as a short decimal, to + // catch a backend that round-trips a uniform through text. + min + span / 3.0, + ] + } + ParamKind::Bool => vec![0.0, 1.0], + ParamKind::Enum { variants } => (0..variants.len()).map(|i| i as f32).collect(), + } +} + +/// Put every parameter back where it started. +fn reset(op: &mut dyn Operation) { + let descriptor = op.descriptor(); + for p in &descriptor.params { + op.set_param(p.id, p.default); + } +} + +/// Assert the two operations are indistinguishable at their current settings. +fn assert_same(id: &str, setting: &str, generated: &dyn Operation, declared: &dyn Operation) { + let g = generated.descriptor(); + let d = declared.descriptor(); + assert_eq!(*g, *d, "{id} [{setting}]: the descriptors differ"); + + assert_eq!( + generated.is_active(), + declared.is_active(), + "{id} [{setting}]: the two disagree about whether the operation is doing anything" + ); + + assert_eq!( + generated.wgsl_body(), + declared.wgsl_body(), + "{id} [{setting}]: the fragment bodies differ" + ); + + assert_eq!( + generated.helpers(), + declared.helpers(), + "{id} [{setting}]: the helper sets differ" + ); + + let (gu, du) = (generated.uniforms(), declared.uniforms()); + assert_eq!( + gu.len(), + du.len(), + "{id} [{setting}]: different numbers of uniforms" + ); + for (a, b) in gu.iter().zip(&du) { + assert_eq!( + a.name, b.name, + "{id} [{setting}]: uniforms in a different order" + ); + // Bits, not values: `assert_eq!` on `f32` would call two NaNs unequal + // and would call `0.0` and `-0.0` equal, and both of those are + // differences worth failing on. + assert_eq!( + a.value.to_bits(), + b.value.to_bits(), + "{id} [{setting}]: uniform `{}` is {} generated and {} declared", + a.name, + a.value, + b.value + ); + } +} + +/// Assert two compositions are the same shader. +fn assert_same_shader(what: &str, a: &ComposedShader, b: &ComposedShader) { + // The source first, and compared as whole strings: a diff in the middle of + // several kilobytes of WGSL is unreadable as an assertion message, so the + // failure below points at the first differing line instead. + if a.source != b.source { + let line = a + .source + .lines() + .zip(b.source.lines()) + .position(|(x, y)| x != y); + match line { + Some(n) => panic!( + "{what}: the composed WGSL differs at line {}:\n generated: {:?}\n declared: {:?}", + n + 1, + a.source.lines().nth(n).unwrap_or(""), + b.source.lines().nth(n).unwrap_or(""), + ), + None => panic!( + "{what}: the composed WGSL differs in length: {} generated, {} declared", + a.source.len(), + b.source.len() + ), + } + } + + assert_eq!( + a.uniforms.len(), + b.uniforms.len(), + "{what}: different uniform block sizes" + ); + for (i, (x, y)) in a.uniforms.iter().zip(&b.uniforms).enumerate() { + assert_eq!( + x.to_bits(), + y.to_bits(), + "{what}: uniform slot {i} is {x} generated and {y} declared" + ); + } + + // The hash is taken over the source, so it follows — but it is what the + // pipeline cache keys on, and asserting it says that a declared node and + // its generated twin would share a compiled pipeline rather than quietly + // splitting the cache in two. + assert_eq!(a.structure_hash, b.structure_hash, "{what}: structure hash"); + assert_eq!(a.output_mode, b.output_mode, "{what}: output mode"); +} + +/// Compose a single operation, the way the display path composes a chain. +fn compose_one(op: Box) -> (ComposedShader, Box) { + let shader = compose(std::slice::from_ref(&op)); + (shader, op) +} + +/// TRACES: FR-PLG-2 +/// Every declared built-in composes to the same shader either way. +#[test] +fn a_declaration_read_at_run_time_composes_byte_for_byte_as_the_generated_one() { + let library = builtin_helpers(); + let mut checked = 0; + + for (id, text) in declaration_files() { + let Node::Declared(declaration) = read(&id, &text) else { + continue; + }; + let mut declared = + DeclaredOp::new(&declaration, library).unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}")); + let mut generated = generated(&id); + + // The neutral state first: it is the state every image starts in, and + // an operation that is inactive in one path and active in the other + // would put a whole fragment into one shader and not the other. + assert_same(&id, "neutral", generated.as_ref(), &declared); + + let descriptor = declared.descriptor(); + for p in &descriptor.params { + for value in probe_values(&p.kind, p.default) { + reset(generated.as_mut()); + reset(&mut declared); + generated.set_param(p.id, value); + declared.set_param(p.id, value); + + let setting = format!("{} = {value}", p.id); + assert_same(&id, &setting, generated.as_ref(), &declared); + + let (gs, back) = compose_one(generated); + generated = back; + let (ds, _) = compose_one(Box::new(declared.clone())); + assert_same_shader(&format!("{id} [{setting}]"), &gs, &ds); + checked += 1; + } + } + + // And every parameter moved at once, which is the only case that + // exercises the *order* uniforms are emitted in. + reset(generated.as_mut()); + reset(&mut declared); + for p in &descriptor.params { + let value = + probe_values(&p.kind, p.default)[3.min(probe_values(&p.kind, p.default).len() - 1)]; + generated.set_param(p.id, value); + declared.set_param(p.id, value); + } + assert_same(&id, "all parameters moved", generated.as_ref(), &declared); + let (gs, _) = compose_one(generated); + let (ds, _) = compose_one(Box::new(declared)); + assert_same_shader(&format!("{id} [all parameters moved]"), &gs, &ds); + checked += 1; + } + + // A test that silently checked nothing would pass forever. There are eight + // declared nodes and several settings each, so this is a floor rather than + // a count anybody has to maintain. + assert!( + checked > 20, + "only {checked} comparisons ran; the declarations were not found" + ); +} + +/// TRACES: FR-PLG-2 +/// The whole chain composes identically with the declared nodes swapped in. +/// +/// The single-operation test above is the sharper one — it isolates each node +/// — but it cannot see an interaction. This composes the *default develop +/// chain*, with every declared node replaced by its interpreted twin and the +/// `rust:` nodes left alone, so it covers uniform slot ordering across +/// operations, helper de-duplication between them, and the order the fragments +/// land in the shader. +#[test] +fn the_whole_chain_composes_identically_with_interpreted_nodes() { + let library = builtin_helpers(); + let files = declaration_files(); + + let mut generated_chain = ops::chain(); + let mut declared_chain = ops::chain(); + let mut swapped = 0; + + for i in 0..declared_chain.len() { + let id = declared_chain[i].descriptor().id.0.to_string(); + let text = files + .get(&id) + .unwrap_or_else(|| panic!("`{id}` is in the chain but has no ops/{id}.yaml")); + if let Node::Declared(declaration) = read(&id, text) { + declared_chain[i] = Box::new( + DeclaredOp::new(&declaration, library) + .unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}")), + ); + swapped += 1; + } + // Move every operation off neutral, declared or not, so the chain is + // not a list of fragments that were all omitted. A neutral chain + // composes to a shader with no operation blocks in it at all, which + // would make this test pass while asserting nothing. + let descriptor = generated_chain[i].descriptor(); + for p in &descriptor.params { + let value = probe_values(&p.kind, p.default)[1]; + generated_chain[i].set_param(p.id, value); + declared_chain[i].set_param(p.id, value); + } + } + + assert!( + swapped >= 8, + "only {swapped} nodes were swapped for declared ones" + ); + assert_same_shader( + "the default chain", + &compose(&generated_chain), + &compose(&declared_chain), + ); +} + +/// TRACES: FR-PLG-2 +/// Nothing in `ops/` escapes this file unnoticed. +/// +/// The coverage guard. A declaration that stopped parsing, or a node that +/// quietly became `rust:`, would otherwise reduce what the parity test covers +/// without anything failing — which is exactly the silent divergence the whole +/// file exists to prevent. +#[test] +fn every_declared_node_is_checked() { + let files = declaration_files(); + let mut declared = Vec::new(); + let mut hand_written = Vec::new(); + + for (id, text) in &files { + match read(id, text) { + Node::Declared(_) => declared.push(id.clone()), + Node::Rust { ty, .. } => hand_written.push((id.clone(), ty)), + } + } + + // Every file is one or the other, and the chain holds exactly them. + assert_eq!(declared.len() + hand_written.len(), files.len()); + assert_eq!( + ops::DECLARED_IDS.len(), + files.len(), + "the chain and ops/ hold different numbers of nodes" + ); + for id in ops::DECLARED_IDS { + assert!( + files.contains_key(*id), + "`{id}` is in the chain but not in ops/" + ); + } + + // Named rather than counted, so that a node changing sides is a failure + // somebody reads rather than a number they update. + let hand: Vec<&str> = hand_written.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!( + hand, + [ + "capture_sharpen", + "clarity", + "colour_mixer", + "film_sim", + "noise_reduction", + "texture", + "tone_curve", + ], + "the set of hand-written nodes changed; if that is deliberate, update \ + this list and the module documentation above" + ); + assert!( + declared.len() >= 8, + "only {} declared nodes: {declared:?}", + declared.len() + ); +} + +/// TRACES: FR-PLG-2 +/// A declared operation is addressed by the ids the generated one uses. +/// +/// The practical form of "indistinguishable downstream": the sidecar stores +/// parameters by `(op_id, param_id)` text, so a declared node whose interned +/// ids did not compare equal to the generated constants would load an edit +/// that silently did nothing. +#[test] +fn an_interpreted_node_answers_to_the_generated_parameter_ids() { + let mut declared = DeclaredOp::from_yaml( + &std::fs::read_to_string(ops_dir().join("exposure.yaml")).expect("readable"), + "ops/exposure.yaml", + builtin_helpers(), + ) + .expect("exposure is a declaration"); + + declared.set_param(ops::exposure::EXPOSURE, 1.5); + assert_eq!(declared.param(ParamId("exposure")), 1.5); + assert_eq!(declared.descriptor().id, ops::exposure::ID); +} diff --git a/docs/traceability.md b/docs/traceability.md index 64cf961..20e1158 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 257 | -| TRACES tags found | 746 | +| Source files scanned | 258 | +| TRACES tags found | 751 | | Requirements defined | 177 | | Requirements covered | 100 | | **Coverage** | **56.5%** (100/177) | @@ -58,9 +58,9 @@ _None._ | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | | FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1280`](../ui/dr-ui/src/develop.rs#L1280), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1758`](../ui/dr-ui/src/develop.rs#L1758), [`ui/dr-ui/src/develop.rs:1776`](../ui/dr-ui/src/develop.rs#L1776), [`ui/dr-ui/src/develop.rs:1790`](../ui/dr-ui/src/develop.rs#L1790), [`ui/dr-ui/src/develop.rs:1812`](../ui/dr-ui/src/develop.rs#L1812), [`ui/dr-ui/src/develop.rs:1958`](../ui/dr-ui/src/develop.rs#L1958), [`ui/dr-ui/src/develop.rs:2056`](../ui/dr-ui/src/develop.rs#L2056), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3851`](../ui/dr-ui/src/develop.rs#L3851), [`ui/dr-ui/src/develop.rs:3905`](../ui/dr-ui/src/develop.rs#L3905), [`ui/dr-ui/src/develop.rs:3949`](../ui/dr-ui/src/develop.rs#L3949), [`ui/dr-ui/src/develop.rs:3999`](../ui/dr-ui/src/develop.rs#L3999), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1422`](../ui/dr-ui/src/lib.rs#L1422), [`ui/dr-ui/src/lib.rs:2124`](../ui/dr-ui/src/lib.rs#L2124), [`ui/dr-ui/src/lib.rs:301`](../ui/dr-ui/src/lib.rs#L301), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:2100`](../ui/dr-ui/ui/app.slint#L2100), [`ui/dr-ui/ui/app.slint:981`](../ui/dr-ui/ui/app.slint#L981) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:1814`](../core/dr-pipeline/build.rs#L1814), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1177`](../ui/dr-ui/src/develop.rs#L1177), [`ui/dr-ui/src/lib.rs:595`](../ui/dr-ui/src/lib.rs#L595) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1177`](../ui/dr-ui/src/develop.rs#L1177), [`ui/dr-ui/src/lib.rs:595`](../ui/dr-ui/src/lib.rs#L595) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1814`](../core/dr-pipeline/build.rs#L1814), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4578`](../ui/dr-ui/src/develop.rs#L4578) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4578`](../ui/dr-ui/src/develop.rs#L4578) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | | FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2827`](../ui/dr-ui/src/develop.rs#L2827), [`ui/dr-ui/src/develop.rs:2844`](../ui/dr-ui/src/develop.rs#L2844), [`ui/dr-ui/src/develop.rs:2856`](../ui/dr-ui/src/develop.rs#L2856), [`ui/dr-ui/src/develop.rs:2894`](../ui/dr-ui/src/develop.rs#L2894), [`ui/dr-ui/src/develop.rs:2903`](../ui/dr-ui/src/develop.rs#L2903), [`ui/dr-ui/src/develop.rs:3006`](../ui/dr-ui/src/develop.rs#L3006), [`ui/dr-ui/src/develop.rs:3324`](../ui/dr-ui/src/develop.rs#L3324), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/lib.rs:2098`](../ui/dr-ui/src/lib.rs#L2098), [`ui/dr-ui/src/lib.rs:528`](../ui/dr-ui/src/lib.rs#L528), [`ui/dr-ui/src/lib.rs:586`](../ui/dr-ui/src/lib.rs#L586), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:959`](../ui/dr-ui/ui/adjust.slint#L959), [`ui/dr-ui/ui/app.slint:2712`](../ui/dr-ui/ui/app.slint#L2712), [`ui/dr-ui/ui/app.slint:775`](../ui/dr-ui/ui/app.slint#L775) | @@ -101,8 +101,8 @@ _None._ | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:845`](../ui/dr-ui/src/lib.rs#L845), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232) | -| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:414`](../core/dr-pipeline/src/declared/decl.rs#L414), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | +| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) | +| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:186`](../ui/dr-ui/src/lib.rs#L186) |