diff --git a/Cargo.lock b/Cargo.lock index 1617f45..72b0378 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,6 +1528,9 @@ dependencies = [ "keyring-core", "log", "thiserror 2.0.20", + "wayland-client", + "wayland-protocols", + "x11rb", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fcd0b34..154d616 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,6 +115,25 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.23" +# Display-server clients, for FR-DSP-8's per-display profile acquisition. +# +# Neither is a new cost: winit already builds both, so the versions are the +# ones Slint's backend has resolved to and pinning anything else here would +# compile a second copy. Both are pure Rust — x11rb speaks the X11 wire +# protocol itself rather than binding libxcb, and wayland-client binds +# libwayland only under a feature that is off — which keeps the Android +# cross-compile a plain Rust dependency graph, the same criterion as the TLS +# and SQLite choices above. They are declared under a target predicate that +# excludes Android, where neither display server exists. +# +# `staging` on wayland-protocols is what carries `wp_color_manager_v1`: the +# colour-management extension is still staging upstream, which is the +# protocol-level statement of the thing FR-DSP-8 anticipates when it says +# Wayland's colour management "is not universally available". +x11rb = { version = "0.13", features = ["randr"] } +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client", "staging"] } + # Platform secure storage: Secret Service on Linux, Keystore on Android # (FR-NC-2). Credentials never touch the catalog or a plain file. # keyring 4 restructured its features: `v1` is the default set and brings diff --git a/core/dr-gpu/examples/frame_budget.rs b/core/dr-gpu/examples/frame_budget.rs new file mode 100644 index 0000000..8040770 --- /dev/null +++ b/core/dr-gpu/examples/frame_budget.rs @@ -0,0 +1,695 @@ +//! What a frame actually costs — the measurement FR-DSP-2 is waiting on. +//! +//! `docs/display-and-extension.md` §2 argues that tiled computation predates +//! the fused-shader design and may not need to exist: the composer folds every +//! active operation into **one dispatch over a viewport-sized target**, so the +//! problem tiles were invented to solve may already be solved. That argument +//! is only worth as much as the numbers behind it, and the decision rule was +//! fixed in advance — if the 99th percentile sits inside 16 ms, FR-DSP-2 is +//! rewritten as a scheduling concern for export rather than implemented on the +//! interactive path. +//! +//! This is the instrument that decides it. Three measurements: +//! +//! - **M1** — the fused pass at proxy resolution, at three viewport sizes and +//! three chain lengths. +//! - **M2** — the same with the view zoomed to 1:1 on a 60 MP source, which is +//! the case FR-DSP-5 names. +//! - **M3** — the neighbourhood stage on its own. It is the only part of the +//! chain that is not one read and one write, so it is the plausible +//! budget-breaker and deserves to be measured apart from the fused pass +//! rather than hidden inside its total. +//! +//! ```sh +//! cargo run --release -p dr-gpu --example frame_budget +//! ``` +//! +//! **Release, always.** A debug build measures rustc's shadow, not the GPU's: +//! the per-frame CPU half is dominated by shader-source assembly, which is +//! string formatting and is several times slower unoptimised. +//! +//! The committed numbers live in `docs/frame-budget.md`. Rerun this and diff +//! that file; a regression should be a diff rather than somebody's memory. +//! +//! # Why the 99th percentile and not the mean +//! +//! A slider drag is judged by its worst frame. A chain averaging 4 ms with one +//! frame in fifty at 30 ms reads as a stutter, and the mean says nothing about +//! it. Percentiles are nearest-rank over the samples with the warm-up already +//! discarded — see [`Percentiles`]. +//! +//! # What is being timed +//! +//! Each frame is `compose` (CPU: assemble the WGSL and its uniforms) followed +//! by `render_detailed` and a `poll` that waits for the device to go idle. +//! Waiting serialises the GPU work into the frame it belongs to, which is +//! pessimistic — a real presentation pipeline overlaps a frame's tail with the +//! next frame's head — and pessimistic is the right direction for a budget. +//! +//! The compose half is reported separately because it is *not* GPU work and +//! would otherwise be invisible: it happens on the UI thread on every slider +//! event, and if it were the expensive half then tiling could not help at all. + +use std::time::Instant; + +use dr_film::bake::{bake, Recipe}; +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::descriptor::ParamKind; +use dr_pipeline::ops::{ + blacks_whites, contrast, exposure, highlights_shadows, local_contrast, noise_reduction, + vibrance, FilmTables, +}; +use dr_pipeline::{Attribute, CropRect, EditGraph, OpId, ParamId}; + +/// The synthetic source: 9504 × 6336 is 60.2 MP, which is a Sony A7R V or a +/// Fujifilm GFX 100 with a little taken off. §2's M2 names 60 MP, so this is +/// that number rather than a round one. +const SOURCE: (u32, u32) = (9504, 6336); + +/// Frames measured per row, after the warm-up. Enough that the 99th percentile +/// means something (nearest-rank picks the second-worst of 100) without the +/// whole matrix taking minutes. +const FRAMES: usize = 100; + +/// Frames discarded before measurement begins. +/// +/// The first frame at a new size allocates a render target, the first frame +/// with a new chain compiles a pipeline, and the first frame of a detail stage +/// allocates its ping-pong pair. None of those recur during a drag, so +/// including them would measure the wrong thing — but they are worth knowing +/// about, so [`Run::first_frame_ms`] reports the very first one separately. +const WARMUP: usize = 12; + +/// The viewport sizes from §2's M1: a laptop panel, a 16:10 desktop, and 4K. +const SIZES: [(u32, u32); 3] = [(1920, 1200), (2560, 1600), (3840, 2160)]; + +/// The frame budget FR-DSP-3 is asserting against, in milliseconds. 60 Hz. +const BUDGET_MS: f64 = 16.0; + +fn main() { + env_logger::init(); + + let ctx = match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => c, + Err(e) => { + eprintln!("no GPU adapter: {e}"); + std::process::exit(1); + } + }; + println!("adapter {} ({:?})", ctx.adapter_name(), ctx.backend()); + + let limit = DemosaicedImage::max_dimension(&ctx); + if SOURCE.0 > limit { + eprintln!( + "this adapter caps textures at {limit} px; {} × {} will not fit", + SOURCE.0, SOURCE.1 + ); + std::process::exit(1); + } + + let t = Instant::now(); + let source = synthetic_source(&ctx); + println!( + "source {} × {} ({:.1} MP, {:.0} MB as rgba16f), built in {:.1} s", + SOURCE.0, + SOURCE.1, + (SOURCE.0 as f64 * SOURCE.1 as f64) / 1e6, + (SOURCE.0 as f64 * SOURCE.1 as f64 * 8.0) / 1e6, + t.elapsed().as_secs_f64() + ); + println!("budget {BUDGET_MS:.0} ms (FR-DSP-3)\n"); + + let mut adjust = AdjustPass::new(&ctx); + + m1_and_m2(&ctx, &mut adjust, &source); + m3(&ctx, &mut adjust, &source); +} + +// --------------------------------------------------------------------------- +// M1 and M2 — the fused pass, fit and at 1:1 +// --------------------------------------------------------------------------- + +/// The chain lengths §2 asks for. +/// +/// "Every operation" means every operation in [`EditGraph::default_chain`], +/// which is what `ops/*.yaml` generates. The lens corrections are not in it — +/// they are constructed from a matched profile rather than being part of the +/// default chain — and neither are masks or spot repairs, which are stacks of +/// their own. So this is an upper bound on the *declared* chain and a lower +/// bound on the worst edit a photograph can carry; §7 of the spec asks for +/// honesty about exactly this sort of thing. +/// +/// [`Chain::AllPoint`] is not in §2's list and is the row that decides the +/// question. §2 asks whether *one dispatch over a viewport-sized target* is +/// fast enough; "every operation" mixes that dispatch together with the +/// neighbourhood stage, which is not one dispatch and is measured separately in +/// M3. Splitting the two apart is what lets M1 answer the question it was +/// written to answer instead of a different, harder one. +#[derive(Clone, Copy, PartialEq)] +enum Chain { + One, + Five, + /// Every operation that contributes a fragment to the fused shader — the + /// full point-operation chain, and nothing with a kernel. + AllPoint, + Everything, +} + +impl Chain { + fn label(self) -> &'static str { + match self { + Chain::One => "one", + Chain::Five => "five", + Chain::AllPoint => "point", + Chain::Everything => "all", + } + } +} + +fn m1_and_m2(ctx: &GpuContext, adjust: &mut AdjustPass, source: &DemosaicedImage) { + for (title, zoomed) in [ + ( + "M1 — fused frame at proxy resolution (fit; the develop view)", + false, + ), + ( + "M2 — the same view zoomed to 1:1 on the 60 MP source (FR-DSP-5)", + true, + ), + ] { + println!("{title}"); + header(); + for size in SIZES { + for chain in [Chain::One, Chain::Five, Chain::AllPoint, Chain::Everything] { + let mut graph = build_chain(chain); + if zoomed { + graph.framing_mut().set_view(one_to_one(size)); + } + if matches!(chain, Chain::AllPoint | Chain::Everything) { + adjust.set_film(Some(&film_tables())); + } + // The drag: exposure, moved by a hundredth of a stop per frame. + // A real slider does exactly this, and it is what stops the + // fused dispatch being skipped by `render_detailed`'s colour + // reuse — which would turn M1 into a measurement of the detail + // stage by accident. + let run = measure(ctx, adjust, source, &mut graph, size, |g, i| { + g.set_param(exposure::ID, exposure::EXPOSURE, 0.30 + i as f32 * 0.01); + }); + adjust.set_film(None); + row(size, chain.label(), &run); + } + } + println!(); + } + println!(" `shader` is `EditGraph::compose` alone; `cpu` adds the detail chain and the"); + println!(" invalidation hash, which is everything the develop view does per frame before"); + println!(" it dispatches. `gpu` is submit plus wait-for-idle. `TOTAL` ranks cpu + gpu"); + println!(" summed within each frame — the column the {BUDGET_MS:.0} ms budget is judged on."); + println!(); + println!(" `point` is every operation that contributes a fragment to the fused shader;"); + println!(" `all` is that plus the four neighbourhood operations, which is why the two"); + println!(" differ by roughly what M3 charges for the detail stage on its own."); + println!(); +} + +// --------------------------------------------------------------------------- +// M3 — the detail stage alone +// --------------------------------------------------------------------------- + +/// Neighbourhood operations, timed with the fused dispatch deliberately reused. +/// +/// `render_detailed` skips the fused pass when the colour key, the shader, its +/// uniforms and the size are all unchanged — that is FR-DEV-3d, and it is also +/// the lever that isolates M3: move only a detail operation's amount and the +/// timing covers the convolutions and nothing else. The `colour` column proves +/// the isolation held rather than asserting it: it counts fused dispatches over +/// the measured frames, and a zero there is what makes the number mean "detail +/// alone". +fn m3(ctx: &GpuContext, adjust: &mut AdjustPass, source: &DemosaicedImage) { + println!("M3 — the neighbourhood stage alone (fused dispatch reused; FR-DEV-3d)"); + println!( + " {:>11} {:>8} {:>5} {:>4} {:>6} {:>6} {:>8} {:>7} {:>7} {:>7}", + "size", "stage", "view", "pass", "radius", "colour", "cpu p99", "p50", "p99", "max" + ); + + for size in SIZES { + for zoomed in [false, true] { + for (label, build) in DETAIL_CHAINS { + let mut graph = build(); + if zoomed { + graph.framing_mut().set_view(one_to_one(size)); + } + let composed = graph.compose_detail(source.size(), size); + let (passes, radius) = (composed.len(), composed.radius()); + // Only the detail parameter moves, so the colour half of the + // chain is bit-identical frame to frame and gets reused. + let run = measure(ctx, adjust, source, &mut graph, size, |g, i| { + let drift = (i % 20) as f32; + g.set_param( + local_contrast::CLARITY, + local_contrast::AMOUNT, + 60.0 + drift, + ); + g.set_param( + local_contrast::TEXTURE, + local_contrast::AMOUNT, + 60.0 + drift, + ); + g.set_param( + noise_reduction::ID, + noise_reduction::LUMINANCE, + 60.0 + drift, + ); + g.set_param(noise_reduction::ID, noise_reduction::CHROMA, 60.0 + drift); + }); + println!( + " {:>5}x{:<5} {:>8} {:>5} {:>4} {:>6} {:>6} {:>6.2}ms {:>5.2}ms {:>5.2}ms {:>5.2}ms", + size.0, + size.1, + label, + if zoomed { "1:1" } else { "fit" }, + passes, + radius, + run.colour_dispatches, + run.cpu.p99, + run.frame.p50, + run.frame.p99, + run.frame.max + ); + } + } + } + println!(); + println!(" `pass` is dispatches in the detail chain and `radius` the widest halo any of"); + println!(" them reads, in render pixels. `colour` is fused dispatches over the {FRAMES}"); + println!(" measured frames, and must be 0 for the row to mean what it says."); + println!(); + println!(" Clarity's kernel is a fraction of the *frame*, so it grows with the viewport"); + println!(" and not with the zoom. Noise reduction's is a fraction of the *sensor*, so it"); + println!(" grows with the zoom and not with the viewport. That is why both are here."); +} + +/// The detail chains M3 walks: the widest kernel alone, then all four +/// neighbourhood operations at once. +/// +/// Clarity is first because §2 names it — "a separable blur at a large radius +/// is the plausible budget-breaker" — and its σ is 1.2% of the shorter edge, +/// which at 4K is a 52-pixel radius and the widest kernel anywhere in the +/// pipeline. +/// A named detail chain: a label for the table, and the graph it builds. +type DetailChain = (&'static str, fn() -> EditGraph); + +const DETAIL_CHAINS: [DetailChain; 2] = [ + ("clarity", || { + let mut g = EditGraph::default_chain(); + g.set_param(local_contrast::CLARITY, local_contrast::AMOUNT, 60.0); + g + }), + ("all four", || { + let mut g = EditGraph::default_chain(); + g.set_param(local_contrast::CLARITY, local_contrast::AMOUNT, 60.0); + g.set_param(local_contrast::TEXTURE, local_contrast::AMOUNT, 60.0); + g.set_param(noise_reduction::ID, noise_reduction::LUMINANCE, 60.0); + g.set_param(noise_reduction::ID, noise_reduction::CHROMA, 60.0); + g.set_param( + dr_pipeline::ops::capture_sharpen::ID, + dr_pipeline::ops::capture_sharpen::AMOUNT, + 60.0, + ); + g + }), +]; + +// --------------------------------------------------------------------------- +// The measurement itself +// --------------------------------------------------------------------------- + +/// Nearest-rank percentiles over a set of frame times, in milliseconds. +/// +/// Nearest-rank rather than an interpolating definition because the samples +/// *are* the population — there is no distribution being estimated, only a +/// hundred frames that either happened inside the budget or did not. At +/// `FRAMES = 100` the 99th percentile is the second-worst frame, which is the +/// honest reading of "one stutter in a hundred is one too many" without +/// letting a single scheduler hiccup on an unrelated process decide the +/// verdict. +struct Percentiles { + p50: f64, + p99: f64, + max: f64, +} + +impl Percentiles { + fn of(mut samples: Vec) -> Self { + samples.sort_by(f64::total_cmp); + let rank = |p: f64| { + let n = samples.len(); + let i = ((p * n as f64).ceil() as usize).clamp(1, n) - 1; + samples[i] + }; + Self { + p50: rank(0.50), + p99: rank(0.99), + max: samples[samples.len() - 1], + } + } +} + +struct Run { + /// CPU: assembling the fused shader alone. + shader: Percentiles, + /// CPU: everything `DevelopSession::render` does before it dispatches — + /// the fused shader, the detail chain, and the invalidation hash the + /// colour key comes from. Split out from [`Self::shader`] because if the + /// expensive half of a frame turns out to be string formatting on the UI + /// thread, no amount of tiling helps and the conclusion is a different + /// one. + cpu: Percentiles, + /// Submit plus wait for the device to go idle. + frame: Percentiles, + /// CPU and GPU summed **per frame**, then ranked. + /// + /// Not the sum of the two percentiles above, which would be a number no + /// frame ever took: the CPU's worst frame and the GPU's worst frame are + /// not generally the same frame, and adding them invents a stutter that + /// did not happen. This is the column the budget is judged on. + total: Percentiles, + /// The very first frame of all, warm-up included — pipeline compilation + /// and target allocation. Reported because it is real (it is what a + /// photograph opening costs) and because it must not be inside the + /// percentiles. + first_frame_ms: f64, + /// Fused dispatches over the measured frames. `FRAMES` when the colour + /// chain is moving, 0 when only a detail parameter is. + colour_dispatches: usize, +} + +/// Render `FRAMES` frames, moving a parameter between each, and time them. +/// +/// `drag` receives the frame index and is expected to move whatever this row +/// is measuring the drag of. It is called for the warm-up frames too, so that +/// nothing measured is the first of its kind. +fn measure( + ctx: &GpuContext, + adjust: &mut AdjustPass, + source: &DemosaicedImage, + graph: &mut EditGraph, + size: (u32, u32), + drag: impl Fn(&mut EditGraph, usize), +) -> Run { + // A constant, because every row here renders the same photograph. In the + // app this is the `VersionId` mixed with the colour invalidation — see + // `render_detailed`'s note on why the caller owns it. + const COLOUR_KEY: u64 = 0x0dd_ba11; + + let src = source.size(); + let mut first_frame_ms = f64::NAN; + + for i in 0..WARMUP { + drag(graph, i); + let t = Instant::now(); + frame(ctx, adjust, source, graph, src, size, COLOUR_KEY); + if i == 0 { + first_frame_ms = t.elapsed().as_secs_f64() * 1e3; + } + } + + let dispatches_before = adjust.colour_dispatches(); + let mut shader_ms = Vec::with_capacity(FRAMES); + let mut cpu_ms = Vec::with_capacity(FRAMES); + let mut gpu_ms = Vec::with_capacity(FRAMES); + let mut total_ms = Vec::with_capacity(FRAMES); + + for i in 0..FRAMES { + drag(graph, WARMUP + i); + + // The same three calls `DevelopSession::render` makes, in the same + // order, so that this is the develop view's frame and not an + // idealisation of it. + let t0 = Instant::now(); + let shader = graph.compose(); + let after_shader = t0.elapsed(); + let detail = graph.compose_detail(src, size); + let colour_key = graph.invalidation().through(dr_pipeline::Affects::Colour); + let cpu = t0.elapsed(); + + let t1 = Instant::now(); + adjust + .render_detailed( + source, + &shader, + size.0, + size.1, + None, + &detail, + colour_key ^ COLOUR_KEY, + ) + .expect("render"); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("poll"); + let gpu = t1.elapsed(); + + shader_ms.push(after_shader.as_secs_f64() * 1e3); + cpu_ms.push(cpu.as_secs_f64() * 1e3); + gpu_ms.push(gpu.as_secs_f64() * 1e3); + total_ms.push((cpu + gpu).as_secs_f64() * 1e3); + } + + Run { + shader: Percentiles::of(shader_ms), + cpu: Percentiles::of(cpu_ms), + frame: Percentiles::of(gpu_ms), + total: Percentiles::of(total_ms), + first_frame_ms, + colour_dispatches: adjust.colour_dispatches() - dispatches_before, + } +} + +/// One frame, composition included, with nothing timed. The warm-up path. +fn frame( + ctx: &GpuContext, + adjust: &mut AdjustPass, + source: &DemosaicedImage, + graph: &EditGraph, + src: (u32, u32), + size: (u32, u32), + colour_key: u64, +) { + let shader = graph.compose(); + let detail = graph.compose_detail(src, size); + let key = graph.invalidation().through(dr_pipeline::Affects::Colour) ^ colour_key; + adjust + .render_detailed(source, &shader, size.0, size.1, None, &detail, key) + .expect("render"); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("poll"); +} + +fn header() { + println!( + " {:>11} {:>5} {:>8} {:>8} {:>8} {:>8} {:>8} {:>7}", + "size", "chain", "shader", "cpu p99", "gpu p50", "gpu p99", "TOTAL", "first" + ); +} + +fn row(size: (u32, u32), chain: &str, run: &Run) { + println!( + " {:>5}x{:<5} {:>5} {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>5.0}ms{}", + size.0, + size.1, + chain, + run.shader.p99, + run.cpu.p99, + run.frame.p50, + run.frame.p99, + run.total.p99, + run.first_frame_ms, + if run.total.p99 > BUDGET_MS { + " OVER" + } else { + "" + } + ); +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/// The view rect that puts one render pixel on one source pixel. +/// +/// This is FR-DSP-5's mechanism stated as arithmetic: the render target keeps +/// its size while the sampled region shrinks, so a view covering exactly +/// `render / source` of the frame samples one-for-one. Nothing anywhere +/// switches to a "full resolution path"; the zoom *is* the full-resolution +/// path. +fn one_to_one(render: (u32, u32)) -> CropRect { + let w = render.0 as f32 / SOURCE.0 as f32; + let h = render.1 as f32 / SOURCE.1 as f32; + CropRect { + // Centred, so the sampled region is somewhere a person would actually + // look and not a corner the caches treat differently. + x: (1.0 - w) * 0.5, + y: (1.0 - h) * 0.5, + width: w, + height: h, + } +} + +/// A 60 MP source with detail at every scale. +/// +/// Not flat and not noise. A flat frame lets the memory system serve every +/// sample from one cache line, which flatters the wide kernels of M3 by an +/// amount that has nothing to do with photographs; pure noise does the +/// opposite. This is a coarse gradient with a fine dither on top, which is +/// closer to a real frame's spectrum than either and costs one multiply per +/// pixel to generate. +fn synthetic_source(ctx: &GpuContext) -> DemosaicedImage { + let (w, h) = SOURCE; + let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4]; + for y in 0..h as usize { + let row = y * (w as usize) * 4; + for x in 0..w as usize { + // A cheap integer hash for the fine structure, so neighbouring + // pixels differ and a bilateral filter has something to reject. + let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13; + let dither = (n & 0x1f) as u32; + let gx = (x * 200 / w as usize) as u32; + let gy = (y * 55 / h as usize) as u32; + let px = &mut rgba[row + x * 4..row + x * 4 + 4]; + px[0] = (30 + gx + dither).min(255) as u8; + px[1] = (40 + gy + dither).min(255) as u8; + px[2] = (60 + gx / 2 + gy + dither).min(255) as u8; + px[3] = 255; + } + } + DemosaicedImage::from_rgba8(ctx, &rgba, w, h).expect("upload source") +} + +fn build_chain(chain: Chain) -> EditGraph { + let mut graph = EditGraph::default_chain(); + match chain { + Chain::One => { + graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3); + } + Chain::Five => { + graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3); + graph.set_param(contrast::ID, contrast::CONTRAST, 25.0); + graph.set_param( + highlights_shadows::ID, + highlights_shadows::HIGHLIGHTS, + -40.0, + ); + graph.set_param(blacks_whites::ID, blacks_whites::BLACKS, -20.0); + graph.set_param(vibrance::ID, vibrance::VIBRANCE, 35.0); + } + Chain::AllPoint => { + activate_everything(&mut graph, false); + } + Chain::Everything => { + activate_everything(&mut graph, true); + } + } + graph +} + +/// Move every parameter of every operation off its default. +/// +/// Written against [`EditGraph::capabilities`] rather than as a list of +/// operations, for the same reason the panel is: this bench must not need +/// editing when an operation is added, or it will quietly stop measuring "all +/// of them" on the first day somebody declares a new node. +/// +/// A quarter of the way from the default towards the maximum, which is a +/// setting a photographer might plausibly reach and — far more importantly — +/// is *active*, since an operation sitting at its neutral contributes no +/// fragment at all and would make this row a shorter chain wearing a longer +/// chain's label. +/// +/// `neighbourhood` selects whether the operations declaring +/// [`Attribute::Detail`] are moved as well. Left off, what remains is exactly +/// the set that contributes a fragment to the fused shader — see [`Chain`] for +/// why that distinction is the point of this bench. +fn activate_everything(graph: &mut EditGraph, neighbourhood: bool) { + let moves: Vec<(OpId, ParamId, f32)> = graph + .capabilities() + .iter() + .filter(|op| neighbourhood || !op.attributes.contains(&Attribute::Detail)) + .flat_map(|op| { + op.params.iter().map(move |p| { + let value = match p.kind { + ParamKind::Scalar { min, max, .. } => { + // Towards whichever end is further away, so a + // parameter defaulting to its maximum still moves. + let far = if (max - p.default).abs() >= (p.default - min).abs() { + max + } else { + min + }; + p.default + (far - p.default) * 0.25 + } + ParamKind::Bool => 1.0, + // The second variant when there is one. The first is the + // default by construction — see `ParamDescriptor::choice`. + // Bound by reference: a descriptor's variants became an + // owned `Vec` when descriptors stopped being `&'static`, + // and this arm only ever reads the length. + ParamKind::Enum { ref variants } => { + if variants.len() > 1 { + 1.0 + } else { + 0.0 + } + } + }; + (op.id, p.id, value) + }) + }) + .collect(); + for (op, param, value) in moves { + graph.set_param(op, param, value); + } + + // The film stock is not a slider and so is not reachable through the loop + // above: `FilmSim::is_active` is true when it has tables and false + // otherwise (see `graph::Film`). Without this the "all" row would be the + // whole chain minus its single most expensive node, which is a 3D lookup + // and a pair of curve textures. + graph.set_film(Some(dr_pipeline::graph::Film { + stock: STOCK.into(), + print: None, + tables: film_tables(), + })); +} + +/// The stock the "every operation" chain renders through. A colour negative +/// viewed directly, which is the more expensive of the two paths: the LUT is +/// consulted either way, and skipping the print step is one fewer thing that +/// could be mistaken for the measurement. +const STOCK: &str = "kodak_portra_400"; + +/// The stock the "all operations" row renders through, in the layout the pass +/// binds. Baked once per row; baking is milliseconds and happens off the frame +/// path in the app too. +fn film_tables() -> FilmTables { + let film = dr_film::find(STOCK).expect("stock"); + let baked = bake(&Recipe::new(film, None)); + FilmTables { + exposure_matrix: baked.exposure_matrix, + curves: baked.curves.clone(), + curve_log_min: baked.curve_log_min, + curve_log_max: baked.curve_log_max, + lut: baked.lut.clone(), + density_max: baked.density_max, + lut_size: baked.lut_size, + // Grain off. It is a per-pixel hash and would be measured; it is also + // not part of every edit, and the chain being measured here is "every + // operation active", not "every option of every operation". + grain_particles: [0.0; 3], + grain_density_max: [baked.density_max; 3], + grain_uniformity: 0.97, + } +} diff --git a/core/dr-gpu/tests/frame_budget.rs b/core/dr-gpu/tests/frame_budget.rs new file mode 100644 index 0000000..ce8bb26 --- /dev/null +++ b/core/dr-gpu/tests/frame_budget.rs @@ -0,0 +1,341 @@ +//! The frame budget, asserted rather than hoped for. +//! +//! FR-DSP-3 says a slider updates the visible region within one frame budget at +//! proxy resolution. Until this file existed nothing checked it, which made it +//! a wish — `docs/display-and-extension.md` §3 is blunt about that, and §7 is +//! blunt about what tagging an unchecked requirement does to the coverage +//! figure. +//! +//! The measurements this guards are in [`docs/frame-budget.md`], produced by +//! `examples/frame_budget.rs`. This file is the part of them that has to keep +//! being true: it renders the **whole point-operation chain** through the real +//! `render_detailed` for a hundred frames, moving a slider between each, and +//! fails if the 99th percentile leaves the budget. +//! +//! # What it does not cover, said out loud +//! +//! **The neighbourhood stage is deliberately not in the asserted chain.** It is +//! over the budget today — clarity alone is 34 ms at 4K, because its kernel is +//! a fraction of the frame and reaches a 52-pixel radius there — and +//! `docs/frame-budget.md` records that, names the fix (a base computed at +//! reduced resolution) and does not pretend otherwise. Asserting a budget the +//! code does not meet would produce a red suite that everyone learns to ignore; +//! asserting it on a chain that quietly excluded the expensive stage *without +//! saying so* would be the coverage overstatement §7 warns about. So it is +//! excluded, loudly, here. +//! +//! What is asserted is exactly the claim the FR-DSP-2 recommendation rests on: +//! that **one fused dispatch over a viewport-sized target is comfortably inside +//! the budget**, at a full chain, fit and at 1:1. If that stops being true, the +//! recommendation to strike tiled computation from the interactive path stops +//! being supported, and this test is what says so. +//! +//! # Why the percentile and not the mean +//! +//! A drag is judged by its worst frame. Nearest-rank over 100 frames puts the +//! 99th percentile at the second-worst, which is strict enough to catch a +//! stutter and forgiving enough that one scheduler hiccup from an unrelated +//! process does not decide the verdict. +//! +//! # Why the CPU half is asserted only in an optimised build +//! +//! Composing the shader is per-frame work on the UI thread and belongs in the +//! budget — `DevelopSession::render` calls `compose` on every frame, and on a +//! full chain it is milliseconds of string formatting. But the workspace builds +//! its own crates at `opt-level = 0` in dev (see the root `Cargo.toml`), and +//! `cargo test` is a dev build, so that formatting runs unoptimised here and +//! measures rustc rather than the pipeline. The GPU half is unaffected: a +//! shader is compiled by the driver either way. +//! +//! So the GPU half is always asserted, and the composition is folded in only +//! when `debug_assertions` is off. Running `cargo test --release -p dr-gpu` +//! therefore checks strictly more than the default run does, and the numbers +//! printed on failure say which of the two halves was over. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::descriptor::ParamKind; +use dr_pipeline::ops::exposure; +use dr_pipeline::{Affects, Attribute, CropRect, EditGraph, OpId, ParamId}; +use std::time::Instant; + +/// 60 Hz. FR-DSP-3 does not name a number; this is the one every interactive +/// application means by "one frame". +const BUDGET_MS: f64 = 16.0; + +/// Measured frames per case. Nearest-rank p99 of 100 is the second-worst. +const FRAMES: usize = 100; + +/// Discarded before measurement: the first frame at a size allocates a render +/// target and the first frame of a chain compiles a pipeline. Neither recurs +/// during a drag, so neither belongs in a drag's percentile. +const WARMUP: usize = 12; + +/// A 24 MP source — a full-frame camera, and large enough that a 1:1 view of it +/// is a genuine zoom rather than a rounding error. +/// +/// Smaller than the bench's 60 MP on purpose. The fused pass costs what the +/// *output* costs, so the source size barely moves these numbers, and 24 MP +/// keeps the fixture inside a second even at `opt-level = 0`. +const SOURCE: (u32, u32) = (6000, 4000); + +/// The viewport the budget is asserted at: a 16:10 desktop display. +/// +/// Not 4K, and the reason is worth stating. At 4K the fused chain still passes +/// with room to spare (4.5 ms of GPU; see `docs/frame-budget.md`), but a test +/// that renders 8.3 M pixels a hundred times twice over is four seconds of +/// suite time to re-establish a conclusion 4.1 M pixels already establishes. +const VIEWPORT: (u32, u32) = (2560, 1600); + +fn ctx() -> Option { + // CI runners and headless machines may have no usable adapter. Skip rather + // than fail, exactly as the rest of this crate's device tests do. + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } +} + +/// TRACES: FR-DSP-3 | FR-DSP-5 +/// A slider drag on the full point-operation chain stays inside one frame — +/// fit, and at 1:1. +/// +/// The develop view's ordinary case, at a full chain rather than a flattering +/// one: every operation that contributes a fragment to the fused shader is +/// active, and exposure moves between frames exactly as a drag moves it. +/// +/// The 1:1 case is the one FR-DSP-5 names and the one FR-DSP-3's asynchronous +/// clause was written for. It is asserted here because the measurement found +/// that clause unnecessary rather than merely unimplemented: a 1:1 view is +/// *cheaper* than a fit view of the same file, since the dispatch is the same +/// size and the reads are contiguous rather than strided. If that ever inverts, +/// the argument for striking the clause weakens, and this is what would notice. +/// +/// # One test and not two, deliberately +/// +/// The two cases were two `#[test]` functions until the numbers said otherwise. +/// Cargo runs a binary's tests on a thread each, both of these want the same +/// GPU, and contending for it took the 1:1 case from 2.5 ms to 14.9 ms — a +/// measurement of the test harness that would have flickered either side of the +/// budget forever. A timing assertion has to own the device while it runs, and +/// the only way to say that in a test binary is to be the only test in it. +#[test] +fn a_slider_drag_stays_inside_the_frame_budget() { + let Some(ctx) = ctx() else { return }; + let source = synthetic_source(&ctx); + + let mut fit = full_point_chain(); + drag(&ctx, &source, &mut fit, VIEWPORT).assert_inside_budget("proxy resolution, fit", VIEWPORT); + + let mut zoomed = full_point_chain(); + zoomed.framing_mut().set_view(one_to_one(VIEWPORT)); + drag(&ctx, &source, &mut zoomed, VIEWPORT) + .assert_inside_budget("1:1 on a 24 MP source", VIEWPORT); +} + +// --------------------------------------------------------------------------- +// The measurement +// --------------------------------------------------------------------------- + +struct Run { + /// Per frame: compose, compose the detail chain, hash the invalidation. + cpu_p99: f64, + /// Per frame: submit and wait for the device to go idle. + gpu_p99: f64, + /// `cpu + gpu` summed within each frame, then ranked. Not the sum of the + /// two percentiles above, which would be a frame that never happened. + total_p99: f64, +} + +impl Run { + /// Fail if the budget was missed, saying which half missed it. + /// + /// In a dev build only the GPU half is judged — see the module + /// documentation for why — and the CPU figure is still printed, because a + /// reader looking at a failure wants both numbers even when only one of + /// them is the verdict. + fn assert_inside_budget(&self, case: &str, viewport: (u32, u32)) { + let judged = if cfg!(debug_assertions) { + self.gpu_p99 + } else { + self.total_p99 + }; + assert!( + judged <= BUDGET_MS, + "{case} at {}x{}: p99 of {FRAMES} frames was {judged:.2} ms, over the \ + {BUDGET_MS:.0} ms budget (cpu {:.2} ms, gpu {:.2} ms, total {:.2} ms). \ + FR-DSP-3 is what this violates; docs/frame-budget.md holds the \ + numbers it used to be.", + viewport.0, + viewport.1, + self.cpu_p99, + self.gpu_p99, + self.total_p99, + ); + } +} + +/// Render `FRAMES` frames with the exposure slider moving between each. +/// +/// The three calls before the dispatch are the three `DevelopSession::render` +/// makes, in the same order, so this is the develop view's frame rather than an +/// idealisation of it. +fn drag( + ctx: &GpuContext, + source: &DemosaicedImage, + graph: &mut EditGraph, + viewport: (u32, u32), +) -> Run { + // Stands for the `VersionId` the app mixes in. Constant because every frame + // here is the same photograph. + const PHOTOGRAPH: u64 = 0x0dd_ba11; + + let mut adjust = AdjustPass::new(ctx); + let src = source.size(); + let (w, h) = viewport; + + let mut cpu = Vec::with_capacity(FRAMES); + let mut gpu = Vec::with_capacity(FRAMES); + let mut total = Vec::with_capacity(FRAMES); + + for i in 0..WARMUP + FRAMES { + // A hundredth of a stop per frame: what a drag does, and what stops + // `render_detailed` reusing the previous frame's colour result and + // turning this into a measurement of nothing. + graph.set_param(exposure::ID, exposure::EXPOSURE, 0.30 + i as f32 * 0.01); + + let t0 = Instant::now(); + let shader = graph.compose(); + let detail = graph.compose_detail(src, viewport); + let colour_key = graph.invalidation().through(Affects::Colour) ^ PHOTOGRAPH; + let cpu_elapsed = t0.elapsed(); + + let t1 = Instant::now(); + adjust + .render_detailed(source, &shader, w, h, None, &detail, colour_key) + .expect("render"); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("poll"); + let gpu_elapsed = t1.elapsed(); + + if i >= WARMUP { + cpu.push(cpu_elapsed.as_secs_f64() * 1e3); + gpu.push(gpu_elapsed.as_secs_f64() * 1e3); + total.push((cpu_elapsed + gpu_elapsed).as_secs_f64() * 1e3); + } + } + + Run { + cpu_p99: p99(cpu), + gpu_p99: p99(gpu), + total_p99: p99(total), + } +} + +/// Nearest-rank 99th percentile. +/// +/// Nearest-rank because the samples *are* the population: there is no +/// distribution being estimated, only a hundred frames that either fitted in +/// the budget or did not. +fn p99(mut samples: Vec) -> f64 { + samples.sort_by(f64::total_cmp); + let n = samples.len(); + samples[((0.99 * n as f64).ceil() as usize).clamp(1, n) - 1] +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/// Every operation that contributes a fragment to the fused shader, active. +/// +/// Built from [`EditGraph::capabilities`] rather than from a list of operation +/// names, for the same reason the develop panel is: declaring a new node must +/// not silently shrink what this test calls "the full chain". A quarter of the +/// way from each parameter's default towards whichever end is further from it, +/// which is a plausible setting and — the part that matters — is never the +/// neutral, since a neutral operation contributes nothing at all. +/// +/// The film stock is not reachable this way (it is a choice of material, not a +/// slider) and is left off. It is one texture lookup and two curve reads; the +/// bench includes it and it is worth about a millisecond at this size. +fn full_point_chain() -> EditGraph { + let mut graph = EditGraph::default_chain(); + let moves: Vec<(OpId, ParamId, f32)> = graph + .capabilities() + .iter() + // The neighbourhood operations. See the module documentation for why + // they are not here. + .filter(|op| !op.attributes.contains(&Attribute::Detail)) + .flat_map(|op| { + op.params.iter().map(move |p| { + let value = match p.kind { + ParamKind::Scalar { min, max, .. } => { + let far = if (max - p.default).abs() >= (p.default - min).abs() { + max + } else { + min + }; + p.default + (far - p.default) * 0.25 + } + ParamKind::Bool => 1.0, + // Bound by reference: a descriptor's variants became an + // owned `Vec` when descriptors stopped being `&'static`, + // and this arm only ever reads the length. + ParamKind::Enum { ref variants } => { + if variants.len() > 1 { + 1.0 + } else { + 0.0 + } + } + }; + (op.id, p.id, value) + }) + }) + .collect(); + for (op, param, value) in moves { + graph.set_param(op, param, value); + } + graph +} + +/// The view rect that puts one render pixel on one source pixel, centred. +fn one_to_one(render: (u32, u32)) -> CropRect { + let w = render.0 as f32 / SOURCE.0 as f32; + let h = render.1 as f32 / SOURCE.1 as f32; + CropRect { + x: (1.0 - w) * 0.5, + y: (1.0 - h) * 0.5, + width: w, + height: h, + } +} + +/// A source with structure at every scale. +/// +/// Not flat: a flat frame lets the memory system serve every sample of every +/// pixel from one cache line, which flatters a bandwidth-bound pass by an amount +/// that has nothing to do with photographs. +fn synthetic_source(ctx: &GpuContext) -> DemosaicedImage { + let (w, h) = SOURCE; + let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4]; + for y in 0..h as usize { + let row = y * (w as usize) * 4; + for x in 0..w as usize { + let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13; + let dither = (n & 0x1f) as u32; + let gx = (x * 200 / w as usize) as u32; + let gy = (y * 55 / h as usize) as u32; + let px = &mut rgba[row + x * 4..row + x * 4 + 4]; + px[0] = (30 + gx + dither).min(255) as u8; + px[1] = (40 + gy + dither).min(255) as u8; + px[2] = (60 + gx / 2 + gy + dither).min(255) as u8; + px[3] = 255; + } + } + DemosaicedImage::from_rgba8(ctx, &rgba, w, h).expect("upload") +} diff --git a/core/dr-gpu/tests/zoom_resolution.rs b/core/dr-gpu/tests/zoom_resolution.rs new file mode 100644 index 0000000..4ef1b53 --- /dev/null +++ b/core/dr-gpu/tests/zoom_resolution.rs @@ -0,0 +1,291 @@ +//! TRACES: FR-DSP-5 +//! Zooming to 1:1 samples the source, pixel for pixel. +//! +//! FR-DSP-5: *"Fit, 1:1, and arbitrary zoom levels. At 1:1 and above, the +//! pipeline operates on the visible crop at full source resolution."* +//! +//! `Framing::view` shrinks the sampled region while the render target keeps its +//! size, so zooming *raises* the resolution the pipeline works at rather than +//! magnifying pixels it has already drawn. There is no second full-resolution +//! code path — the zoom is the full-resolution path — which is why the +//! requirement has been satisfied for some time without anyone tagging it. +//! +//! `docs/display-and-extension.md` §7 is the reason this file exists rather +//! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES` +//! comment names it, and nothing checks that the code under the tag does the +//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use. +//! So the rule that document sets is that a requirement is closed by **a test +//! that would fail if the behaviour were removed**, and these are written to +//! fail in exactly that case: delete the view from `Framing::visible_rect` and +//! the 1:1 render collapses into the fit render, which +//! [`a_proxy_cannot_resolve_the_finest_detail_in_the_source`] establishes +//! carries none of the information the 1:1 render reproduces. +//! +//! # Why the fixture is alternating columns +//! +//! Because it makes "sampled at source resolution" a *pixel* assertion rather +//! than a "something got sharper" one. +//! +//! The source is one pixel black, one pixel white, all the way across. That is +//! the highest spatial frequency the image can hold, and it is exactly what a +//! proxy render throws away: a 1024-wide source in a 128-wide viewport maps +//! output column `x` to source column `8x + 4`, every one of which has the same +//! parity, so the whole proxy comes out flat. No amount of resampling that flat +//! image recovers the stripes. If the 1:1 render shows them — and shows them in +//! the right phase, from the right place in the source — then it read the +//! source and did not magnify the proxy. There is no third explanation. +//! +//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it +//! non-linear, so the fused shader decodes sRGB before the chain and re-encodes +//! after it. Bytes 0 and 255 are fixed points of that round trip, which is why +//! the pattern is black and white and why the comparison can be for equality +//! rather than within a tolerance. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::{CropRect, EditGraph}; + +/// A power of two, so every view rect below is exact in binary32 and the +/// mapping from output column to source column is exact arithmetic rather than +/// something that happens to round the right way. +const SOURCE: u32 = 1024; + +/// The viewport. `SOURCE / RENDER` is 8, so a fit render steps eight source +/// columns per output column — four full periods of the pattern. +const RENDER: u32 = 128; + +/// Where the 1:1 window sits in the source. **Odd on both axes on purpose**: a +/// view that honoured `width` but dropped `x` would land on the opposite phase +/// of the stripes and produce an exactly inverted image, which is the most +/// likely way for this to be subtly wrong and the one an assertion about +/// "contrast" or "variance" would sail straight past. +const WINDOW: (u32, u32) = (301, 157); + +fn ctx() -> Option { + // CI runners and headless machines may have no usable adapter. Skip rather + // than fail, exactly as the rest of this crate's device tests do. + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } +} + +/// Black and white alternating every column: the finest detail an image can +/// carry. +fn stripes(ctx: &GpuContext) -> DemosaicedImage { + let data: Vec = (0..SOURCE * SOURCE) + .flat_map(|i| { + let v = if (i % SOURCE).is_multiple_of(2) { + 0u8 + } else { + 255 + }; + [v, v, v, 255] + }) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, SOURCE, SOURCE).expect("upload") +} + +/// What the source holds at `(x, y)` — the same rule [`stripes`] wrote. +fn source_byte(x: u32, _y: u32) -> u8 { + if x.is_multiple_of(2) { + 0 + } else { + 255 + } +} + +/// Render a neutral edit through `view` and hand back the bytes and the size. +/// +/// Neutral because this is a test about *which pixel* is read, and any active +/// operation would put a colour transform between the source byte and the +/// rendered one for no gain. +fn render(ctx: &GpuContext, source: &DemosaicedImage, view: CropRect) -> (Vec, u32, u32) { + let mut graph = EditGraph::default_chain(); + graph.framing_mut().set_view(view); + let shader = graph.compose(); + + let mut adjust = AdjustPass::new(ctx); + adjust + .render(source, &shader, RENDER, RENDER) + .expect("render"); + adjust.export_pixels().expect("readback") +} + +/// The view rect that puts one render pixel on one source pixel, with its +/// top-left corner at `WINDOW`. +fn one_to_one() -> CropRect { + CropRect { + x: WINDOW.0 as f32 / SOURCE as f32, + y: WINDOW.1 as f32 / SOURCE as f32, + width: RENDER as f32 / SOURCE as f32, + height: RENDER as f32 / SOURCE as f32, + } +} + +/// The red channel of one row of a rendered frame. +fn row(pixels: &[u8], width: u32, y: u32) -> Vec { + (0..width) + .map(|x| pixels[((y * width + x) * 4) as usize]) + .collect() +} + +/// TRACES: FR-DSP-5 +/// The proxy render carries none of the source's finest detail. +/// +/// Half of the argument, and the half that makes the other half mean something: +/// if the fit render already showed the stripes, a 1:1 render showing them would +/// prove nothing at all. It comes out uniform, so whatever the 1:1 render +/// contains cannot have come from resampling it. +#[test] +fn a_proxy_cannot_resolve_the_finest_detail_in_the_source() { + let Some(ctx) = ctx() else { return }; + let source = stripes(&ctx); + + let (pixels, w, h) = render(&ctx, &source, CropRect::default()); + assert_eq!((w, h), (RENDER, RENDER)); + + let first = pixels[0]; + let uniform = pixels + .chunks_exact(4) + .all(|px| px[0] == first && px[1] == first && px[2] == first); + assert!( + uniform, + "the fit render of a one-pixel stripe pattern should be flat — a 1024 px \ + source in a {RENDER} px viewport steps 8 source columns per output \ + column, so every sample lands on the same phase. It was not: row 0 is \ + {:?}. Either the sampling changed or the fixture no longer says what it \ + is meant to, and the 1:1 test below is worthless until this is true \ + again.", + &row(&pixels, w, 0)[..16.min(w as usize)] + ); +} + +/// TRACES: FR-DSP-5 +/// At 1:1 the render *is* the source region, byte for byte. +/// +/// The requirement's actual content — "at 1:1 and above, the pipeline operates +/// on the visible crop at full source resolution" — stated as the strongest +/// thing that could be true of it: not that the result is sharper, but that +/// output pixel `(x, y)` is source pixel `(WINDOW.0 + x, WINDOW.1 + y)` and +/// nothing has been interpolated, averaged or magnified on the way. +#[test] +fn a_one_to_one_view_reproduces_the_source_pixel_for_pixel() { + let Some(ctx) = ctx() else { return }; + let source = stripes(&ctx); + + let (pixels, w, h) = render(&ctx, &source, one_to_one()); + + // The render target keeps its size while the sampled region shrinks. That + // is the whole mechanism, and a zoom that resized the target would be + // magnification rather than resolution. + assert_eq!( + (w, h), + (RENDER, RENDER), + "zooming must not change the size of the render target" + ); + + let mut mismatches = Vec::new(); + for y in 0..h { + for x in 0..w { + let got = pixels[((y * w + x) * 4) as usize]; + let want = source_byte(WINDOW.0 + x, WINDOW.1 + y); + if got != want && mismatches.len() < 8 { + mismatches.push((x, y, got, want)); + } + } + } + assert!( + mismatches.is_empty(), + "a 1:1 view starting at {WINDOW:?} must reproduce the source exactly. \ + First mismatches (x, y, got, want): {mismatches:?}\n\ + rendered row 0: {:?}\n\ + source row 0: {:?}\n\ + An exactly inverted row means the view's *offset* was dropped while its \ + width was honoured; a flat row means the view was ignored altogether \ + and the pipeline is still rendering the proxy.", + &row(&pixels, w, 0)[..12], + (0..12) + .map(|x| source_byte(WINDOW.0 + x, WINDOW.1)) + .collect::>(), + ); +} + +/// TRACES: FR-DSP-5 +/// An arbitrary zoom between fit and 1:1 samples at the ratio it asks for. +/// +/// FR-DSP-5 says "fit, 1:1, **and arbitrary zoom levels**", and the two tests +/// above only pin the ends. This one takes the middle: a view a quarter of the +/// frame wide, which puts four source pixels behind each output pixel, and +/// checks that the pipeline reports and samples at that ratio rather than +/// snapping to one of the two cases anybody would have special-cased. +/// +/// `render_scale` is asserted alongside the pixels because it is what the +/// neighbourhood stage converts kernel radii through: a zoom that moved the +/// pixels but not the scale would silently sharpen at the wrong radius, which +/// is invisible until somebody compares a preview against an export. +#[test] +fn an_arbitrary_zoom_samples_at_the_ratio_it_asks_for() { + let Some(ctx) = ctx() else { return }; + let source = stripes(&ctx); + + // A quarter of the frame: 256 source columns across 128 output columns. + let view = CropRect { + x: 0.25, + y: 0.25, + width: 0.25, + height: 0.25, + }; + + let mut graph = EditGraph::default_chain(); + graph.framing_mut().set_view(view); + + // The region on screen is 256 source pixels wide, rendered into 128, so the + // ratio is one render pixel per two source pixels. + let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER)); + assert_eq!(scale.full_size(), (256, 256)); + assert!( + (scale.ratio() - 0.5).abs() < 1e-6, + "ratio {}", + scale.ratio() + ); + + let (pixels, w, _) = render(&ctx, &source, view); + + // Where output column `x` reads from, worked through rather than asserted + // from a previous run — a test that recomputed this with the shader's own + // expression would agree with a bug in it. + // + // uv = 0.25 + (x + 0.5) / 128 * 0.25 = (128 + x + 0.5) / 512 + // col = floor(uv * 1024) = floor(256 + 2x + 1.0) = 257 + 2x + // + // Odd for every `x`, so this zoom lands flat — as the fit render does, and + // for the same reason. **The 1.0 is the interesting part.** At a two-to-one + // downsample an output pixel's centre falls exactly on the boundary between + // the two source pixels it covers, and truncation takes the right-hand one. + // That is nearest-neighbour behaving correctly and not an off-by-one; a + // reader checking this file by hand will get 256 on the first attempt, so + // it is written out. + const SAMPLED: u32 = 257; + + let first = pixels[0]; + assert!( + pixels.chunks_exact(4).all(|px| px[0] == first), + "a two-to-one zoom steps two source columns per output column, so every \ + sample has the same parity and the frame should be flat. row 0: {:?}", + &row(&pixels, w, 0)[..12] + ); + // And it is flat on the *right* phase. A pipeline that had ignored the view + // entirely would also be flat — but its `ratio` would not be 0.5, which the + // assertion above already rules out — and one that had snapped to 1:1 would + // show the stripes instead. Between them, only sampling the window the view + // actually asked for produces this. + assert_eq!( + first, + source_byte(SAMPLED, SAMPLED), + "a view starting a quarter of the way across a {SOURCE} px source should \ + sample from source column {SAMPLED}" + ); +} 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 e0e1a51..263ea46 100644 --- a/core/dr-pipeline/build.rs +++ b/core/dr-pipeline/build.rs @@ -18,11 +18,26 @@ //! //! 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. //! +//! # 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({:?}, {:?}, &[{}])", - 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); @@ -1495,7 +428,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,27 +445,33 @@ 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, " {},", param_ctor(p)); } - 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. 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") @@ -1539,8 +479,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 { @@ -1549,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()) ); } @@ -1583,7 +523,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( @@ -1617,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))) @@ -1632,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"); @@ -1643,14 +583,15 @@ 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"); 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,18 +602,27 @@ 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(", "), + 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(", ") ); } @@ -1680,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]) { @@ -1824,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, .. } => { @@ -1855,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 @@ -1865,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('#'); @@ -1932,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/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..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, @@ -81,13 +83,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 +219,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 +238,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/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/core/dr-types/src/colour.rs b/core/dr-types/src/colour.rs index a38639c..6a40bae 100644 --- a/core/dr-types/src/colour.rs +++ b/core/dr-types/src/colour.rs @@ -39,6 +39,22 @@ pub struct Chromaticities { pub white: [f64; 2], } +impl Chromaticities { + /// This gamut's linear RGB to the ICC profile connection space, row-major. + /// + /// The same three columns [`ColourSpace::to_pcs_xyz`] produces, for a set + /// of primaries that is *not* one of the four the pipeline knows. That is + /// the only reason this is public: a display's profile describes primaries + /// nobody chose, and the only way to ask which of the four it is nearest + /// is to put both through the same reduction and compare the numbers + /// (see `dr_plat::display`). Comparing raw xy pairs instead would be + /// wrong, because a profile's colorants have already been adapted to D50 + /// and a space's published chromaticities have not. + pub fn to_pcs_xyz(&self) -> [f32; 9] { + narrow(mul(adaptation(white_xyz(self), PCS_D50), rgb_to_xyz(self))) + } +} + /// How a space maps linear light onto the numbers stored in a file. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Transfer { @@ -183,8 +199,7 @@ impl ColourSpace { /// defined at D50 and nowhere else — a profile carrying unadapted D65 /// colorants describes a space nobody asked for. pub fn to_pcs_xyz(self) -> [f32; 9] { - let c = self.chromaticities(); - narrow(mul(adaptation(white_xyz(&c), PCS_D50), rgb_to_xyz(&c))) + self.chromaticities().to_pcs_xyz() } /// The white-point adaptation folded into [`Self::to_pcs_xyz`], row-major. diff --git a/docs/display-and-extension.md b/docs/display-and-extension.md index d9056aa..3dec37a 100644 --- a/docs/display-and-extension.md +++ b/docs/display-and-extension.md @@ -20,24 +20,37 @@ and the reason is that some of the work is done and untagged. | Requirement | Reality | |---|---| | FR-DSP-1 proxy rendering | **Done.** The develop view renders at viewport resolution, not source. | -| FR-DSP-2 tiled computation | **Absent.** The fused pass renders the whole viewport in one dispatch. | -| FR-DSP-3 interactive latency | **Unmeasured.** No frame budget is asserted anywhere. | -| FR-DSP-4 progressive refinement | **Absent.** Every render is full quality. | -| FR-DSP-5 zoom and pan | **Substantially done, untagged.** `Framing::view` shrinks the sampled region while the render target keeps its size, so zooming *raises* the resolution the pipeline works at. That is FR-DSP-5's requirement, arrived at without tiles. | +| FR-DSP-2 tiled computation | **Absent, and §2 now says it should stay that way.** Measured: the fused pass is inside the budget everywhere. See [frame-budget.md](frame-budget.md). | +| FR-DSP-3 interactive latency | **Measured and asserted** for the fused path — `core/dr-gpu/tests/frame_budget.rs`. Missed by one operation, clarity, for the reason recorded as TD-4. | +| FR-DSP-4 progressive refinement | **Absent**, and §4's condition did not fire. Every render is full quality and can afford to be. | +| FR-DSP-5 zoom and pan | **Done and tagged**, against tests that fail if the behaviour is removed — `core/dr-gpu/tests/zoom_resolution.rs`. `Framing::view` shrinks the sampled region while the render target keeps its size, so zooming *raises* the resolution the pipeline works at. That is FR-DSP-5's requirement, arrived at without tiles. | | FR-DSP-6 colour management | **Done.** Output space is a parameter of composition. | | FR-DSP-7 histogram and clipping | **Done**, GPU-side, no per-frame readback. | -| FR-DSP-8 per-display colour | **Absent.** One transform, not per-display. | +| FR-DSP-8 per-display colour | **Done**, with one caveat named in §5.4. Acquisition per display server, an sRGB fallback that is visible in About, and the canvas rendered at physical pixel size. | | FR-PLG-* | **Zero tagged.** But `ops/*.yaml` + `build.rs` is already the class-1 plugin format compiled at build time rather than loaded. | Two of the five uncovered display requirements are therefore *measurement and tagging*, not construction. That is worth knowing before anyone plans a quarter around them. +**Both have since been done.** [frame-budget.md](frame-budget.md) holds the measurements §2 asks +for and the reading of its decision rule; the table above is updated to match. The rest of this +document is left as it was written, because a plan that has been overtaken by its own evidence is +more useful read in order than quietly edited into agreement. + --- ## 2. Measure before building tiles **FR-DSP-2 is the one requirement in this document that may not be worth satisfying as written.** +> **Resolved.** M1–M3 were run; the numbers and the verdict are in +> [frame-budget.md](frame-budget.md). The rule below fired for *rewrite*: every point-operation +> chain is inside 16 ms at the 99th percentile at every viewport size, fit and at 1:1, the widest +> being 4.5 ms of GPU at 4K. The measurement did find a stage that misses the budget — clarity's +> 52-pixel kernel, 34 ms at 4K — and tiling makes that stage *worse*, since a tiled convolution +> reads a halo per tile. It is recorded as TD-4 with the fix its own module already names. + + The requirement predates the fused-shader design. It assumes the pipeline is a chain of passes over a large buffer, where recomputing everything on each frame would be ruinous and tiles are the way out. What was built instead composes every active operation into **one dispatch over a @@ -119,15 +132,44 @@ stated in the About page beside the other diagnostics so a photographer can see on rather than wonder. **5.2 Reacting to a move.** The transform is selected per the display currently showing the canvas -and updates when the window moves. Slint reports window moves; the composed output space is already -a parameter of composition (`compose_with_framing(..., output)`), so a display change is a -recomposition, not a pipeline change. This is the part the existing design makes cheap. +and updates when the window moves. The composed output space is already a parameter of composition +(`compose_with_framing(..., output)`), so a display change is a recomposition, not a pipeline +change. This is the part the existing design makes cheap. + +Slint turned out *not* to report window moves — there is no `on_moved` on any backend — so the +window's position and scale factor are sampled twice a second and the platform re-surveyed only when +they differ. See §5.4 for the display server where the position itself is unavailable. **5.3 Fractional scaling.** "Handled without resampling artefacts in the canvas" — the canvas is a wgpu texture handed to the compositor, so the requirement is that we render at the *physical* pixel size rather than the logical one and let the compositor present 1:1. Worth an explicit test, since the failure is subtle: a slightly soft canvas that looks like a bad demosaic. +### 5.4 What landed, and the one thing that did not + +`dr_plat::display` surveys the session's displays; `dr_ui::display_ui` decides which one is showing +the canvas and keeps `DevelopSession`'s output space pointed at it. Composition was already +parameterised on the space, so the pixel path changed by one argument. + +Two things are worth recording because they are trades rather than omissions. + +**A profile is matched to the nearest of four spaces, not applied.** A measured panel is none of +`Srgb`, `DisplayP3`, `AdobeRgb` or `ProPhoto`, and a general ICC engine is a much larger piece of +work — a CMM, rendering intents, LUT-based profiles, and a per-frame cost to argue about. The +profile is reduced to its D50-adapted colorants and matched against the four; a match that is merely +nearest is marked as such, and About says "nearest to Display P3" rather than "Display P3". A +LUT-based profile, which is what a hardware calibrator often writes, is declined by shape and falls +back to sRGB with that stated. An approximation the photographer can see beats a silent one. + +**On Wayland the canvas follows the first output, not the window.** A Wayland client is never told +where its window is — `xdg_toplevel` carries no position, deliberately — so the "which display" +question cannot be answered by geometry there. The protocol's own answer is +`wp_color_management_surface_feedback_v1`, which hands a client the preferred image description for +*its surface* and re-sends it on a move; it needs the application's `wl_surface`, which Slint owns +and does not expose. So the profiles are read correctly for every output and the *selection* among +them is right on X11 and on any single-monitor Wayland session, which is most of them. Closing the +gap is a Slint surface handle, not a change to any of this. + --- ## 6. Extensibility: the format already exists diff --git a/docs/frame-budget.md b/docs/frame-budget.md new file mode 100644 index 0000000..7b09057 --- /dev/null +++ b/docs/frame-budget.md @@ -0,0 +1,297 @@ +# What a frame costs + +**Status:** Measured · 2026-08-27 +**Companion to:** [display-and-extension.md](display-and-extension.md) §2–3 · +[requirements.md](requirements.md) §3.4 FR-DSP-2, FR-DSP-3, FR-DSP-4 +**Instrument:** [`core/dr-gpu/examples/frame_budget.rs`](../core/dr-gpu/examples/frame_budget.rs) +**Guard:** [`core/dr-gpu/tests/frame_budget.rs`](../core/dr-gpu/tests/frame_budget.rs) + +[display-and-extension.md](display-and-extension.md) §2 fixed a decision rule in +advance and made three measurements the thing that settles it. This file is +those measurements, and the recommendation they support. + +Rerun with: + +```sh +cargo run --release -p dr-gpu --example frame_budget +``` + +and diff this file. That is the whole point of committing numbers: a regression +should be a diff rather than somebody's recollection of how fast it used to be. + +--- + +## The answer, first + +**FR-DSP-2 should be rewritten, not implemented.** M1 and M2 sit inside the +16 ms budget at the 99th percentile for every chain of point operations at every +viewport size measured, fit and at 1:1 — the widest case, every operation that +contributes a fragment to the fused shader at 4K, costs **4.5 ms** on the GPU and +**8.2 ms** including the composition that precedes it. Tiling the interactive +path would be optimising something that is already using a quarter of its budget. + +**But the measurement did find a budget-breaker, and it is not the one tiling +fixes.** The neighbourhood stage — clarity in particular — costs **34 ms at 4K +on its own**, twice the whole budget, and tiles do not help it: a tile of a +convolution has to read its halo, so tiling raises the total tap count rather +than lowering it. §2 predicted this exactly ("a separable blur at a large radius +is the plausible budget-breaker, not the fused pass"), and the fix it needs is +the one `local_contrast`'s own module documentation already names — a base +computed at reduced resolution — which is a change to `crate::detail`, not a +tile scheduler. + +There is a third finding nobody was looking for: **shader composition costs +3–5 ms of CPU per frame on a full chain**, on the UI thread, before any GPU work +is submitted. That is a fifth to a third of the budget spent formatting strings, +and it is invisible to any amount of tiling. + +--- + +## Conditions + +| | | +|---|---| +| Adapter | NVIDIA GeForce RTX 3050 6GB Laptop GPU (Vulkan) | +| Source | 9504 × 6336 synthetic (60.2 MP, 482 MB as `rgba16f`) | +| Frames | 100 measured per row, 12 warm-up frames discarded | +| Percentile | Nearest-rank, so p99 of 100 frames is the second-worst frame | +| Build | `--release` | +| Date | 2026-08-27 | + +`shader` is `EditGraph::compose` alone. `cpu` adds the detail chain and the +invalidation hash — everything `DevelopSession::render` does per frame before it +dispatches. `gpu` is submit plus wait-for-idle, which serialises the GPU work +into the frame that caused it and is therefore pessimistic. `TOTAL` ranks +`cpu + gpu` summed **within each frame**, which is the column the budget is +judged on; adding two percentiles instead would invent a stutter that no frame +actually had. + +Chains: `one` is exposure. `five` is exposure, contrast, highlights/shadows, +blacks/whites, vibrance. `point` is every operation in the default chain that +contributes a fragment to the fused shader, film stock included. `all` is `point` +plus the four neighbourhood operations — noise reduction, capture sharpening, +clarity and texture. + +--- + +## M1 — the fused pass at proxy resolution + +The develop view: the whole frame fit to the viewport. + +| size | chain | shader | cpu p99 | gpu p50 | gpu p99 | TOTAL | | +|------------:|------:|-------:|--------:|--------:|--------:|--------:|:-----| +| 1920 × 1200 | one | 0.08ms | 0.10ms | 1.02ms | 1.23ms | 1.31ms | | +| 1920 × 1200 | five | 0.18ms | 0.20ms | 1.01ms | 1.20ms | 1.36ms | | +| 1920 × 1200 | point | 2.79ms | 2.82ms | 1.98ms | 2.18ms | 4.83ms | | +| 1920 × 1200 | all | 3.65ms | 4.73ms | 6.86ms | 7.37ms | 12.02ms | | +| 2560 × 1600 | one | 0.08ms | 0.10ms | 1.73ms | 2.00ms | 2.12ms | | +| 2560 × 1600 | five | 0.24ms | 0.27ms | 1.73ms | 2.26ms | 2.46ms | | +| 2560 × 1600 | point | 2.82ms | 2.85ms | 2.37ms | 2.65ms | 5.38ms | | +| 2560 × 1600 | all | 3.37ms | 4.35ms | 14.31ms | 15.65ms | 18.42ms | OVER | +| 3840 × 2160 | one | 0.10ms | 0.14ms | 3.09ms | 3.31ms | 3.42ms | | +| 3840 × 2160 | five | 0.30ms | 0.32ms | 3.03ms | 3.40ms | 3.61ms | | +| 3840 × 2160 | point | 3.62ms | 3.65ms | 4.12ms | 4.52ms | 8.23ms | | +| 3840 × 2160 | all | 4.12ms | 5.07ms | 37.73ms | 40.17ms | 43.24ms | OVER | + +Read the `point` rows: **the fused dispatch scales with pixels and almost not at +all with chain length.** Going from one operation to the entire point chain at +4K costs 1.2 ms of GPU. Going from 2.3 M pixels to 8.3 M costs 2.3 ms. Both are +small, and the second is the one tiling would address. + +The `all` rows go over, and the `point` rows in the same block are what say why: +the difference between them is the neighbourhood stage, measured on its own in +M3 and arriving at almost exactly the same figure. + +## M2 — the same, zoomed to 1:1 on the 60 MP source + +FR-DSP-5's case. `Framing::view` shrinks the sampled region while the render +target keeps its size, so one render pixel lands on one source pixel. + +| size | chain | shader | cpu p99 | gpu p50 | gpu p99 | TOTAL | | +|------------:|------:|-------:|--------:|--------:|--------:|--------:|:-----| +| 1920 × 1200 | one | 0.12ms | 0.14ms | 0.42ms | 0.66ms | 0.75ms | | +| 1920 × 1200 | five | 0.27ms | 0.30ms | 0.49ms | 1.14ms | 1.22ms | | +| 1920 × 1200 | point | 3.49ms | 3.52ms | 1.18ms | 1.39ms | 4.85ms | | +| 1920 × 1200 | all | 3.64ms | 5.18ms | 8.96ms | 9.55ms | 14.30ms | | +| 2560 × 1600 | one | 0.11ms | 0.12ms | 0.56ms | 0.99ms | 1.06ms | | +| 2560 × 1600 | five | 0.22ms | 0.25ms | 0.77ms | 1.02ms | 1.17ms | | +| 2560 × 1600 | point | 2.96ms | 2.99ms | 2.03ms | 2.52ms | 5.61ms | | +| 2560 × 1600 | all | 5.24ms | 7.35ms | 18.80ms | 21.62ms | 25.81ms | OVER | +| 3840 × 2160 | one | 0.10ms | 0.12ms | 1.31ms | 1.52ms | 1.63ms | | +| 3840 × 2160 | five | 0.14ms | 0.27ms | 1.39ms | 1.64ms | 1.75ms | | +| 3840 × 2160 | point | 3.14ms | 3.16ms | 4.04ms | 4.50ms | 7.21ms | | +| 3840 × 2160 | all | 4.84ms | 6.78ms | 47.22ms | 48.79ms | 54.47ms | OVER | + +**A 1:1 view of a 60 MP file is cheaper than the fit view of the same file**, for +every point chain and at every size — 1.52 ms against 3.31 ms for one operation +at 4K. That is not a rounding artefact and it is worth stating plainly, because +it is the opposite of what "full resolution" sounds like it should cost. The +dispatch is the same number of pixels either way; what changes is where those +pixels read from. A fit view walks the whole 482 MB texture on a stride, and a +1:1 view reads a contiguous window of it that fits comfortably in cache. + +So the resolution FR-DSP-5 promises costs nothing extra on the fused path. +Zooming is not an expensive mode to be dreaded and progressively refined into; +it is the cheap one. + +The `all` rows are worse at 1:1 than fit, and that is the detail stage again for +a specific reason: noise reduction's radius is stated in *source* pixels, so +`RenderScale::ratio` climbing to 1.0 widens its kernel. Clarity's is stated as a +fraction of the frame and does not move. M3 separates the two. + +## M3 — the neighbourhood stage alone + +Timed with the fused dispatch deliberately reused: only a detail parameter moves, +so `render_detailed` skips the colour pass (FR-DEV-3d) and what remains is the +convolutions. `colour` counts fused dispatches over the measured frames and is +zero on every row, which is what makes these numbers mean "detail alone" rather +than asserting it. + +| size | stage | view | pass | radius | colour | cpu p99 | p50 | p99 | +|------------:|---------:|:-----|-----:|-------:|-------:|--------:|--------:|--------:| +| 1920 × 1200 | clarity | fit | 2 | 29 | 0 | 1.60ms | 5.42ms | 5.99ms | +| 1920 × 1200 | all four | fit | 7 | 29 | 0 | 1.71ms | 5.78ms | 6.16ms | +| 1920 × 1200 | clarity | 1:1 | 2 | 29 | 0 | 1.12ms | 7.51ms | 8.01ms | +| 1920 × 1200 | all four | 1:1 | 9 | 29 | 0 | 2.71ms | 8.25ms | 9.11ms | +| 2560 × 1600 | clarity | fit | 2 | 38 | 0 | 1.03ms | 12.02ms | 12.44ms | +| 2560 × 1600 | all four | fit | 7 | 38 | 0 | 1.87ms | 12.49ms | 13.16ms | +| 2560 × 1600 | clarity | 1:1 | 2 | 38 | 0 | 1.87ms | 15.82ms | 16.60ms | +| 2560 × 1600 | all four | 1:1 | 9 | 38 | 0 | 2.71ms | 17.24ms | 18.06ms | +| 3840 × 2160 | clarity | fit | 2 | 52 | 0 | 1.75ms | 33.11ms | 33.89ms | +| 3840 × 2160 | all four | fit | 7 | 52 | 0 | 1.76ms | 34.21ms | 35.03ms | +| 3840 × 2160 | clarity | 1:1 | 2 | 52 | 0 | 1.08ms | 40.39ms | 41.86ms | +| 3840 × 2160 | all four | 1:1 | 9 | 52 | 0 | 2.37ms | 43.29ms | 44.72ms | + +`radius` is the widest halo any pass reads, in render pixels. + +Clarity alone is 97% of the cost of all four neighbourhood operations together, +at every size. Its σ is 1.2% of the shorter edge and it truncates at 2σ, so its +radius is 29 px on a 1200 px viewport and **52 px at 4K** — two separable passes +of 105 taps each, over 8.3 M pixels, which is 1.7 billion texture reads. That is +the whole of the problem, and the numbers scale as `radius × pixels` exactly as +that description predicts: 5.99 → 12.44 → 33.89 ms for radii of 29 → 38 → 52 over +2.3 → 4.1 → 8.3 M pixels. + +The extra cost at 1:1 is noise reduction and capture sharpening, whose radii are +properties of the sensor rather than of the frame. That is the correct behaviour +— it is why `RenderScale` has two units — and it is bounded by the kernel caps +those operations already declare. + +--- + +## Reading this against §2's decision rule + +§2: *"If M1 and M2 sit inside 16 ms at the 99th percentile, FR-DSP-2 is +rewritten rather than implemented … If they do not, the measurement tells us +which stage to tile."* + +Both halves of the rule fire, on different stages, and the honest reading takes +both. + +### FR-DSP-2 — rewrite it + +For the fused pass the rule passes with a wide margin. Every point chain at +every size, fit and at 1:1, is inside 16 ms — the worst `TOTAL` is 8.23 ms and +the worst GPU figure is 4.52 ms. There is no viewport size on a desktop display +where recomputing the entire point chain over every visible pixel is a problem. + +Two further reasons not to build the tile scheduler as written: + +1. **Panning, which is the case ARCH §5.3's tile cache is designed for, gets no + benefit here.** Reusing already-valid tiles saves recomputation. Recomputing + the whole 4K viewport costs 4.5 ms, so a perfect tile cache could save at most + 4.5 ms of a 16 ms budget, at the price of a cache keyed by + `(VersionId, tile, zoom, graph_hash_prefix)` that has to stay correct across + every parameter change in the graph. That is a large correctness surface + bought with a small number. + +2. **It would make the actual problem worse.** The stage that misses the budget + is a convolution, and a tiled convolution reads a halo per tile. At a 52-pixel + radius, 256-pixel tiles would read (256+104)² instead of 256² — very nearly + *twice* the taps. Tiling is the wrong tool for the one stage that needs a + tool. + +So FR-DSP-2 becomes what §2 said it actually is for this architecture: a +scheduling concern for export and thumbnailing, both of which already run off +the frame path. The interactive path does not tile. + +### The stage that does need work — and it is not tiling + +The measurement's real product is naming the stage. It is `local_contrast`, and +the fix is stated in that module's own documentation: + +> The right optimisation is a base computed at reduced resolution, which needs a +> detail stage that can write a smaller target than it reads; that is a change to +> `crate::detail`, not to this file. + +A Gaussian base at a quarter resolution is 1/16 the pixels at 1/4 the radius — +about 1/64 of the work — and the result is visually identical because a base at +σ = 26 px has no content above the quarter-resolution Nyquist to lose. That is a +change to two files with a bounded blast radius, and it is what the 34 ms buys +back. It should be tracked as its own item rather than smuggled in under a +requirement about tiles. + +### FR-DSP-3 — the clause that should be narrowed + +§3.3 proposes narrowing "when a full-resolution result is needed it is computed +asynchronously, and the proxy result remains on screen until it is ready" to +export and 1:1 zoom, or striking it. + +**M2 says strike it.** The clause exists to hide the latency of a +full-resolution render behind a proxy. There is no such latency: the 1:1 view is +*faster* than the fit view on the fused path, and there is no second +full-resolution code path to be asynchronous about — `Framing::view` is the +whole mechanism. Export renders its own frames on a worker already. Keeping the +clause would mean building a progressive-swap machine to conceal a render that +completes in 1.4 ms. + +### FR-DSP-4 — satisfied vacuously, on the fused path + +§4 makes progressive refinement conditional on M1 failing. On the fused path M1 +passes, so reduced-quality rendering during a drag would buy nothing and cost the +visible softness the requirement itself warns against. + +The neighbourhood stage is the exception, and it is worth being precise: what +that stage needs is not *progressive* refinement — it is a permanently cheaper +base, computed at reduced resolution and correct at any moment the user stops. +"Render coarse while dragging, sharpen when it settles" would paper over the same +34 ms with a visible swap. Fix the stage. + +--- + +## What is not measured here + +Stated because §7 of [display-and-extension.md](display-and-extension.md) asks +for it, and because each of these could move the numbers. + +- **Local adjustments.** The mask stack is a separate chain per layer and is not + in any row above. `render_masked` takes them and the fused shader addresses + them per layer, so a heavily masked edit costs more than `all`. +- **Spot repairs.** These add detail passes, and their cost is per spot. +- **Lens corrections.** Not part of `EditGraph::default_chain` — they are built + from a matched profile — so the `point` row does not include the warp chain. +- **Demosaic.** Once per photograph on a worker, not on the frame path. +- **Presentation.** The bench waits for the device to go idle inside the frame it + measures. A real compositor overlaps frames, so these figures are an upper + bound rather than an estimate. +- **One adapter.** A discrete laptop GPU. The Intel iGPU on the same machine, and + Android, will be slower — which is an argument for the conclusion rather than + against it: the stage with no headroom has none to lose. + +## The CPU finding, which deserves its own item + +`EditGraph::compose` costs 2.8–5.2 ms per frame on a full chain, at every +resolution, because it is resolution-independent: it assembles a WGSL string and +hashes it. On the `all` rows it is a third of what is left of the budget after +the GPU has taken its share, and at 1920 × 1200 it is larger than the entire +fused dispatch. + +Nothing in this document's recommendations changes it, and it is the cheapest +remaining win. The generated *source* depends only on the structure of the graph +— that is what `structure_hash` already identifies, and it is precisely what does +not change while a slider is being dragged, which is why the pipeline cache in +`AdjustPass` does not recompile. The uniforms do change, but assembling them is a +handful of floats per operation. So caching the source string against the +structure hash and rebuilding only the uniforms would take these milliseconds to +approximately nothing, on the path that needs them most. Worth its own entry in +[technical-debt.md](technical-debt.md). diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 69c76e5..0c6dda8 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -142,6 +142,103 @@ in well under a second. --- +## TD-4 — The local-contrast base is computed at full render resolution + +**Where:** `dr_pipeline::ops::local_contrast::LocalContrast::passes` — the `base` and `combine` +passes, and the stage that dispatches them, `dr_pipeline::detail`. + +Breaks **FR-DSP-3** at large viewports. Measured, and the numbers are in +[frame-budget.md](frame-budget.md) §M3. + +### What it does + +Clarity's Gaussian σ is 1.2% of the frame's shorter edge, truncated at 2σ, so its kernel radius is +a property of the *viewport*: 29 px at 1920 × 1200, 38 px at 2560 × 1600, **52 px at 4K**. The two +separable passes therefore run 105 taps each over 8.3 M pixels at 4K, which is 1.7 billion texture +reads for one control. + +| viewport | radius | clarity alone, p99 | +|---|---:|---:| +| 1920 × 1200 | 29 | 5.99 ms | +| 2560 × 1600 | 38 | 12.44 ms | +| 3840 × 2160 | 52 | **33.89 ms** | + +RTX 3050 laptop, `examples/frame_budget`, fused dispatch reused so this is the convolutions alone. +Clarity is 97% of the cost of all four neighbourhood operations together at every size. + +For scale: the entire fused chain — every point operation active, film stock included — costs +4.5 ms at the same 4K viewport. **A single slider is seven times the rest of the pipeline.** + +### Why + +Because the stage cannot do otherwise yet. `dr_pipeline::detail` dispatches every pass at the +render size; there is no way to express "read this target and write a smaller one". The module's own +documentation has said so since it was written: + +> The right optimisation is a base computed at reduced resolution, which needs a detail stage that +> can write a smaller target than it reads; that is a change to `crate::detail`, not to this file. + +It was the right call to ship the correct answer slowly rather than a fast approximation nobody had +checked — the halo behaviour is the hard part of this operation and it is tested. + +### Not a tiling problem + +Worth saying because ARCH §5.3 offers a tile cache and this is the stage that looks like it wants +one. It does not: a tiled convolution reads a halo per tile, so at a 52-pixel radius, 256-pixel +tiles would read (256 + 104)² instead of 256² — very nearly **twice** the taps. +[display-and-extension.md](display-and-extension.md) §2's decision rule was resolved on this +evidence; see [frame-budget.md](frame-budget.md). + +### Paying it off + +A detail pass that declares an output scale, so the base can be computed at a quarter resolution and +sampled back up in `combine`. A quarter-resolution base is 1/16 the pixels at 1/4 the radius — +about **1/64 of the work** — and is visually identical, because a base at σ = 26 px holds no content +above the quarter-resolution Nyquist to lose. Texture's σ is a decade finer and must stay at full +resolution; the scale therefore belongs on the `DetailPass`, not on the stage. + +**Done when:** clarity at 100% is inside the frame budget at 3840 × 2160, the halo tests in +`tests/local_contrast.rs` still pass unchanged, and `examples/frame_budget`'s M3 table in +[frame-budget.md](frame-budget.md) has been rerun and committed. + +--- + +## TD-5 — The fused shader is reassembled from strings on every frame + +**Where:** `dr_pipeline::operation::compose_full`, called from `DevelopSession::render`. + +### What it does + +`EditGraph::compose` walks the active operations and formats a WGSL source string, per frame, on +the UI thread. On a full chain that is **2.8–5.2 ms** — at 1920 × 1200 it is larger than the entire +fused dispatch it precedes, and on the chains that also carry a detail stage it is a third of what +is left of the 16 ms budget after the GPU has taken its share. It does not vary with resolution, +because it is not pixel work. + +### Why + +Because it was free until the chain got long. Composition was written when an edit was two or three +operations, and the cost is roughly linear in generated source: the colour mixer emits twelve hue bands +and the tone curve emits a spline evaluator, so a full chain is a large string built from scratch +sixty times a second. + +### Paying it off + +The generated source depends only on the *structure* of the graph — which is precisely what +`ComposedShader::structure_hash` already identifies, and precisely what does not change while a +slider is being dragged. `AdjustPass` relies on that already: it caches compiled pipelines against +that hash and does not recompile during a drag. Caching the source string against the same hash and +rebuilding only the uniforms — a handful of floats per operation — takes this to approximately +nothing on the path that needs it most. + +The care needed is in what the hash covers. It deliberately excludes parameter *magnitudes*, so a +cache keyed on it is sound for the source and would be wrong for anything else in `ComposedShader`. + +**Done when:** the `shader` column of [frame-budget.md](frame-budget.md)'s M1 table is under a +millisecond for the `point` and `all` chains, and the codegen tests still pass byte for byte. + +--- + ## Related, and deliberately not here The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed* diff --git a/docs/traceability.md b/docs/traceability.md index 5109405..ebfa90b 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,18 +9,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 256 | -| TRACES tags found | 734 | +| Source files scanned | 274 | +| TRACES tags found | 791 | | Requirements defined | 177 | -| Requirements covered | 98 | -| **Coverage** | **55.4%** (98/177) | +| Requirements covered | 106 | +| **Coverage** | **59.9%** (106/177) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 78 | 122 | -| NFR | 18 | 49 | +| FR | 84 | 122 | +| NFR | 20 | 49 | | R | 2 | 6 | ## Orphan tags @@ -33,108 +33,116 @@ _None._ | ID | Tagged in | |---|---| -| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`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-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | -| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`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:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1105`](../ui/dr-ui/src/lib.rs#L1105), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1008`](../ui/dr-ui/ui/library.slint#L1008), [`ui/dr-ui/ui/library.slint:1170`](../ui/dr-ui/ui/library.slint#L1170), [`ui/dr-ui/ui/library.slint:857`](../ui/dr-ui/ui/library.slint#L857) | -| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1105`](../ui/dr-ui/src/lib.rs#L1105), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2128`](../ui/dr-ui/src/library.rs#L2128), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`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-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | +| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1106`](../ui/dr-ui/src/lib.rs#L1106), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1008`](../ui/dr-ui/ui/library.slint#L1008), [`ui/dr-ui/ui/library.slint:1170`](../ui/dr-ui/ui/library.slint#L1170), [`ui/dr-ui/ui/library.slint:857`](../ui/dr-ui/ui/library.slint#L857) | +| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1106`](../ui/dr-ui/src/lib.rs#L1106), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2128`](../ui/dr-ui/src/library.rs#L2128), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) | | FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) | | FR-CAT-15 | [`core/dr-catalog/src/schema.rs:583`](../core/dr-catalog/src/schema.rs#L583), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:198`](../ui/dr-ui/src/library.rs#L198), [`ui/dr-ui/src/library.rs:3095`](../ui/dr-ui/src/library.rs#L3095), [`ui/dr-ui/src/library.rs:3129`](../ui/dr-ui/src/library.rs#L3129), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:757`](../ui/dr-ui/src/library_ui.rs#L757), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | -| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | +| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | -| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2604`](../ui/dr-ui/src/library.rs#L2604), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`ui/dr-ui/ui/app.slint:540`](../ui/dr-ui/ui/app.slint#L540), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2604`](../ui/dr-ui/src/library.rs#L2604), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`ui/dr-ui/ui/app.slint:547`](../ui/dr-ui/ui/app.slint#L547), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | FR-CAT-4 | [`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/schema.rs:304`](../core/dr-catalog/src/schema.rs#L304), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| 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:3143`](../ui/dr-ui/src/library.rs#L3143), [`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:658`](../ui/dr-ui/ui/app.slint#L658), [`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:3389`](../ui/dr-ui/src/library.rs#L3389), [`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:658`](../ui/dr-ui/ui/app.slint#L658), [`ui/dr-ui/ui/app.slint:897`](../ui/dr-ui/ui/app.slint#L897), [`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:653`](../ui/dr-ui/ui/app.slint#L653), [`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:1366`](../ui/dr-ui/src/lib.rs#L1366), [`ui/dr-ui/src/lib.rs:1739`](../ui/dr-ui/src/lib.rs#L1739), [`ui/dr-ui/src/lib.rs:1875`](../ui/dr-ui/src/lib.rs#L1875), [`ui/dr-ui/src/lib.rs:481`](../ui/dr-ui/src/lib.rs#L481), [`ui/dr-ui/src/lib.rs:906`](../ui/dr-ui/src/lib.rs#L906), [`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:3496`](../ui/dr-ui/src/library.rs#L3496), [`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-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:3143`](../ui/dr-ui/src/library.rs#L3143), [`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:492`](../ui/dr-ui/ui/app.slint#L492), [`ui/dr-ui/ui/app.slint:665`](../ui/dr-ui/ui/app.slint#L665), [`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:3389`](../ui/dr-ui/src/library.rs#L3389), [`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:495`](../ui/dr-ui/ui/app.slint#L495), [`ui/dr-ui/ui/app.slint:665`](../ui/dr-ui/ui/app.slint#L665), [`ui/dr-ui/ui/app.slint:904`](../ui/dr-ui/ui/app.slint#L904), [`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:147`](../ui/dr-ui/ui/settings.slint#L147) | +| 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:660`](../ui/dr-ui/ui/app.slint#L660), [`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: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:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3374`](../ui/dr-ui/src/develop.rs#L3374), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1367`](../ui/dr-ui/src/lib.rs#L1367), [`ui/dr-ui/src/lib.rs:1768`](../ui/dr-ui/src/lib.rs#L1768), [`ui/dr-ui/src/lib.rs:1904`](../ui/dr-ui/src/lib.rs#L1904), [`ui/dr-ui/src/lib.rs:482`](../ui/dr-ui/src/lib.rs#L482), [`ui/dr-ui/src/lib.rs:907`](../ui/dr-ui/src/lib.rs#L907), [`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:2870`](../ui/dr-ui/src/develop.rs#L2870), [`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:3496`](../ui/dr-ui/src/library.rs#L3496), [`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:1847`](../ui/dr-ui/src/lib.rs#L1847), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| 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:1793`](../ui/dr-ui/src/develop.rs#L1793), [`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:1876`](../ui/dr-ui/src/lib.rs#L1876), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-11 | [`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/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/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-12 | [`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/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) | | 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-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:404`](../ui/dr-ui/ui/settings.slint#L404), [`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:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/lib.rs:2125`](../ui/dr-ui/src/lib.rs#L2125), [`ui/dr-ui/src/lib.rs:302`](../ui/dr-ui/src/lib.rs#L302), [`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:2105`](../ui/dr-ui/ui/app.slint#L2105), [`ui/dr-ui/ui/app.slint:984`](../ui/dr-ui/ui/app.slint#L984) | -| 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:596`](../ui/dr-ui/src/lib.rs#L596), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | -| 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:2099`](../ui/dr-ui/src/lib.rs#L2099), [`ui/dr-ui/src/lib.rs:529`](../ui/dr-ui/src/lib.rs#L529), [`ui/dr-ui/src/lib.rs:587`](../ui/dr-ui/src/lib.rs#L587), [`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:2717`](../ui/dr-ui/ui/app.slint#L2717), [`ui/dr-ui/ui/app.slint:778`](../ui/dr-ui/ui/app.slint#L778) | -| 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:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`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:1452`](../ui/dr-ui/src/lib.rs#L1452), [`ui/dr-ui/src/lib.rs:2154`](../ui/dr-ui/src/lib.rs#L2154), [`ui/dr-ui/src/lib.rs:303`](../ui/dr-ui/src/lib.rs#L303), [`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:2118`](../ui/dr-ui/ui/app.slint#L2118), [`ui/dr-ui/ui/app.slint:991`](../ui/dr-ui/ui/app.slint#L991) | +| 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:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:597`](../ui/dr-ui/src/lib.rs#L597), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | +| 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: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:4636`](../ui/dr-ui/src/develop.rs#L4636) | +| 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:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2128`](../ui/dr-ui/src/lib.rs#L2128), [`ui/dr-ui/src/lib.rs:530`](../ui/dr-ui/src/lib.rs#L530), [`ui/dr-ui/src/lib.rs:588`](../ui/dr-ui/src/lib.rs#L588), [`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:2730`](../ui/dr-ui/ui/app.slint#L2730), [`ui/dr-ui/ui/app.slint:785`](../ui/dr-ui/ui/app.slint#L785) | +| 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:1992`](../ui/dr-ui/src/develop.rs#L1992), [`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:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/lib.rs:1401`](../ui/dr-ui/src/lib.rs#L1401), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:2295`](../ui/dr-ui/src/lib.rs#L2295), [`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:1366`](../ui/dr-ui/src/lib.rs#L1366), [`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:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:2295`](../ui/dr-ui/src/lib.rs#L2295), [`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:1430`](../ui/dr-ui/src/lib.rs#L1430), [`ui/dr-ui/src/lib.rs:2392`](../ui/dr-ui/src/lib.rs#L2392), [`ui/dr-ui/src/lib.rs:307`](../ui/dr-ui/src/lib.rs#L307), [`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:1840`](../ui/dr-ui/ui/app.slint#L1840), [`ui/dr-ui/ui/app.slint:2357`](../ui/dr-ui/ui/app.slint#L2357), [`ui/dr-ui/ui/app.slint:2623`](../ui/dr-ui/ui/app.slint#L2623), [`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:71`](../ui/dr-ui/src/lib.rs#L71), [`ui/dr-ui/src/lib.rs:737`](../ui/dr-ui/src/lib.rs#L737), [`ui/dr-ui/src/lib.rs:796`](../ui/dr-ui/src/lib.rs#L796) | -| 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-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:1486`](../ui/dr-ui/src/lib.rs#L1486), [`ui/dr-ui/src/lib.rs:297`](../ui/dr-ui/src/lib.rs#L297), [`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-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:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`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:1400`](../ui/dr-ui/src/lib.rs#L1400), [`ui/dr-ui/src/lib.rs:1430`](../ui/dr-ui/src/lib.rs#L1430), [`ui/dr-ui/src/lib.rs:1438`](../ui/dr-ui/src/lib.rs#L1438), [`ui/dr-ui/src/lib.rs:2324`](../ui/dr-ui/src/lib.rs#L2324), [`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:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1367`](../ui/dr-ui/src/lib.rs#L1367), [`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:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1438`](../ui/dr-ui/src/lib.rs#L1438), [`ui/dr-ui/src/lib.rs:2324`](../ui/dr-ui/src/lib.rs#L2324), [`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: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:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`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:1459`](../ui/dr-ui/src/lib.rs#L1459), [`ui/dr-ui/src/lib.rs:2421`](../ui/dr-ui/src/lib.rs#L2421), [`ui/dr-ui/src/lib.rs:308`](../ui/dr-ui/src/lib.rs#L308), [`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:1853`](../ui/dr-ui/ui/app.slint#L1853), [`ui/dr-ui/ui/app.slint:2370`](../ui/dr-ui/ui/app.slint#L2370), [`ui/dr-ui/ui/app.slint:2636`](../ui/dr-ui/ui/app.slint#L2636), [`ui/dr-ui/ui/app.slint:339`](../ui/dr-ui/ui/app.slint#L339), [`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:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:738`](../ui/dr-ui/src/lib.rs#L738), [`ui/dr-ui/src/lib.rs:797`](../ui/dr-ui/src/lib.rs#L797) | +| FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101) | +| FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) | +| 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), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1415`](../ui/dr-ui/src/lib.rs#L1415), [`ui/dr-ui/src/lib.rs:2627`](../ui/dr-ui/src/lib.rs#L2627) | +| 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:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`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:1515`](../ui/dr-ui/src/lib.rs#L1515), [`ui/dr-ui/src/lib.rs:298`](../ui/dr-ui/src/lib.rs#L298), [`ui/dr-ui/ui/app.slint:299`](../ui/dr-ui/ui/app.slint#L299), [`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-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/develop.rs:5519`](../ui/dr-ui/src/develop.rs#L5519), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1389`](../ui/dr-ui/src/lib.rs#L1389), [`ui/dr-ui/src/lib.rs:1415`](../ui/dr-ui/src/lib.rs#L1415), [`ui/dr-ui/src/lib.rs:2604`](../ui/dr-ui/src/lib.rs#L2604), [`ui/dr-ui/src/lib.rs:2627`](../ui/dr-ui/src/lib.rs#L2627), [`ui/dr-ui/ui/app.slint:1713`](../ui/dr-ui/ui/app.slint#L1713), [`ui/dr-ui/ui/app.slint:278`](../ui/dr-ui/ui/app.slint#L278), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:730`](../ui/dr-ui/ui/settings.slint#L730) | | 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/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`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) | -| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:345`](../ui/dr-ui/src/lib.rs#L345), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:187`](../ui/dr-ui/src/lib.rs#L187), [`ui/dr-ui/src/lib.rs:2041`](../ui/dr-ui/src/lib.rs#L2041), [`ui/dr-ui/src/lib.rs:345`](../ui/dr-ui/src/lib.rs#L345), [`ui/dr-ui/src/lib.rs:380`](../ui/dr-ui/src/lib.rs#L380), [`ui/dr-ui/src/lib.rs:407`](../ui/dr-ui/src/lib.rs#L407), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/library_ui.rs:6253`](../ui/dr-ui/src/library_ui.rs#L6253), [`ui/dr-ui/src/library_ui.rs:6330`](../ui/dr-ui/src/library_ui.rs#L6330), [`ui/dr-ui/src/library_ui.rs:6342`](../ui/dr-ui/src/library_ui.rs#L6342), [`ui/dr-ui/src/library_ui.rs:660`](../ui/dr-ui/src/library_ui.rs#L660), [`ui/dr-ui/src/library_ui.rs:717`](../ui/dr-ui/src/library_ui.rs#L717), [`ui/dr-ui/ui/app.slint:1120`](../ui/dr-ui/ui/app.slint#L1120), [`ui/dr-ui/ui/app.slint:1548`](../ui/dr-ui/ui/app.slint#L1548), [`ui/dr-ui/ui/library.slint:1323`](../ui/dr-ui/ui/library.slint#L1323), [`ui/dr-ui/ui/library.slint:835`](../ui/dr-ui/ui/library.slint#L835), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) | +| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:346`](../ui/dr-ui/src/lib.rs#L346), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188), [`ui/dr-ui/src/lib.rs:2070`](../ui/dr-ui/src/lib.rs#L2070), [`ui/dr-ui/src/lib.rs:346`](../ui/dr-ui/src/lib.rs#L346), [`ui/dr-ui/src/lib.rs:381`](../ui/dr-ui/src/lib.rs#L381), [`ui/dr-ui/src/lib.rs:408`](../ui/dr-ui/src/lib.rs#L408), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/library_ui.rs:6253`](../ui/dr-ui/src/library_ui.rs#L6253), [`ui/dr-ui/src/library_ui.rs:6330`](../ui/dr-ui/src/library_ui.rs#L6330), [`ui/dr-ui/src/library_ui.rs:6342`](../ui/dr-ui/src/library_ui.rs#L6342), [`ui/dr-ui/src/library_ui.rs:660`](../ui/dr-ui/src/library_ui.rs#L660), [`ui/dr-ui/src/library_ui.rs:717`](../ui/dr-ui/src/library_ui.rs#L717), [`ui/dr-ui/ui/app.slint:1127`](../ui/dr-ui/ui/app.slint#L1127), [`ui/dr-ui/ui/app.slint:1558`](../ui/dr-ui/ui/app.slint#L1558), [`ui/dr-ui/ui/library.slint:1323`](../ui/dr-ui/ui/library.slint#L1323), [`ui/dr-ui/ui/library.slint:835`](../ui/dr-ui/ui/library.slint#L835), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) | | FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2771`](../ui/dr-ui/src/develop.rs#L2771), [`ui/dr-ui/src/lib.rs:345`](../ui/dr-ui/src/lib.rs#L345) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2829`](../ui/dr-ui/src/develop.rs#L2829), [`ui/dr-ui/src/lib.rs:346`](../ui/dr-ui/src/lib.rs#L346) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) | -| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:407`](../ui/dr-ui/src/lib.rs#L407), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:953`](../ui/dr-ui/src/library.rs#L953), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:408`](../ui/dr-ui/src/lib.rs#L408), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:953`](../ui/dr-ui/src/library.rs#L953), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | -| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2604`](../ui/dr-ui/src/library.rs#L2604), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`ui/dr-ui/ui/app.slint:540`](../ui/dr-ui/ui/app.slint#L540), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | +| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2604`](../ui/dr-ui/src/library.rs#L2604), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`ui/dr-ui/ui/app.slint:547`](../ui/dr-ui/ui/app.slint#L547), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/schema.rs:956`](../core/dr-catalog/src/schema.rs#L956), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1770`](../ui/dr-ui/src/lib.rs#L1770), [`ui/dr-ui/src/lib.rs:2648`](../ui/dr-ui/src/lib.rs#L2648), [`ui/dr-ui/src/library.rs:1330`](../ui/dr-ui/src/library.rs#L1330), [`ui/dr-ui/src/library.rs:1353`](../ui/dr-ui/src/library.rs#L1353), [`ui/dr-ui/src/library.rs:1511`](../ui/dr-ui/src/library.rs#L1511), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1531`](../ui/dr-ui/src/library_ui.rs#L1531), [`ui/dr-ui/src/library_ui.rs:2058`](../ui/dr-ui/src/library_ui.rs#L2058), [`ui/dr-ui/src/library_ui.rs:267`](../ui/dr-ui/src/library_ui.rs#L267), [`ui/dr-ui/src/library_ui.rs:278`](../ui/dr-ui/src/library_ui.rs#L278), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:298`](../ui/dr-ui/src/library_ui.rs#L298), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:399`](../ui/dr-ui/src/library_ui.rs#L399), [`ui/dr-ui/src/library_ui.rs:409`](../ui/dr-ui/src/library_ui.rs#L409), [`ui/dr-ui/src/library_ui.rs:451`](../ui/dr-ui/src/library_ui.rs#L451), [`ui/dr-ui/src/library_ui.rs:514`](../ui/dr-ui/src/library_ui.rs#L514), [`ui/dr-ui/src/library_ui.rs:5255`](../ui/dr-ui/src/library_ui.rs#L5255), [`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/src/library_ui.rs:545`](../ui/dr-ui/src/library_ui.rs#L545), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2796`](../ui/dr-ui/ui/app.slint#L2796), [`ui/dr-ui/ui/app.slint:647`](../ui/dr-ui/ui/app.slint#L647), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:974`](../ui/dr-ui/ui/library.slint#L974) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/schema.rs:956`](../core/dr-catalog/src/schema.rs#L956), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1799`](../ui/dr-ui/src/lib.rs#L1799), [`ui/dr-ui/src/lib.rs:2690`](../ui/dr-ui/src/lib.rs#L2690), [`ui/dr-ui/src/library.rs:1330`](../ui/dr-ui/src/library.rs#L1330), [`ui/dr-ui/src/library.rs:1353`](../ui/dr-ui/src/library.rs#L1353), [`ui/dr-ui/src/library.rs:1511`](../ui/dr-ui/src/library.rs#L1511), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1531`](../ui/dr-ui/src/library_ui.rs#L1531), [`ui/dr-ui/src/library_ui.rs:2058`](../ui/dr-ui/src/library_ui.rs#L2058), [`ui/dr-ui/src/library_ui.rs:267`](../ui/dr-ui/src/library_ui.rs#L267), [`ui/dr-ui/src/library_ui.rs:278`](../ui/dr-ui/src/library_ui.rs#L278), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:298`](../ui/dr-ui/src/library_ui.rs#L298), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:399`](../ui/dr-ui/src/library_ui.rs#L399), [`ui/dr-ui/src/library_ui.rs:409`](../ui/dr-ui/src/library_ui.rs#L409), [`ui/dr-ui/src/library_ui.rs:451`](../ui/dr-ui/src/library_ui.rs#L451), [`ui/dr-ui/src/library_ui.rs:514`](../ui/dr-ui/src/library_ui.rs#L514), [`ui/dr-ui/src/library_ui.rs:5255`](../ui/dr-ui/src/library_ui.rs#L5255), [`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/src/library_ui.rs:545`](../ui/dr-ui/src/library_ui.rs#L545), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2809`](../ui/dr-ui/ui/app.slint#L2809), [`ui/dr-ui/ui/app.slint:654`](../ui/dr-ui/ui/app.slint#L654), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:974`](../ui/dr-ui/ui/library.slint#L974) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) | | FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | -| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353) | -| 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:1105`](../ui/dr-ui/src/lib.rs#L1105), [`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:1105`](../ui/dr-ui/src/lib.rs#L1105) | -| 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:1739`](../ui/dr-ui/src/lib.rs#L1739), [`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-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | +| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372) | +| 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:1106`](../ui/dr-ui/src/lib.rs#L1106), [`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:1106`](../ui/dr-ui/src/lib.rs#L1106) | +| 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:1768`](../ui/dr-ui/src/lib.rs#L1768), [`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: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), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | | 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:846`](../ui/dr-ui/src/lib.rs#L846), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | +| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:847`](../ui/dr-ui/src/lib.rs#L847), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | +| FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.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), [`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:187`](../ui/dr-ui/src/lib.rs#L187) | +| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2730`](../ui/dr-ui/src/lib.rs#L2730), [`ui/dr-ui/src/lib.rs:79`](../ui/dr-ui/src/lib.rs#L79), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:1040`](../ui/dr-ui/ui/library.slint#L1040) | -| FR-UI-2 | [`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: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: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:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:79`](../ui/dr-ui/src/lib.rs#L79), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/src/library_ui.rs:6361`](../ui/dr-ui/src/library_ui.rs#L6361), [`ui/dr-ui/ui/app.slint:2463`](../ui/dr-ui/ui/app.slint#L2463), [`ui/dr-ui/ui/app.slint:278`](../ui/dr-ui/ui/app.slint#L278), [`ui/dr-ui/ui/app.slint:677`](../ui/dr-ui/ui/app.slint#L677), [`ui/dr-ui/ui/app.slint:684`](../ui/dr-ui/ui/app.slint#L684), [`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/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106) | -| 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:2105`](../ui/dr-ui/ui/app.slint#L2105), [`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:1777`](../ui/dr-ui/ui/app.slint#L1777), [`ui/dr-ui/ui/app.slint:653`](../ui/dr-ui/ui/app.slint#L653), [`ui/dr-ui/ui/app.slint:684`](../ui/dr-ui/ui/app.slint#L684), [`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:2357`](../ui/dr-ui/src/lib.rs#L2357), [`ui/dr-ui/src/lib.rs:2764`](../ui/dr-ui/src/lib.rs#L2764), [`ui/dr-ui/src/lib.rs:2949`](../ui/dr-ui/src/lib.rs#L2949), [`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-1 | [`ui/dr-ui/src/lib.rs:2772`](../ui/dr-ui/src/lib.rs#L2772), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:1040`](../ui/dr-ui/ui/library.slint#L1040) | +| FR-UI-2 | [`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: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: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:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/src/library_ui.rs:6361`](../ui/dr-ui/src/library_ui.rs#L6361), [`ui/dr-ui/ui/app.slint:2476`](../ui/dr-ui/ui/app.slint#L2476), [`ui/dr-ui/ui/app.slint:285`](../ui/dr-ui/ui/app.slint#L285), [`ui/dr-ui/ui/app.slint:684`](../ui/dr-ui/ui/app.slint#L684), [`ui/dr-ui/ui/app.slint:691`](../ui/dr-ui/ui/app.slint#L691), [`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/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`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:2118`](../ui/dr-ui/ui/app.slint#L2118), [`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:1790`](../ui/dr-ui/ui/app.slint#L1790), [`ui/dr-ui/ui/app.slint:660`](../ui/dr-ui/ui/app.slint#L660), [`ui/dr-ui/ui/app.slint:691`](../ui/dr-ui/ui/app.slint#L691), [`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:2386`](../ui/dr-ui/src/lib.rs#L2386), [`ui/dr-ui/src/lib.rs:2806`](../ui/dr-ui/src/lib.rs#L2806), [`ui/dr-ui/src/lib.rs:2991`](../ui/dr-ui/src/lib.rs#L2991), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:320`](../ui/dr-ui/ui/app.slint#L320), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| 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:2075`](../ui/dr-ui/src/lib.rs#L2075), [`ui/dr-ui/ui/app.slint:1131`](../ui/dr-ui/ui/app.slint#L1131), [`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) | +| 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:2104`](../ui/dr-ui/src/lib.rs#L2104), [`ui/dr-ui/ui/app.slint:1138`](../ui/dr-ui/ui/app.slint#L1138), [`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), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`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), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | | NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | | NFR-P5 | [`core/dr-catalog/src/schema.rs:304`](../core/dr-catalog/src/schema.rs#L304), [`ui/dr-ui/src/library.rs:3229`](../ui/dr-ui/src/library.rs#L3229), [`ui/dr-ui/src/library.rs:4002`](../ui/dr-ui/src/library.rs#L4002), [`ui/dr-ui/src/library_ui.rs:4070`](../ui/dr-ui/src/library_ui.rs#L4070), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | | NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | -| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302) | +| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | +| NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) | | NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:953`](../ui/dr-ui/src/library.rs#L953) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:71`](../ui/dr-ui/src/lib.rs#L71) | +| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2604`](../ui/dr-ui/src/library.rs#L2604) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | +| NFR-SEC-2 | [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | | NFR-SEC-5 | [`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.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/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) | | R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | ## 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. +71 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -146,27 +154,21 @@ _None._ - FR-DEV-1 - FR-DEV-3g - FR-DSP-2 -- FR-DSP-3 - FR-DSP-4 -- FR-DSP-5 -- FR-DSP-8 - FR-NC-11 - FR-PLAT-AND-2 - FR-PLAT-AND-4 - FR-PLAT-AND-5 - FR-PLAT-AND-6 -- FR-PLAT-LIN-2 - FR-PLAT-LIN-3 - FR-PLG-1 - FR-PLG-10 - 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 @@ -203,13 +205,11 @@ _None._ - NFR-P7 - NFR-P8 - NFR-PORT-2 -- NFR-PORT-3 - NFR-R3 - NFR-R4 - NFR-R6 - NFR-RES-2 - NFR-RES-3 -- NFR-SEC-2 - NFR-SEC-3 - NFR-SEC-4 - NFR-SEC-6 diff --git a/platform/dr-plat/Cargo.toml b/platform/dr-plat/Cargo.toml index 11fbc00..d2ac29c 100644 --- a/platform/dr-plat/Cargo.toml +++ b/platform/dr-plat/Cargo.toml @@ -12,6 +12,12 @@ log.workspace = true [target.'cfg(all(unix, not(target_os = "android")))'.dependencies] keyring.workspace = true +# The two display servers FR-PLAT-LIN-2 names, each asked for the profile it +# knows how to state (FR-DSP-8, `display.rs`). Both are already in the tree +# via winit, so this adds compilation of nothing that was not already built. +x11rb.workspace = true +wayland-client.workspace = true +wayland-protocols.workspace = true [target.'cfg(target_os = "android")'.dependencies] android-native-keyring-store.workspace = true diff --git a/platform/dr-plat/examples/display_probe.rs b/platform/dr-plat/examples/display_probe.rs new file mode 100644 index 0000000..3e9e217 --- /dev/null +++ b/platform/dr-plat/examples/display_probe.rs @@ -0,0 +1,36 @@ +//! What this machine's display server will say about its monitors. +//! +//! The same probe the application runs at startup, printed instead of used. +//! It exists for the reason `keyring_check` does: the answer depends entirely +//! on the desktop the code is running on, so the only way to know which +//! acquisition path a given machine takes is to ask it there. +//! +//! cargo run -p dr-plat --example display_probe + +fn main() { + env_logger::init(); + + let survey = dr_plat::DisplaySurvey::probe(); + println!("display server: {}", survey.server.label()); + println!(); + for (index, display) in survey.displays.iter().enumerate() { + println!("[{index}] {}", display.name); + println!(" space: {}", display.profile.space.label()); + println!(" reading: {}", display.profile.describe()); + println!( + " source: {:?} measured: {} approximated: {}", + display.profile.source, + display.profile.source.is_measured(), + display.profile.approximated + ); + match display.bounds { + Some(b) => println!( + " bounds: {}×{} at ({}, {})", + b.width, b.height, b.x, b.y + ), + // Expected on Wayland; see `DisplaySurvey::containing`. + None => println!(" bounds: not stated by this display server"), + } + println!(); + } +} diff --git a/platform/dr-plat/src/display.rs b/platform/dr-plat/src/display.rs new file mode 100644 index 0000000..5d60d9d --- /dev/null +++ b/platform/dr-plat/src/display.rs @@ -0,0 +1,566 @@ +//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2 | NFR-PORT-1 +//! What colour the screen in front of the photographer actually is. +//! +//! The display path is colour-managed by encoding the render into the output +//! device's space (FR-DSP-6); that space is a parameter of composition, so +//! getting it right is a question of *asking the platform* rather than of +//! doing anything to the pipeline. This module is the asking. +//! +//! # Why this is a correctness problem and not a polish one +//! +//! FR-DSP-8 says so itself: "on a multi-monitor desktop with differing +//! profiles, showing wrong colours on the second display is a correctness +//! defect, not a polish item". Every other display requirement fails by being +//! slow. This one fails by being *quietly wrong* — a photographer grades a +//! skin tone on a wide-gamut panel, the app encodes sRGB into it, and the +//! result is oversaturated on screen and correct in the file, or the reverse. +//! Nothing about the image looks broken. That is the whole danger. +//! +//! # Acquisition is stated per display server, because it differs entirely +//! +//! - **X11** publishes ICC profiles as root-window properties: `_ICC_PROFILE` +//! for the first output and `_ICC_PROFILE_` for the rest, a convention +//! from the ICC Profiles in X specification that colord, xiccd and +//! `xcalib` all write to. The bytes are a whole ICC profile. +//! - **Wayland** has no such back door — a client cannot read another +//! client's or the compositor's state — and the protocol that replaces it, +//! `wp_color_manager_v1`, is still *staging* upstream. Where the compositor +//! advertises it we ask it; where it does not, there is no mechanism at all +//! and we say so. +//! +//! FR-DSP-8 anticipates exactly that gap and demands "a defined fallback where +//! Wayland provides no profile". The fallback is sRGB, and the requirement's +//! wording — *defined*, not *silent* — is why [`ProfileSource`] carries the +//! reason as data rather than logging it: the About page renders it, so a +//! photographer being shown sRGB because the compositor would not say +//! otherwise can find that out rather than wonder. +//! +//! # Four spaces, and no ICC engine +//! +//! The pipeline can encode into four spaces ([`ColourSpace`]). A real display +//! profile is a measurement of one panel and is none of them. Rather than +//! grow a general ICC transform engine — a much larger piece of work, with a +//! CMM, rendering intents, LUT-based profiles and a per-frame cost to argue +//! about — this reduces the profile to its primaries and picks the nearest of +//! the four, and then *says that it did*. See [`nearest_space`]. +//! +//! An approximation the photographer can see beats an exact answer they +//! cannot, and beats a silent approximation by a much larger margin. + +use dr_types::colour::Chromaticities; +use dr_types::ColourSpace; + +#[cfg(all(unix, not(target_os = "android")))] +mod icc; +#[cfg(all(unix, not(target_os = "android")))] +mod wayland; +#[cfg(all(unix, not(target_os = "android")))] +mod x11; + +#[cfg(all(unix, not(target_os = "android")))] +pub use icc::{read_profile, IccSummary}; + +/// Which display server the session is running on. +/// +/// Decided from the environment rather than by trying to connect, because the +/// answer decides *which* connection to attempt: `DISPLAY` is set inside a +/// Wayland session too (Xwayland sets it), so probing X11 first would put +/// every GNOME and KDE user on the X11 path and read an Xwayland root window +/// that no colour manager necessarily writes to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisplayServer { + Wayland, + X11, + /// Neither — a headless test runner, Android, or a build with no display + /// integration. Not an error: it is the case the fallback exists for. + None, +} + +impl DisplayServer { + /// What the session says it is. + pub fn detect() -> Self { + // `WAYLAND_DISPLAY` first and unconditionally. See the type comment: + // both variables are set in a Wayland session, and only this order + // gets such a session onto the path its compositor actually owns. + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + Self::Wayland + } else if std::env::var_os("DISPLAY").is_some() { + Self::X11 + } else { + Self::None + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Wayland => "Wayland", + Self::X11 => "X11", + Self::None => "no display server", + } + } +} + +/// Why a display ended up on the sRGB fallback. +/// +/// Carried as data because the About page renders it. FR-DSP-8 asks for a +/// *defined* fallback, and a fallback whose reason cannot be shown is +/// indistinguishable from a display that genuinely is sRGB. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FallbackReason { + /// Wayland, and the compositor does not advertise `wp_color_manager_v1`. + /// The common case today and the one FR-DSP-8's wording exists for. + NoWaylandProtocol, + /// The compositor or X server was asked and had nothing to say about this + /// display — no `_ICC_PROFILE` atom, or an image description that failed. + NoProfileForDisplay, + /// Something was found and could not be read. Distinguished from "nothing + /// was there" because it is a bug report rather than a configuration. + Unreadable(String), + /// The connection itself failed, or there is no display server. + NoConnection(String), +} + +impl FallbackReason { + /// A phrase for the About page, reading after "sRGB assumed:". + pub fn describe(&self) -> String { + match self { + Self::NoWaylandProtocol => { + "the compositor does not offer colour management".to_string() + } + Self::NoProfileForDisplay => "no profile is assigned to this display".to_string(), + Self::Unreadable(why) => format!("the profile could not be read ({why})"), + Self::NoConnection(why) => format!("no display server ({why})"), + } + } +} + +/// How a display's colour was established. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileSource { + /// An ICC profile read from an X11 root-window property, named here so a + /// bug report can quote which one. + X11RootProperty(String), + /// The compositor's own answer, over `wp_color_manager_v1`. + WaylandColourManagement, + /// FR-DSP-8's stated fallback. + FallbackSrgb(FallbackReason), +} + +impl ProfileSource { + /// Whether this is a profile the platform stated, rather than an assumption. + pub fn is_measured(&self) -> bool { + !matches!(self, Self::FallbackSrgb(_)) + } + + /// A short phrase naming the acquisition path, for the About page. + pub fn label(&self) -> String { + match self { + Self::X11RootProperty(atom) => format!("X11 {atom}"), + Self::WaylandColourManagement => "Wayland colour management".to_string(), + Self::FallbackSrgb(_) => "fallback".to_string(), + } + } +} + +/// The colour of one display, reduced to something the pipeline can encode. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DisplayProfile { + /// The space to compose the canvas into while this display is showing it. + pub space: ColourSpace, + /// Where `space` came from. + pub source: ProfileSource, + /// The profile's own name, where it carried one. A photographer who + /// calibrated this panel recognises the string their calibrator wrote, + /// which is the quickest way to confirm we are reading the right profile. + pub described_as: Option, + /// Whether `space` is the *nearest* of the four rather than a match. + /// + /// True for essentially every measured panel, and that is the honest + /// answer rather than a defect: a calibration profile describes one piece + /// of glass and no standard space describes it exactly. + pub approximated: bool, +} + +impl DisplayProfile { + /// The sRGB fallback FR-DSP-8 defines, carrying why it was reached. + pub fn fallback(reason: FallbackReason) -> Self { + Self { + space: ColourSpace::Srgb, + source: ProfileSource::FallbackSrgb(reason), + described_as: None, + // Not an approximation of anything. sRGB is being *assumed*, which + // is a different claim and reads differently on the About page. + approximated: false, + } + } + + /// One line for the About page: the space, then how we know. + /// + /// Written here rather than in the interface because the three cases — + /// measured and exact, measured and approximated, assumed — differ in + /// what they are claiming, and a format string assembled in Slint would + /// lose that distinction the first time someone edited it. + pub fn describe(&self) -> String { + let space = self.space.label(); + match &self.source { + ProfileSource::FallbackSrgb(reason) => { + format!("{space} assumed — {}", reason.describe()) + } + source if self.approximated => { + let named = self + .described_as + .as_deref() + .map_or_else(String::new, |d| format!("{d}, ")); + format!("nearest to {space} ({named}via {})", source.label()) + } + source => format!("{space} (via {})", source.label()), + } + } +} + +/// A display, as the platform describes it. +#[derive(Debug, Clone, PartialEq)] +pub struct DisplayInfo { + /// The connector or output name — "DP-1", "eDP-1" — where the server gave + /// one, otherwise an index. Shown in About so a two-monitor user can tell + /// which row is which. + pub name: String, + /// Where this display sits in the desktop's coordinate space, if the + /// server said. `None` on Wayland, which deliberately does not tell a + /// client where anything is; see [`DisplaySurvey::containing`]. + pub bounds: Option, + pub profile: DisplayProfile, +} + +/// A display's rectangle in the display server's own screen coordinates. +/// +/// Physical device pixels on X11, which is what RandR states a CRTC's origin +/// and size in — and, not by coincidence, the same space Slint reports a +/// window's position in. The two are comparable without conversion, and a +/// conversion inserted "for tidiness" is how they would stop being. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Bounds { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +impl Bounds { + pub fn contains(&self, x: i32, y: i32) -> bool { + x >= self.x + && y >= self.y + && x < self.x.saturating_add_unsigned(self.width) + && y < self.y.saturating_add_unsigned(self.height) + } +} + +/// Every display the session has, and how each was asked. +#[derive(Debug, Clone, PartialEq)] +pub struct DisplaySurvey { + pub server: DisplayServer, + pub displays: Vec, +} + +impl DisplaySurvey { + /// Ask the platform. Never fails: a failure *is* the fallback. + /// + /// Cheap enough to repeat — an X11 property fetch or a Wayland roundtrip — + /// but not free, so callers hold the result and re-survey on a display + /// change rather than per frame. + pub fn probe() -> Self { + let server = DisplayServer::detect(); + #[cfg(all(unix, not(target_os = "android")))] + let displays = match server { + DisplayServer::Wayland => wayland::probe(), + DisplayServer::X11 => x11::probe(), + DisplayServer::None => Err(FallbackReason::NoConnection( + "neither WAYLAND_DISPLAY nor DISPLAY is set".to_string(), + )), + }; + // Android draws through the platform compositor and has its own + // wide-gamut path (FR-DSP-6); neither display server exists there, so + // the crates are not even compiled in. sRGB, stated as such. + #[cfg(not(all(unix, not(target_os = "android"))))] + let displays: Result, FallbackReason> = Err(FallbackReason::NoConnection( + "no display-server integration on this platform".to_string(), + )); + + let displays = match displays { + // A server that connected but listed nothing is the same situation + // as one that would not connect: there is no display to describe, + // and the canvas still has to be composed into *something*. + Ok(found) if !found.is_empty() => found, + Ok(_) => vec![Self::assumed(FallbackReason::NoProfileForDisplay)], + Err(reason) => vec![Self::assumed(reason)], + }; + Self { server, displays } + } + + fn assumed(reason: FallbackReason) -> DisplayInfo { + DisplayInfo { + name: "display".to_string(), + bounds: None, + profile: DisplayProfile::fallback(reason), + } + } + + /// A survey with no platform in it, for tests and for the headless case. + pub fn assumed_srgb(reason: FallbackReason) -> Self { + Self { + server: DisplayServer::None, + displays: vec![Self::assumed(reason)], + } + } + + /// Which display is showing a window at `(x, y)` in the display server's + /// screen coordinates, as an index into [`Self::displays`]. + /// + /// **The point is the window's centre, and the caller computes it.** A + /// window straddling two monitors is showing more of itself on one of + /// them, and its centre is the cheapest statement of which. + /// + /// # Wayland returns 0, and that is the protocol's answer, not a bug + /// + /// A Wayland client is not told where its window is — there is no + /// equivalent of `XGetGeometry` against the root, deliberately, and + /// `xdg_toplevel` carries no position. The protocol's own answer to "which + /// output am I on" is `wl_surface.enter`, or better, + /// `wp_color_management_surface_feedback_v1`, which hands the client the + /// preferred image description for *its surface* and re-sends it when the + /// window moves. Both require the application's `wl_surface`, which Slint + /// owns and does not expose, so this falls back to the first display and + /// the About page shows every display's profile rather than one. + /// + /// The consequence is worth stating plainly: on a multi-monitor Wayland + /// desktop the canvas is composed for the *first* output. That is a + /// smaller error than composing for the wrong space entirely — the + /// profiles are read correctly, and a single-monitor session, which is + /// most of them, is exactly right — and the fix is a Slint surface handle + /// rather than anything in this module. + pub fn containing(&self, x: i32, y: i32) -> usize { + self.displays + .iter() + .position(|d| d.bounds.is_some_and(|b| b.contains(x, y))) + // Off every known rectangle: a window dragged past the edge of the + // desktop, or a server that gave no geometry. The first display is + // the primary one on both servers we speak to. + .unwrap_or(0) + } + + /// The profile for a display index, tolerating an index that has gone + /// stale because a monitor was unplugged between survey and render. + pub fn profile(&self, index: usize) -> &DisplayProfile { + self.displays + .get(index) + .or_else(|| self.displays.first()) + .map(|d| &d.profile) + .expect("probe always yields at least one display") + } +} + +/// The nearest of the four spaces the pipeline can encode, and whether it is +/// near enough to call a match. +/// +/// # Compared as colorants, not as chromaticity pairs +/// +/// The obvious comparison — eight xy numbers against eight xy numbers — is +/// wrong, and wrong in a way that would pass a casual test. A profile's +/// colorants have already been chromatically adapted to the D50 connection +/// space; a colour space's *published* chromaticities have not. Comparing the +/// two directly makes every D65 space look like it has the wrong white point +/// and pushes matches towards ProPhoto, the only D50 member of the four. +/// +/// So both sides go through [`Chromaticities::to_pcs_xyz`] and are compared as +/// the nine numbers a matrix/TRC profile would carry. That is the same +/// reduction `dr-export` writes into a file, which means a profile this +/// pipeline produced round-trips back to the space it was produced from — the +/// test at the bottom of this module. +/// +/// # Primaries and not the tone curve +/// +/// The four spaces are distinguished by gamut first: sRGB and Display P3 +/// share a transfer function entirely. A measured panel's TRC is a tabulated +/// curve fitted to a target gamma and matching it against three analytic +/// curves would decide nothing the primaries have not already decided. The +/// transfer function comes along with whichever space the primaries choose, +/// which is the right pairing — encoding P3 primaries through an Adobe RGB +/// gamma would be a space nobody has. +pub fn nearest_space(measured: &Chromaticities) -> (ColourSpace, bool) { + nearest_by_colorants(&measured.to_pcs_xyz()) +} + +/// [`nearest_space`], entered from the nine numbers directly. +/// +/// The two acquisition paths arrive with different halves of the same thing: +/// an ICC profile carries D50-adapted colorants and nothing else, while +/// Wayland's `primaries` event carries xy chromaticities and no matrix. Both +/// reduce to this, so there is one metric and not two that can drift apart. +pub fn nearest_by_colorants(want: &[f32; 9]) -> (ColourSpace, bool) { + let (space, distance) = ColourSpace::ALL + .into_iter() + .map(|candidate| { + let have = candidate.to_pcs_xyz(); + let d: f32 = want + .iter() + .zip(have.iter()) + .map(|(a, b)| (a - b) * (a - b)) + .sum(); + (candidate, d) + }) + // `f32` and no NaN: every input is a finite colorant matrix, so + // `total_cmp` here is a formality that avoids `partial_cmp().unwrap()`. + .min_by(|a, b| a.1.total_cmp(&b.1)) + .expect("ColourSpace::ALL is not empty"); + + // The threshold is a sum of nine squared differences over numbers of order + // one, so 1e-6 is roughly "every colorant agrees to three decimal places" + // — tight enough that a genuinely different gamut never passes, loose + // enough that a profile written from these same numbers and read back + // through s15Fixed16 rounding does. It is not a perceptual threshold and + // is not trying to be: this decides whether to *say* "approximated", and + // saying it when in doubt is the honest direction to err. + (space, distance < 1e-6) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chromaticities_of(space: ColourSpace) -> Chromaticities { + space.chromaticities() + } + + #[test] + fn each_known_space_recognises_itself_exactly() { + for space in ColourSpace::ALL { + let (found, exact) = nearest_space(&chromaticities_of(space)); + assert_eq!(found, space, "{} matched the wrong space", space.label()); + assert!(exact, "{} was not recognised as itself", space.label()); + } + } + + #[test] + fn a_panel_between_two_gamuts_is_reported_as_an_approximation() { + // A plausible measured wide-gamut panel: near P3's red and green, not + // on them. The requirement is not that it picks P3 — it is that it + // picks *something* and admits it is not exact. + let measured = Chromaticities { + red: [0.6790, 0.3190], + green: [0.2680, 0.6880], + blue: [0.1490, 0.0570], + white: [0.3127, 0.3290], + }; + let (found, exact) = nearest_space(&measured); + assert_eq!(found, ColourSpace::DisplayP3); + assert!(!exact, "a measured panel must not claim to be a standard"); + } + + #[test] + fn a_d50_wide_gamut_panel_does_not_collapse_onto_srgb() { + // The failure this guards: comparing raw xy pairs rather than adapted + // colorants makes white-point differences dominate, and everything + // lands on whichever space shares its illuminant. ProPhoto's gamut is + // unmistakable, so if the metric is right this cannot be anything else. + let (found, _) = nearest_space(&chromaticities_of(ColourSpace::ProPhoto)); + assert_eq!(found, ColourSpace::ProPhoto); + } + + #[test] + fn adobe_rgb_and_srgb_are_told_apart_despite_sharing_two_primaries() { + // Adobe RGB (1998) differs from sRGB in the green corner alone. A + // metric summing over all three colorants must still separate them, + // or a wide-gamut photo monitor is driven as sRGB. + let (found, exact) = nearest_space(&chromaticities_of(ColourSpace::AdobeRgb)); + assert_eq!(found, ColourSpace::AdobeRgb); + assert!(exact); + } + + #[test] + fn the_fallback_says_srgb_and_says_why() { + let profile = DisplayProfile::fallback(FallbackReason::NoWaylandProtocol); + assert_eq!(profile.space, ColourSpace::Srgb); + assert!(!profile.source.is_measured()); + let described = profile.describe(); + assert!(described.contains("sRGB"), "{described}"); + assert!(described.contains("assumed"), "{described}"); + // The specific reason has to survive into the string the About page + // renders, or the fallback is defined in the code and invisible on + // screen — which is the half of FR-DSP-8 that is easy to skip. + assert!(described.contains("colour management"), "{described}"); + } + + #[test] + fn an_approximated_profile_says_nearest_rather_than_claiming_the_space() { + let profile = DisplayProfile { + space: ColourSpace::DisplayP3, + source: ProfileSource::X11RootProperty("_ICC_PROFILE".to_string()), + described_as: Some("Studio Display calibrated".to_string()), + approximated: true, + }; + let described = profile.describe(); + assert!(described.contains("nearest to Display P3"), "{described}"); + assert!( + described.contains("Studio Display calibrated"), + "{described}" + ); + } + + #[test] + fn probing_a_platform_that_cannot_answer_still_yields_a_display() { + // Whatever happens, composition needs a space. The one thing this + // module may never do is hand back nothing. + let survey = DisplaySurvey::probe(); + assert!(!survey.displays.is_empty()); + assert_eq!(survey.profile(99).space, survey.displays[0].profile.space); + } + + #[test] + fn a_window_is_located_on_the_display_its_centre_falls_in() { + let survey = DisplaySurvey { + server: DisplayServer::X11, + displays: vec![ + DisplayInfo { + name: "DP-1".to_string(), + bounds: Some(Bounds { + x: 0, + y: 0, + width: 2560, + height: 1440, + }), + profile: DisplayProfile::fallback(FallbackReason::NoProfileForDisplay), + }, + DisplayInfo { + name: "DP-2".to_string(), + bounds: Some(Bounds { + x: 2560, + y: 0, + width: 1920, + height: 1080, + }), + profile: DisplayProfile { + space: ColourSpace::AdobeRgb, + source: ProfileSource::X11RootProperty("_ICC_PROFILE_1".to_string()), + described_as: None, + approximated: true, + }, + }, + ], + }; + + assert_eq!(survey.containing(100, 100), 0); + assert_eq!(survey.containing(3000, 500), 1); + // The seam: the first pixel of the second monitor belongs to it, and + // the last pixel of the first does not. An inclusive upper bound here + // would flip the transform one pixel early on every drag across. + assert_eq!(survey.containing(2559, 0), 0); + assert_eq!(survey.containing(2560, 0), 1); + // Dragged off the desktop entirely — the primary, not a panic. + assert_eq!(survey.containing(-4000, 0), 0); + + // And the point of the whole exercise: the two displays yield + // different output spaces. + assert_eq!(survey.profile(0).space, ColourSpace::Srgb); + assert_eq!(survey.profile(1).space, ColourSpace::AdobeRgb); + } +} diff --git a/platform/dr-plat/src/display/icc.rs b/platform/dr-plat/src/display/icc.rs new file mode 100644 index 0000000..2a00ea4 --- /dev/null +++ b/platform/dr-plat/src/display/icc.rs @@ -0,0 +1,372 @@ +//! TRACES: FR-DSP-8 +//! Reading just enough of an ICC profile to say which of four spaces it is. +//! +//! # Deliberately not a profile parser +//! +//! ICC.1 is a large specification, and a complete reader of it is the front +//! half of a colour management module: LUT-based transforms, rendering +//! intents, per-intent tag sets, v2 and v4 divergence. None of that helps +//! here, because the pipeline does not *apply* a profile — it encodes into one +//! of four spaces it already knows the numbers for (`dr_types::colour`). The +//! only question a display profile has to answer is which of the four it is +//! nearest to, and three tags answer it. +//! +//! So this reads the tag table, pulls the three colorants and the description, +//! and refuses everything else with a reason the About page can show. A +//! profile it refuses is not a failure of the photographer's setup and must +//! not read like one; it means "this panel is described in a way we do not +//! approximate", and sRGB is assumed as FR-DSP-8 defines. +//! +//! # What it does read +//! +//! `rXYZ`, `gXYZ`, `bXYZ` — the three colorant tags of a matrix/TRC profile, +//! which are the space's primaries already adapted to the D50 connection +//! space. That is the same reduction `ColourSpace::to_pcs_xyz` produces, which +//! is what makes the comparison in [`super::nearest_by_colorants`] an +//! apples-to-apples one, and what makes a profile this project *wrote* +//! round-trip back to the space it was written from. +//! +//! `desc` (v2) or `mluc` (v4) for the profile's own name, which exists only to +//! be shown: a photographer recognises the string their calibrator wrote and +//! can tell at a glance whether we are reading the profile they think we are. + +/// What a display profile was reduced to. +#[derive(Debug, Clone, PartialEq)] +pub struct IccSummary { + /// The three colorant columns, row-major, in the D50 connection space. + pub colorants: [f32; 9], + /// The profile's own description, where it carried a readable one. + pub description: Option, +} + +/// The ICC header's fixed size. Everything before the tag count. +const HEADER: usize = 128; + +/// Reduce a profile to the parts that decide an output space. +/// +/// `Err` carries a phrase for [`super::FallbackReason::Unreadable`], so it is +/// written to read after "the profile could not be read (…)" on the About page +/// rather than as a developer's error string. +pub fn read_profile(bytes: &[u8]) -> Result { + if bytes.len() < HEADER + 4 { + return Err("it is too short to be a profile".to_string()); + } + // Offset 36 is `acsp`, the profile file signature. Checked because the X11 + // property is a byte array with no type discipline at all: anything can + // write anything to a root window, and a profile-shaped blob that is not + // one would otherwise be read as colorants of arbitrary magnitude. + if &bytes[36..40] != b"acsp" { + return Err("it does not carry the ICC file signature".to_string()); + } + // The header's own length field. A profile whose declared size exceeds the + // property is truncated — X11 properties have a fetch length, and reading + // colorants out of a half-transferred profile would be silently wrong. + let declared = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; + if declared > bytes.len() { + return Err(format!( + "it declares {declared} bytes and only {} arrived", + bytes.len() + )); + } + + let count = be_u32(bytes, HEADER)? as usize; + // 12 bytes per tag table entry. The bound is not paranoia: `count` comes + // from the profile, and a corrupt one multiplied out is how a reader ends + // up indexing megabytes past the end. + let table_end = HEADER + 4 + count.checked_mul(12).ok_or("its tag table is absurd")?; + if table_end > bytes.len() { + return Err("its tag table runs past the end of the profile".to_string()); + } + + let mut tags: Vec<([u8; 4], usize, usize)> = Vec::with_capacity(count); + for i in 0..count { + let at = HEADER + 4 + i * 12; + let sig = [bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]; + let offset = be_u32(bytes, at + 4)? as usize; + let size = be_u32(bytes, at + 8)? as usize; + // Silently skipping a tag that does not fit rather than rejecting the + // whole profile: the three we want may all be well-formed while some + // fourth tag we will never look at is not. + if offset + .checked_add(size) + .is_some_and(|end| end <= bytes.len()) + { + tags.push((sig, offset, size)); + } + } + + let find = |want: &[u8; 4]| { + tags.iter() + .find(|(sig, _, _)| sig == want) + .map(|&(_, offset, size)| &bytes[offset..offset + size]) + }; + + let (Some(r), Some(g), Some(b)) = (find(b"rXYZ"), find(b"gXYZ"), find(b"bXYZ")) else { + // The honest description of a LUT-based profile, which is what a + // hardware calibrator often produces. It describes the panel more + // accurately than a matrix could, and approximating it would need the + // CMM this module exists to avoid. Named as a shape rather than as an + // error, because nothing is wrong with such a profile. + return Err("it is not a matrix/TRC profile".to_string()); + }; + let r = xyz_tag(r)?; + let g = xyz_tag(g)?; + let b = xyz_tag(b)?; + + Ok(IccSummary { + // Row-major, columns R/G/B — the layout `to_pcs_xyz` produces, so the + // two can be subtracted entry by entry. Transposing one of them is the + // single most plausible bug in this file, which is why the round-trip + // test asserts a written-then-read profile lands back on its own space + // rather than merely on *some* space. + colorants: [r[0], g[0], b[0], r[1], g[1], b[1], r[2], g[2], b[2]], + description: find(b"desc").and_then(text_tag), + }) +} + +/// An `XYZType` tag: signature, four reserved bytes, then s15Fixed16 triples. +/// +/// Only the first triple is read. A colorant tag carries exactly one; the +/// array form exists for other tags that share the type. +fn xyz_tag(data: &[u8]) -> Result<[f32; 3], String> { + if data.len() < 20 || &data[0..4] != b"XYZ " { + return Err("a colorant tag is not an XYZ value".to_string()); + } + Ok([ + s15_fixed16(be_i32(data, 8)?), + s15_fixed16(be_i32(data, 12)?), + s15_fixed16(be_i32(data, 16)?), + ]) +} + +/// A profile's description, from either of the two types that carry one. +/// +/// Returns `None` rather than an error throughout: the name is decoration. A +/// profile with unreadable text still has perfectly good colorants, and +/// refusing it over a string would put a correctly-described display on the +/// fallback for a cosmetic reason. +fn text_tag(data: &[u8]) -> Option { + match data.get(0..4)? { + // ICC v2 `textDescriptionType`: signature, reserved, an ASCII byte + // count, then that many bytes with a trailing NUL included in the + // count. + b"desc" => { + let count = be_u32(data, 8).ok()? as usize; + let text = data.get(12..12 + count)?; + let text = text.split(|&b| b == 0).next().unwrap_or(text); + Some(String::from_utf8_lossy(text).trim().to_string()).filter(|s| !s.is_empty()) + } + // ICC v4 `multiLocalizedUnicodeType`: UTF-16BE records. The first + // record is taken rather than the one matching the user's locale — a + // profile name is an identifier here, shown so it can be recognised, + // and picking a locale would be answering a question nobody asked. + b"mluc" => { + let records = be_u32(data, 8).ok()? as usize; + let size = be_u32(data, 12).ok()? as usize; + if records == 0 || size < 12 { + return None; + } + let length = be_u32(data, 16 + 4).ok()? as usize; + let offset = be_u32(data, 16 + 8).ok()? as usize; + let raw = data.get(offset..offset + length)?; + let units: Vec = raw + .chunks_exact(2) + .map(|p| u16::from_be_bytes([p[0], p[1]])) + .collect(); + Some(String::from_utf16_lossy(&units).trim().to_string()).filter(|s| !s.is_empty()) + } + _ => None, + } +} + +fn be_u32(bytes: &[u8], at: usize) -> Result { + bytes + .get(at..at + 4) + .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + .ok_or_else(|| "it ends in the middle of a field".to_string()) +} + +fn be_i32(bytes: &[u8], at: usize) -> Result { + be_u32(bytes, at).map(|v| v as i32) +} + +/// ICC's fixed-point number: sixteen integer bits, sixteen fractional. +fn s15_fixed16(v: i32) -> f32 { + v as f32 / 65536.0 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::display::{nearest_by_colorants, nearest_space}; + use dr_types::ColourSpace; + + /// A minimal matrix/TRC profile carrying one space's colorants. + /// + /// Assembled here rather than taken from `dr-export`, deliberately. That + /// crate writes profiles from the same `to_pcs_xyz` this reads back, so a + /// round-trip through it would confirm the two halves of *one* set of + /// assumptions agree with each other and would still pass if both were + /// transposed. Laying the bytes out by hand against ICC.1's field offsets + /// is the independent statement. + fn profile_for(space: ColourSpace, name: &str) -> Vec { + let m = space.to_pcs_xyz(); + let colorant = |col: usize| { + let mut tag = b"XYZ \0\0\0\0".to_vec(); + for row in 0..3 { + let v = (m[row * 3 + col] * 65536.0).round() as i32; + tag.extend_from_slice(&v.to_be_bytes()); + } + tag + }; + let mut desc = b"desc\0\0\0\0".to_vec(); + let text = format!("{name}\0"); + desc.extend_from_slice(&(text.len() as u32).to_be_bytes()); + desc.extend_from_slice(text.as_bytes()); + + let tags: Vec<(&[u8; 4], Vec)> = vec![ + (b"rXYZ", colorant(0)), + (b"gXYZ", colorant(1)), + (b"bXYZ", colorant(2)), + (b"desc", desc), + ]; + + let mut header = vec![0u8; HEADER]; + header[36..40].copy_from_slice(b"acsp"); + let mut table = (tags.len() as u32).to_be_bytes().to_vec(); + let mut body = Vec::new(); + let base = HEADER + 4 + tags.len() * 12; + for (sig, data) in &tags { + table.extend_from_slice(*sig); + table.extend_from_slice(&((base + body.len()) as u32).to_be_bytes()); + table.extend_from_slice(&(data.len() as u32).to_be_bytes()); + body.extend_from_slice(data); + } + let mut out = header; + out.extend_from_slice(&table); + out.extend_from_slice(&body); + let len = out.len() as u32; + out[0..4].copy_from_slice(&len.to_be_bytes()); + out + } + + #[test] + fn a_profile_is_recognised_as_the_space_it_describes() { + // The whole acquisition path in one assertion: bytes that a colour + // manager would publish for a P3 panel must reach the pipeline as + // Display P3, and not as sRGB. + for space in ColourSpace::ALL { + let bytes = profile_for(space, space.label()); + let summary = read_profile(&bytes).expect("a well-formed profile"); + let (found, exact) = nearest_by_colorants(&summary.colorants); + assert_eq!(found, space, "{} was read as {found:?}", space.label()); + assert!( + exact, + "{} did not survive s15Fixed16 rounding", + space.label() + ); + } + } + + #[test] + fn the_colorant_matrix_is_not_transposed() { + // Reading the three tags into rows rather than columns produces a + // matrix that is still plausible, still finite, and describes a + // different gamut. Adobe RGB is the case that catches it: it differs + // from sRGB in one primary, so a transposition moves it onto a space + // that is genuinely close and the nearest-match would hide it. + let bytes = profile_for(ColourSpace::AdobeRgb, "Adobe RGB"); + let summary = read_profile(&bytes).expect("a well-formed profile"); + let want = ColourSpace::AdobeRgb.to_pcs_xyz(); + + // Not `assert_eq`: the values went out through s15Fixed16 and came + // back, so they agree to about 1e-5 and never exactly. The tolerance + // is three orders of magnitude tighter than the smallest off-diagonal + // difference below, so it still catches the thing it is here for. + for (have, want) in summary.colorants.iter().zip(want.iter()) { + assert!((have - want).abs() < 1e-4, "{:?}", summary.colorants); + } + + // And the guard that makes the assertion above mean something: a + // symmetric matrix would satisfy it transposed as well. + assert!( + (want[1] - want[3]).abs() > 1e-2, + "the colorant matrix must not be symmetric or this proves nothing" + ); + } + + #[test] + fn a_profile_names_itself_where_it_can() { + let bytes = profile_for(ColourSpace::Srgb, "EIZO CG279X calibrated 2024-03"); + let summary = read_profile(&bytes).expect("a well-formed profile"); + assert_eq!( + summary.description.as_deref(), + Some("EIZO CG279X calibrated 2024-03") + ); + } + + #[test] + fn a_v4_profile_names_itself_from_its_unicode_records() { + let mut mluc = b"mluc\0\0\0\0".to_vec(); + let text: Vec = "LG UltraFine" + .encode_utf16() + .flat_map(u16::to_be_bytes) + .collect(); + mluc.extend_from_slice(&1u32.to_be_bytes()); // one record + mluc.extend_from_slice(&12u32.to_be_bytes()); // record size + mluc.extend_from_slice(b"enUS"); + mluc.extend_from_slice(&(text.len() as u32).to_be_bytes()); + mluc.extend_from_slice(&28u32.to_be_bytes()); // offset within the tag + mluc.extend_from_slice(&text); + assert_eq!(text_tag(&mluc).as_deref(), Some("LG UltraFine")); + } + + #[test] + fn a_lut_profile_is_refused_by_shape_and_not_by_crashing() { + // A hardware calibrator's `mAB `-based profile. There is nothing wrong + // with it; we simply cannot approximate it without the CMM this module + // exists to avoid, and the About page has to be able to say so. + let mut header = vec![0u8; HEADER]; + header[36..40].copy_from_slice(b"acsp"); + header.extend_from_slice(&0u32.to_be_bytes()); + let len = header.len() as u32; + header[0..4].copy_from_slice(&len.to_be_bytes()); + let err = read_profile(&header).expect_err("no colorants to read"); + assert!(err.contains("matrix/TRC"), "{err}"); + } + + #[test] + fn rubbish_on_a_root_window_is_refused_rather_than_read_as_colour() { + // An X11 property is untyped and world-writable by convention. None of + // these may panic, and none may produce a colour space. + assert!(read_profile(&[]).is_err()); + assert!(read_profile(&[0u8; 200]).is_err()); + + let mut truncated = profile_for(ColourSpace::Srgb, "sRGB"); + truncated.truncate(truncated.len() / 2); + assert!(read_profile(&truncated).is_err()); + + // A profile whose tag table claims far more tags than there are bytes. + let mut absurd = profile_for(ColourSpace::Srgb, "sRGB"); + absurd[HEADER..HEADER + 4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(read_profile(&absurd).is_err()); + } + + #[test] + fn the_two_entry_points_agree() { + // `nearest_space` (chromaticities, from Wayland) and + // `nearest_by_colorants` (a matrix, from ICC) must not drift apart: + // they are the same decision reached from the two halves of the same + // definition, and a session that answered differently depending on + // which display server it was on would be the worst kind of bug to + // reproduce. + for space in ColourSpace::ALL { + let bytes = profile_for(space, "x"); + let summary = read_profile(&bytes).expect("a well-formed profile"); + assert_eq!( + nearest_by_colorants(&summary.colorants).0, + nearest_space(&space.chromaticities()).0 + ); + } + } +} diff --git a/platform/dr-plat/src/display/wayland.rs b/platform/dr-plat/src/display/wayland.rs new file mode 100644 index 0000000..eed1484 --- /dev/null +++ b/platform/dr-plat/src/display/wayland.rs @@ -0,0 +1,381 @@ +//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2 +//! Acquisition on Wayland: ask the compositor, or admit that we cannot. +//! +//! # Why there is no back door here +//! +//! X11's mechanism works because any client may read any root-window +//! property. Wayland has no such shared state by design — a client sees its +//! own surfaces and nothing else — so the profile has to be *given* to us, and +//! the protocol that gives it, `wp_color_manager_v1`, is still staging +//! upstream. That is the concrete form of FR-DSP-8's "a defined fallback where +//! Wayland provides no profile": on a compositor that does not advertise the +//! global there is no mechanism to fall back *from*, and pretending otherwise +//! would mean reading the Xwayland root window and presenting a value the +//! compositor never agreed to. +//! +//! So: bind the global if it is there, ask each `wl_output` for its image +//! description, and where it is not there return +//! [`FallbackReason::NoWaylandProtocol`], which the About page renders in +//! words. +//! +//! # Two forms of the same answer +//! +//! The compositor may describe an output either by handing over an ICC profile +//! on a file descriptor or by stating primaries as CIE xy chromaticities. +//! Both are accepted, and both reduce to the same nearest-space decision — see +//! `super::nearest_by_colorants` for why that reduction has to happen in one +//! place. The ICC form is preferred where both arrive, because it carries the +//! profile's own name, which is the string a photographer recognises. +//! +//! # Position is deliberately not asked for +//! +//! [`super::DisplaySurvey::containing`] explains the consequence at length: +//! `bounds` is `None` on every Wayland output, not because `wl_output` refuses +//! to state its position but because a client is never told where its *own* +//! window is, so a rectangle to test against would have no point to test. + +use std::io::{Read, Seek, SeekFrom}; + +use wayland_client::protocol::{wl_output, wl_registry}; +use wayland_client::{Connection, Dispatch, Proxy, QueueHandle}; +use wayland_protocols::wp::color_management::v1::client::{ + wp_color_management_output_v1, wp_color_manager_v1, wp_image_description_info_v1, + wp_image_description_v1, +}; + +use dr_types::colour::Chromaticities; + +use super::{ + nearest_by_colorants, nearest_space, DisplayInfo, DisplayProfile, FallbackReason, ProfileSource, +}; + +/// The highest protocol version this client knows how to receive. +/// +/// Bound as high as the compositor offers rather than at 1, because +/// `get_image_description` fails with `low_version` when the client cannot +/// receive every event the output's description needs — so asking for less +/// than we understand turns a describable display into a fallback. +const MAX_MANAGER_VERSION: u32 = 3; + +/// `wl_output.name` arrived in version 4. Below it an output has no stable +/// identifier and is listed by index, which is cosmetic only. +const MAX_OUTPUT_VERSION: u32 = 4; + +/// What one output told us, accumulated across the events that describe it. +#[derive(Default)] +struct Pending { + name: Option, + description: Option, + /// The ICC profile the compositor handed over, if it did. + icc: Option>, + /// Primaries as CIE xy, if it stated them instead. + primaries: Option, + /// Set when the image description could not be produced at all. + failed: Option, + /// Whether anything at all came back for this output. + answered: bool, +} + +struct State { + manager: Option, + outputs: Vec, + pending: Vec, +} + +/// Every output the compositor has, with whatever colour it will state. +pub fn probe() -> Result, FallbackReason> { + let conn = Connection::connect_to_env() + .map_err(|e| FallbackReason::NoConnection(format!("Wayland: {e}")))?; + let mut queue = conn.new_event_queue(); + let qh = queue.handle(); + conn.display().get_registry(&qh, ()); + + let mut state = State { + manager: None, + outputs: Vec::new(), + pending: Vec::new(), + }; + + // First roundtrip: the registry's globals, which is where both the manager + // and the outputs are bound. + roundtrip(&mut queue, &mut state)?; + // Second: the events those newly bound objects emit — `wl_output.name`, + // and the manager's capability advertisements. A single roundtrip cannot + // carry them, because the objects did not exist when the first request + // went out. + roundtrip(&mut queue, &mut state)?; + + let Some(manager) = state.manager.clone() else { + // The case FR-DSP-8's fallback clause is written for, and today the + // common one. Reported as its own reason rather than folded into + // "no profile", because the two lead a user somewhere different: this + // one is the compositor's feature set, not their calibration. + return Err(FallbackReason::NoWaylandProtocol); + }; + if state.outputs.is_empty() { + return Err(FallbackReason::NoConnection( + "Wayland: the compositor listed no outputs".to_string(), + )); + } + + // Ask every output at once and let one roundtrip settle them all. Asking + // serially would be a roundtrip per monitor on a path that runs at startup + // and on every display change. + for (index, output) in state.outputs.clone().iter().enumerate() { + let cm_output = manager.get_output(output, &qh, index); + cm_output.get_image_description(&qh, index); + } + roundtrip(&mut queue, &mut state)?; + // `get_information` is only legal once the description is ready, so it + // cannot be batched with the request above; this is the second and last + // roundtrip of the exchange. + roundtrip(&mut queue, &mut state)?; + + Ok(state + .pending + .iter() + .enumerate() + .map(|(index, pending)| DisplayInfo { + name: pending + .name + .clone() + .unwrap_or_else(|| format!("display {}", index + 1)), + // See the module comment: a rectangle with no point to test. + bounds: None, + profile: resolve(pending), + }) + .collect()) +} + +/// Turn one output's accumulated events into an output space. +fn resolve(pending: &Pending) -> DisplayProfile { + if let Some(why) = &pending.failed { + return DisplayProfile::fallback(FallbackReason::Unreadable(format!("Wayland: {why}"))); + } + + // The ICC form first: it is the only one that carries a name, and a named + // profile is what lets a photographer confirm we are reading theirs. + if let Some(bytes) = &pending.icc { + return match super::icc::read_profile(bytes) { + Ok(summary) => { + let (space, exact) = nearest_by_colorants(&summary.colorants); + DisplayProfile { + space, + source: ProfileSource::WaylandColourManagement, + described_as: summary.description.or_else(|| pending.description.clone()), + approximated: !exact, + } + } + Err(why) => DisplayProfile::fallback(FallbackReason::Unreadable(why)), + }; + } + + if let Some(primaries) = pending.primaries { + let (space, exact) = nearest_space(&primaries); + return DisplayProfile { + space, + source: ProfileSource::WaylandColourManagement, + described_as: pending.description.clone(), + approximated: !exact, + }; + } + + // The protocol was there and the output had nothing to say through it. + DisplayProfile::fallback(if pending.answered { + FallbackReason::NoProfileForDisplay + } else { + FallbackReason::Unreadable("Wayland: the compositor did not answer".to_string()) + }) +} + +fn roundtrip( + queue: &mut wayland_client::EventQueue, + state: &mut State, +) -> Result<(), FallbackReason> { + queue + .roundtrip(state) + .map(|_| ()) + .map_err(|e| FallbackReason::NoConnection(format!("Wayland: {e}"))) +} + +impl Dispatch for State { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + qh: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + // `GlobalRemove` is a monitor being unplugged mid-probe. Ignored + // here: the survey is re-run on a display change anyway, and + // reacting to it inside a two-roundtrip exchange would only make + // the indices disagree with the vector. + return; + }; + if interface == wp_color_manager_v1::WpColorManagerV1::interface().name { + state.manager = Some(registry.bind(name, version.min(MAX_MANAGER_VERSION), qh, ())); + } else if interface == wl_output::WlOutput::interface().name { + let index = state.outputs.len(); + state.pending.push(Pending::default()); + state + .outputs + .push(registry.bind(name, version.min(MAX_OUTPUT_VERSION), qh, index)); + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wl_output::WlOutput, + event: wl_output::Event, + index: &usize, + _: &Connection, + _: &QueueHandle, + ) { + let Some(pending) = state.pending.get_mut(*index) else { + return; + }; + match event { + // The connector name — "DP-1", "eDP-1" — which is what a user with + // two monitors recognises in a list. + wl_output::Event::Name { name } => pending.name = Some(name), + wl_output::Event::Description { description } => { + pending.description = Some(description) + } + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + _: &mut Self, + _: &wp_color_manager_v1::WpColorManagerV1, + _: wp_color_manager_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + // The manager advertises which intents, features, transfer functions + // and named primaries it supports. All of that constrains what a + // client may *create*; this one only reads what the outputs already + // are, so none of it applies. + } +} + +impl Dispatch for State { + fn event( + _: &mut Self, + _: &wp_color_management_output_v1::WpColorManagementOutputV1, + _: wp_color_management_output_v1::Event, + _: &usize, + _: &Connection, + _: &QueueHandle, + ) { + // `image_description_changed` says the output's colour has changed — + // a user switching their monitor's picture mode, or assigning a new + // profile. Not acted on here: this object lives for the length of one + // probe and is dropped with it. The application re-surveys instead, + // which also covers the X11 side, where there is no such event. + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + description: &wp_image_description_v1::WpImageDescriptionV1, + event: wp_image_description_v1::Event, + index: &usize, + _: &Connection, + qh: &QueueHandle, + ) { + let Some(pending) = state.pending.get_mut(*index) else { + return; + }; + match event { + // `ready` at protocol version 1, `ready2` from version 2. Both + // mean the same thing and both are handled, because the version + // bound depends on the compositor. + wp_image_description_v1::Event::Ready { .. } + | wp_image_description_v1::Event::Ready2 { .. } => { + pending.answered = true; + description.get_information(qh, *index); + } + wp_image_description_v1::Event::Failed { cause, msg } => { + pending.answered = true; + pending.failed = Some(format!("{cause:?}: {msg}")); + } + _ => {} + } + } +} + +impl Dispatch for State { + fn event( + state: &mut Self, + _: &wp_image_description_info_v1::WpImageDescriptionInfoV1, + event: wp_image_description_info_v1::Event, + index: &usize, + _: &Connection, + _: &QueueHandle, + ) { + let Some(pending) = state.pending.get_mut(*index) else { + return; + }; + match event { + wp_image_description_info_v1::Event::IccFile { icc, icc_size } => { + match read_fd(icc, icc_size as usize) { + Ok(bytes) => pending.icc = Some(bytes), + Err(e) => log::warn!("reading the compositor's ICC profile: {e}"), + } + } + wp_image_description_info_v1::Event::Primaries { + r_x, + r_y, + g_x, + g_y, + b_x, + b_y, + w_x, + w_y, + } => { + // The protocol carries six decimals by multiplying by a + // million; undoing it here rather than anywhere downstream + // keeps `Chromaticities` meaning one thing everywhere. + let xy = |x: i32, y: i32| [f64::from(x) / 1e6, f64::from(y) / 1e6]; + pending.primaries = Some(Chromaticities { + red: xy(r_x, r_y), + green: xy(g_x, g_y), + blue: xy(b_x, b_y), + white: xy(w_x, w_y), + }); + } + _ => {} + } + } +} + +/// Read a profile out of the file descriptor the compositor passed. +/// +/// Seeks to the start first. The descriptor is a dup of the compositor's own, +/// so it shares a file offset with it; assuming zero is the kind of assumption +/// that works on every compositor until it does not. +fn read_fd(fd: std::os::fd::OwnedFd, size: usize) -> std::io::Result> { + let mut file = std::fs::File::from(fd); + file.seek(SeekFrom::Start(0))?; + let mut bytes = Vec::with_capacity(size.min(1 << 20)); + // Bounded by what the compositor declared, and separately by a megabyte: + // a display profile is a few kilobytes, and the size is a number from + // another process. + file.take(size.min(1 << 20) as u64) + .read_to_end(&mut bytes)?; + Ok(bytes) +} diff --git a/platform/dr-plat/src/display/x11.rs b/platform/dr-plat/src/display/x11.rs new file mode 100644 index 0000000..e00c777 --- /dev/null +++ b/platform/dr-plat/src/display/x11.rs @@ -0,0 +1,161 @@ +//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2 +//! Acquisition on X11: ICC profiles as root-window properties. +//! +//! The mechanism is the ICC Profiles in X specification, and it predates +//! everything else in this module by two decades. A colour manager — colord +//! under GNOME, `xiccd`, `xcalib`, or the desktop's own — loads the profile +//! for each output and publishes its bytes on the *root window* as a property: +//! `_ICC_PROFILE` for the first output, `_ICC_PROFILE_1`, `_ICC_PROFILE_2` and +//! so on for the rest, numbered by the output's position in RandR's list. +//! +//! Any client can read them, which is exactly why this works and exactly why +//! [`super::icc::read_profile`] validates so carefully: a root-window property +//! is untyped shared state that anything on the display may write. +//! +//! # Geometry comes from RandR, and it is what makes a move detectable +//! +//! X11 will also say where each output sits in the desktop's coordinate space, +//! and will tell a client where its own window is. Those two facts together +//! are the whole of FR-DSP-8's "updates when the window moves between +//! displays" on this display server — the window's centre falls in one +//! rectangle or another, and the answer changes as it is dragged. Wayland +//! gives neither, which is why `wayland.rs` says what it says. + +use x11rb::connection::Connection; +use x11rb::protocol::randr::ConnectionExt as RandrExt; +use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as XExt}; + +use super::{ + nearest_by_colorants, Bounds, DisplayInfo, DisplayProfile, FallbackReason, ProfileSource, +}; + +/// Every output the X server has, with whatever profile is published for it. +pub fn probe() -> Result, FallbackReason> { + let (conn, screen_num) = + x11rb::connect(None).map_err(|e| FallbackReason::NoConnection(format!("X11: {e}")))?; + let root = conn + .setup() + .roots + .get(screen_num) + .ok_or_else(|| FallbackReason::NoConnection("X11: no screen".to_string()))? + .root; + + // RandR describes the outputs. Its absence is survivable: a server without + // it still has a root window with `_ICC_PROFILE` on it, so fall through to + // a single unnamed display carrying the first property rather than giving + // up on colour management entirely. + let outputs = enumerate(&conn, root).unwrap_or_default(); + let outputs = if outputs.is_empty() { + vec![(String::from("display"), None)] + } else { + outputs + }; + + Ok(outputs + .into_iter() + .enumerate() + .map(|(index, (name, bounds))| DisplayInfo { + name, + bounds, + profile: profile_for(&conn, root, index), + }) + .collect()) +} + +/// An output's connector name and the rectangle it occupies, where RandR +/// would say. Named so that the order of the two is fixed in one place. +type Output = (String, Option); + +/// The connected outputs, in RandR order, with their desktop rectangles. +/// +/// Order is load-bearing rather than cosmetic: it is what numbers the +/// `_ICC_PROFILE_` properties, so reordering this list would hand every +/// monitor its neighbour's profile. +fn enumerate(conn: &impl Connection, root: u32) -> Result, Box> { + let resources = conn.randr_get_screen_resources_current(root)?.reply()?; + let stamp = resources.config_timestamp; + let mut found = Vec::new(); + for output in resources.outputs { + let Ok(info) = conn.randr_get_output_info(output, stamp)?.reply() else { + continue; + }; + // `crtc == 0` is an output with no CRTC driving it: a port with + // nothing plugged in, or a disabled monitor. It occupies no part of + // the desktop and takes no place in the `_ICC_PROFILE_` numbering. + if info.crtc == 0 { + continue; + } + let name = String::from_utf8_lossy(&info.name).to_string(); + let bounds = conn + .randr_get_crtc_info(info.crtc, stamp) + .ok() + .and_then(|c| c.reply().ok()) + .map(|c| Bounds { + x: i32::from(c.x), + y: i32::from(c.y), + width: u32::from(c.width), + height: u32::from(c.height), + }); + found.push((name, bounds)); + } + Ok(found) +} + +/// The property this output's profile would be published on, read and reduced. +fn profile_for(conn: &impl Connection, root: u32, index: usize) -> DisplayProfile { + // The specification's numbering: the first output's property carries no + // suffix. Writing `_ICC_PROFILE_0` instead reads nothing on every desktop. + let name = if index == 0 { + "_ICC_PROFILE".to_string() + } else { + format!("_ICC_PROFILE_{index}") + }; + + let bytes = match fetch(conn, root, &name) { + Ok(Some(bytes)) => bytes, + // No property. The overwhelmingly common case on a stock desktop with + // no calibration installed, and not a fault of any kind. + Ok(None) => return DisplayProfile::fallback(FallbackReason::NoProfileForDisplay), + Err(e) => return DisplayProfile::fallback(FallbackReason::Unreadable(format!("X11: {e}"))), + }; + + match super::icc::read_profile(&bytes) { + Ok(summary) => { + let (space, exact) = nearest_by_colorants(&summary.colorants); + DisplayProfile { + space, + source: ProfileSource::X11RootProperty(name), + described_as: summary.description, + approximated: !exact, + } + } + Err(why) => DisplayProfile::fallback(FallbackReason::Unreadable(why)), + } +} + +/// Read a root-window property whole. +/// +/// The length is asked for in 32-bit units, which is X11's unit for this call +/// and a trap worth naming: a display profile is a few kilobytes and a request +/// stated in bytes would truncate it into a shape `read_profile` then rejects +/// as corrupt. `u32::MAX / 4` asks for all of it. +fn fetch( + conn: &impl Connection, + root: u32, + name: &str, +) -> Result>, Box> { + let atom = conn.intern_atom(true, name.as_bytes())?.reply()?.atom; + // `only_if_exists` above: an atom that no one has interned is a property + // no one has set, and interning it ourselves would litter the server with + // atoms for the monitors this desktop does not have. + if atom == 0 { + return Ok(None); + } + let reply = conn + .get_property(false, root, atom, AtomEnum::CARDINAL, 0, u32::MAX / 4)? + .reply()?; + if reply.value.is_empty() { + return Ok(None); + } + Ok(Some(reply.value)) +} diff --git a/platform/dr-plat/src/lib.rs b/platform/dr-plat/src/lib.rs index a64ae44..808ed80 100644 --- a/platform/dr-plat/src/lib.rs +++ b/platform/dr-plat/src/lib.rs @@ -4,10 +4,15 @@ //! construction, so `core/` contains no `#[cfg(target_os)]` (NFR-PORT-1, //! ARCH §10). +pub mod display; pub mod secrets; pub mod storage; pub mod volumes; +pub use display::{ + Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason, + ProfileSource, +}; pub use secrets::{ EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore, }; diff --git a/tools/traceability/src/main.rs b/tools/traceability/src/main.rs index 5f872b7..5fd008c 100644 --- a/tools/traceability/src/main.rs +++ b/tools/traceability/src/main.rs @@ -18,7 +18,12 @@ use anyhow::{bail, Context, Result}; use traceability::*; /// Directories scanned for tags. -const SOURCE_ROOTS: &[&str] = &["core", "ui", "apps", "tools"]; +/// +/// `platform` belongs here as much as the rest: it is where the display-server +/// and secret-store integrations live, so leaving it out made every +/// FR-PLAT-\* and NFR-PORT-\* tag in `dr-plat` invisible and understated the +/// matrix by exactly the requirements the platform layer exists to satisfy. +const SOURCE_ROOTS: &[&str] = &["core", "ui", "platform", "apps", "tools"]; /// Minimum coverage the gate accepts. /// diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 90a46f9..07ab5aa 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -686,6 +686,22 @@ pub struct DevelopSession { /// open. One value rather than one per operation, for the same reason /// `curve_samples` is one polyline: the panel draws one curve. curve_channel: usize, + /// TRACES: FR-DSP-8 + /// The space the canvas is encoded into, for the display now showing it. + /// + /// **Not part of the edit, and not interface state either.** It is a fact + /// about the glass in front of the photographer: the same graph on the + /// same file composes differently on a wide-gamut second monitor, and + /// neither the sidecar nor the undo stack has any business knowing about + /// it. That is also why it lives here rather than on the `EditGraph` — + /// `compose_for` deliberately takes the space per call because "the same + /// edit goes to the screen in the display's space and to a file in + /// whatever the export asks for, and neither is more authoritative". + /// + /// sRGB until the application says otherwise, which is the same answer + /// `dr_plat::display`'s fallback gives and means a session constructed in + /// a test behaves exactly as it did before this existed. + display_space: dr_types::ColourSpace, } impl DevelopSession { @@ -756,6 +772,7 @@ impl DevelopSession { show_overlay: false, active_tab: None, curve_channel: 0, + display_space: dr_types::ColourSpace::Srgb, } } @@ -1196,7 +1213,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)?; @@ -2624,6 +2641,30 @@ impl DevelopSession { Some((op.id, param.id)) } + /// TRACES: FR-DSP-8 + /// Encode the canvas for a different display from now on. + /// + /// Returns whether anything changed, so a caller polling for window moves + /// can redraw only when the answer is genuinely different — the poll runs + /// far more often than a monitor is changed, and a redraw per poll would + /// undo the point of rendering on demand. + /// + /// Nothing is invalidated here and nothing needs to be. The next + /// [`Self::render`] composes against the new space, the pipeline cache + /// distinguishes the two shaders by the structure hash the space enters, + /// and the mask array — rasterised in source space, sampled through the + /// framing — is unaffected because a colour space is not a geometry. + pub fn set_display_space(&mut self, space: dr_types::ColourSpace) -> bool { + let changed = self.display_space != space; + self.display_space = space; + changed + } + + /// The space the canvas is currently being encoded into. + pub fn display_space(&self) -> dr_types::ColourSpace { + self.display_space + } + /// TRACES: FR-DSP-1 | AC-8 /// Render at the requested display size and hand back a Slint image. /// @@ -2654,11 +2695,24 @@ impl DevelopSession { let (fw, fh) = self.graph.output_size(sw, sh); let (w, h) = fit(fw, fh, width.max(1), height.max(1)); - let shader = self.graph.compose(); + // TRACES: FR-DSP-8 | FR-DSP-6 + // **Composed for the display that is showing this canvas**, not for + // sRGB. This is the whole of FR-DSP-8's second half arriving at the + // pipeline: a display change is a *recomposition* and nothing more, + // because the output space was always a parameter of composition and + // always entered the structure hash. Moving the window to a P3 panel + // therefore costs one shader compile and no pipeline change at all. + let space = self.display_space; + let shader = self.graph.compose_for(space); // Rasterise the masks first: the shader addresses array slices by // index, so the array has to describe *this* stack before it is bound. - self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?; + // + // The same `space` to both, necessarily: where a detail stage exists + // it is the *last* pass that performs the output transform, and two + // halves composed for different spaces would encode the frame twice + // or not at all. + self.render_with_masks(&shader, w, h, space)?; let texture = self.adjust.output().ok_or("nothing was rendered")?; // The import is fallible on format and usage only, and both are fixed @@ -2702,7 +2756,11 @@ impl DevelopSession { /// explicit about. It is in the output colour space, which is what /// FR-DSP-7 asks for — the levels counted are the levels the display will /// show, so a clipped bin means a highlight that is actually gone rather - /// than one the transform might still recover. And when the view is zoomed + /// than one the transform might still recover. Since FR-DSP-8 that is the + /// space of *this display* rather than sRGB, which makes the reading more + /// truthful and not less: a highlight that survives on a wide-gamut panel + /// and clips on the laptop's screen genuinely is two different facts, and + /// the histogram now reports whichever one the photographer is looking at. And when the view is zoomed /// or cropped it describes the visible region, not the whole file: a /// photographer inspecting a highlight at 4× is asking about *that* /// highlight, and a histogram of the parts of the frame off screen would @@ -4480,15 +4538,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 +4670,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 +4680,7 @@ mod tests { facet: None, }, ], - attributes: &[dr_pipeline::Attribute::Tone], + attributes: vec![dr_pipeline::Attribute::Tone], }; let rows = rows_from(&[invented]); @@ -4660,12 +4718,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 +4755,7 @@ mod tests { facet: None, }, ], - attributes: &[dr_pipeline::Attribute::Tone], + attributes: vec![dr_pipeline::Attribute::Tone], }; assert!(!supported(WidgetKind::ColourWheel), "precondition"); @@ -5146,15 +5204,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"); @@ -5407,4 +5465,111 @@ mod tests { session.set_active_tab(99); assert_eq!(session.rows().len(), all); } + + // ---------------------------------------------------------------------- + // Per-display colour (FR-DSP-8) + // ---------------------------------------------------------------------- + + /// TRACES: FR-DSP-8 | FR-DSP-6 + /// The canvas is encoded for the display, not always for sRGB. + /// + /// This is the assertion the requirement is actually about. Before it, + /// `render` composed `ColourSpace::Srgb` unconditionally, and a second + /// monitor with a different profile got sRGB pixels *labelled* as its own + /// space by the compositor — the silent wrongness FR-DSP-8 calls a + /// correctness defect. If someone re-hardcodes the space, these pixels + /// stop differing and this fails. + /// + /// A saturated red is the probe deliberately: it sits near the edge of + /// sRGB's gamut, so re-encoding it into a wider one moves it a long way, + /// where a mid grey would move by almost nothing in any of the four and + /// the test would pass on a broken build. + #[test] + fn the_canvas_is_encoded_for_the_display_showing_it() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..64 * 64).flat_map(|_| [230u8, 20, 20, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) + .expect("session"); + + assert_eq!( + session.display_space(), + dr_types::ColourSpace::Srgb, + "a session starts on the fallback, so nothing changes for a \ + desktop whose display server will not say otherwise" + ); + let on_srgb = read_back(&ctx, &session.render(64, 64).expect("render")); + + assert!(session.set_display_space(dr_types::ColourSpace::AdobeRgb)); + let on_wide = read_back(&ctx, &session.render(64, 64).expect("render")); + + assert_ne!( + on_srgb, on_wide, + "the same edit rendered for two displays produced the same pixels" + ); + + // And back again, because a photographer dragging a window between + // two monitors expects the first one to look as it did rather than to + // accumulate a transform. + assert!(session.set_display_space(dr_types::ColourSpace::Srgb)); + let returned = read_back(&ctx, &session.render(64, 64).expect("render")); + assert_eq!(on_srgb, returned); + } + + /// TRACES: FR-DSP-8 + /// A move that changes nothing reports nothing, so nothing is redrawn. + /// + /// The window's position is polled twice a second and two displays often + /// share a profile. A setter that reported a change every time it was + /// called would turn that poll into a redraw loop. + #[test] + fn setting_the_same_display_space_twice_is_not_a_change() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) + .expect("session"); + + assert!(session.set_display_space(dr_types::ColourSpace::DisplayP3)); + assert!(!session.set_display_space(dr_types::ColourSpace::DisplayP3)); + assert_eq!(session.display_space(), dr_types::ColourSpace::DisplayP3); + } + + /// TRACES: FR-DSP-8 | FR-EXP-2 + /// The display's space is the *canvas's*, and reaches nothing else. + /// + /// A thumbnail goes into a shard that syncs between devices and an export + /// claims the space the export dialogue asked for. Letting the monitor in + /// front of the photographer decide either would write a file whose + /// profile describes the desk it was made at. + #[test] + fn a_wide_gamut_monitor_does_not_reach_the_thumbnail_or_the_export() { + let Some(ctx) = headless() else { return }; + let rgba: Vec = (0..32 * 32).flat_map(|_| [230u8, 20, 20, 255]).collect(); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) + .expect("session"); + + let thumb_before = session.render_thumbnail(16).expect("thumbnail"); + let export_before = session + .render_for_export(dr_types::ColourSpace::Srgb) + .expect("export") + .rgba; + + session.set_display_space(dr_types::ColourSpace::ProPhoto); + + assert_eq!( + session.render_thumbnail(16).expect("thumbnail"), + thumb_before, + "the grid's thumbnail followed the monitor" + ); + assert_eq!( + session + .render_for_export(dr_types::ColourSpace::Srgb) + .expect("export") + .rgba, + export_before, + "an sRGB export followed the monitor" + ); + } } diff --git a/ui/dr-ui/src/display_ui.rs b/ui/dr-ui/src/display_ui.rs new file mode 100644 index 0000000..012f3f6 --- /dev/null +++ b/ui/dr-ui/src/display_ui.rs @@ -0,0 +1,411 @@ +//! TRACES: FR-DSP-8 +//! Following the canvas from one display to the next. +//! +//! `dr_plat::display` establishes what each display *is*. This decides which +//! of them is showing the canvas right now, keeps the develop session's output +//! space pointed at it, and renders at that display's physical pixel size +//! rather than its logical one. +//! +//! # Why a poll and not a callback +//! +//! Slint reports a window's size and its scale factor and does not report that +//! it has moved — there is no `on_moved`, on any backend. What there is, is +//! `Window::position()`, so this samples it. A sample is three integers +//! compared against three integers and happens twice a second; a monitor is +//! changed at human speed, and nothing here runs on the frame path. +//! +//! The re-survey is what costs something — a Wayland roundtrip pair, or an X11 +//! property fetch — so it is deliberately *not* done every tick. It runs when +//! the geometry actually changed, which is also the moment a monitor could +//! have been plugged in or unplugged: both of those move or rescale the window +//! on every desktop that does anything sensible. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use dr_plat::DisplaySurvey; +use slint::ComponentHandle; + +use crate::AppWindow; + +/// How often the window's geometry is sampled. +const POLL: std::time::Duration = std::time::Duration::from_millis(500); + +/// The render-target size for a canvas that occupies `logical` logical pixels +/// on a display scaled by `scale`. +/// +/// # FR-DSP-8's scaling clause, in one multiplication +/// +/// "Fractional and mixed DPI scaling are handled without resampling artefacts +/// in the canvas." The canvas is a `wgpu::Texture` handed straight to the +/// compositor (ARCH §6.1), which draws it into a box measured in *logical* +/// pixels. Render 1600 logical pixels wide onto a display at 1.25 and the +/// compositor has 1600 samples to fill 2000 device pixels, so every frame is +/// resampled up by a quarter — a softness that reads like a bad demosaic +/// rather than like a scaling bug, which is exactly why the requirement calls +/// it out and why it is worth an explicit test. +/// +/// Rendering the physical count instead makes the presentation 1:1. It costs +/// what the extra pixels cost — 56% more work at 1.25 — and that cost is the +/// requirement: the alternative is not cheaper, it is blurrier. +/// +/// A `scale` of zero or worse is clamped rather than trusted. It arrives from +/// the windowing system, and a zero would collapse the render target to one +/// pixel and blank the canvas. +pub(crate) fn physical(logical: (u32, u32), scale: f32) -> (u32, u32) { + let scale = if scale.is_finite() && scale > 0.01 { + scale + } else { + 1.0 + }; + let convert = |v: u32| ((v as f32) * scale).round().max(1.0) as u32; + (convert(logical.0), convert(logical.1)) +} + +/// The geometry the window had when the display was last resolved. +/// +/// Position *and* scale factor, because either changing can mean a different +/// display: dragging across a seam moves the window, and a compositor that +/// rescales in place — a fractional-scaling change, or a monitor unplugged +/// from under a window — changes only the second. +#[derive(Clone, Copy, PartialEq)] +struct Geometry { + x: i32, + y: i32, + scale_milli: u32, +} + +impl Geometry { + fn of(window: &AppWindow) -> Self { + let position = window.window().position(); + Self { + x: position.x, + y: position.y, + // Compared as thousandths rather than as a float: this is an + // equality test running twice a second, and a scale factor that + // differs in its last bit must not read as a display change and + // provoke a re-survey on every tick. + scale_milli: (window.window().scale_factor().max(0.0) * 1000.0) as u32, + } + } +} + +/// Everything the window needs to keep the canvas on the right display. +pub(crate) struct DisplayWatch { + survey: RefCell, + /// Which entry of the survey is showing the canvas. + current: Cell, + seen: Cell>, + /// The canvas's size in *logical* pixels, as Slint last reported it. + /// + /// Kept so that a scale-factor change can re-derive the physical size + /// without waiting for a resize that may never come: moving a window + /// between a 1× and a 2× display changes what to render and not how large + /// the box is. + logical_canvas: Cell<(u32, u32)>, +} + +impl DisplayWatch { + /// Ask the platform once, at startup. + pub(crate) fn probe() -> Rc { + let survey = DisplaySurvey::probe(); + log::info!( + "display server: {}, {} display(s)", + survey.server.label(), + survey.displays.len() + ); + for display in &survey.displays { + log::info!(" {}: {}", display.name, display.profile.describe()); + } + Rc::new(Self { + survey: RefCell::new(survey), + current: Cell::new(0), + seen: Cell::new(None), + logical_canvas: Cell::new((1024, 768)), + }) + } + + /// The output space the canvas should be encoded into right now. + pub(crate) fn space(&self) -> dr_types::ColourSpace { + self.survey.borrow().profile(self.current.get()).space + } + + /// Record the canvas's logical size and return the size to render at. + pub(crate) fn canvas_resized(&self, window: &AppWindow, logical: (u32, u32)) -> (u32, u32) { + self.logical_canvas.set(logical); + physical(logical, window.window().scale_factor()) + } + + /// The size the canvas should be rendered at, from the last known logical + /// size and the current scale factor. + fn canvas_physical(&self, window: &AppWindow) -> (u32, u32) { + physical(self.logical_canvas.get(), window.window().scale_factor()) + } + + /// Push the About-page readouts. + fn publish(&self, window: &AppWindow) { + let Some(readouts) = readouts(&self.survey.borrow(), self.current.get()) else { + return; + }; + window.set_display_name(readouts.name.into()); + window.set_display_colour(readouts.colour.into()); + window.set_display_others(readouts.others.into()); + } + + /// Re-ask the platform and re-resolve which display is showing the canvas. + /// + /// Returns whether the output space changed, which is what decides a + /// redraw. Nothing is pushed into the develop session from here: the + /// session is asked for its space on the way into every render (see + /// `render_now` in the crate root), so "the canvas is composed for the + /// display showing it" is structural rather than something a callback has + /// to remember to do. A session opened while the window sits on the second + /// monitor is then right on its first frame, without this having to know + /// that a photograph was opened at all. + fn resolve(&self, window: &AppWindow) -> bool { + let before = self.space(); + *self.survey.borrow_mut() = DisplaySurvey::probe(); + + // The window's *centre*, in the display server's own screen + // coordinates. A window straddling a seam is showing more of itself on + // one side, and its centre says which — where its top-left corner + // would flip the transform as soon as one pixel crossed. + let position = window.window().position(); + let size = window.window().size(); + let centre_x = position.x.saturating_add_unsigned(size.width / 2); + let centre_y = position.y.saturating_add_unsigned(size.height / 2); + + let index = self.survey.borrow().containing(centre_x, centre_y); + self.current.set(index); + self.publish(window); + self.space() != before + } +} + +/// The three strings the About page shows about the session's colour path. +pub(crate) struct Readouts { + pub name: String, + pub colour: String, + pub others: String, +} + +/// TRACES: FR-DSP-8 +/// Turn a survey into what the About page says. +/// +/// A free function over the survey rather than a method that writes into the +/// window, because the requirement's visibility clause is the part worth +/// asserting: FR-DSP-8 asks for a fallback that is *defined*, and a reason +/// that reaches the code but never the screen satisfies half of that. This is +/// where a test can hold the strings. +/// +/// `None` only for a survey with no displays at all, which `probe` does not +/// produce. +pub(crate) fn readouts(survey: &DisplaySurvey, index: usize) -> Option { + // Clamped rather than indexed: a monitor unplugged between the survey and + // this call leaves an index pointing past the end, and the page must + // describe *a* display rather than nothing. + let index = if index < survey.displays.len() { + index + } else { + 0 + }; + let here = survey.displays.get(index)?; + + // The other monitors, listed rather than hidden. FR-DSP-8 is a + // multi-monitor requirement and the failure it names — the second display + // showing wrong colours — is invisible from the first, so a page that + // described only the display it was being read on would report nothing + // about the case that matters. + let others: Vec = survey + .displays + .iter() + .enumerate() + .filter(|(i, _)| *i != index) + .map(|(_, d)| format!("{}: {}", d.name, d.profile.describe())) + .collect(); + + Some(Readouts { + name: format!("{} ({})", here.name, survey.server.label()), + colour: here.profile.describe(), + others: others.join(" · "), + }) +} + +/// Start following the window's display, and answer once immediately. +/// +/// `redraw` is the window's ordinary re-render, so a display change costs +/// exactly one recomposition and one dispatch — the property the fused design +/// was always going to give us here (`compose_with_framing`'s output space is +/// a parameter, and enters the structure hash). +pub(crate) fn attach( + window: &AppWindow, + watch: &Rc, + viewport: &Rc>, + redraw: Rc, +) { + // The first answer, before any frame is drawn. Without it the canvas's + // first render is sRGB on every desktop and corrects itself half a second + // later, which on a wide-gamut panel is a visible flash of the wrong + // colour on every image opened. + watch.seen.set(Some(Geometry::of(window))); + watch.resolve(window); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let watch = watch.clone(); + let viewport = viewport.clone(); + timer.start(slint::TimerMode::Repeated, POLL, move || { + let Some(window) = weak.upgrade() else { return }; + let now = Geometry::of(&window); + if watch.seen.get() == Some(now) { + return; + } + watch.seen.set(Some(now)); + + // The physical size follows the scale factor, so a move onto a + // differently-scaled display changes what to render as well as what + // to render it into (FR-DSP-8's two halves arriving together). + let size = watch.canvas_physical(&window); + let resized = *viewport.borrow() != size; + if resized { + *viewport.borrow_mut() = size; + } + + if watch.resolve(&window) || resized { + redraw(&window); + } + }); + // The timer stops when it is dropped, and it must outlive this function. + // Leaked deliberately rather than threaded through the window's state: + // it lives exactly as long as the process, and there is nothing that + // could sensibly stop it. + std::mem::forget(timer); +} + +#[cfg(test)] +mod tests { + use super::*; + use dr_plat::{Bounds, DisplayInfo, DisplayProfile, DisplayServer, FallbackReason}; + + fn two_monitors() -> DisplaySurvey { + DisplaySurvey { + server: DisplayServer::X11, + displays: vec![ + DisplayInfo { + name: "eDP-1".to_string(), + bounds: Some(Bounds { + x: 0, + y: 0, + width: 1920, + height: 1200, + }), + profile: DisplayProfile::fallback(FallbackReason::NoWaylandProtocol), + }, + DisplayInfo { + name: "DP-2".to_string(), + bounds: Some(Bounds { + x: 1920, + y: 0, + width: 2560, + height: 1440, + }), + profile: DisplayProfile { + space: dr_types::ColourSpace::DisplayP3, + source: dr_plat::ProfileSource::X11RootProperty( + "_ICC_PROFILE_1".to_string(), + ), + described_as: Some("EIZO CG279X".to_string()), + approximated: true, + }, + }, + ], + } + } + + /// TRACES: FR-DSP-8 + /// The fallback is defined *and visible*, which is what the requirement + /// asks for and the half that is easy to leave out. + #[test] + fn the_about_page_says_which_acquisition_path_the_session_is_on() { + let survey = two_monitors(); + let shown = readouts(&survey, 0).expect("a display"); + + assert!(shown.name.contains("eDP-1"), "{}", shown.name); + assert!(shown.name.contains("X11"), "{}", shown.name); + // A photographer being shown sRGB because the compositor would not say + // otherwise must be able to find that out rather than wonder. + assert!(shown.colour.contains("sRGB"), "{}", shown.colour); + assert!(shown.colour.contains("assumed"), "{}", shown.colour); + assert!( + shown.colour.contains("colour management"), + "the reason for the fallback did not reach the page: {}", + shown.colour + ); + } + + /// TRACES: FR-DSP-8 + /// The second monitor is described on the page read on the first. + /// + /// The requirement's whole subject is the display the reader is *not* + /// looking at: "showing wrong colours on the second display is a + /// correctness defect". A page that listed only the current one would say + /// nothing about the case it exists for. + #[test] + fn the_other_monitor_is_named_on_the_page_read_from_this_one() { + let survey = two_monitors(); + let shown = readouts(&survey, 0).expect("a display"); + assert!(shown.others.contains("DP-2"), "{}", shown.others); + assert!(shown.others.contains("Display P3"), "{}", shown.others); + // Approximated, and saying so. An honest approximation the user can + // see is the whole trade made in `dr_plat::display`. + assert!(shown.others.contains("nearest to"), "{}", shown.others); + assert!(shown.others.contains("EIZO CG279X"), "{}", shown.others); + + // And from the second monitor's point of view, the first is the other. + let shown = readouts(&survey, 1).expect("a display"); + assert!(shown.colour.contains("Display P3"), "{}", shown.colour); + assert!(shown.others.contains("eDP-1"), "{}", shown.others); + } + + /// A single-monitor desktop leaves the row empty rather than repeating + /// itself, which is what makes the row conditional in `settings.slint`. + #[test] + fn one_display_has_no_others_to_list() { + let survey = DisplaySurvey::assumed_srgb(FallbackReason::NoWaylandProtocol); + let shown = readouts(&survey, 0).expect("a display"); + assert!(shown.others.is_empty(), "{}", shown.others); + } + + /// TRACES: FR-DSP-8 + /// Fractional scaling renders more pixels, not the same pixels stretched. + #[test] + fn a_fractionally_scaled_canvas_is_rendered_at_its_physical_size() { + // GNOME's fractional steps, against a canvas of a plausible size. The + // failure being guarded is silent: at 1.25 the compositor is handed + // 1600 samples for 2000 device pixels and the photograph goes soft. + assert_eq!(physical((1600, 1000), 1.25), (2000, 1250)); + assert_eq!(physical((1600, 1000), 1.5), (2400, 1500)); + assert_eq!(physical((1600, 1000), 1.75), (2800, 1750)); + assert_eq!(physical((1600, 1000), 2.0), (3200, 2000)); + } + + #[test] + fn an_unscaled_display_renders_exactly_what_it_is_asked_for() { + // The other half of the same requirement: 1:1 must stay 1:1 rather + // than acquiring a rounding error that resamples every frame by a + // fraction of a pixel. + assert_eq!(physical((1923, 1081), 1.0), (1923, 1081)); + } + + #[test] + fn a_nonsense_scale_factor_does_not_blank_the_canvas() { + // It comes from the windowing system, and a zero would collapse the + // render target to a single pixel with no error anywhere. + assert_eq!(physical((800, 600), 0.0), (800, 600)); + assert_eq!(physical((800, 600), f32::NAN), (800, 600)); + assert_eq!(physical((800, 600), -2.0), (800, 600)); + // And a canvas that has been collapsed to nothing by a dragged + // splitter still has to produce a renderable target. + assert_eq!(physical((0, 0), 2.0), (1, 1)); + } +} diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 1eef484..f80a548 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -23,6 +23,7 @@ mod activity; mod collections_ui; mod derived_sync; mod develop; +mod display_ui; mod export; pub mod faces; mod gradient; @@ -1378,8 +1379,20 @@ pub fn run(paths: Vec) -> Result<()> { let rows: Rc> = Rc::new(slint::VecModel::default()); window.set_adjust_rows(rows.clone().into()); // Viewport size, tracked so a re-render after a slider move matches it. + // + // In *physical* pixels, which is what the render target wants and what + // FR-DSP-8 means by handling fractional scaling without resampling. The + // one caller that thinks in logical pixels is Slint's resize callback, + // and it converts on the way in. let viewport = Rc::new(RefCell::new((1024u32, 768u32))); + // TRACES: FR-DSP-8 + // What the displays are, and which one the canvas is on. Probed once here + // so that the About page can describe the session's colour path before any + // photograph is opened — the acquisition path is a property of the desktop + // and not of the image. + let display = display_ui::DisplayWatch::probe(); + // Re-render the current session into the canvas. // // Called on every slider change, so it must do no more than run the @@ -1394,10 +1407,26 @@ pub fn run(paths: Vec) -> Result<()> { let session = session.clone(); let viewport = viewport.clone(); let drawn_history = drawn_history.clone(); + let display = display.clone(); Rc::new(move |window: &AppWindow, draft: bool| { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; + // TRACES: FR-DSP-8 | FR-DSP-6 + // **Every canvas render is encoded for the display showing it.** + // + // Set here rather than pushed from the display watch, and rather + // than set once when a photograph is opened, because this is the + // one path every frame takes. A session opened while the window + // sits on the second monitor is then correct on its *first* frame + // — where a push would leave it sRGB until the next poll, which is + // a visible flash of the wrong colour on every image opened. + // + // Free when nothing has changed: the field is compared before it + // is written, and an unchanged output space composes to the same + // structure hash and the same cached pipeline. + s.set_display_space(display.space()); + // TRACES: FR-DEV-5 // Whether undo has anywhere to go, pushed from here because every // edit ends in a redraw and nothing else is on all of their paths: @@ -2571,13 +2600,22 @@ pub fn run(paths: Vec) -> Result<()> { // Track the canvas size so the adjust pass renders at viewport // resolution rather than sensor resolution (FR-DSP-1). + // + // TRACES: FR-DSP-8 + // Slint reports the canvas in *logical* pixels, which is the box the + // compositor will draw into and not the number of device pixels it will + // fill. `display_ui::physical` converts, so that a fractionally scaled + // desktop is presented 1:1 rather than resampled — see that function for + // why a soft canvas is the failure being avoided here. { let weak = window.as_weak(); let viewport = viewport.clone(); let redraw = redraw.clone(); + let display = display.clone(); window.on_canvas_resized(move |w_px, h_px| { let Some(w) = weak.upgrade() else { return }; - let size = (w_px.max(1) as u32, h_px.max(1) as u32); + let logical = (w_px.max(1) as u32, h_px.max(1) as u32); + let size = display.canvas_resized(&w, logical); if *viewport.borrow() == size { return; } @@ -2586,6 +2624,10 @@ pub fn run(paths: Vec) -> Result<()> { }); } + // TRACES: FR-DSP-8 | FR-DSP-6 + // And which display that canvas is on, from now until the window closes. + display_ui::attach(&window, &display, &viewport, redraw.clone()); + // FR-UI-1: layout class from window width. Computed here rather than in // Slint because a property that both derives from and feeds the layout is // a binding loop. diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 75a3d87..0d64579 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -275,6 +275,13 @@ export component AppWindow inherits Window { in property adapter: "detecting…"; in property backend: "—"; in property fps: 0; + /// TRACES: FR-DSP-8 + /// The display showing the canvas and the colour transform it is getting. + /// Shown under ABOUT in Settings; see `settings.slint` for why the second + /// one is a sentence rather than a name. + in property display-name: "detecting…"; + in property display-colour: "detecting…"; + in property display-others: ""; /// TRACES: FR-UI-2 /// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with /// the binary it is part of. @@ -1267,6 +1274,9 @@ in property panel-visible: true; fps: root.fps; layout-class: root.layout-class; app-version: root.app-version; + display-name: root.display-name; + display-colour: root.display-colour; + display-others: root.display-others; original-budget: root.settings-original-budget; original-unlimited: root.settings-original-unlimited; @@ -1700,11 +1710,14 @@ in property panel-visible: true; // that far. Below 1:1 it stays smooth, where filtering is // what keeps the image from aliasing. // - // This matters even at moderate zoom on a HiDPI display: - // the canvas is rendered at *logical* size and Slint scales - // it up by the device pixel ratio, so the buffer is - // resampled on its way to the screen whatever the pipeline - // did. + // TRACES: FR-DSP-8 + // The canvas is rendered at the *physical* pixel size of + // the box this image occupies, so `contain` presents it + // 1:1 and neither filter is reached at all until the + // photographer zooms. That is the point: on a fractionally + // scaled desktop the buffer used to be a logical-sized one + // that the compositor stretched, and no choice of filter + // recovers detail that was never rendered. image-rendering: root.magnified ? ImageRendering.pixelated : ImageRendering.smooth; diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint index 5ece585..b732780 100644 --- a/ui/dr-ui/ui/settings.slint +++ b/ui/dr-ui/ui/settings.slint @@ -117,6 +117,25 @@ export component SettingsPage inherits Rectangle { in property layout-class; in property app-version; + /// TRACES: FR-DSP-8 + /// The display showing the canvas, and the colour it is being given. + /// + /// These are the same kind of thing as the two above — a fact about the + /// session, not a setting — with one difference that earns them their own + /// row rather than a line in a log. FR-DSP-8's fallback is *defined* to be + /// sRGB where the display server will not say otherwise, and a photographer + /// being shown sRGB because their compositor has no colour-management + /// protocol has no other way to find that out. `display-colour` says which + /// of the acquisition paths this session is on, in words. + in property display-name; + in property display-colour; + /// The *other* monitors, where there are any. Empty on a single-display + /// desktop, which is why the row below is conditional: FR-DSP-8 is about + /// the second display, and the failure it names is invisible from the + /// first, so this page has to be readable about a monitor the reader is + /// not currently looking at. + in property display-others; + callback original-budget-changed(string); callback original-unlimited-toggled(bool); callback thumbnail-budget-changed(string); @@ -708,10 +727,46 @@ export component SettingsPage inherits Rectangle { Value { text: root.layout-class; horizontal-stretch: 1; } } + // TRACES: FR-DSP-8 + // Which display, and what colour it is being sent. + HorizontalLayout { + spacing: Theme.gap; + Label { text: "Display"; } + Value { + text: root.display-name; + horizontal-stretch: 1; + overflow: elide; + } + } + + HorizontalLayout { + spacing: Theme.gap; + Label { text: "Display colour"; } + Value { + text: root.display-colour; + horizontal-stretch: 1; + // Wraps rather than elides: this is the one + // value on the page that is a sentence, and + // eliding it would cut off the half that says + // *why* — which is the half FR-DSP-8 asks for. + wrap: word-wrap; + } + } + + if root.display-others != "": HorizontalLayout { + spacing: Theme.gap; + Label { text: "Other displays"; } + Value { + text: root.display-others; + horizontal-stretch: 1; + wrap: word-wrap; + } + } + Caption { - text: "Graphics and frame rate describe this session, " - + "not a setting — they are here so a bug report " - + "can quote them."; + text: "Graphics, frame rate and display colour " + + "describe this session, not a setting — they " + + "are here so a bug report can quote them."; wrap: word-wrap; } }