diff --git a/Cargo.lock b/Cargo.lock index a897932..01625a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1423,6 +1423,8 @@ dependencies = [ "env_logger", "log", "rawler", + "serde", + "serde_norway", "thiserror 2.0.20", "zune-jpeg 0.4.21", ] diff --git a/core/dr-decode/Cargo.toml b/core/dr-decode/Cargo.toml index e088edf..8cd25d8 100644 --- a/core/dr-decode/Cargo.toml +++ b/core/dr-decode/Cargo.toml @@ -8,6 +8,13 @@ license.workspace = true [dependencies] dr-types.workspace = true rawler.workspace = true +# The camera profile database is data, not code (FR-DEV-3e): a YAML file that +# ships with the binary and is superseded by a newer one on disk. serde_norway +# is the workspace's YAML crate — the fork still receiving releases — and it is +# already in the tree for `dr-pipeline`'s node declarations and `dr-ui`'s style +# tokens. Pure Rust, so it costs nothing under the Android NDK. +serde = { workspace = true } +serde_norway.workspace = true zune-jpeg.workspace = true thiserror.workspace = true log.workspace = true diff --git a/core/dr-decode/profiles/base_curves.yaml b/core/dr-decode/profiles/base_curves.yaml new file mode 100644 index 0000000..4e21f33 --- /dev/null +++ b/core/dr-decode/profiles/base_curves.yaml @@ -0,0 +1,160 @@ +# DarkRoom camera base curves (FR-DEV-3e). +# +# --------------------------------------------------------------------------- +# Adding a body is editing this file. It is not a code change. +# --------------------------------------------------------------------------- +# +# The copy you are reading is compiled into the binary as a floor. At startup +# `dr_decode::base_curve::load` also looks for `base_curves.yaml` in: +# +# 1. $DARKROOM_PROFILES/ (set it while you are tuning) +# 2. $XDG_DATA_HOME/darkroom/profiles/ +# or $HOME/.local/share/darkroom/profiles/ +# +# and uses the first one it finds *whose `version:` is higher than this one's*. +# So: bump `version`, drop the file in that directory, restart. A body added +# this afternoon renders correctly this afternoon, with no release and no +# rebuild — which is what the requirement asks for, and what makes these +# contributable under the GPL. +# +# The version check runs both ways on purpose. A file older than the built-in +# copy is ignored with a log line, so upgrading DarkRoom cannot silently lose +# curves to a pack somebody downloaded a year ago. +# +# --------------------------------------------------------------------------- +# What the numbers mean +# --------------------------------------------------------------------------- +# +# Five `[x, y]` control points on a monotone spline (Fritsch-Carlson, the same +# one the tone curve widget draws). Both axes are **linear**: +# +# x scene-referred camera RGB after white balance, 1.0 = sensor saturation +# y display-referred linear; the sRGB transfer function is applied later, +# at the end of the shader, so do not pre-apply a gamma here +# +# The identity is y = x, and it is what an unrecognised body gets if `default:` +# is removed. It is also the wrong answer for almost every photograph: linear +# scene data has middle grey at about 13% and a camera JPEG puts it near 18%, +# so an uncurved render is roughly half a stop dark through the midtones and +# has no highlight rolloff at all. +# +# A curve that works has three parts, and it is worth naming them because they +# are what you are actually tuning: +# +# the toe the first span, slope near or below 1. Deep shadows stay +# deep. Lift it and blacks go milky; crush it and shadow +# detail the sensor recorded disappears. +# the midtones the middle spans, slope well above 1. This is the contrast +# and the brightness people read as "the camera's look". +# the shoulder the last span, slope well below 1. Highlights compress +# toward white instead of arriving there and clipping. It is +# the difference between a rolled-off sky and a white hole. +# +# Two invariants are enforced in code and tested, so a mistake here fails the +# build rather than the photograph: x must strictly increase, y must not +# decrease, and everything must lie inside the unit square. +# +# --------------------------------------------------------------------------- +# Honesty about these values +# --------------------------------------------------------------------------- +# +# These are hand-tuned shapes, not measurements. They encode what every camera +# JPEG rendering has in common — the toe/midtone/shoulder structure above — +# plus each maker's well-known house differences: Canon's gentler shoulder and +# warmer-reading midtones, Nikon's slightly higher midtone contrast, Sony's +# flatter and more conservative default, Fujifilm's markedly contrastier +# Provia-derived rendering. +# +# FR-DEV-3e's acceptance criterion is subjective comparison against each body's +# own JPEG, and meeting it properly needs a frame from that body in front of +# you. Where that has not been done, the entry is still much closer to right +# than the identity — which is the bar these have to clear, and do. + +version: 1 + +# The rendering for a body with no entry of its own. +# +# **Deliberately not the identity.** The failure this requirement exists to fix +# is the flat render, and a conservative curve is far closer to right for every +# body than no curve is for any of them. It is gentler than the per-body +# entries below — a shallower midtone and an earlier, softer shoulder — because +# it has to be safe on a sensor nobody has looked at, and the cost of being too +# tame is a photograph that wants a little contrast rather than one that has +# lost its highlights. +default: + points: + - [0.00, 0.000] + - [0.04, 0.043] + - [0.13, 0.175] + - [0.45, 0.690] + - [1.00, 1.000] + +bodies: + # Canon. A soft toe and a long, gradual shoulder — the reason Canon files + # are described as forgiving in highlights and a little low in contrast + # straight out of camera. + - make: Canon + model: EOS 6D + points: + - [0.00, 0.000] + - [0.04, 0.045] + - [0.13, 0.190] + - [0.45, 0.720] + - [1.00, 1.000] + + - make: Canon + model: EOS R6 + points: + - [0.00, 0.000] + - [0.04, 0.044] + - [0.13, 0.195] + - [0.45, 0.730] + - [1.00, 1.000] + + # Nikon. A slightly deeper toe and more midtone slope than Canon, which is + # the "punchier out of camera" difference people describe between the two. + - make: Nikon + model: Z 6 + points: + - [0.00, 0.000] + - [0.04, 0.038] + - [0.13, 0.200] + - [0.46, 0.750] + - [1.00, 1.000] + + - make: Nikon + model: D750 + points: + - [0.00, 0.000] + - [0.04, 0.039] + - [0.13, 0.198] + - [0.46, 0.745] + - [1.00, 1.000] + + # Sony. The flattest default of the four, and intentionally so — Sony's own + # rendering leaves more headroom than it uses, which is why Sony files are + # the ones people describe as needing the most work. + - make: Sony + model: ILCE-7M3 + points: + - [0.00, 0.000] + - [0.04, 0.048] + - [0.13, 0.185] + - [0.44, 0.700] + - [1.00, 1.000] + + # Fujifilm. Provia, the default film simulation: a firm toe, the steepest + # midtones here, and a hard shoulder. It is the most distinctive rendering of + # the four and the one where a flat render looks most obviously wrong. + # + # This entry does *not* read the in-RAF film simulation tag — that is + # FR-DEV-3f, and until it lands every Fujifilm file gets the Provia shape + # whatever the camera was set to. + - make: Fujifilm + model: X-T3 + points: + - [0.00, 0.000] + - [0.045, 0.040] + - [0.14, 0.215] + - [0.47, 0.775] + - [1.00, 1.000] diff --git a/core/dr-decode/src/base_curve.rs b/core/dr-decode/src/base_curve.rs new file mode 100644 index 0000000..898db24 --- /dev/null +++ b/core/dr-decode/src/base_curve.rs @@ -0,0 +1,763 @@ +//! TRACES: FR-DEV-3e +//! Base curves — the per-body rendering that turns a correct exposure into a +//! photograph. +//! +//! # What this is for +//! +//! A camera matrix gets the *colours* right and leaves the picture flat. Sensor +//! data is scene-referred and very nearly linear; a print, a screen and a +//! camera's own JPEG are none of those things. Rendering linear data straight +//! out is the dcraw default, and FR-DEV-3e names it precisely: "the flat, +//! poor-skin-tone rendering characteristic of dcraw defaults, which is the +//! documented reason people abandon darktable in the first hour." +//! +//! The fix is a tone curve applied as part of *reading* the file rather than as +//! an edit — a toe, a steep midtone, and a shoulder that rolls highlights off +//! instead of clipping them. Every raw converter has one. Adobe calls it the +//! camera profile's tone curve, darktable calls it the base curve, and the name +//! here follows darktable's because the placement does too: it runs in camera +//! RGB, after white balance and the user's adjustments, immediately before the +//! conversion out to a working space. +//! +//! # Why it is not an edit +//! +//! It never reaches the sidecar and there is no slider for it, for the same +//! reason the EXIF orientation is not an edit (FR-DEV-3h): it is a property of +//! the body that took the frame, not of what anyone decided about the frame. +//! Sidecars are shared between devices and bodies (FR-NC-9), and one camera's +//! rendering must not follow an edit onto another camera's file. +//! +//! # Why it is data +//! +//! FR-DEV-3e requires the profile database to be "versioned independently of +//! the app binary so bodies and curves can be added without a release — and, +//! under D8's GPLv3, contributed by users". So the curves live in +//! `profiles/base_curves.yaml`, a file that is compiled in as a floor and +//! *overridden* by a copy on disk carrying a higher `version:`. Adding a body +//! is adding ten numbers to a YAML file; shipping that body to users is +//! publishing the file. Neither is a code change and neither needs a release. +//! +//! See [`load`] for the search path and [`Curves::body`] for the matching. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// How many control points a base curve has. +/// +/// Five, which is not a coincidence: it is what the tone curve widget uses +/// (`dr_pipeline::ops::curve::POINTS`), so the shader evaluates a profile's +/// curve and a photographer's curve through exactly the same spline. A profile +/// author and a photographer dragging a point mean the same thing by it, and +/// the generated shader carries one implementation rather than two that could +/// disagree. +pub const POINTS: usize = 5; + +/// TRACES: FR-DEV-3e +/// A base curve: five points on a monotone spline through the unit square. +/// +/// `xs` is scene-linear camera RGB, normalised so that 1.0 is the sensor's +/// saturation point. `ys` is display-referred linear — *not* gamma-encoded, +/// because the sRGB transfer function is applied at the very end of the +/// generated shader and applying it twice would wash the image out. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BaseCurve { + pub xs: [f32; POINTS], + pub ys: [f32; POINTS], +} + +impl BaseCurve { + /// The curve that does nothing — the identity diagonal. + /// + /// What an unrecognised body gets if the database carries no default, and + /// what a JPEG gets always: an already-rendered image must not be rendered + /// a second time. + pub const IDENTITY: Self = Self { + xs: [0.0, 0.25, 0.5, 0.75, 1.0], + ys: [0.0, 0.25, 0.5, 0.75, 1.0], + }; + + /// Whether this curve would leave the image alone. + /// + /// The shader is told to skip the stage entirely when it would, so an + /// unprofiled body costs a branch that is uniform across the dispatch + /// rather than a spline evaluation per channel per pixel. + pub fn is_identity(&self) -> bool { + self.xs + .iter() + .zip(self.ys.iter()) + .all(|(x, y)| (x - y).abs() < 1e-6) + } + + /// Build from raw pairs, rejecting anything that is not a curve. + /// + /// A profile file is data a user may have edited, so this is the boundary + /// where "ten numbers" becomes "a curve": the x coordinates must increase, + /// the y coordinates must not decrease, and both must lie in the unit + /// square. A non-monotone x sends the spline's span search backwards and + /// divides by a negative width; a decreasing y inverts tones locally, + /// which reads as a dark halo through smooth gradients rather than as a + /// bad profile. + /// + /// Endpoints are not forced to (0,0) and (1,1). A curve that lifts black + /// slightly, or that places the shoulder below white, is a legitimate + /// rendering choice and several bodies make it. + pub fn from_points(points: &[[f32; 2]]) -> Option { + if points.len() != POINTS { + return None; + } + let mut xs = [0.0f32; POINTS]; + let mut ys = [0.0f32; POINTS]; + for (i, p) in points.iter().enumerate() { + if !p[0].is_finite() || !p[1].is_finite() { + return None; + } + if !(0.0..=1.0).contains(&p[0]) || !(0.0..=1.0).contains(&p[1]) { + return None; + } + xs[i] = p[0]; + ys[i] = p[1]; + } + for i in 1..POINTS { + // Strictly increasing in x — the spline divides by the span width. + if xs[i] <= xs[i - 1] { + return None; + } + // Non-decreasing in y. Flat is allowed: a curve that holds a + // highlight range at white is clipping deliberately. + if ys[i] < ys[i - 1] { + return None; + } + } + Some(Self { xs, ys }) + } +} + +/// One body's entry in the database. +#[derive(Debug, Clone, PartialEq)] +pub struct BodyCurve { + /// The manufacturer, as the file writes it — "Canon", "NIKON CORPORATION". + pub make: String, + /// The model, as the file writes it — "EOS 6D", "ILCE-7M3". + pub model: String, + pub curve: BaseCurve, +} + +/// TRACES: FR-DEV-3e +/// The base curve database. +/// +/// Versioned as a whole rather than per body, because that is the unit a user +/// downloads and the unit that has to beat the built-in copy. See [`load`]. +#[derive(Debug, Clone, PartialEq)] +pub struct Curves { + version: u32, + default: Option, + bodies: Vec, +} + +impl Curves { + /// TRACES: FR-DEV-3e + /// The curve to render a frame from this body with. + /// + /// Falls back, in order, to the database's `default:` and then to the + /// identity. **The default is deliberately not the identity**: an + /// unrecognised body rendered flat is the failure this requirement exists + /// to prevent, and a gentle, conservative curve is much closer to right for + /// every body than no curve is for any of them. A body with its own entry + /// gets that instead. + /// + /// # What "this body" has to survive + /// + /// The same camera names itself three ways depending on which program last + /// touched the file. A native NEF says make "NIKON CORPORATION", model + /// "NIKON Z 6"; rawler's own database cleans that to "Nikon" and "Z 6"; an + /// Adobe-converted DNG keeps the uncleaned pair. A database that had to + /// spell every variant would go stale the first time a maker changed its + /// mind about its own name, so the matching does the folding instead: + /// + /// - Case, punctuation and runs of whitespace are flattened, so + /// "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" are one body. + /// - The make is compared on its **first word only**. Every maker's + /// trailing corporate boilerplate — "CORPORATION", "IMAGING CORP" — is + /// noise, and no two camera manufacturers share a first word. + /// - The model is tried both as written and with a leading copy of the + /// make removed, which is what lets one "Canon"/"EOS 6D" entry cover + /// "Canon EOS 6D" as well. + pub fn body(&self, make: &str, model: &str) -> BaseCurve { + let (make, model) = (make_key(make), normalise(model)); + // The model with a leading copy of the maker's name removed. + let bare = model.strip_prefix(&format!("{make} ")).unwrap_or(&model); + + self.bodies + .iter() + .find(|b| { + let entry_model = normalise(&b.model); + make_key(&b.make) == make && (entry_model == model || entry_model == bare) + }) + .map(|b| b.curve) + .or(self.default) + .unwrap_or(BaseCurve::IDENTITY) + } + + /// The database version. Higher wins; see [`load`]. + pub fn version(&self) -> u32 { + self.version + } + + /// How many bodies have their own curve, excluding the default. + pub fn len(&self) -> usize { + self.bodies.len() + } + + pub fn is_empty(&self) -> bool { + self.bodies.is_empty() + } + + /// Parse a database from YAML. + /// + /// Entries that are not curves are dropped with a warning rather than + /// failing the parse. A user-contributed file with one bad body should + /// cost that body's rendering, not every body's — and the alternative is an + /// application that will not open a photograph because somebody typed a + /// comma. + pub fn parse(yaml: &str) -> Result { + let file: File = serde_norway::from_str(yaml).map_err(|e| e.to_string())?; + + let default = file.default.and_then(|d| { + BaseCurve::from_points(&d.points).or_else(|| { + log::warn!("base curves: the default entry is not a monotone curve; ignoring it"); + None + }) + }); + + let bodies = file + .bodies + .into_iter() + .filter_map(|b| match BaseCurve::from_points(&b.points) { + Some(curve) => Some(BodyCurve { + make: b.make, + model: b.model, + curve, + }), + None => { + log::warn!( + "base curves: {} {} is not a monotone curve; ignoring it", + b.make, + b.model + ); + None + } + }) + .collect(); + + Ok(Self { + version: file.version, + default, + bodies, + }) + } +} + +/// The copy that ships inside the binary. +/// +/// A floor, not the answer: [`load`] prefers a newer file on disk. Compiled in +/// so that a fresh install with no profile directory — and every Android build, +/// where there is no such directory to speak of — still renders properly. +const BUILT_IN: &str = include_str!("../profiles/base_curves.yaml"); + +/// TRACES: FR-DEV-3e +/// The base curve database, loaded once. +/// +/// # The search path, and why it is a version comparison +/// +/// 1. `$DARKROOM_PROFILES`, a directory, when set. The escape hatch: a profile +/// author iterating on a curve points this at their working copy and does +/// not have to install anything. +/// 2. `$XDG_DATA_HOME/darkroom/profiles/`, else `$HOME/.local/share/darkroom/profiles/`. +/// The same base directory the catalog uses, chosen there for the same +/// reason — it is data, not cache, and must survive a storage sweep. +/// 3. The copy compiled into the binary. +/// +/// The first file that parses *and carries a higher `version:` than the +/// built-in copy* wins. The version check is the whole mechanism the +/// requirement asks for, and it runs in both directions: +/// +/// - A downloaded pack at version 7 supersedes a binary shipping version 3, so +/// a body added after the release renders correctly with no release. +/// - A stale pack at version 2 does **not** supersede a binary shipping version +/// 3, so upgrading the application cannot silently lose curves to a file +/// somebody downloaded a year ago and forgot. +/// +/// Failures are warnings, never errors. A malformed profile file must cost the +/// user their curves, not their photographs. +pub fn load() -> &'static Curves { + static LOADED: OnceLock = OnceLock::new(); + LOADED.get_or_init(|| { + let built_in = Curves::parse(BUILT_IN).unwrap_or_else(|e| { + // Unreachable in a build that ran its tests — `the_shipped_database_parses` + // asserts exactly this — but a panic here would mean an + // application that cannot open a photograph because of a typo in a + // data file, which is never the right trade. + log::error!("base curves: the built-in database does not parse: {e}"); + Curves { + version: 0, + default: None, + bodies: Vec::new(), + } + }); + + choose(built_in, &search_path()) + }) +} + +/// The version comparison, separated from where the directories come from. +/// +/// Split out so it can be tested against real files in a real directory +/// without the process-wide `OnceLock` and the environment `load` reads. The +/// rule this implements is the whole of what FR-DEV-3e asks for, so it is +/// worth being able to state it as a test rather than as a comment. +fn choose(built_in: Curves, dirs: &[PathBuf]) -> Curves { + for dir in dirs { + let path = dir.join("base_curves.yaml"); + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + match Curves::parse(&text) { + Ok(external) if external.version > built_in.version => { + log::info!( + "base curves: using {} (version {}, {} bodies) over the built-in version {}", + path.display(), + external.version, + external.len(), + built_in.version + ); + return external; + } + Ok(external) => log::info!( + "base curves: ignoring {} at version {}; the built-in database is version {}", + path.display(), + external.version, + built_in.version + ), + Err(e) => log::warn!("base curves: {} does not parse: {e}", path.display()), + } + } + built_in +} + +/// TRACES: FR-DEV-3e +/// The curve for a body, from the loaded database. +/// +/// The one call site the decoder needs; everything above is reachable for +/// tests and for a future profile editor. +pub fn for_body(make: &str, model: &str) -> BaseCurve { + load().body(make, model) +} + +/// Directories that may hold a `base_curves.yaml`, most specific first. +fn search_path() -> Vec { + let mut dirs = Vec::new(); + if let Some(explicit) = std::env::var_os("DARKROOM_PROFILES") { + dirs.push(PathBuf::from(explicit)); + } + // The same resolution `dr_ui::library::catalog_path` uses, and for the + // same reason: this is data a user may have installed, not a cache. It is + // duplicated rather than shared because `dr-decode` sits far below the UI + // and must not acquire a dependency on it to find a directory. + let base = std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share"))); + if let Some(base) = base { + dirs.push(base.join("darkroom").join("profiles")); + } + dirs +} + +/// A manufacturer's first word, folded. +/// +/// "NIKON CORPORATION", "Nikon" and "nikon" all become `NIKON`. The corporate +/// suffixes are not information — they appear or not depending on whether the +/// file went through a DNG converter — and no two camera manufacturers share a +/// first word, so nothing is lost by dropping them. +fn make_key(s: &str) -> String { + normalise(s) + .split(' ') + .next() + .unwrap_or_default() + .to_string() +} + +/// Fold a make or model into something two files can agree on. +/// +/// Upper-cased, with every run of non-alphanumeric characters collapsed to one +/// space and the ends trimmed, so that "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" +/// become one. +fn normalise(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut pending_space = false; + for c in s.chars() { + if c.is_ascii_alphanumeric() { + if pending_space && !out.is_empty() { + out.push(' '); + } + pending_space = false; + out.push(c.to_ascii_uppercase()); + } else { + pending_space = true; + } + } + out +} + +// ---- The on-disk shape, kept apart from the in-memory one ---------------- +// +// Deliberately separate types. The file is data a user edits and is allowed to +// be wrong; `Curves` is a parsed database whose every entry is known to be a +// monotone curve. Deriving `Deserialize` on `BaseCurve` directly would delete +// that boundary and let an unchecked five-point array reach the shader. +// +// Unknown fields are **accepted**, which is not laziness. The database is +// versioned independently of the binary and moves in both directions: a pack +// published after this release may carry keys this build has never heard of — +// a hue twist, a look table (FR-DEV-3f) — and it must still deliver its curves +// to an older DarkRoom rather than failing to parse and leaving every body +// flat. `deny_unknown_fields` would trade that for a diagnostic nobody needs. + +#[derive(serde::Deserialize)] +struct File { + version: u32, + #[serde(default)] + default: Option, + #[serde(default)] + bodies: Vec, +} + +#[derive(serde::Deserialize)] +struct Entry { + points: Vec<[f32; 2]>, +} + +#[derive(serde::Deserialize)] +struct BodyEntry { + make: String, + model: String, + points: Vec<[f32; 2]>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_shipped_database_parses_and_carries_a_default() { + // The one test that must never be allowed to fail quietly: `load` + // degrades to an empty database rather than panicking, so without this + // a typo in the YAML would ship as "every photograph renders flat" + // rather than as a build failure. + let curves = Curves::parse(BUILT_IN).expect("the shipped database parses"); + assert!(curves.version() >= 1); + assert!(!curves.is_empty(), "the database ships bodies"); + assert!( + !curves.body("Nobody", "Nothing").is_identity(), + "an unknown body must still get the default rendering" + ); + } + + #[test] + fn every_shipped_curve_lifts_the_midtones_and_rolls_the_highlights() { + // What makes a base curve a base curve rather than a decoration. If a + // shipped curve failed either half it would be a worse rendering than + // the flat one it replaced, which is the one outcome forbidden. + let curves = Curves::parse(BUILT_IN).expect("parses"); + let all = curves + .bodies + .iter() + .map(|b| (format!("{} {}", b.make, b.model), b.curve)) + .chain(curves.default.map(|c| ("default".to_string(), c))); + + for (name, curve) in all { + // The midtone point sits above the diagonal: a linear midtone is + // roughly a stop and a half darker than any camera renders it. + let mid = 2; + assert!( + curve.ys[mid] > curve.xs[mid], + "{name} does not lift its midtones ({} -> {})", + curve.xs[mid], + curve.ys[mid] + ); + // And the last span is shallower than the one before it, which is + // what a shoulder *is*. Without one the curve clips highlights + // harder than the linear rendering did. + let slope = |i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]); + assert!( + slope(POINTS - 2) < slope(POINTS - 3), + "{name} has no highlight shoulder" + ); + } + } + + #[test] + fn a_curve_that_is_not_monotone_is_refused() { + // The profile file is user-editable, so this is a real boundary and + // not a formality. A decreasing y inverts tones locally and shows up + // as a dark halo in a gradient, which reads as a rendering fault + // rather than as a bad profile. + assert_eq!( + BaseCurve::from_points(&[ + [0.0, 0.0], + [0.25, 0.4], + [0.5, 0.3], + [0.75, 0.8], + [1.0, 1.0] + ]), + None + ); + } + + #[test] + fn a_curve_whose_x_does_not_advance_is_refused() { + // The spline divides by the span width; a repeated x is a division by + // zero in the shader, which is a NaN pixel rather than an error. + assert_eq!( + BaseCurve::from_points(&[ + [0.0, 0.0], + [0.25, 0.3], + [0.25, 0.5], + [0.75, 0.8], + [1.0, 1.0] + ]), + None + ); + } + + #[test] + fn a_curve_of_the_wrong_length_is_refused() { + assert_eq!(BaseCurve::from_points(&[[0.0, 0.0], [1.0, 1.0]]), None); + } + + #[test] + fn values_outside_the_unit_square_are_refused() { + // The shader clamps its output at the very end anyway, but a control + // point above 1.0 would put the shoulder outside the range the curve + // is defined over and silently flatten everything below it. + assert_eq!( + BaseCurve::from_points(&[ + [0.0, 0.0], + [0.25, 0.3], + [0.5, 1.4], + [0.75, 1.5], + [1.0, 1.6] + ]), + None + ); + } + + #[test] + fn a_body_with_its_own_entry_beats_the_default() { + let curves = Curves::parse( + "version: 2 +default: + points: [[0.0, 0.0], [0.25, 0.3], [0.5, 0.6], [0.75, 0.85], [1.0, 1.0]] +bodies: + - make: Canon + model: EOS 6D + points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] +", + ) + .expect("parses"); + + assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35); + assert_eq!(curves.body("Canon", "EOS 5D").ys[1], 0.30); + } + + #[test] + fn the_make_may_be_repeated_in_the_model() { + // Canon writes "Canon" as the make and "Canon EOS 6D" as the model; + // rawler's cleaned strings drop the repetition and both reach here. + // One entry has to cover both or half the files on a card miss. + let curves = Curves::parse( + "version: 1 +bodies: + - make: Canon + model: EOS 6D + points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] +", + ) + .expect("parses"); + + assert_eq!(curves.body("Canon", "Canon EOS 6D").ys[1], 0.35); + assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35); + assert_eq!(curves.body("CANON", "eos 6d").ys[1], 0.35); + } + + #[test] + fn a_corporate_suffix_does_not_hide_a_body() { + // The same Z 6 arrives as "Nikon"/"Z 6" from rawler's camera database + // and as "NIKON CORPORATION"/"NIKON Z 6" from a DNG converted out of + // the same file. Both must find the entry, or converting a file to + // DNG would silently change how it renders. + let curves = Curves::parse( + "version: 1 +bodies: + - make: Nikon + model: Z 6 + points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] +", + ) + .expect("parses"); + + assert_eq!(curves.body("Nikon", "Z 6").ys[1], 0.35); + assert_eq!(curves.body("NIKON CORPORATION", "NIKON Z 6").ys[1], 0.35); + } + + #[test] + fn punctuation_and_spacing_do_not_decide_whether_a_body_is_known() { + let curves = Curves::parse( + "version: 1 +bodies: + - make: Sony + model: ILCE-7M3 + points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] +", + ) + .expect("parses"); + + assert_eq!(curves.body("SONY", "ILCE 7M3").ys[1], 0.35); + assert_eq!(curves.body("sony", "ilce-7m3").ys[1], 0.35); + } + + #[test] + fn one_bad_entry_does_not_cost_the_rest() { + // A user-contributed file with one typo should cost that body's + // rendering, not every body's. + let curves = Curves::parse( + "version: 1 +bodies: + - make: Broken + model: Body + points: [[0.0, 0.0], [0.25, 0.9], [0.5, 0.1], [0.75, 0.9], [1.0, 1.0]] + - make: Canon + model: EOS 6D + points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] +", + ) + .expect("parses"); + + assert_eq!(curves.len(), 1); + assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35); + assert!(curves.body("Broken", "Body").is_identity()); + } + + #[test] + fn a_pack_from_the_future_still_delivers_its_curves() { + // The database is versioned independently of the binary, so a pack + // published after this build may carry keys this build has never heard + // of. It must still hand over the curves it does understand — failing + // the parse would leave every body flat, which is the exact failure + // FR-DEV-3e exists to prevent, delivered by the mechanism meant to + // prevent it. + let curves = Curves::parse( + "version: 9 +look_table: ambitious +bodies: + - make: Canon + model: EOS 6D + hue_twist: [1, 2, 3] + points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] +", + ) + .expect("an unfamiliar key must not fail the parse"); + + assert_eq!(curves.version(), 9); + assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35); + } + + #[test] + fn an_unknown_body_with_no_default_gets_the_identity() { + // Graceful fallback, stated as a property: never worse than a flat + // render, and never a curve tuned for somebody else's sensor when the + // database declines to offer one. + let curves = Curves::parse("version: 1\nbodies: []\n").expect("parses"); + assert!(curves.body("Nobody", "Nothing").is_identity()); + } + + /// A directory holding one `base_curves.yaml`, unique to the caller. + fn a_pack_dir(name: &str, yaml: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("darkroom-base-curves-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a writable temp directory"); + std::fs::write(dir.join("base_curves.yaml"), yaml).expect("write"); + dir + } + + const A_CANON_ENTRY: &str = "bodies: + - make: Canon + model: EOS 6D + points: [[0.0, 0.0], [0.25, 0.42], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]] +"; + + #[test] + fn a_newer_pack_on_disk_supersedes_the_built_in_database() { + // **This is the requirement.** FR-DEV-3e asks for a profile database + // versioned independently of the app binary "so bodies and curves can + // be added without a release". A file with a higher version, dropped + // in the profile directory, is what that means in practice. + let built_in = Curves::parse(BUILT_IN).expect("parses"); + let newer = format!("version: {}\n{A_CANON_ENTRY}", built_in.version() + 1); + let dir = a_pack_dir("newer", &newer); + + let chosen = choose(built_in.clone(), &[dir]); + assert_eq!(chosen.version(), built_in.version() + 1); + assert_eq!(chosen.body("Canon", "EOS 6D").ys[1], 0.42); + } + + #[test] + fn a_stale_pack_does_not_survive_an_upgrade() { + // The other direction, and the one that protects the user. Somebody + // downloads a pack, a release later ships better curves for the same + // bodies, and the forgotten file must not quietly hold the application + // back at last year's rendering. + let built_in = Curves::parse(BUILT_IN).expect("parses"); + let stale = format!("version: {}\n{A_CANON_ENTRY}", built_in.version()); + let dir = a_pack_dir("stale", &stale); + + let chosen = choose(built_in.clone(), &[dir]); + assert_eq!(chosen.version(), built_in.version()); + assert_ne!( + chosen.body("Canon", "EOS 6D").ys[1], + 0.42, + "an equal version must not displace the built-in database" + ); + } + + #[test] + fn a_broken_pack_costs_the_curves_and_not_the_photographs() { + // A malformed profile file must degrade to the built-in database, not + // to an error. The user came here to look at a photograph. + let built_in = Curves::parse(BUILT_IN).expect("parses"); + let dir = a_pack_dir("broken", "version: [this is not a number\n"); + + let chosen = choose(built_in.clone(), &[dir]); + assert_eq!(chosen.version(), built_in.version()); + assert_eq!(chosen.len(), built_in.len()); + } + + #[test] + fn a_directory_with_no_pack_in_it_is_simply_skipped() { + // The ordinary case on every machine: the search path exists, the file + // does not. It must not be a warning, an error, or a slow path. + let built_in = Curves::parse(BUILT_IN).expect("parses"); + let missing = std::env::temp_dir().join("darkroom-base-curves-nothing-here"); + let _ = std::fs::remove_dir_all(&missing); + + assert_eq!(choose(built_in.clone(), &[missing]), built_in); + } + + #[test] + fn the_identity_is_recognised_as_doing_nothing() { + assert!(BaseCurve::IDENTITY.is_identity()); + assert!(!Curves::parse(BUILT_IN) + .expect("parses") + .body("Canon", "EOS 6D") + .is_identity()); + } +} diff --git a/core/dr-decode/src/lib.rs b/core/dr-decode/src/lib.rs index 6f8d457..66892ee 100644 --- a/core/dr-decode/src/lib.rs +++ b/core/dr-decode/src/lib.rs @@ -12,10 +12,13 @@ //! Fusing them would force a full decode where a header read suffices, which //! is exactly why Lightroom stalls ~2 s per image during culling. +pub mod base_curve; mod error; mod locate; mod preview; +pub mod profile; +pub use base_curve::BaseCurve; pub use error::DecodeError; pub use locate::{ defects, is_complete_jpeg, locate_preview, BadLine, BadPixel, Defects, PreviewLocation, @@ -25,6 +28,7 @@ pub use preview::{ decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize, PREVIEW_PROBE_BYTES, }; +pub use profile::CameraProfile; use dr_types::{Format, Orientation}; @@ -89,7 +93,24 @@ pub struct RawImage { /// `None` where the body is unknown to the decoder, in which case the /// pipeline falls back to identity and the result is uncalibrated rather /// than wrong-by-a-guess. + /// + /// Where the body carries two calibrations this is already *interpolated* + /// for the light the frame was shot under; see [`profile::CameraProfile`]. pub color_matrix: Option<[f32; 9]>, + /// TRACES: FR-DEV-3e + /// The per-body rendering curve, the other half of the camera profile. + /// + /// The matrix above decides what the colours *are*; this decides what the + /// picture looks like. Carried on the decoded image rather than looked up + /// downstream because this is the only point in the system that knows + /// which body took the frame, and because it is not an edit: it belongs to + /// the file in the same way the masked-photosite crop does, and must never + /// reach a sidecar (FR-NC-9). + /// + /// [`BaseCurve::IDENTITY`] for an unknown body with no default in the + /// database, which renders exactly as this decoder did before profiles + /// existed. + pub base_curve: BaseCurve, /// The usable region of `data`, excluding masked and border photosites. pub crop: CropRect, } @@ -421,15 +442,38 @@ pub fn decode(bytes: &[u8]) -> Result { let source = RawSource::new_from_slice(bytes); let decoder = rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?; + + // Read before decoding, while the decoder is still the cheapest thing in + // the room. These are the DNG tags rawler parses into its IFD and then + // never surfaces — `ForwardMatrix1/2` above all — and they are empty for + // every non-DNG file, which is not a failure (FR-DEV-3e). + let dng = profile::read_dng_matrices(decoder.as_ref()); + let image = decoder .raw_image(&source, &Default::default(), false) .map_err(|e| DecodeError::Decode(e.to_string()))?; - // Both derived before the match below moves `image.data`, and from the - // same matrix: the balance and the conversion must agree about which white - // is neutral or the frame carries a cast that looks like a decode fault. - let color_matrix = cam_to_srgb(&image); - let wb_coeffs = sane_wb(image.wb_coeffs, xyz_to_cam_of(&image).as_ref()); + // The camera profile, and both of the things derived from it, are built + // before the match below moves `image.data`. + // + // They come from *one* profile deliberately: the balance and the + // conversion must agree about which white is neutral, or the frame carries + // a cast that looks like a decode fault. That agreement used to be + // maintained by hand — two functions reading the same illuminant key — and + // is now structural, because there is only one interpolated matrix and + // both callers ask the same object for it. + let profile = profile::CameraProfile::extract(&image, &dng); + let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb()); + let wb_coeffs = sane_wb(image.wb_coeffs, profile.as_ref().map(|p| p.xyz_to_cam()).as_ref()); + + // The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings + // are preferred where it has them — they are what the shipped database is + // written against — and the matching folds the variants either way, so a + // DNG naming the same body differently still finds its curve. + let base_curve = base_curve::for_body( + image.camera.clean_make.as_str(), + image.camera.clean_model.as_str(), + ); let data = match image.data { rawler::RawImageData::Integer(v) => v, @@ -498,68 +542,10 @@ pub fn decode(bytes: &[u8]) -> Result { .unwrap_or(u16::MAX), wb_coeffs, color_matrix, + base_curve, }) } -/// TRACES: FR-DEV-3e -/// Compose the camera→sRGB-linear matrix from rawler's XYZ→camera. -/// -/// **Two rawler traps this avoids**, both measured on a Canon 6D CR2 -/// (2026-08-09): -/// -/// 1. `RawImage::xyz_to_cam` is **all zeros** — it carries an upstream -/// deprecation note and 0.7.2 no longer fills it. The live data is -/// `color_matrix`, keyed by illuminant. Reading the old field silently -/// yields no colour transform at all. -/// 2. `cam_to_xyz_normalized()` divides each of four rows by its own sum, and -/// the fourth row (emerald/white, unused on any Bayer body) sums to zero. -/// Every element came back `NaN`. Inverting the 3×3 ourselves avoids the -/// fourth channel entirely. -fn cam_to_srgb(image: &rawler::RawImage) -> Option<[f32; 9]> { - use rawler::imgop::xyz::Illuminant; - - // Prefer D65 — it matches sRGB's white point, so no chromatic adaptation - // is needed. Illuminant A (tungsten) is a distant fallback for bodies - // that ship only one matrix; adapting it properly is a v0.2 colour- - // management concern (ARCH §5.2), not something to fake here. - let flat = image - .color_matrix - .get(&Illuminant::D65) - .or_else(|| image.color_matrix.get(&Illuminant::A))?; - if flat.len() < 9 { - return None; - } - - let xyz_to_cam: [[f32; 3]; 3] = [ - [flat[0], flat[1], flat[2]], - [flat[3], flat[4], flat[5]], - [flat[6], flat[7], flat[8]], - ]; - cam_to_srgb_from(&xyz_to_cam) -} - -/// The camera's XYZ→camera matrix, as rawler holds it. -/// -/// Split out so the white-balance fallback and the colour matrix read the same -/// data through the same illuminant preference; two readers disagreeing about -/// which matrix a body uses would balance to one white and convert from -/// another. -fn xyz_to_cam_of(image: &rawler::RawImage) -> Option<[[f32; 3]; 3]> { - use rawler::imgop::xyz::Illuminant; - let flat = image - .color_matrix - .get(&Illuminant::D65) - .or_else(|| image.color_matrix.get(&Illuminant::A))?; - if flat.len() < 9 { - return None; - } - Some([ - [flat[0], flat[1], flat[2]], - [flat[3], flat[4], flat[5]], - [flat[6], flat[7], flat[8]], - ]) -} - /// The matrix maths, split out so it can be tested without a RAW file. // // The constants below are quoted at their published precision rather than @@ -567,7 +553,7 @@ fn xyz_to_cam_of(image: &rawler::RawImage) -> Option<[[f32; 3]; 3]> { // a linter makes it harder to check against the specification, and the // rounding happens identically either way. #[allow(clippy::excessive_precision)] -fn cam_to_srgb_from(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 9]> { +pub(crate) fn cam_to_srgb_from(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 9]> { // XYZ (D65) → linear sRGB, the standard primaries. const XYZ_TO_SRGB: [[f32; 3]; 3] = [ [3.2404542, -1.5371385, -0.4985314], @@ -722,7 +708,7 @@ pub fn daylight_wb(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 3]> { } /// Invert a 3×3 matrix, or `None` if it is singular. -fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> { +pub(crate) fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> { let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); diff --git a/core/dr-decode/src/profile.rs b/core/dr-decode/src/profile.rs new file mode 100644 index 0000000..115a211 --- /dev/null +++ b/core/dr-decode/src/profile.rs @@ -0,0 +1,1081 @@ +//! TRACES: FR-DEV-3e +//! Camera input profiles — deciding what a sensor's numbers are supposed to +//! mean. +//! +//! A RAW file is a count of electrons per photosite. Turning that into a +//! colour needs two things the file cannot supply on its own: a **matrix** +//! saying how this sensor's three responses relate to the CIE observer, and a +//! **rendering** saying what to do with the resulting scene-referred values so +//! that a photograph looks like a photograph. This module supplies the first +//! and looks up the second ([`crate::base_curve`]). +//! +//! # What is extracted, and from where +//! +//! | Quantity | DNG tag | Where rawler keeps it | +//! |---|---|---| +//! | `ColorMatrix1/2` | 50721 / 50722 | `RawImage::color_matrix`, keyed by illuminant | +//! | `CalibrationIlluminant1/2` | 50778 / 50779 | *is* that map's key | +//! | `ForwardMatrix1/2` | 50964 / 50965 | nowhere — read here, from the IFD | +//! | `AsShotNeutral` | 50728 | reciprocated into `RawImage::wb_coeffs` | +//! +//! The interesting row is the third. rawler 0.7.2 parses `ColorMatrix1/2` and +//! their illuminants for DNG files and drops `ForwardMatrix1/2` on the floor, +//! so that pair is read straight out of the root IFD here — the decoder hands +//! out a parsed `IFD` through [`rawler::decoders::Decoder::ifd`], which means +//! no TIFF parser of our own and no second interpretation of the same bytes. +//! +//! The second row is the one worth not misreading. rawler's map is keyed by +//! `Illuminant`, so `CalibrationIlluminant1/2` are not *lost* by being absent +//! from the table above — they are the keys. That is also true for bodies that +//! are not DNG at all: rawler's camera database stores the same per-illuminant +//! matrices for native formats, so a Canon CR2 arrives with an `A` matrix and +//! a `D65` matrix exactly as an Adobe-converted DNG of the same frame would. +//! Everything below therefore applies to every supported body, not only to +//! DNGs. +//! +//! # Two rawler fields that are traps, and stay traps +//! +//! Both measured on a Canon 6D CR2 (2026-08-09), and neither is used here: +//! +//! 1. `RawImage::xyz_to_cam` is **all zeros**. It carries an upstream +//! deprecation note and 0.7.2 no longer fills it. Reading it silently +//! yields no colour transform at all — a black frame, from code that looks +//! exactly right. +//! 2. `cam_to_xyz_normalized()` divides each of four rows by its own sum, and +//! the fourth row (emerald/white, unused on any Bayer body) sums to zero. +//! Every element came back `NaN`. Inverting the 3×3 ourselves avoids the +//! fourth channel entirely, which is what [`crate::invert3`] is for. +//! +//! # The rendering this replaces +//! +//! Before this module, the decoder took whichever matrix was keyed `D65`, +//! ignored the other, and inverted it. That is the dcraw default and it is the +//! rendering FR-DEV-3e exists to get away from: correct in the abstract, flat +//! and poor on skin in practice, because a single daylight matrix is asked to +//! describe a sensor under every illuminant it will ever see. +//! +//! It also rendered some bodies with no matrix at all. The lookup asked for +//! `D65`, then `A`, and gave up — so a Phase One IQ3, calibrated at `D55` and +//! `D75`, went through the pipeline uncalibrated despite carrying two perfectly +//! good matrices. Placing every illuminant on a temperature axis fixes that as +//! a side effect of making the interpolation possible at all. +//! +//! # What is deliberately not here yet +//! +//! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and +//! `ProfileLookTable` — and requires that they arrive as *additions* rather +//! than as a pipeline reordering. They would: both are lookups applied to a +//! colour after this matrix and before, or alongside, the base curve, so they +//! extend [`CameraProfile`] with more calibration data and extend the shader's +//! camera-profile stage with more work. Nothing above would move. + +use crate::{cam_to_srgb_from, invert3}; +use rawler::imgop::xyz::Illuminant; + +/// One calibration: a matrix, and the light it was measured under. +/// +/// `forward` is the DNG `ForwardMatrix`, present only where the file carries +/// one. It is a different and better-conditioned statement of the same +/// relationship — see [`CameraProfile::cam_to_srgb`] — and its absence is +/// ordinary rather than exceptional, because no native RAW format has the tag +/// and rawler's camera database does not carry one either. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Calibration { + /// Correlated colour temperature of the calibration illuminant, in kelvin. + pub temperature: f32, + /// CIE XYZ → camera RGB, as the tag defines it. + /// + /// The DNG specification states `ColorMatrix` against D50, its connection + /// space. [`crate::cam_to_srgb_from`] instead normalises the rows by the + /// camera's response to *sRGB* white and then inverts, which is the dcraw + /// treatment: it performs the adaptation in camera space rather than in + /// XYZ. The two differ by a chromatic adaptation that the row + /// normalisation absorbs, and the property that has to hold — camera + /// neutral lands on sRGB neutral — holds either way. Doing it the other + /// way round is a colour-management concern (ARCH §5.2) and would want an + /// ICC pipeline behind it rather than a different constant here. + pub xyz_to_cam: [[f32; 3]; 3], + /// White-balanced camera RGB → CIE XYZ (D50), where the file says so. + pub forward: Option<[[f32; 3]; 3]>, +} + +/// TRACES: FR-DEV-3e +/// Everything known about how this body sees colour. +/// +/// Held as *calibrations plus a neutral* rather than as a finished matrix +/// because the finished matrix depends on the scene: a two-illuminant profile +/// describes the sensor under tungsten and under daylight, and the frame in +/// hand was shot under neither. [`Self::cam_to_srgb`] is where that gets +/// resolved. +#[derive(Debug, Clone, PartialEq)] +pub struct CameraProfile { + /// Ordered coolest last; two in practice, since that is all rawler + /// surfaces. Never empty — [`Self::extract`] returns `None` rather than + /// build an empty profile, so that "no profile" and "a profile that says + /// nothing" cannot be confused downstream. + calibrations: Vec, + /// `AsShotNeutral`: the camera-space colour the camera considered white. + /// + /// Optional because a body that wrote no white balance leaves nothing to + /// estimate the scene's illuminant from. The interpolation then falls back + /// to the coolest calibration, which is the daylight one on every body + /// this has been seen on. + neutral: Option<[f32; 3]>, +} + +/// The DNG tags rawler parses but does not surface, read from the root IFD. +/// +/// Kept as a separate step from [`CameraProfile::extract`] because it needs +/// the *decoder*, which exists only for as long as the file is open, while the +/// profile is built from the decoded `RawImage` and outlives it. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct DngMatrices { + /// `(CalibrationIlluminant1, ForwardMatrix1)`, and the same for 2. + /// + /// The illuminant is carried alongside because the pairing is *positional* + /// in the file — `ForwardMatrix2` belongs to `CalibrationIlluminant2` — + /// and rawler's illuminant-keyed map has already thrown that ordering + /// away. Reading the illuminant here is what lets a forward matrix be + /// matched back to the colour matrix it was measured with. + pub forward: [Option<(Illuminant, [[f32; 3]; 3])>; 2], + /// `AsShotNeutral`, in camera RGB. + /// + /// rawler already reciprocates this into `wb_coeffs`, so this is a + /// cross-check rather than the only source — but it is the source that is + /// exactly what the file said, with no green normalisation applied and no + /// `NaN` fourth channel to strip. + pub as_shot_neutral: Option<[f32; 3]>, +} + +impl CameraProfile { + /// TRACES: FR-DEV-3e + /// Build a profile from a decoded image and whatever the file's IFD added. + /// + /// `None` where the body has no usable matrix at all — an unknown camera, + /// or rawler's all-zero placeholder. That is a real answer and the caller + /// must keep treating it as one: rendering uncalibrated is recoverable, + /// rendering through a matrix that is secretly zeros is a black frame. + pub fn extract(image: &rawler::RawImage, dng: &DngMatrices) -> Option { + let mut calibrations: Vec = Vec::new(); + + for (illuminant, flat) in &image.color_matrix { + if flat.len() < 9 { + // A four-row matrix (RGBE sensors) would be 12; anything + // shorter than nine is not a 3×3 and there is nothing + // sensible to do with it. + continue; + } + let xyz_to_cam = [ + [flat[0], flat[1], flat[2]], + [flat[3], flat[4], flat[5]], + [flat[6], flat[7], flat[8]], + ]; + if !usable(&xyz_to_cam) { + continue; + } + let Some(temperature) = illuminant_temperature(*illuminant) else { + // A calibration under a light with no defined temperature — + // `Flash`, `Unknown` — cannot be placed on the axis the + // interpolation runs along. Skipping it is better than + // inventing a temperature, which would drag the interpolation + // toward a point that means nothing. + continue; + }; + // The forward matrix measured under *this* illuminant, if the file + // carried one. Matched by illuminant rather than by position, + // because position is what the map key already discarded. + let forward = dng + .forward + .iter() + .flatten() + .find(|(i, _)| i == illuminant) + .map(|(_, m)| *m) + .filter(usable); + calibrations.push(Calibration { + temperature, + xyz_to_cam, + forward, + }); + } + + if calibrations.is_empty() { + return None; + } + // Coolest last, so "the daylight one" is `.last()` and the + // interpolation below can talk about a low and a high end without + // re-sorting. `HashMap` iteration order is not defined, so without + // this the same file would produce different profiles between runs. + calibrations.sort_by(|a, b| a.temperature.total_cmp(&b.temperature)); + + let neutral = dng + .as_shot_neutral + .or_else(|| neutral_from_coefficients(image.wb_coeffs)) + .filter(|n| n.iter().all(|v| v.is_finite() && *v > 0.0)); + + Some(Self { + calibrations, + neutral, + }) + } + + /// Build a profile directly from calibrations, for tests and for callers + /// that already hold the numbers. + pub fn new(calibrations: Vec, neutral: Option<[f32; 3]>) -> Option { + if calibrations.is_empty() { + return None; + } + let mut calibrations = calibrations; + calibrations.sort_by(|a, b| a.temperature.total_cmp(&b.temperature)); + Some(Self { + calibrations, + neutral, + }) + } + + /// TRACES: FR-DEV-3e + /// The scene's estimated colour temperature, in kelvin. + /// + /// # Why this is a fixed point rather than a calculation + /// + /// The temperature is read off the as-shot neutral — the camera-space + /// colour the camera called white — by carrying it into XYZ and asking + /// which black body it sits nearest. But carrying it into XYZ needs a + /// matrix, and *which* matrix is exactly what the temperature is being + /// computed to decide. The two definitions are circular. + /// + /// Adobe's DNG SDK resolves it by iterating, and so does this: guess + /// daylight, interpolate, re-read the temperature, repeat. It converges in + /// two or three rounds because the interpolation weight is a smooth, + /// shallow function of the temperature — a hundred kelvin of error in the + /// guess moves the next estimate by far less than a hundred kelvin. + /// + /// Three rounds, fixed, rather than a convergence test. The remaining + /// disagreement after three is smaller than the difference between two + /// bodies' idea of the same illuminant, and a loop that ran until it + /// settled would spend its time proving that. + pub fn scene_temperature(&self) -> f32 { + // A single calibration has nothing to interpolate between, so its own + // illuminant is the answer and the iteration would be busy-work. + if self.calibrations.len() < 2 { + return self.calibrations[0].temperature; + } + let Some(neutral) = self.neutral else { + // No white balance to read: assume the light the coolest + // calibration was measured under, which is D65 on every + // dual-illuminant body rawler knows. Not the midpoint — a midpoint + // is a scene lit by nothing anyone has photographed, whereas + // daylight is the single most likely answer, and it is also what + // this decoder assumed before it could interpolate at all. + return self.calibrations.last().expect("checked above").temperature; + }; + + let mut temperature = self.calibrations.last().expect("checked above").temperature; + for _ in 0..3 { + let m = self.interpolated_xyz_to_cam_at(temperature); + let Some((x, y)) = neutral_to_xy(&neutral, &m) else { + // A singular interpolant. Keep the previous estimate rather + // than propagating a NaN into the matrix that renders the + // frame. + break; + }; + temperature = cct_from_xy(x, y); + } + temperature + } + + /// TRACES: FR-DEV-3e + /// The XYZ→camera matrix for the light this frame was actually shot under. + /// + /// Exposed because two callers need the *same* answer: the colour + /// conversion, and the white-balance fallback that runs when a file + /// carries no coefficients. Two readers disagreeing about which matrix a + /// body uses would balance to one white and convert from another, which + /// looks like a decode fault rather than like a disagreement. + pub fn xyz_to_cam(&self) -> [[f32; 3]; 3] { + self.interpolated_xyz_to_cam_at(self.scene_temperature()) + } + + /// The interpolation itself, at a stated temperature. + /// + /// **In mireds, not kelvin.** Reciprocal temperature is the axis on which + /// equal steps are equally visible — 2000 K to 2500 K is an enormous + /// change in the light and 9000 K to 9500 K is barely one — so a linear + /// blend in kelvin would put nearly all of its travel in the tungsten end + /// and leave daylight to a sliver at the top. Adobe's DNG specification + /// interpolates in mireds for the same reason, so this also agrees with + /// what the profile's author was picturing. + fn interpolated_xyz_to_cam_at(&self, temperature: f32) -> [[f32; 3]; 3] { + if let [only] = self.calibrations.as_slice() { + return only.xyz_to_cam; + } + let (low, high) = self.bracketing(temperature); + lerp3( + &low.xyz_to_cam, + &high.xyz_to_cam, + blend(low, high, temperature), + ) + } + + /// The two calibrations this temperature sits between. + /// + /// Two is the whole story for every file rawler can produce — the DNG + /// decoder reads `ColorMatrix1/2` and stops, and no camera in its database + /// carries more than two usable illuminants. Bracketing rather than + /// hard-coding the pair anyway, because `ColorMatrix3` exists in DNG 1.6 + /// and the day rawler starts reading it, a profile with a tungsten, a + /// daylight and a shade calibration must interpolate between the two the + /// scene actually falls between rather than across the whole range. + /// + /// Outside the calibrated range the outermost pair is returned and + /// [`blend`] clamps, which is the same answer either way. + fn bracketing(&self, temperature: f32) -> (&Calibration, &Calibration) { + let cals = &self.calibrations; + debug_assert!(cals.len() >= 2, "callers handle the single-matrix case"); + let last_pair = cals.len() - 2; + let i = cals + .iter() + .rposition(|c| c.temperature <= temperature) + .unwrap_or(0) + .min(last_pair); + (&cals[i], &cals[i + 1]) + } + + /// TRACES: FR-DEV-3e + /// Camera RGB → linear sRGB (D65), row-major, ready for the shader. + /// + /// # Two routes, and why the forward matrix wins where it exists + /// + /// The colour-matrix route inverts a measurement of the *sensor* to get a + /// transform of the *scene*: it says what camera values a given XYZ would + /// produce, and rendering needs the other direction. Inversion is exact + /// arithmetic on an inexact measurement, and it amplifies whatever error + /// the measurement had — most visibly in saturated reds and in the + /// near-neutrals that skin is made of. + /// + /// `ForwardMatrix` is the manufacturer's answer to that: the same + /// relationship measured in the direction rendering actually wants, from + /// white-balanced camera RGB straight to XYZ, and constrained so that a + /// neutral maps exactly onto the D50 white point. Where a file carries + /// one, using it is strictly better than inverting the colour matrix, and + /// it costs one matrix multiply less. + /// + /// Both routes end by normalising the rows to sum to one. That is not + /// cosmetic: the shader applies green-normalised as-shot multipliers + /// before this matrix, so camera neutral arrives as (1, 1, 1), and a + /// matrix whose rows did not sum to one would render it as something other + /// than white. It is also what makes the two routes interchangeable — they + /// agree about neutral by construction, so a body gaining a forward matrix + /// changes its colour rendering without changing its exposure. + pub fn cam_to_srgb(&self) -> Option<[f32; 9]> { + let temperature = self.scene_temperature(); + + // The forward route needs a forward matrix at *both* ends, or at the + // only end there is. Interpolating a forward matrix against an + // inverted colour matrix would mix two different statements of the + // same thing and land between them, which is worse than either. + if let Some(forward) = self.interpolated_forward_at(temperature) { + return forward_to_srgb(&forward); + } + + cam_to_srgb_from(&self.interpolated_xyz_to_cam_at(temperature)) + } + + /// The forward matrix for this temperature, where every calibration has + /// one. + fn interpolated_forward_at(&self, temperature: f32) -> Option<[[f32; 3]; 3]> { + if let [only] = self.calibrations.as_slice() { + return only.forward; + } + let (low, high) = self.bracketing(temperature); + let (a, b) = (low.forward?, high.forward?); + Some(lerp3(&a, &b, blend(low, high, temperature))) + } + + /// The calibrations this profile was built from, coolest first. + pub fn calibrations(&self) -> &[Calibration] { + &self.calibrations + } + + /// The as-shot neutral, in camera RGB, where the file carried one. + pub fn neutral(&self) -> Option<[f32; 3]> { + self.neutral + } +} + +/// Where `temperature` sits between two calibrations, in mireds, clamped. +/// +/// Returns the weight of `high` — 0 at `low`'s illuminant, 1 at `high`'s. +/// Clamped because extrapolating a two-point calibration past either end +/// produces matrices that are not measurements of anything: candlelight is +/// well below illuminant A and open shade well above D65, and both happen +/// often enough to matter. +fn blend(low: &Calibration, high: &Calibration, temperature: f32) -> f32 { + let mired = |k: f32| 1.0e6 / k.max(1.0); + let (lo, hi) = (mired(low.temperature), mired(high.temperature)); + if (lo - hi).abs() < 1e-6 { + // Two calibrations under the same light. Nothing to interpolate; take + // the second, which is the one a DNG writer would have meant as the + // primary. + return 1.0; + } + ((mired(temperature) - lo) / (hi - lo)).clamp(0.0, 1.0) +} + +/// Linear blend of two matrices. `t` is the weight of `b`. +fn lerp3(a: &[[f32; 3]; 3], b: &[[f32; 3]; 3], t: f32) -> [[f32; 3]; 3] { + let mut out = [[0.0f32; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + out[i][j] = a[i][j] + (b[i][j] - a[i][j]) * t; + } + } + out +} + +/// The chromaticity of a camera-space neutral, under a given matrix. +/// +/// `xyz_to_cam` says what the camera would report for a colour; the neutral is +/// what it *did* report, so the colour is the inverse applied to it. +fn neutral_to_xy(neutral: &[f32; 3], xyz_to_cam: &[[f32; 3]; 3]) -> Option<(f32, f32)> { + let cam_to_xyz = invert3(xyz_to_cam)?; + let mut xyz = [0.0f32; 3]; + for (i, row) in cam_to_xyz.iter().enumerate() { + for k in 0..3 { + xyz[i] += row[k] * neutral[k]; + } + } + let sum: f32 = xyz.iter().sum(); + if !sum.is_finite() || sum.abs() < 1e-9 { + return None; + } + let (x, y) = (xyz[0] / sum, xyz[1] / sum); + if !x.is_finite() || !y.is_finite() { + return None; + } + Some((x, y)) +} + +/// TRACES: FR-DEV-3e +/// Correlated colour temperature of a chromaticity, by McCamy's approximation. +/// +/// The exact answer is the nearest point on the Planckian locus measured in a +/// uniform chromaticity space, which means either a minimisation or Robertson's +/// thirty-one-row isotemperature table. McCamy's cubic reproduces it to within +/// a couple of kelvin between 2856 K and 6504 K — which is not a coincidence, +/// it is the range every dual-illuminant profile is calibrated across — and it +/// is four multiplies. +/// +/// Accuracy here buys very little in any case. The value feeds an +/// interpolation weight that moves smoothly and slowly: on a Canon 6D, being +/// 200 K wrong about the scene shifts the resulting matrix by well under a +/// thousandth in every element. What would matter is being wrong by +/// *thousands*, which is what the clamp below prevents. +fn cct_from_xy(x: f32, y: f32) -> f32 { + // The epicentre McCamy fits the isotemperature lines around. A neutral + // landing exactly on it would divide by zero, which is a chromaticity no + // camera reports but not one to leave undefended. + let denominator = 0.1858 - y; + if denominator.abs() < 1e-6 { + return 6504.0; + } + let n = (x - 0.3320) / denominator; + let cct = 449.0 * n * n * n + 3525.0 * n * n + 6823.3 * n + 5520.33; + if !cct.is_finite() { + return 6504.0; + } + // The DNG specification's own working range. Outside it the fit is not a + // temperature so much as a number, and a profile has nothing calibrated + // there to interpolate toward anyway. + cct.clamp(1667.0, 25000.0) +} + +/// TRACES: FR-DEV-3e +/// The nominal colour temperature of a DNG calibration illuminant. +/// +/// `None` for the ones that do not have one. `Flash` is a real illuminant with +/// a real spectrum and no agreed temperature; `Unknown` is the tag's way of +/// saying nothing. Neither can be placed on the interpolation axis, and giving +/// them a plausible-looking number would put a calibration somewhere its author +/// never claimed it belonged. +/// +/// The fluorescent entries are the CIE F-series' nominal correlated +/// temperatures. They are correlated temperatures of decidedly non-Planckian +/// light, so interpolating toward one is approximate in a way daylight is not — +/// but a profile calibrated under fluorescent light is describing a sensor +/// under fluorescent light, and placing it at roughly the right colour is much +/// better than discarding it. +fn illuminant_temperature(illuminant: Illuminant) -> Option { + Some(match illuminant { + // CIE standard illuminant A: a tungsten filament at 2856 K. The low + // end of essentially every dual-illuminant profile ever written. + Illuminant::A | Illuminant::Tungsten => 2856.0, + Illuminant::IsoStudioTungsten => 3200.0, + Illuminant::WhiteFluorescent => 3500.0, + Illuminant::CoolWhiteFluorescent => 4150.0, + Illuminant::B => 4874.0, + Illuminant::D50 => 5003.0, + Illuminant::DaylightWhiteFluorescent => 5000.0, + Illuminant::D55 | Illuminant::Daylight | Illuminant::FineWeather => 5503.0, + Illuminant::Fluorescent => 4230.0, + Illuminant::DaylightFluorescent => 6430.0, + // The sRGB white point, and the high end of essentially every + // dual-illuminant profile. + Illuminant::D65 => 6504.0, + Illuminant::C => 6774.0, + Illuminant::CloudyWeather => 6504.0, + Illuminant::D75 | Illuminant::Shade => 7504.0, + Illuminant::Flash | Illuminant::Unknown => return None, + }) +} + +/// Compose a forward matrix into camera RGB → linear sRGB. +/// +/// `forward` takes white-balanced camera RGB to XYZ under D50, which is the +/// DNG connection space and *not* the space sRGB is defined in. So: adapt D50 +/// to D65 with Bradford, convert to sRGB primaries, and normalise the rows so +/// that camera neutral lands on sRGB white. +/// +/// The normalisation should be very close to a no-op — a forward matrix's rows +/// sum to the D50 white point by definition, which survives the adaptation as +/// D65 white and the conversion as (1, 1, 1). It is applied anyway, because +/// "should be" is doing load-bearing work in that sentence for a tag written +/// by a hundred different converters, and because it also absorbs the green +/// normalisation the pipeline's white balance applies and this matrix would +/// otherwise have to know about. +fn forward_to_srgb(forward: &[[f32; 3]; 3]) -> Option<[f32; 9]> { + // Bradford-adapted D50 → D65. The chromatic adaptation everything else in + // colour management uses, so that a DarkRoom render and an ICC-aware + // application disagree about nothing here. + #[allow(clippy::excessive_precision)] + const D50_TO_D65: [[f32; 3]; 3] = [ + [0.9555766, -0.0230393, 0.0631636], + [-0.0282895, 1.0099416, 0.0210077], + [0.0122982, -0.0204830, 1.3299098], + ]; + // XYZ (D65) → linear sRGB. Written out rather than shared with + // `cam_to_srgb_from`'s copy for the reason `daylight_wb` states: two + // matrices that must agree are better checked against the specification + // than against each other. + #[allow(clippy::excessive_precision)] + const XYZ_TO_SRGB: [[f32; 3]; 3] = [ + [3.2404542, -1.5371385, -0.4985314], + [-0.9692660, 1.8760108, 0.0415560], + [0.0556434, -0.2040259, 1.0572252], + ]; + + if !usable(forward) { + return None; + } + + let m = mul3(&XYZ_TO_SRGB, &mul3(&D50_TO_D65, forward)); + + let mut out = [0.0f32; 9]; + for (i, row) in m.iter().enumerate() { + let sum: f32 = row.iter().sum(); + // A row summing to zero would send neutral to black in that channel. + // Something is very wrong with the tag; say so rather than render it. + if !sum.is_finite() || sum.abs() < 1e-6 { + return None; + } + for j in 0..3 { + out[i * 3 + j] = row[j] / sum; + } + } + if out.iter().any(|v| !v.is_finite()) { + return None; + } + Some(out) +} + +/// Ordinary 3×3 multiply, `a · b`. +fn mul3(a: &[[f32; 3]; 3], b: &[[f32; 3]; 3]) -> [[f32; 3]; 3] { + let mut out = [[0.0f32; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + for k in 0..3 { + out[i][j] += a[i][k] * b[k][j]; + } + } + } + out +} + +/// Whether a matrix is a measurement rather than a placeholder. +/// +/// All-zero is how rawler represents "this body has no matrix", and inverting +/// it renders black. A non-finite element is a corrupt tag. Both are absence, +/// and absence has to reach the caller as absence. +fn usable(m: &[[f32; 3]; 3]) -> bool { + m.iter().flatten().any(|v| v.abs() > f32::EPSILON) && m.iter().flatten().all(|v| v.is_finite()) +} + +/// Recover `AsShotNeutral` from rawler's white balance coefficients. +/// +/// They are its reciprocal — `wb_coeffs[i] == 1.0 / neutral[i]` — for DNG +/// files by construction, and for native formats because a maker's white +/// balance tag means the same thing. The fourth channel is `NaN` on every +/// three-colour sensor and is not consulted. +fn neutral_from_coefficients(coefficients: [f32; 4]) -> Option<[f32; 3]> { + let usable = |v: f32| v.is_finite() && v > 0.0; + if !(usable(coefficients[0]) && usable(coefficients[1]) && usable(coefficients[2])) { + return None; + } + Some([ + 1.0 / coefficients[0], + 1.0 / coefficients[1], + 1.0 / coefficients[2], + ]) +} + +/// TRACES: FR-DEV-3e +/// Read the DNG tags rawler parses but does not expose. +/// +/// Returns an empty set for every non-DNG file, and that is not a failure: +/// [`rawler::decoders::Decoder::ifd`] is implemented by the DNG decoder alone +/// and defaults to `Ok(None)` everywhere else, so a CR2 or an ARW simply has +/// no forward matrix to find. Those bodies still get dual-illuminant +/// interpolation from rawler's own camera database — the forward matrix is the +/// only thing this adds that they cannot have. +pub fn read_dng_matrices(decoder: &dyn rawler::decoders::Decoder) -> DngMatrices { + use rawler::decoders::WellKnownIFD; + use rawler::tags::DngTag; + + let Ok(Some(ifd)) = decoder.ifd(WellKnownIFD::Root) else { + return DngMatrices::default(); + }; + + // Recursive, because a DNG converter is free to put the colour tags in a + // sub-IFD beside the raw image rather than in IFD0, and both layouts are + // in the wild. + let matrix_at = |tag: DngTag| -> Option<[[f32; 3]; 3]> { + let entry = ifd.get_entry_recursive(tag)?; + if entry.count() < 9 { + return None; + } + let at = |i: usize| entry.force_f32(i); + Some([ + [at(0), at(1), at(2)], + [at(3), at(4), at(5)], + [at(6), at(7), at(8)], + ]) + }; + let illuminant_at = |tag: DngTag| -> Illuminant { + // 21 is D65, which is the DNG specification's default for a missing + // `CalibrationIlluminant` and is what rawler assumes for the colour + // matrices. Assuming anything else here would pair a forward matrix + // with the wrong colour matrix. + ifd.get_entry_recursive(tag) + .map(|e| e.force_u16(0)) + .unwrap_or(21) + .try_into() + .unwrap_or(Illuminant::D65) + }; + + let forward = [ + matrix_at(DngTag::ForwardMatrix1) + .map(|m| (illuminant_at(DngTag::CalibrationIlluminant1), m)), + matrix_at(DngTag::ForwardMatrix2) + .map(|m| (illuminant_at(DngTag::CalibrationIlluminant2), m)), + ]; + + let as_shot_neutral = ifd + .get_entry_recursive(DngTag::AsShotNeutral) + .filter(|e| e.count() >= 3) + .map(|e| [e.force_f32(0), e.force_f32(1), e.force_f32(2)]) + .filter(|n| n.iter().all(|v| v.is_finite() && *v > 0.0)); + + DngMatrices { + forward, + as_shot_neutral, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Canon EOS 6D's two matrices, as rawler's camera database holds + /// them. A real dual-illuminant body, and the one every other measurement + /// in this crate was taken on. + const SIX_D_A: [[f32; 3]; 3] = [ + [0.7546, -0.1435, -0.0929], + [-0.3846, 1.1488, 0.2692], + [-0.0332, 0.1209, 0.6370], + ]; + const SIX_D_D65: [[f32; 3]; 3] = [ + [0.7034, -0.0804, -0.1014], + [-0.4420, 1.2564, 0.2058], + [-0.0851, 0.1994, 0.5758], + ]; + + fn six_d(neutral: Option<[f32; 3]>) -> CameraProfile { + CameraProfile::new( + vec![ + Calibration { + temperature: 2856.0, + xyz_to_cam: SIX_D_A, + forward: None, + }, + Calibration { + temperature: 6504.0, + xyz_to_cam: SIX_D_D65, + forward: None, + }, + ], + neutral, + ) + .expect("two calibrations") + } + + /// The camera-space neutral of a scene at roughly `kelvin`, derived from + /// the body's own matrices so the test is not asserting against a number + /// somebody typed. + fn neutral_at(profile: &CameraProfile, kelvin: f32) -> [f32; 3] { + // The daylight locus is close enough to Planckian over this range for + // a test fixture; what matters is that warm scenes and cool scenes + // produce distinguishable neutrals, not that they are exact. + let m = profile.interpolated_xyz_to_cam_at(kelvin); + let xy = planckian_xy(kelvin); + let xyz = [xy.0 / xy.1, 1.0, (1.0 - xy.0 - xy.1) / xy.1]; + let mut n = [0.0f32; 3]; + for (i, row) in m.iter().enumerate() { + for k in 0..3 { + n[i] += row[k] * xyz[k]; + } + } + n + } + + /// Kim et al.'s cubic fit to the daylight/Planckian locus, good from + /// 1667 K to 25000 K. Test scaffolding only — nothing outside this module + /// needs to go from a temperature back to a chromaticity. + // + // Coefficients at their published precision rather than trimmed to what + // f32 can hold, for the reason `cam_to_srgb_from` states about its own: + // a fit checked against its paper is worth more than one checked against + // a linter, and the rounding happens identically either way. + #[allow(clippy::excessive_precision)] + fn planckian_xy(kelvin: f32) -> (f32, f32) { + let t = kelvin.clamp(1667.0, 25000.0); + let (t1, t2, t3) = (1.0e3 / t, 1.0e6 / (t * t), 1.0e9 / (t * t * t)); + let x = if t <= 4000.0 { + -0.2661239 * t3 - 0.2343589 * t2 + 0.8776956 * t1 + 0.179910 + } else { + -3.0258469 * t3 + 2.1070379 * t2 + 0.2226347 * t1 + 0.240390 + }; + let y = if t <= 2222.0 { + -1.1063814 * x * x * x - 1.34811020 * x * x + 2.18555832 * x - 0.20219683 + } else if t <= 4000.0 { + -0.9549476 * x * x * x - 1.37418593 * x * x + 2.09137015 * x - 0.16748867 + } else { + 3.0817580 * x * x * x - 5.87338670 * x * x + 3.75112997 * x - 0.37001483 + }; + (x, y) + } + + #[test] + fn a_single_matrix_body_is_unchanged_by_the_interpolation() { + // The regression that matters most: most bodies in rawler's database + // carry one matrix, and dual-illuminant support must not perturb what + // they rendered as yesterday. + let profile = CameraProfile::new( + vec![Calibration { + temperature: 6504.0, + xyz_to_cam: SIX_D_D65, + forward: None, + }], + Some([0.5, 1.0, 0.7]), + ) + .expect("one calibration"); + + assert_eq!(profile.xyz_to_cam(), SIX_D_D65); + assert_eq!(profile.cam_to_srgb(), cam_to_srgb_from(&SIX_D_D65)); + } + + #[test] + fn a_tungsten_scene_lands_nearer_the_tungsten_matrix() { + // The whole point of two matrices. If the weighting ran the wrong way + // this test is the only thing between that and a warm scene rendered + // through a daylight calibration, which is the flat, orange-skinned + // look the requirement exists to avoid. + let profile = six_d(None); + let warm = neutral_at(&profile, 2900.0); + let profile = six_d(Some(warm)); + + let t = profile.scene_temperature(); + assert!( + (2500.0..3600.0).contains(&t), + "a tungsten neutral was read as {t} K" + ); + + let m = profile.xyz_to_cam(); + let to_a: f32 = distance(&m, &SIX_D_A); + let to_d65: f32 = distance(&m, &SIX_D_D65); + assert!( + to_a < to_d65, + "interpolated matrix sits {to_a} from A and {to_d65} from D65" + ); + } + + #[test] + fn a_daylight_scene_lands_nearer_the_daylight_matrix() { + let profile = six_d(None); + let cool = neutral_at(&profile, 6500.0); + let profile = six_d(Some(cool)); + + let t = profile.scene_temperature(); + assert!( + (5200.0..8000.0).contains(&t), + "a daylight neutral was read as {t} K" + ); + + let m = profile.xyz_to_cam(); + assert!(distance(&m, &SIX_D_D65) < distance(&m, &SIX_D_A)); + } + + #[test] + fn the_interpolated_matrix_still_keeps_neutral_neutral() { + // A blend of two valid matrices is not automatically a valid matrix. + // This is the property the render depends on: whatever the scene, the + // composed transform must send camera neutral to sRGB neutral, or the + // frame carries a cast that looks like a bug in the decoder. + for kelvin in [2500.0, 3000.0, 4500.0, 6500.0, 9000.0] { + let base = six_d(None); + let profile = six_d(Some(neutral_at(&base, kelvin))); + let m = profile.cam_to_srgb().expect("a real body inverts"); + for (i, row) in m.chunks(3).enumerate() { + let sum: f32 = row.iter().sum(); + assert!( + (sum - 1.0).abs() < 1e-4, + "at {kelvin} K row {i} sums to {sum}" + ); + } + } + } + + #[test] + fn extrapolation_is_clamped_to_what_was_measured() { + // Candlelight is far below illuminant A and open shade far above D65. + // A linear blend run past either end produces matrices that are not + // measurements of anything, and the failure is silent — a plausible + // matrix with implausible primaries. + let profile = six_d(None); + let candle = six_d(Some(neutral_at(&profile, 1700.0))); + let shade = six_d(Some(neutral_at(&profile, 20000.0))); + + assert_eq!(candle.xyz_to_cam(), SIX_D_A, "clamped to the warm end"); + assert_eq!(shade.xyz_to_cam(), SIX_D_D65, "clamped to the cool end"); + } + + #[test] + fn with_no_white_balance_the_daylight_calibration_is_assumed() { + // Not the midpoint: a midpoint is a scene lit by nothing anyone has + // photographed. Daylight is the single most likely answer and is the + // behaviour every previous version of this decoder had. + let profile = six_d(None); + assert_eq!(profile.xyz_to_cam(), SIX_D_D65); + } + + #[test] + fn a_forward_matrix_is_preferred_and_agrees_about_white() { + // The property that lets the two routes coexist: a body gaining a + // forward matrix must change its colour rendering without changing its + // exposure or its neutral. Rows summing to one is what says so. + // + // The matrix is the D50-adapted forward matrix of a body whose + // primaries are close to sRGB's, which is enough to exercise the path + // — the arithmetic under test is the adaptation and the normalisation, + // not anyone's measurement. + let forward = [ + [0.7500, 0.1800, 0.0347], + [0.2900, 0.6800, 0.0300], + [0.0200, 0.0900, 0.7147], + ]; + let profile = CameraProfile::new( + vec![Calibration { + temperature: 6504.0, + xyz_to_cam: SIX_D_D65, + forward: Some(forward), + }], + None, + ) + .expect("one calibration"); + + let m = profile.cam_to_srgb().expect("a forward matrix composes"); + for (i, row) in m.chunks(3).enumerate() { + let sum: f32 = row.iter().sum(); + assert!((sum - 1.0).abs() < 1e-4, "row {i} sums to {sum}"); + } + assert_ne!( + Some(m), + cam_to_srgb_from(&SIX_D_D65), + "the forward matrix must actually be used, not merely accepted" + ); + } + + #[test] + fn a_forward_matrix_at_only_one_end_is_not_mixed_with_an_inverted_one() { + // Interpolating a forward matrix against an inverted colour matrix + // would blend two different statements of the same relationship and + // land between them — worse than either, and invisible. + let profile = CameraProfile::new( + vec![ + Calibration { + temperature: 2856.0, + xyz_to_cam: SIX_D_A, + forward: Some([[0.75, 0.18, 0.03], [0.29, 0.68, 0.03], [0.02, 0.09, 0.71]]), + }, + Calibration { + temperature: 6504.0, + xyz_to_cam: SIX_D_D65, + forward: None, + }, + ], + None, + ) + .expect("two calibrations"); + + assert_eq!( + profile.cam_to_srgb(), + cam_to_srgb_from(&SIX_D_D65), + "a half-populated forward pair must fall back to the colour matrices" + ); + } + + #[test] + fn a_third_calibration_brackets_rather_than_widening_the_blend() { + // Not reachable through rawler today, which reads `ColorMatrix1/2` and + // stops. Asserted anyway because the failure it prevents is silent: a + // profile with tungsten, daylight and shade calibrations interpolated + // across the *extremes* would ignore the daylight matrix entirely and + // render every ordinary photograph through a blend of candlelight and + // open shade. + let shade: [[f32; 3]; 3] = [ + [0.68, -0.07, -0.10], + [-0.46, 1.28, 0.20], + [-0.09, 0.21, 0.56], + ]; + let profile = CameraProfile::new( + vec![ + Calibration { + temperature: 2856.0, + xyz_to_cam: SIX_D_A, + forward: None, + }, + Calibration { + temperature: 6504.0, + xyz_to_cam: SIX_D_D65, + forward: None, + }, + Calibration { + temperature: 7504.0, + xyz_to_cam: shade, + forward: None, + }, + ], + None, + ) + .expect("three calibrations"); + + // 5000 K falls between A and D65, so the shade matrix contributes + // nothing at all. + let m = profile.interpolated_xyz_to_cam_at(5000.0); + let between = |a: f32, b: f32, v: f32| (v - a) * (v - b) <= 1e-6; + assert!( + between(SIX_D_A[0][0], SIX_D_D65[0][0], m[0][0]), + "5000 K landed at {}, outside the A..D65 span", + m[0][0] + ); + // And 7000 K falls in the upper pair, where A contributes nothing. + let m = profile.interpolated_xyz_to_cam_at(7000.0); + assert!(between(SIX_D_D65[0][0], shade[0][0], m[0][0])); + } + + #[test] + fn an_illuminant_with_no_temperature_is_dropped_rather_than_guessed() { + // `Flash` has a real spectrum and no agreed temperature, `Unknown` is + // the tag saying nothing. Placing either on the interpolation axis + // would drag the result toward a point its author never claimed. + assert_eq!(illuminant_temperature(Illuminant::Flash), None); + assert_eq!(illuminant_temperature(Illuminant::Unknown), None); + assert_eq!(illuminant_temperature(Illuminant::A), Some(2856.0)); + assert_eq!(illuminant_temperature(Illuminant::D65), Some(6504.0)); + } + + #[test] + fn the_temperature_estimate_is_stable_under_iteration() { + // The fixed point is only worth three rounds if three rounds is where + // it has stopped moving. If this failed, the render would depend on an + // iteration count nobody chose deliberately. + let base = six_d(None); + for kelvin in [3000.0, 4500.0, 6500.0] { + let profile = six_d(Some(neutral_at(&base, kelvin))); + let once = profile.scene_temperature(); + // Re-seeding from the answer must not move it. + let again = { + let mut t = once; + for _ in 0..3 { + let m = profile.interpolated_xyz_to_cam_at(t); + let (x, y) = neutral_to_xy(&profile.neutral.unwrap(), &m).unwrap(); + t = cct_from_xy(x, y); + } + t + }; + assert!( + (once - again).abs() < 25.0, + "at {kelvin} K the estimate moved from {once} to {again}" + ); + } + } + + #[test] + fn mccamy_recovers_the_temperatures_the_profiles_are_calibrated_at() { + // The only range the approximation has to be right over: the two ends + // of a dual-illuminant profile, and the daylight between them. + for kelvin in [2856.0f32, 3200.0, 4000.0, 5003.0, 5503.0, 6504.0] { + let (x, y) = planckian_xy(kelvin); + let got = cct_from_xy(x, y); + assert!( + (got - kelvin).abs() / kelvin < 0.03, + "{kelvin} K read back as {got} K" + ); + } + } + + #[test] + fn an_all_zero_matrix_never_becomes_a_calibration() { + // rawler's placeholder for "this body has no matrix". Accepting it + // would render black, which is much harder to diagnose than + // uncalibrated. + assert!(!usable(&[[0.0; 3]; 3])); + assert!(!usable(&[[f32::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])); + assert!(usable(&SIX_D_D65)); + } + + #[test] + fn as_shot_neutral_is_the_reciprocal_of_the_coefficients() { + // Confusing the two inverts the estimate: a tungsten scene would be + // read as daylight and rendered through the wrong end of the profile. + let n = neutral_from_coefficients([2.0, 1.0, 4.0, f32::NAN]).expect("three usable"); + assert!((n[0] - 0.5).abs() < 1e-6); + assert!((n[1] - 1.0).abs() < 1e-6); + assert!((n[2] - 0.25).abs() < 1e-6); + } + + #[test] + fn absent_coefficients_yield_no_neutral_rather_than_a_neutral_one() { + // (1, 1, 1) is a claim about the scene — that it was lit by something + // this sensor happens to see as grey — and it is one no file made. + assert_eq!(neutral_from_coefficients([0.0, 0.0, 0.0, 0.0]), None); + assert_eq!(neutral_from_coefficients([f32::NAN, 1.0, 1.0, 1.0]), None); + } + + /// Sum of absolute differences between two matrices. + fn distance(a: &[[f32; 3]; 3], b: &[[f32; 3]; 3]) -> f32 { + a.iter() + .flatten() + .zip(b.iter().flatten()) + .map(|(x, y)| (x - y).abs()) + .sum() + } +} diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index 9b2e842..5b1e83e 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -32,6 +32,16 @@ use crate::{DemosaicedImage, GpuContext, GpuError}; /// reads them. const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS; +/// TRACES: FR-DEV-3e +/// The two crates must agree on how many points a base curve has. +/// +/// `dr-decode` reads them from the profile database and `dr-pipeline` declares +/// the uniform slots; this file is the only place the two meet, and it packs +/// them by index. A disagreement would not fail to compile — it would upload a +/// curve with a point missing or a stale float in it, which renders as a +/// plausible-looking wrong tone response. Cheaper to catch here, at build time. +const _: () = assert!(dr_decode::base_curve::POINTS == dr_pipeline::BASE_CURVE_POINTS); + /// Runs composed operation chains against demosaiced images. pub struct AdjustPass { ctx: GpuContext, @@ -332,6 +342,18 @@ impl AdjustPass { // runs. See `DemosaicedImage::is_non_linear`. let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 }; uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]); + // TRACES: FR-DEV-3e + // The camera profile's base curve, packed the way the generated block + // declares it: four x, four y, then the fifth point and the flag. The + // flag is what lets one compiled shader serve a profiled body and an + // unprofiled one, so the pipeline cache is not split in two by which + // camera took the frame. + let curve = source.base_curve(); + let on = if curve.is_identity() { 0.0 } else { 1.0 }; + let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET; + uniforms[b..b + 4].copy_from_slice(&curve.xs[0..4]); + uniforms[b + 4..b + 8].copy_from_slice(&curve.ys[0..4]); + uniforms[b + 8..b + 12].copy_from_slice(&[curve.xs[4], curve.ys[4], on, 0.0]); let params_buf = self .ctx @@ -512,7 +534,7 @@ fn numbered(src: &str) -> String { #[cfg(test)] mod tests { use super::*; - use dr_decode::{CfaPattern, CropRect, RawImage}; + use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; use dr_pipeline::ops::{colour_mixer, exposure, saturation}; use dr_pipeline::EditGraph; @@ -546,6 +568,7 @@ mod tests { // Identity, so the test reasons about the operations alone // rather than about a camera's colour response. color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), + base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, @@ -719,6 +742,7 @@ mod tests { white_level: 16383, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), + base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, @@ -1141,6 +1165,7 @@ mod tests { white_level: 16383, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), + base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, @@ -1240,6 +1265,7 @@ mod tests { white_level: 16383, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), + base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, diff --git a/core/dr-gpu/src/demosaic.rs b/core/dr-gpu/src/demosaic.rs index 3b79fb6..265424f 100644 --- a/core/dr-gpu/src/demosaic.rs +++ b/core/dr-gpu/src/demosaic.rs @@ -10,7 +10,7 @@ //! pass over this texture; it does not re-demosaic, which is what keeps the //! interaction budget (NFR-P9) reachable on a 24 MP file. -use dr_decode::{CfaPattern, RawImage}; +use dr_decode::{BaseCurve, CfaPattern, RawImage}; use wgpu::util::DeviceExt; use crate::{GpuContext, GpuError}; @@ -83,6 +83,16 @@ pub struct DemosaicedImage { color_matrix: [f32; 9], /// As-shot white balance, the neutral starting point for the WB control. as_shot_wb: [f32; 3], + /// TRACES: FR-DEV-3e + /// The camera profile's rendering curve, carried through for the adjust + /// pass exactly as `color_matrix` is. + /// + /// It rides on the image rather than on the edit graph because it is not + /// an edit: it belongs to the body that took the frame, the way the + /// masked-photosite crop and the EXIF orientation do, and a sidecar shared + /// between two bodies must never carry one body's rendering onto the + /// other's file (FR-NC-9). + base_curve: BaseCurve, /// Whether the texture holds gamma-encoded rather than linear values. non_linear: bool, } @@ -108,6 +118,15 @@ impl DemosaicedImage { self.color_matrix } + /// TRACES: FR-DEV-3e + /// The camera profile's base curve, as five `(x, y)` points. + /// + /// [`BaseCurve::IDENTITY`] where the body is unprofiled or the source was + /// never raw, in which case the adjust pass skips the stage entirely. + pub fn base_curve(&self) -> BaseCurve { + self.base_curve + } + /// As-shot white balance multipliers, green-normalised. /// /// The white balance control is expressed *relative* to these, so its @@ -220,6 +239,12 @@ impl DemosaicedImage { height, color_matrix: IDENTITY_3X3, as_shot_wb: [1.0, 1.0, 1.0], + // **The identity, and this is the whole reason the field is here + // rather than resolved further down.** A JPEG has already had its + // camera's base curve baked in by the camera; applying one again + // would render the rendering, crushing the shadows and flattening + // the highlights of an image that was already finished. + base_curve: BaseCurve::IDENTITY, non_linear: true, }) } @@ -495,6 +520,10 @@ impl Demosaicer { // no colour transform rather than not at all. color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3), as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]], + // Whatever the profile database had for this body (FR-DEV-3e), + // resolved at decode because that is the only place the make and + // model are known. + base_curve: raw.base_curve, // Sensor data is linear by construction — the demosaic shader // normalises against black and white levels and applies no // transfer function. @@ -808,6 +837,7 @@ mod tests { white_level: white, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: None, + base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, @@ -919,6 +949,7 @@ mod tests { white_level: white, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: None, + base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, @@ -1164,6 +1195,7 @@ mod tests { white_level: 16383, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: None, + base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, @@ -1244,6 +1276,7 @@ mod tests { 1.0, ], color_matrix: None, + base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, diff --git a/core/dr-pipeline/ops/README.md b/core/dr-pipeline/ops/README.md index 29344fa..303bc46 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -216,6 +216,44 @@ carries lens-profile coefficients that are not parameters. `distortion` and `aberration` are `Warp`s rather than operations: they rewrite coordinates before sampling rather than transforming a colour after it. +## What is not a node, and why + +Three things act on every pixel and are deliberately not in this directory: +the as-shot white balance, the camera matrix, and the **base curve** +(FR-DEV-3e). They are emitted by [`../src/operation.rs`](../src/operation.rs) +into the composed shader's fixed preamble, around the block of nodes. + +The test is not "does it transform a colour" — all three do. It is **whose +decision is it**. A node is something a photographer chose: it has parameters, +it moves off a neutral, it lands in the sidecar, it can be undone. These three +are properties of the *file*, at the same standing as the masked-photosite crop +(FR-RAW-3) and the stored orientation (FR-DEV-3h). Nobody chose the sensor's +green sensitivity or the body's rendering; they are what reading the file +correctly means. + +Making the base curve a node would have said the opposite in four places at +once. It would have appeared in the develop panel as a control, so an +unprofiled body would show a slider that does nothing. Its values would have +gone into the sidecar, and sidecars are shared between devices and bodies +(FR-NC-9) — one camera's rendering would follow an edit onto another camera's +file. Its neutral would have had to be "the identity", so a profiled body would +open reporting itself modified. And there is no seam through which a node could +learn which camera took the frame: the profile arrives on the decoded image, +travels through `DemosaicedImage` beside the matrix it belongs with, and is +written into the uniform block by the same three lines in `dr-gpu` — which is +exactly the path the matrix already took, because it is exactly the same kind +of thing. + +What it *does* share with the tone curve node is the spline. The composer asks +`ToneCurve` for its `curve_span`/`curve_eval` helpers rather than emitting a +second copy, so a profile author placing a control point and a photographer +dragging one mean the same thing by it. + +The order still reads correctly from this directory: the base curve runs after +every node in the chain and before the conversion out of camera space. That is +the same reasoning `exposure` records under `placement:` — corrections to +capture are only meaningful on linear values, so the rendering goes last. + ## Errors The build script reports failures by naming the key you got wrong, and exits diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index 4619418..b5bdde6 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -52,7 +52,7 @@ pub use history::{Edit, History}; pub use lens::{compose_warps, ComposedWarp, Warp}; pub use operation::{ compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform, - RESERVED_UNIFORM_FIELDS, + BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS, }; pub use preset::{Preset, Scope}; pub use sidecar::{Sidecar, Version}; diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index 3a5df12..e69f23c 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -145,7 +145,35 @@ pub struct ComposedShader { /// /// WGSL requires a uniform struct to be non-empty and 16-byte aligned; these /// are needed by every generated shader in any case. -const BASE_UNIFORM_FIELDS: usize = 16; +/// +/// Twelve of the twenty-eight are the camera profile's base curve +/// ([`BASE_CURVE_UNIFORM_FIELDS`]); the rest are the matrix, the as-shot +/// balance and framing's own block. +const BASE_UNIFORM_FIELDS: usize = 16 + BASE_CURVE_UNIFORM_FIELDS; + +/// TRACES: FR-DEV-3e +/// Slots the base curve occupies: five `(x, y)` points and an active flag. +/// +/// Twelve rather than eleven so the block stays a whole number of `vec4`s, +/// which is what std140 requires of a uniform struct's members. The spare +/// float is left zero rather than repurposed — a uniform slot that means one +/// thing today and two things next year is how a shader comes to read a +/// highlight rolloff out of a crop rectangle. +const BASE_CURVE_UNIFORM_FIELDS: usize = 12; + +/// TRACES: FR-DEV-3e +/// Where the base curve's slots begin in the generated uniform block. +/// +/// Exported for the same reason [`RESERVED_UNIFORM_FIELDS`] is: `dr-gpu` +/// writes these by index, and an offset computed independently at both ends is +/// an offset that will eventually disagree with itself. +pub const BASE_CURVE_UNIFORM_OFFSET: usize = 16; + +/// How many control points a base curve carries. +/// +/// The same five the tone curve widget has, deliberately — see the helper +/// selection in [`compose_full`]. +pub const BASE_CURVE_POINTS: usize = 5; /// Where an operation's own uniforms begin in the generated block. /// @@ -235,10 +263,44 @@ pub fn compose_full( \x20 // `.w` is not padding: it flags a non-linear source (1.0 for a\n\ \x20 // gamma-encoded JPEG, 0.0 for demosaiced sensor data), which the\n\ \x20 // prologue reads to decide whether to linearise.\n\ - \x20 as_shot_wb: vec4,\n", + \x20 as_shot_wb: vec4,\n\ + \x20 // The camera profile's base curve (FR-DEV-3e): five points on a\n\ + \x20 // monotone spline, packed as x0..x3, y0..y3, then (x4, y4, on).\n\ + \x20 // `.z` of the last is the flag, not padding — it is 0 for a\n\ + \x20 // body with no profile and for an already-rendered source.\n\ + \x20 base_curve_x: vec4,\n\ + \x20 base_curve_y: vec4,\n\ + \x20 base_curve_last: vec4,\n", ); uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0); + // TRACES: FR-DEV-3e + // The spline the base curve is evaluated on is the *tone curve's* spline, + // reached through the trait rather than reimplemented here. + // + // Two reasons, and the second is the one that matters. The obvious one is + // that a shader carrying two `curve_eval`s would not compile, and the + // composer's helper de-duplication is what makes both stages able to ask + // for it. The real one is that a profile author placing a control point + // and a photographer dragging one must mean the same thing by it — down to + // the Fritsch-Carlson tangent limiting, which is what decides how a + // shoulder actually rolls off. Two implementations that agreed today would + // be two that could disagree later, and the disagreement would show up as + // a body whose profile renders subtly differently from the curve someone + // drew to match it. + // + // Emitted unconditionally, unlike an operation's helpers. The base curve + // is active for every RAW frame — an unprofiled body still gets the + // database's default rendering — so making the shader's shape depend on it + // would split the pipeline cache in two for no benefit. The uniform flag + // above turns it off for the cases that are genuinely already rendered, + // and a branch on a uniform is coherent across the whole dispatch. + for h in crate::ops::ToneCurve::new().helpers() { + if matches!(h.name, "curve_span" | "curve_eval") { + helpers.push(*h); + } + } + // Framing's block follows the base one at a fixed offset, for the same // reason: the prologue is emitted whether or not any operation is active, // so these slots cannot be positioned by the op loop below. @@ -419,6 +481,58 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ c = mix(c, neutral, clipped); }} {body} + // ==== camera profile: the base curve (FR-DEV-3e) ==== + // + // Marked with `====` and not the `----` an operation block carries: this + // is not one, and the difference is what several tests count on to tell + // an edit apart from the reading of a file. + // + // The stage between demosaic and the working space that turns a correct + // exposure into a photograph. Sensor data is scene-referred and nearly + // linear; nothing anybody looks at is. Rendering it straight out is the + // dcraw default, and it is flat, dark through the midtones and clips its + // highlights instead of rolling them off. + // + // **In camera RGB, and after the adjustments**, which is a deliberate pair + // of choices: + // + // - Before the matrix, because that is where a base curve is defined and + // where every other converter applies one. The curve was tuned against + // this body's own primaries; moving it after the conversion would apply + // a Canon rendering to sRGB values and change what it does. + // - After exposure and the tonal operations, because those are corrections + // to *capture* and are only meaningful on linear values. A stop is a + // doubling; run exposure after a curve and it stops being one. + // + // Per channel rather than on luminance. It desaturates the extremes + // slightly, and that is the point — it is what makes a blown sky roll + // toward white rather than toward a saturated corner of the gamut, and it + // is what the camera's own JPEG does. + // + // The branch is on a uniform, so the whole dispatch takes the same path. + // It is off for a JPEG and any other already-rendered source, which must + // not be rendered twice, and for a body the profile database declines to + // offer any curve for at all. + if (u.base_curve_last.z > 0.5) {{ + c = vec3( + curve_eval( + u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, + u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, + u.base_curve_last.x, u.base_curve_last.y, c.r, + ), + curve_eval( + u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, + u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, + u.base_curve_last.x, u.base_curve_last.y, c.g, + ), + curve_eval( + u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y, + u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w, + u.base_curve_last.x, u.base_curve_last.y, c.b, + ), + ); + }} + // Camera space -> linear sRGB. Applied after the adjustments so white // balance and exposure act on sensor-native values, which is where they // are physically meaningful. @@ -960,6 +1074,84 @@ mod tests { assert!(op < matrix, "the camera matrix must come after operations"); } + #[test] + fn the_base_curve_runs_after_the_operations_and_before_the_camera_matrix() { + // TRACES: FR-DEV-3e + // Both halves matter and for different reasons. + // + // After the operations: exposure and the tonal controls are + // corrections to capture, and they are only meaningful on linear + // values. A stop is a doubling; run exposure after a curve and it is + // not one any more, and every slider in the panel starts lying about + // what it does. + // + // 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 source = compose(&ops).source; + let op = source.find("---- op_a ----").expect("op present"); + let curve = source + .find("if (u.base_curve_last.z > 0.5)") + .expect("base curve applied"); + let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied"); + assert!(op < curve, "the base curve must come after the operations"); + assert!(curve < matrix, "and before the camera matrix"); + } + + #[test] + fn the_base_curve_reaches_a_shader_with_no_operations_at_all() { + // TRACES: FR-DEV-3e + // The same property as as-shot white balance, and for the same reason: + // it is part of interpreting the file, not part of the edit. An + // unedited RAW must open looking like a photograph rather than like a + // scan of one. + let shader = compose(&[]); + assert!(shader.source.contains("u.base_curve_x")); + assert!( + shader.source.contains("fn curve_eval("), + "the spline it is evaluated on must be emitted too" + ); + } + + #[test] + fn the_base_curve_and_the_tone_curve_share_one_spline() { + // TRACES: FR-DEV-3e + // Two `curve_eval`s in one shader would not compile — but the reason + // the helper is *shared* rather than merely renamed is that a profile + // author placing a control point and a photographer dragging one must + // mean the same thing by it, down to the tangent limiting that decides + // how a shoulder rolls off. + let mut curve = crate::ops::ToneCurve::new(); + curve.set_param(crate::ops::curve::P2_Y, 0.7); + assert!(curve.is_active(), "the fixture must actually reach the shader"); + + let source = compose(&[Box::new(curve)]).source; + assert_eq!( + source.matches("fn curve_eval(").count(), + 1, + "the spline must be declared exactly once" + ); + assert_eq!(source.matches("fn curve_span(").count(), 1); + } + + #[test] + fn the_base_curve_owns_the_slots_dr_gpu_writes() { + // TRACES: FR-DEV-3e + // `dr-gpu` fills these by index. The offset is exported rather than + // recomputed there, and this asserts the exported number still points + // at the block the shader declares — the failure otherwise is a + // highlight rolloff read out of a crop rectangle, which renders as + // nonsense rather than as an error. + assert_eq!( + BASE_CURVE_UNIFORM_OFFSET + BASE_CURVE_UNIFORM_FIELDS, + BASE_UNIFORM_FIELDS, + "the base curve must be the last thing in the base block" + ); + assert_eq!(BASE_CURVE_POINTS * 2 + 1, BASE_CURVE_UNIFORM_FIELDS - 1); + assert!(compose(&[]).uniforms.len() >= BASE_UNIFORM_FIELDS); + } + /// Compose with neutral framing into a chosen output space. fn compose_to(ops: &[Box], output: ColourSpace) -> ComposedShader { compose_with_framing(ops, &Framing::new(), output) diff --git a/docs/traceability.md b/docs/traceability.md index b8297fd..4467697 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 143 | -| TRACES tags found | 330 | +| Source files scanned | 151 | +| TRACES tags found | 370 | | Requirements defined | 151 | | Requirements covered | 83 | | **Coverage** | **55.0%** (83/151) | @@ -36,70 +36,70 @@ _None._ | 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:196`](../core/dr-types/src/lib.rs#L196), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298), [`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-11 | [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`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:122`](../core/dr-sync/src/lib.rs#L122), [`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:1087`](../ui/dr-ui/src/collections_ui.rs#L1087), [`ui/dr-ui/src/collections_ui.rs:1762`](../ui/dr-ui/src/collections_ui.rs#L1762), [`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:2576`](../ui/dr-ui/src/library.rs#L2576), [`ui/dr-ui/src/library.rs:2608`](../ui/dr-ui/src/library.rs#L2608), [`ui/dr-ui/src/library_ui.rs:112`](../ui/dr-ui/src/library_ui.rs#L112), [`ui/dr-ui/src/library_ui.rs:616`](../ui/dr-ui/src/library_ui.rs#L616), [`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-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`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:122`](../core/dr-sync/src/lib.rs#L122), [`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:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`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:2576`](../ui/dr-ui/src/library.rs#L2576), [`ui/dr-ui/src/library.rs:2608`](../ui/dr-ui/src/library.rs#L2608), [`ui/dr-ui/src/library_ui.rs:123`](../ui/dr-ui/src/library_ui.rs#L123), [`ui/dr-ui/src/library_ui.rs:637`](../ui/dr-ui/src/library_ui.rs#L637), [`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:49`](../core/dr-types/src/lib.rs#L49) | | 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) | | 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), [`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/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:240`](../core/dr-decode/src/lib.rs#L240), [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126) | +| FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:264`](../core/dr-decode/src/lib.rs#L264), [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126) | | FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.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-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179) | -| 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:1460`](../ui/dr-ui/src/collections_ui.rs#L1460), [`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/ui/app.slint:492`](../ui/dr-ui/ui/app.slint#L492), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:492`](../ui/dr-ui/ui/library.slint#L492), [`ui/dr-ui/ui/library.slint:511`](../ui/dr-ui/ui/library.slint#L511) | -| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/develop.rs:1429`](../ui/dr-ui/src/develop.rs#L1429), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1051`](../ui/dr-ui/src/lib.rs#L1051), [`ui/dr-ui/src/lib.rs:1384`](../ui/dr-ui/src/lib.rs#L1384), [`ui/dr-ui/src/lib.rs:1489`](../ui/dr-ui/src/lib.rs#L1489), [`ui/dr-ui/src/lib.rs:460`](../ui/dr-ui/src/lib.rs#L460), [`ui/dr-ui/src/lib.rs:734`](../ui/dr-ui/src/lib.rs#L734), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:3496`](../ui/dr-ui/src/library_ui.rs#L3496), [`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:230`](../core/dr-catalog/src/schema.rs#L230), [`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:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1400`](../ui/dr-ui/src/library_ui.rs#L1400), [`ui/dr-ui/src/library_ui.rs:1426`](../ui/dr-ui/src/library_ui.rs#L1426), [`ui/dr-ui/src/library_ui.rs:1442`](../ui/dr-ui/src/library_ui.rs#L1442), [`ui/dr-ui/src/library_ui.rs:1536`](../ui/dr-ui/src/library_ui.rs#L1536), [`ui/dr-ui/src/library_ui.rs:154`](../ui/dr-ui/src/library_ui.rs#L154), [`ui/dr-ui/src/library_ui.rs:1806`](../ui/dr-ui/src/library_ui.rs#L1806), [`ui/dr-ui/src/library_ui.rs:187`](../ui/dr-ui/src/library_ui.rs#L187), [`ui/dr-ui/src/library_ui.rs:2071`](../ui/dr-ui/src/library_ui.rs#L2071), [`ui/dr-ui/src/library_ui.rs:2405`](../ui/dr-ui/src/library_ui.rs#L2405), [`ui/dr-ui/src/library_ui.rs:2477`](../ui/dr-ui/src/library_ui.rs#L2477), [`ui/dr-ui/src/library_ui.rs:2642`](../ui/dr-ui/src/library_ui.rs#L2642), [`ui/dr-ui/src/library_ui.rs:331`](../ui/dr-ui/src/library_ui.rs#L331), [`ui/dr-ui/src/library_ui.rs:3692`](../ui/dr-ui/src/library_ui.rs#L3692), [`ui/dr-ui/src/library_ui.rs:3807`](../ui/dr-ui/src/library_ui.rs#L3807), [`ui/dr-ui/src/library_ui.rs:389`](../ui/dr-ui/src/library_ui.rs#L389), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| 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:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`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/ui/app.slint:570`](../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:687`](../ui/dr-ui/ui/library.slint#L687) | +| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/develop.rs:1770`](../ui/dr-ui/src/develop.rs#L1770), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1065`](../ui/dr-ui/src/lib.rs#L1065), [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/lib.rs:1510`](../ui/dr-ui/src/lib.rs#L1510), [`ui/dr-ui/src/lib.rs:460`](../ui/dr-ui/src/lib.rs#L460), [`ui/dr-ui/src/lib.rs:748`](../ui/dr-ui/src/lib.rs#L748), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4071`](../ui/dr-ui/src/library_ui.rs#L4071), [`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:230`](../core/dr-catalog/src/schema.rs#L230), [`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:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/develop.rs:1478`](../ui/dr-ui/src/develop.rs#L1478), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1443`](../ui/dr-ui/src/library_ui.rs#L1443), [`ui/dr-ui/src/library_ui.rs:1469`](../ui/dr-ui/src/library_ui.rs#L1469), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1579`](../ui/dr-ui/src/library_ui.rs#L1579), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2377`](../ui/dr-ui/src/library_ui.rs#L2377), [`ui/dr-ui/src/library_ui.rs:2559`](../ui/dr-ui/src/library_ui.rs#L2559), [`ui/dr-ui/src/library_ui.rs:2840`](../ui/dr-ui/src/library_ui.rs#L2840), [`ui/dr-ui/src/library_ui.rs:2912`](../ui/dr-ui/src/library_ui.rs#L2912), [`ui/dr-ui/src/library_ui.rs:3077`](../ui/dr-ui/src/library_ui.rs#L3077), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:4267`](../ui/dr-ui/src/library_ui.rs#L4267), [`ui/dr-ui/src/library_ui.rs:4382`](../ui/dr-ui/src/library_ui.rs#L4382), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`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:134`](../core/dr-decode/src/preview.rs#L134) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1388`](../core/dr-gpu/src/adjust.rs#L1388), [`core/dr-gpu/src/adjust.rs:297`](../core/dr-gpu/src/adjust.rs#L297), [`core/dr-pipeline/src/framing.rs:185`](../core/dr-pipeline/src/framing.rs#L185), [`core/dr-pipeline/src/operation.rs:196`](../core/dr-pipeline/src/operation.rs#L196), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`ui/dr-ui/src/develop.rs:49`](../ui/dr-ui/src/develop.rs#L49), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/develop.rs:732`](../ui/dr-ui/src/develop.rs#L732), [`ui/dr-ui/src/lib.rs:1732`](../ui/dr-ui/src/lib.rs#L1732), [`ui/dr-ui/src/lib.rs:286`](../ui/dr-ui/src/lib.rs#L286) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`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:256`](../core/dr-pipeline/src/framing.rs#L256), [`core/dr-pipeline/src/graph.rs:151`](../core/dr-pipeline/src/graph.rs#L151), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:42`](../core/dr-pipeline/src/graph.rs#L42), [`core/dr-pipeline/src/mask.rs:237`](../core/dr-pipeline/src/mask.rs#L237), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107) | -| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256), [`core/dr-pipeline/src/graph.rs:42`](../core/dr-pipeline/src/graph.rs#L42), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:151`](../core/dr-pipeline/src/graph.rs#L151), [`core/dr-pipeline/src/mask.rs:237`](../core/dr-pipeline/src/mask.rs#L237), [`ui/dr-ui/src/develop.rs:2121`](../ui/dr-ui/src/develop.rs#L2121) | -| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:185`](../core/dr-pipeline/src/framing.rs#L185) | -| FR-DEV-3e | [`core/dr-decode/src/lib.rs:501`](../core/dr-decode/src/lib.rs#L501), [`core/dr-decode/src/lib.rs:643`](../core/dr-decode/src/lib.rs#L643), [`core/dr-decode/src/lib.rs:683`](../core/dr-decode/src/lib.rs#L683) | -| FR-DEV-3h | [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:199`](../core/dr-pipeline/src/framing.rs#L199), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1414`](../core/dr-gpu/src/adjust.rs#L1414), [`core/dr-gpu/src/adjust.rs:307`](../core/dr-gpu/src/adjust.rs#L307), [`core/dr-pipeline/src/framing.rs:186`](../core/dr-pipeline/src/framing.rs#L186), [`core/dr-pipeline/src/operation.rs:224`](../core/dr-pipeline/src/operation.rs#L224), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`ui/dr-ui/src/develop.rs:1016`](../ui/dr-ui/src/develop.rs#L1016), [`ui/dr-ui/src/develop.rs:62`](../ui/dr-ui/src/develop.rs#L62), [`ui/dr-ui/src/develop.rs:867`](../ui/dr-ui/src/develop.rs#L867), [`ui/dr-ui/src/lib.rs:1101`](../ui/dr-ui/src/lib.rs#L1101), [`ui/dr-ui/src/lib.rs:1753`](../ui/dr-ui/src/lib.rs#L1753), [`ui/dr-ui/src/lib.rs:286`](../ui/dr-ui/src/lib.rs#L286) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`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:257`](../core/dr-pipeline/src/framing.rs#L257), [`core/dr-pipeline/src/graph.rs:163`](../core/dr-pipeline/src/graph.rs#L163), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:490`](../core/dr-pipeline/src/mask.rs#L490), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107), [`ui/dr-ui/src/lib.rs:522`](../ui/dr-ui/src/lib.rs#L522) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:163`](../core/dr-pipeline/src/graph.rs#L163), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:490`](../core/dr-pipeline/src/mask.rs#L490), [`ui/dr-ui/src/develop.rs:2579`](../ui/dr-ui/src/develop.rs#L2579) | +| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:186`](../core/dr-pipeline/src/framing.rs#L186) | +| 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:100`](../core/dr-decode/src/lib.rs#L100), [`core/dr-decode/src/lib.rs:632`](../core/dr-decode/src/lib.rs#L632), [`core/dr-decode/src/lib.rs:672`](../core/dr-decode/src/lib.rs#L672), [`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:345`](../core/dr-gpu/src/adjust.rs#L345), [`core/dr-gpu/src/adjust.rs:35`](../core/dr-gpu/src/adjust.rs#L35), [`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-pipeline/src/operation.rs:1079`](../core/dr-pipeline/src/operation.rs#L1079), [`core/dr-pipeline/src/operation.rs:1104`](../core/dr-pipeline/src/operation.rs#L1104), [`core/dr-pipeline/src/operation.rs:1119`](../core/dr-pipeline/src/operation.rs#L1119), [`core/dr-pipeline/src/operation.rs:1140`](../core/dr-pipeline/src/operation.rs#L1140), [`core/dr-pipeline/src/operation.rs:154`](../core/dr-pipeline/src/operation.rs#L154), [`core/dr-pipeline/src/operation.rs:164`](../core/dr-pipeline/src/operation.rs#L164), [`core/dr-pipeline/src/operation.rs:277`](../core/dr-pipeline/src/operation.rs#L277) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:200`](../core/dr-pipeline/src/framing.rs#L200), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) | | FR-DEV-4 | [`core/dr-gpu/src/lib.rs:211`](../core/dr-gpu/src/lib.rs#L211) | -| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:1445`](../ui/dr-ui/src/develop.rs#L1445), [`ui/dr-ui/src/develop.rs:1455`](../ui/dr-ui/src/develop.rs#L1455), [`ui/dr-ui/src/develop.rs:31`](../ui/dr-ui/src/develop.rs#L31), [`ui/dr-ui/src/lib.rs:1079`](../ui/dr-ui/src/lib.rs#L1079) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:1406`](../ui/dr-ui/src/develop.rs#L1406), [`ui/dr-ui/src/develop.rs:1416`](../ui/dr-ui/src/develop.rs#L1416), [`ui/dr-ui/src/lib.rs:1051`](../ui/dr-ui/src/lib.rs#L1051), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:1902`](../ui/dr-ui/src/library_ui.rs#L1902), [`ui/dr-ui/src/library_ui.rs:357`](../ui/dr-ui/src/library_ui.rs#L357), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:464`](../ui/dr-ui/ui/library.slint#L464), [`ui/dr-ui/ui/library.slint:521`](../ui/dr-ui/ui/library.slint#L521), [`ui/dr-ui/ui/library.slint:856`](../ui/dr-ui/ui/library.slint#L856), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1311`](../core/dr-gpu/src/adjust.rs#L1311), [`core/dr-gpu/src/adjust.rs:1388`](../core/dr-gpu/src/adjust.rs#L1388), [`core/dr-gpu/src/adjust.rs:1473`](../core/dr-gpu/src/adjust.rs#L1473), [`core/dr-gpu/src/adjust.rs:42`](../core/dr-gpu/src/adjust.rs#L42), [`core/dr-gpu/src/lib.rs:48`](../core/dr-gpu/src/lib.rs#L48), [`core/dr-gpu/src/lib.rs:88`](../core/dr-gpu/src/lib.rs#L88), [`ui/dr-ui/src/develop.rs:1593`](../ui/dr-ui/src/develop.rs#L1593), [`ui/dr-ui/src/develop.rs:1667`](../ui/dr-ui/src/develop.rs#L1667), [`ui/dr-ui/src/develop.rs:1701`](../ui/dr-ui/src/develop.rs#L1701), [`ui/dr-ui/src/develop.rs:992`](../ui/dr-ui/src/develop.rs#L992), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56), [`ui/dr-ui/src/lib.rs:633`](../ui/dr-ui/src/lib.rs#L633) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:169`](../core/dr-pipeline/src/operation.rs#L169), [`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:1043`](../ui/dr-ui/src/develop.rs#L1043), [`ui/dr-ui/src/develop.rs:2624`](../ui/dr-ui/src/develop.rs#L2624), [`ui/dr-ui/src/develop.rs:2656`](../ui/dr-ui/src/develop.rs#L2656), [`ui/dr-ui/src/develop.rs:42`](../ui/dr-ui/src/develop.rs#L42), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1131`](../ui/dr-ui/src/lib.rs#L1131), [`ui/dr-ui/src/lib.rs:281`](../ui/dr-ui/src/lib.rs#L281), [`ui/dr-ui/ui/app.slint:242`](../ui/dr-ui/ui/app.slint#L242), [`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/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:1786`](../ui/dr-ui/src/develop.rs#L1786), [`ui/dr-ui/src/develop.rs:1796`](../ui/dr-ui/src/develop.rs#L1796), [`ui/dr-ui/src/develop.rs:44`](../ui/dr-ui/src/develop.rs#L44), [`ui/dr-ui/src/lib.rs:1093`](../ui/dr-ui/src/lib.rs#L1093) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:1747`](../ui/dr-ui/src/develop.rs#L1747), [`ui/dr-ui/src/develop.rs:1757`](../ui/dr-ui/src/develop.rs#L1757), [`ui/dr-ui/src/lib.rs:1065`](../ui/dr-ui/src/lib.rs#L1065), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2208`](../ui/dr-ui/src/library_ui.rs#L2208), [`ui/dr-ui/src/library_ui.rs:2559`](../ui/dr-ui/src/library_ui.rs#L2559), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:1037`](../ui/dr-ui/ui/library.slint#L1037), [`ui/dr-ui/ui/library.slint:640`](../ui/dr-ui/ui/library.slint#L640), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1337`](../core/dr-gpu/src/adjust.rs#L1337), [`core/dr-gpu/src/adjust.rs:1414`](../core/dr-gpu/src/adjust.rs#L1414), [`core/dr-gpu/src/adjust.rs:1499`](../core/dr-gpu/src/adjust.rs#L1499), [`core/dr-gpu/src/adjust.rs:52`](../core/dr-gpu/src/adjust.rs#L52), [`core/dr-gpu/src/lib.rs:48`](../core/dr-gpu/src/lib.rs#L48), [`core/dr-gpu/src/lib.rs:88`](../core/dr-gpu/src/lib.rs#L88), [`ui/dr-ui/src/develop.rs:1314`](../ui/dr-ui/src/develop.rs#L1314), [`ui/dr-ui/src/develop.rs:1934`](../ui/dr-ui/src/develop.rs#L1934), [`ui/dr-ui/src/develop.rs:2124`](../ui/dr-ui/src/develop.rs#L2124), [`ui/dr-ui/src/develop.rs:2158`](../ui/dr-ui/src/develop.rs#L2158), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56), [`ui/dr-ui/src/lib.rs:647`](../ui/dr-ui/src/lib.rs#L647) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:197`](../core/dr-pipeline/src/operation.rs#L197), [`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:1359`](../ui/dr-ui/src/develop.rs#L1359), [`ui/dr-ui/src/develop.rs:3084`](../ui/dr-ui/src/develop.rs#L3084), [`ui/dr-ui/src/develop.rs:3116`](../ui/dr-ui/src/develop.rs#L3116), [`ui/dr-ui/src/develop.rs:55`](../ui/dr-ui/src/develop.rs#L55), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1152`](../ui/dr-ui/src/lib.rs#L1152), [`ui/dr-ui/src/lib.rs:281`](../ui/dr-ui/src/lib.rs#L281), [`ui/dr-ui/ui/app.slint:303`](../ui/dr-ui/ui/app.slint#L303), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:1621`](../core/dr-gpu/src/adjust.rs#L1621), [`core/dr-pipeline/src/graph.rs:322`](../core/dr-pipeline/src/graph.rs#L322), [`core/dr-pipeline/src/operation.rs:169`](../core/dr-pipeline/src/operation.rs#L169), [`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:454`](../core/dr-types/src/settings.rs#L454), [`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:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:1647`](../core/dr-gpu/src/adjust.rs#L1647), [`core/dr-pipeline/src/graph.rs:336`](../core/dr-pipeline/src/graph.rs#L336), [`core/dr-pipeline/src/operation.rs:197`](../core/dr-pipeline/src/operation.rs#L197), [`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:454`](../core/dr-types/src/settings.rs#L454), [`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:324`](../ui/dr-ui/src/lib.rs#L324), [`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:561`](../ui/dr-ui/src/settings_ui.rs#L561) | -| 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:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1655`](../ui/dr-ui/src/lib.rs#L1655), [`ui/dr-ui/src/lib.rs:172`](../ui/dr-ui/src/lib.rs#L172), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/lib.rs:359`](../ui/dr-ui/src/lib.rs#L359), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library_ui.rs:2494`](../ui/dr-ui/src/library_ui.rs#L2494), [`ui/dr-ui/src/library_ui.rs:4245`](../ui/dr-ui/src/library_ui.rs#L4245), [`ui/dr-ui/src/library_ui.rs:4257`](../ui/dr-ui/src/library_ui.rs#L4257), [`ui/dr-ui/src/library_ui.rs:4269`](../ui/dr-ui/src/library_ui.rs#L4269), [`ui/dr-ui/src/library_ui.rs:448`](../ui/dr-ui/src/library_ui.rs#L448), [`ui/dr-ui/src/library_ui.rs:519`](../ui/dr-ui/src/library_ui.rs#L519), [`ui/dr-ui/src/library_ui.rs:576`](../ui/dr-ui/src/library_ui.rs#L576), [`ui/dr-ui/ui/app.slint:1089`](../ui/dr-ui/ui/app.slint#L1089), [`ui/dr-ui/ui/app.slint:778`](../ui/dr-ui/ui/app.slint#L778), [`ui/dr-ui/ui/library.slint:468`](../ui/dr-ui/ui/library.slint#L468), [`ui/dr-ui/ui/library.slint:536`](../ui/dr-ui/ui/library.slint#L536), [`ui/dr-ui/ui/library.slint:864`](../ui/dr-ui/ui/library.slint#L864) | +| 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:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1676`](../ui/dr-ui/src/lib.rs#L1676), [`ui/dr-ui/src/lib.rs:172`](../ui/dr-ui/src/lib.rs#L172), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/lib.rs:359`](../ui/dr-ui/src/lib.rs#L359), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library_ui.rs:2929`](../ui/dr-ui/src/library_ui.rs#L2929), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:5008`](../ui/dr-ui/src/library_ui.rs#L5008), [`ui/dr-ui/src/library_ui.rs:5020`](../ui/dr-ui/src/library_ui.rs#L5020), [`ui/dr-ui/src/library_ui.rs:5032`](../ui/dr-ui/src/library_ui.rs#L5032), [`ui/dr-ui/src/library_ui.rs:540`](../ui/dr-ui/src/library_ui.rs#L540), [`ui/dr-ui/src/library_ui.rs:597`](../ui/dr-ui/src/library_ui.rs#L597), [`ui/dr-ui/ui/app.slint:1180`](../ui/dr-ui/ui/app.slint#L1180), [`ui/dr-ui/ui/app.slint:869`](../ui/dr-ui/ui/app.slint#L869), [`ui/dr-ui/ui/library.slint:1045`](../ui/dr-ui/ui/library.slint#L1045), [`ui/dr-ui/ui/library.slint:644`](../ui/dr-ui/ui/library.slint#L644), [`ui/dr-ui/ui/library.slint:712`](../ui/dr-ui/ui/library.slint#L712) | | FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`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:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:415`](../core/dr-gpu/src/adjust.rs#L415), [`ui/dr-ui/src/develop.rs:1121`](../ui/dr-ui/src/develop.rs#L1121), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:437`](../core/dr-gpu/src/adjust.rs#L437), [`ui/dr-ui/src/develop.rs:1437`](../ui/dr-ui/src/develop.rs#L1437), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324) | | 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:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1442`](../ui/dr-ui/src/library_ui.rs#L1442), [`ui/dr-ui/src/library_ui.rs:2494`](../ui/dr-ui/src/library_ui.rs#L2494), [`ui/dr-ui/src/library_ui.rs:389`](../ui/dr-ui/src/library_ui.rs#L389), [`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:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:2929`](../ui/dr-ui/src/library_ui.rs#L2929), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`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:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.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:161`](../core/dr-decode/src/preview.rs#L161), [`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_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | | 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:155`](../core/dr-sync/src/lib.rs#L155), [`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) | | 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:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`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:2847`](../ui/dr-ui/src/collections_ui.rs#L2847), [`ui/dr-ui/src/collections_ui.rs:490`](../ui/dr-ui/src/collections_ui.rs#L490), [`ui/dr-ui/src/collections_ui.rs:552`](../ui/dr-ui/src/collections_ui.rs#L552), [`ui/dr-ui/src/lib.rs:1415`](../ui/dr-ui/src/lib.rs#L1415), [`ui/dr-ui/src/lib.rs:2095`](../ui/dr-ui/src/lib.rs#L2095), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1075`](../ui/dr-ui/src/library_ui.rs#L1075), [`ui/dr-ui/src/library_ui.rs:1130`](../ui/dr-ui/src/library_ui.rs#L1130), [`ui/dr-ui/src/library_ui.rs:1248`](../ui/dr-ui/src/library_ui.rs#L1248), [`ui/dr-ui/src/library_ui.rs:1367`](../ui/dr-ui/src/library_ui.rs#L1367), [`ui/dr-ui/src/library_ui.rs:1599`](../ui/dr-ui/src/library_ui.rs#L1599), [`ui/dr-ui/src/library_ui.rs:195`](../ui/dr-ui/src/library_ui.rs#L195), [`ui/dr-ui/src/library_ui.rs:199`](../ui/dr-ui/src/library_ui.rs#L199), [`ui/dr-ui/src/library_ui.rs:207`](../ui/dr-ui/src/library_ui.rs#L207), [`ui/dr-ui/src/library_ui.rs:219`](../ui/dr-ui/src/library_ui.rs#L219), [`ui/dr-ui/src/library_ui.rs:228`](../ui/dr-ui/src/library_ui.rs#L228), [`ui/dr-ui/src/library_ui.rs:290`](../ui/dr-ui/src/library_ui.rs#L290), [`ui/dr-ui/src/library_ui.rs:300`](../ui/dr-ui/src/library_ui.rs#L300), [`ui/dr-ui/src/library_ui.rs:342`](../ui/dr-ui/src/library_ui.rs#L342), [`ui/dr-ui/src/library_ui.rs:3709`](../ui/dr-ui/src/library_ui.rs#L3709), [`ui/dr-ui/src/library_ui.rs:3727`](../ui/dr-ui/src/library_ui.rs#L3727), [`ui/dr-ui/src/library_ui.rs:3739`](../ui/dr-ui/src/library_ui.rs#L3739), [`ui/dr-ui/src/library_ui.rs:405`](../ui/dr-ui/src/library_ui.rs#L405), [`ui/dr-ui/src/library_ui.rs:436`](../ui/dr-ui/src/library_ui.rs#L436), [`ui/dr-ui/src/library_ui.rs:448`](../ui/dr-ui/src/library_ui.rs#L448), [`ui/dr-ui/src/library_ui.rs:901`](../ui/dr-ui/src/library_ui.rs#L901), [`ui/dr-ui/src/library_ui.rs:974`](../ui/dr-ui/src/library_ui.rs#L974), [`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:1880`](../ui/dr-ui/ui/app.slint#L1880), [`ui/dr-ui/ui/app.slint:486`](../ui/dr-ui/ui/app.slint#L486), [`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:582`](../ui/dr-ui/ui/library.slint#L582) | -| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1130`](../ui/dr-ui/src/library_ui.rs#L1130) | -| 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:115`](../core/dr-types/src/lib.rs#L115), [`core/dr-types/src/lib.rs:197`](../core/dr-types/src/lib.rs#L197), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2847`](../ui/dr-ui/src/collections_ui.rs#L2847), [`ui/dr-ui/src/collections_ui.rs:490`](../ui/dr-ui/src/collections_ui.rs#L490), [`ui/dr-ui/src/collections_ui.rs:552`](../ui/dr-ui/src/collections_ui.rs#L552), [`ui/dr-ui/src/library_ui.rs:901`](../ui/dr-ui/src/library_ui.rs#L901), [`ui/dr-ui/src/library_ui.rs:974`](../ui/dr-ui/src/library_ui.rs#L974), [`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-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`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:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1436`](../ui/dr-ui/src/lib.rs#L1436), [`ui/dr-ui/src/lib.rs:2131`](../ui/dr-ui/src/lib.rs#L2131), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:1118`](../ui/dr-ui/src/library_ui.rs#L1118), [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173), [`ui/dr-ui/src/library_ui.rs:1291`](../ui/dr-ui/src/library_ui.rs#L1291), [`ui/dr-ui/src/library_ui.rs:1410`](../ui/dr-ui/src/library_ui.rs#L1410), [`ui/dr-ui/src/library_ui.rs:1888`](../ui/dr-ui/src/library_ui.rs#L1888), [`ui/dr-ui/src/library_ui.rs:206`](../ui/dr-ui/src/library_ui.rs#L206), [`ui/dr-ui/src/library_ui.rs:217`](../ui/dr-ui/src/library_ui.rs#L217), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:237`](../ui/dr-ui/src/library_ui.rs#L237), [`ui/dr-ui/src/library_ui.rs:246`](../ui/dr-ui/src/library_ui.rs#L246), [`ui/dr-ui/src/library_ui.rs:311`](../ui/dr-ui/src/library_ui.rs#L311), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:363`](../ui/dr-ui/src/library_ui.rs#L363), [`ui/dr-ui/src/library_ui.rs:426`](../ui/dr-ui/src/library_ui.rs#L426), [`ui/dr-ui/src/library_ui.rs:4284`](../ui/dr-ui/src/library_ui.rs#L4284), [`ui/dr-ui/src/library_ui.rs:4302`](../ui/dr-ui/src/library_ui.rs#L4302), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`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:2026`](../ui/dr-ui/ui/app.slint#L2026), [`ui/dr-ui/ui/app.slint:564`](../ui/dr-ui/ui/app.slint#L564), [`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:758`](../ui/dr-ui/ui/library.slint#L758) | +| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173) | +| 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:115`](../core/dr-types/src/lib.rs#L115), [`core/dr-types/src/lib.rs:197`](../core/dr-types/src/lib.rs#L197), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`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-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) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1384`](../ui/dr-ui/src/lib.rs#L1384), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:357`](../ui/dr-ui/src/library_ui.rs#L357) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378) | | FR-NC-9 | [`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-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/src/sidecar.rs:301`](../core/dr-pipeline/src/sidecar.rs#L301), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) | | 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/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-RAW-1 | [`core/dr-decode/src/lib.rs:198`](../core/dr-decode/src/lib.rs#L198), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) | -| FR-RAW-3 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-decode/src/lib.rs:94`](../core/dr-decode/src/lib.rs#L94) | +| FR-RAW-1 | [`core/dr-decode/src/lib.rs:222`](../core/dr-decode/src/lib.rs#L222), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) | +| FR-RAW-3 | [`core/dr-decode/src/lib.rs:118`](../core/dr-decode/src/lib.rs#L118), [`core/dr-decode/src/lib.rs:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-decode/src/locate.rs:1045`](../core/dr-decode/src/locate.rs#L1045) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:172`](../ui/dr-ui/src/lib.rs#L172) | -| FR-RAW-5 | [`core/dr-decode/src/lib.rs:122`](../core/dr-decode/src/lib.rs#L122), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:573`](../core/dr-gpu/src/demosaic.rs#L573), [`core/dr-gpu/src/demosaic.rs:652`](../core/dr-gpu/src/demosaic.rs#L652), [`core/dr-gpu/src/demosaic.rs:776`](../core/dr-gpu/src/demosaic.rs#L776) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2177`](../ui/dr-ui/src/lib.rs#L2177), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/ui/library.slint:628`](../ui/dr-ui/ui/library.slint#L628) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1364`](../ui/dr-ui/src/collections_ui.rs#L1364), [`ui/dr-ui/src/collections_ui.rs:1378`](../ui/dr-ui/src/collections_ui.rs#L1378), [`ui/dr-ui/src/collections_ui.rs:1423`](../ui/dr-ui/src/collections_ui.rs#L1423), [`ui/dr-ui/src/collections_ui.rs:1433`](../ui/dr-ui/src/collections_ui.rs#L1433), [`ui/dr-ui/src/collections_ui.rs:390`](../ui/dr-ui/src/collections_ui.rs#L390), [`ui/dr-ui/src/collections_ui.rs:418`](../ui/dr-ui/src/collections_ui.rs#L418), [`ui/dr-ui/src/collections_ui.rs:70`](../ui/dr-ui/src/collections_ui.rs#L70), [`ui/dr-ui/src/collections_ui.rs:857`](../ui/dr-ui/src/collections_ui.rs#L857), [`ui/dr-ui/src/collections_ui.rs:867`](../ui/dr-ui/src/collections_ui.rs#L867), [`ui/dr-ui/src/collections_ui.rs:98`](../ui/dr-ui/src/collections_ui.rs#L98), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/src/library_ui.rs:207`](../ui/dr-ui/src/library_ui.rs#L207), [`ui/dr-ui/src/library_ui.rs:3739`](../ui/dr-ui/src/library_ui.rs#L3739), [`ui/dr-ui/ui/app.slint:497`](../ui/dr-ui/ui/app.slint#L497), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/library.slint:1456`](../ui/dr-ui/ui/library.slint#L1456), [`ui/dr-ui/ui/library.slint:456`](../ui/dr-ui/ui/library.slint#L456), [`ui/dr-ui/ui/library.slint:492`](../ui/dr-ui/ui/library.slint#L492), [`ui/dr-ui/ui/library.slint:766`](../ui/dr-ui/ui/library.slint#L766), [`ui/dr-ui/ui/library.slint:773`](../ui/dr-ui/ui/library.slint#L773), [`ui/dr-ui/ui/library.slint:779`](../ui/dr-ui/ui/library.slint#L779) | -| FR-UI-3 | [`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:1364`](../ui/dr-ui/src/collections_ui.rs#L1364), [`ui/dr-ui/src/collections_ui.rs:1378`](../ui/dr-ui/src/collections_ui.rs#L1378), [`ui/dr-ui/src/collections_ui.rs:1423`](../ui/dr-ui/src/collections_ui.rs#L1423), [`ui/dr-ui/src/collections_ui.rs:1433`](../ui/dr-ui/src/collections_ui.rs#L1433), [`ui/dr-ui/src/collections_ui.rs:1460`](../ui/dr-ui/src/collections_ui.rs#L1460), [`ui/dr-ui/src/collections_ui.rs:390`](../ui/dr-ui/src/collections_ui.rs#L390), [`ui/dr-ui/src/collections_ui.rs:418`](../ui/dr-ui/src/collections_ui.rs#L418), [`ui/dr-ui/src/collections_ui.rs:70`](../ui/dr-ui/src/collections_ui.rs#L70), [`ui/dr-ui/src/collections_ui.rs:857`](../ui/dr-ui/src/collections_ui.rs#L857), [`ui/dr-ui/src/collections_ui.rs:867`](../ui/dr-ui/src/collections_ui.rs#L867), [`ui/dr-ui/src/collections_ui.rs:98`](../ui/dr-ui/src/collections_ui.rs#L98), [`ui/dr-ui/src/library_ui.rs:3727`](../ui/dr-ui/src/library_ui.rs#L3727), [`ui/dr-ui/src/library_ui.rs:3739`](../ui/dr-ui/src/library_ui.rs#L3739), [`ui/dr-ui/ui/app.slint:1296`](../ui/dr-ui/ui/app.slint#L1296), [`ui/dr-ui/ui/app.slint:492`](../ui/dr-ui/ui/app.slint#L492), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/library.slint:1456`](../ui/dr-ui/ui/library.slint#L1456), [`ui/dr-ui/ui/library.slint:456`](../ui/dr-ui/ui/library.slint#L456), [`ui/dr-ui/ui/library.slint:492`](../ui/dr-ui/ui/library.slint#L492), [`ui/dr-ui/ui/library.slint:511`](../ui/dr-ui/ui/library.slint#L511), [`ui/dr-ui/ui/library.slint:766`](../ui/dr-ui/ui/library.slint#L766), [`ui/dr-ui/ui/library.slint:773`](../ui/dr-ui/ui/library.slint#L773), [`ui/dr-ui/ui/library.slint:779`](../ui/dr-ui/ui/library.slint#L779) | -| 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:2211`](../ui/dr-ui/src/lib.rs#L2211), [`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:256`](../core/dr-pipeline/src/framing.rs#L256) | +| FR-RAW-5 | [`core/dr-decode/src/lib.rs:146`](../core/dr-decode/src/lib.rs#L146), [`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:2213`](../ui/dr-ui/src/lib.rs#L2213), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/ui/library.slint:804`](../ui/dr-ui/ui/library.slint#L804) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/ui/app.slint:575`](../ui/dr-ui/ui/app.slint#L575), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/library.slint:1784`](../ui/dr-ui/ui/library.slint#L1784), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:947`](../ui/dr-ui/ui/library.slint#L947), [`ui/dr-ui/ui/library.slint:954`](../ui/dr-ui/ui/library.slint#L954), [`ui/dr-ui/ui/library.slint:960`](../ui/dr-ui/ui/library.slint#L960) | +| FR-UI-3 | [`ui/dr-ui/src/library_ui.rs:3590`](../ui/dr-ui/src/library_ui.rs#L3590), [`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:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3590`](../ui/dr-ui/src/library_ui.rs#L3590), [`ui/dr-ui/src/library_ui.rs:3635`](../ui/dr-ui/src/library_ui.rs#L3635), [`ui/dr-ui/src/library_ui.rs:3738`](../ui/dr-ui/src/library_ui.rs#L3738), [`ui/dr-ui/src/library_ui.rs:3766`](../ui/dr-ui/src/library_ui.rs#L3766), [`ui/dr-ui/src/library_ui.rs:4302`](../ui/dr-ui/src/library_ui.rs#L4302), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/ui/app.slint:1401`](../ui/dr-ui/ui/app.slint#L1401), [`ui/dr-ui/ui/app.slint:570`](../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/library.slint:1784`](../ui/dr-ui/ui/library.slint#L1784), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:687`](../ui/dr-ui/ui/library.slint#L687), [`ui/dr-ui/ui/library.slint:947`](../ui/dr-ui/ui/library.slint#L947), [`ui/dr-ui/ui/library.slint:954`](../ui/dr-ui/ui/library.slint#L954), [`ui/dr-ui/ui/library.slint:960`](../ui/dr-ui/ui/library.slint#L960) | +| 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:2247`](../ui/dr-ui/src/lib.rs#L2247), [`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:257`](../core/dr-pipeline/src/framing.rs#L257) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1490`](../ui/dr-ui/src/export.rs#L1490), [`ui/dr-ui/src/export.rs:1516`](../ui/dr-ui/src/export.rs#L1516), [`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:1689`](../ui/dr-ui/src/lib.rs#L1689), [`ui/dr-ui/ui/app.slint:789`](../ui/dr-ui/ui/app.slint#L789), [`ui/dr-ui/ui/library.slint:536`](../ui/dr-ui/ui/library.slint#L536) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1490`](../ui/dr-ui/src/export.rs#L1490), [`ui/dr-ui/src/export.rs:1516`](../ui/dr-ui/src/export.rs#L1516), [`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:1710`](../ui/dr-ui/src/lib.rs#L1710), [`ui/dr-ui/ui/app.slint:880`](../ui/dr-ui/ui/app.slint#L880), [`ui/dr-ui/ui/library.slint:712`](../ui/dr-ui/ui/library.slint#L712) | | 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-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) |