diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 1cb9b41..ffa0cc3 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -14,6 +14,7 @@ use crate::framing::{CropRect, Framing}; use crate::mask::MaskStack; use crate::operation::{compose_full, ComposedShader, Operation}; use crate::ops; +use crate::spot::SpotSet; /// TRACES: FR-DEV-3a /// What one operation offers, as plain data. @@ -105,6 +106,15 @@ pub struct EditGraph { /// that render — because they are one fact, and holding them apart is how /// a sidecar comes to name one stock while the shader draws another. film: Option, + /// TRACES: FR-DEV-8 + /// The repairs (`docs/spot-removal.md`). + /// + /// Apart from `ops` for the third time and the same reason: a spot is not + /// a scalar, and a list of them is not a slider. It sits beside the masks + /// rather than among them because it is not a mask either — a mask says + /// *where* an adjustment applies, and a spot says where a piece of the + /// photograph comes from. + spots: SpotSet, } /// TRACES: FR-DEV-3f @@ -148,6 +158,7 @@ impl EditGraph { framing: Framing::new(), masks: MaskStack::new(), film: None, + spots: SpotSet::new(), } } @@ -173,6 +184,16 @@ impl EditGraph { graph } + /// TRACES: FR-DEV-8 + /// The repairs. + pub fn spots(&self) -> &SpotSet { + &self.spots + } + + pub fn spots_mut(&mut self) -> &mut SpotSet { + &mut self.spots + } + /// The local adjustment stack. pub fn masks(&self) -> &MaskStack { &self.masks @@ -402,6 +423,7 @@ impl EditGraph { !self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image() && self.masks.is_neutral() + && self.spots.is_neutral() } /// Generate the fused shader for the current state, encoded to sRGB. @@ -552,6 +574,14 @@ impl EditGraph { } } + // TRACES: FR-DEV-8 + // The repairs belong to the detail stage, because that is where they + // run. Moving a spot therefore re-runs the neighbourhood passes and + // leaves the fused colour dispatch and the demosaic alone, which is + // the difference between a spot that follows the finger and one that + // stutters (FR-DEV-3d). + detail = self.spots.hash(detail); + crate::Invalidation::new(geometry, colour, detail) } } diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index 38f7ea4..f1ad53f 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -42,6 +42,7 @@ pub mod operation; pub mod ops; pub mod preset; pub mod sidecar; +pub mod spot; pub use descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, @@ -60,6 +61,7 @@ pub use operation::{ }; pub use preset::{Preset, Scope}; pub use sidecar::{Sidecar, Version}; +pub use spot::{Spot, SpotMode, SpotSet}; #[cfg(test)] mod tests { diff --git a/core/dr-pipeline/src/spot.rs b/core/dr-pipeline/src/spot.rs new file mode 100644 index 0000000..0899db0 --- /dev/null +++ b/core/dr-pipeline/src/spot.rs @@ -0,0 +1,492 @@ +//! TRACES: FR-DEV-8 +//! Spot removal — the marks a photographer paints out, as parameters. +//! +//! A spot is a disc over something unwanted, a source offset saying where the +//! replacement comes from, and the handful of numbers that decide how the two +//! are blended. No pixels are stored, here or anywhere: the shader draws the +//! repair from these numbers every time the photograph is rendered, which is +//! what makes it non-destructive, cheap to sync, and undoable +//! (`docs/spot-removal.md`). +//! +//! # Why this is not an operation +//! +//! [`crate::Operation`] is `ParamId -> f32`, and the generic machinery built on +//! that — the develop panel, the sidecar, the presets — works precisely because +//! it is true. A spot list is neither scalar nor of fixed length, so it lives +//! beside `ops` in [`crate::EditGraph`], as `framing`, `masks` and `film` +//! already do for the same reason. The trait says as much where it refuses a +//! downcast for film tables: a thing that is not a slider should not pretend to +//! be one. +//! +//! # Units, once, for all of a spot's lengths +//! +//! `centre` is in **normalised source coordinates**, the space every mask uses, +//! so a spot survives a crop, a straighten, a zoom and an export at another +//! size with no arithmetic to keep it where the dust was. +//! +//! Every *length* — the radius, the feather, the source offset — is in the +//! frame's **isotropic units**, where y spans `0..1` and x spans `0..aspect`. +//! That is [`crate::mask::MaskSource::Radial`]'s convention and it is chosen +//! here for the same reason: only in those units is a disc a disc. Normalised +//! coordinates would make a spot on a 3:2 frame an ellipse half again wider +//! than it is tall, and the source offset would point somewhere other than +//! where the photographer dragged it. +//! +//! It is deliberately *one* unit for all three. A radius in shorter-edge +//! fractions beside an offset in frame units agrees on a landscape frame and +//! silently disagrees on a portrait one, which is the kind of bug that stays +//! invisible until somebody rotates a photograph. +//! +//! # What is not decided here +//! +//! How a spot is drawn. That is `dr-gpu`, from the passes [`crate::detail`] +//! composes — this module holds the state and the two pieces of arithmetic +//! nobody downstream should have to repeat: where a spot's source is +//! ([`Spot::source`]), and which spots may share a pass ([`SpotSet::rounds`]). + +use crate::operation::{canonical_bits, hash_bytes, mix, FNV_OFFSET}; + +/// The most spots one edit holds. +/// +/// Past a few dozen marks the answer is to clean the sensor, and a bound is +/// what keeps a sidecar a file a human can still read. Pushing past it refuses +/// rather than dropping the oldest — the rule [`crate::mask::MaskStack::push`] +/// follows, for the reason it gives: work the user can see on screen must not +/// vanish without being told. +pub const MAX_SPOTS: usize = 64; + +/// The furthest a source may be dragged from what it repairs, in frame units. +/// +/// Half the frame's height is well past any repair a photographer makes, and it +/// bounds something that is otherwise unbounded: a detail pass declares how far +/// it reads from the pixel it writes, and for a spot that is the offset plus +/// the radius. An unbounded offset is an unbounded halo, which is a pass the +/// tile scheduler cannot plan (ARCH §5.3, `docs/spot-removal.md` §5.3). +pub const MAX_SOURCE_DISTANCE: f32 = 0.5; + +/// The radius a new spot starts at, in frame units. +/// +/// About 25 px on the short edge of a 24 MP frame — a dust mark. Small enough +/// that the first click on a speck usually covers it, large enough to be worth +/// clicking at all. +pub const DEFAULT_RADIUS: f32 = 0.012; + +/// The smallest radius a spot may be dragged to, in frame units. +/// +/// Not zero: a spot with no radius repairs nothing and reads as the tool being +/// broken rather than as a spot being small. +pub const MIN_RADIUS: f32 = 0.001; + +/// The largest radius a spot may be dragged to, in frame units. +/// +/// A repair wider than half the frame is not a repair, and the same bound on +/// the radius as on the offset keeps the halo arithmetic honest. +pub const MAX_RADIUS: f32 = 0.5; + +/// The fraction of the radius over which a new spot's edge falls away. +/// +/// Soft by default because the common repair is dust on a gradient sky, where +/// a hard edge shows as a disc even when the colour underneath it is right. +pub const DEFAULT_FEATHER: f32 = 0.35; + +/// The grid every stored length and coordinate is rounded to, as a divisor. +/// +/// The same value and the same reasoning as [`crate::mask::Stroke`]'s grid: +/// values are snapped on the way in *and* written at that precision, so a +/// sidecar round trip is exact rather than nearly exact, and two devices that +/// placed the same spot produce the same line instead of a diff of noise in the +/// sixth decimal — which under per-field merge (FR-NC-9) is a conflict over +/// nothing. +const SPOT_GRID: f32 = 10_000.0; + +/// Round to the stored grid. See [`SPOT_GRID`]. +fn snap(v: f32) -> f32 { + (v * SPOT_GRID).round() / SPOT_GRID +} + +/// TRACES: FR-DEV-8 +/// How a spot's patch meets what is already there. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SpotMode { + /// Copy the source's texture and take the destination's colour and + /// brightness from the boundary. The right answer for dust on a sky, and + /// the default because that is the overwhelming majority of spots. + #[default] + Heal, + /// Copy the source, unaltered. + /// + /// Kept because heal is wrong on an edge: a spot straddling a horizon + /// healed by interpolating its boundary smears the horizon's contrast + /// across the disc, and the honest tool then is a straight copy from a + /// matching part of the frame. FR-DEV-8 asks for both for this reason. + Clone, +} + +impl SpotMode { + /// The name this mode is stored under. Stable: it is in every sidecar. + pub fn name(self) -> &'static str { + match self { + Self::Heal => "heal", + Self::Clone => "clone", + } + } + + pub fn from_name(name: &str) -> Option { + match name { + "heal" => Some(Self::Heal), + "clone" => Some(Self::Clone), + _ => None, + } + } +} + +/// TRACES: FR-DEV-8 +/// One repair: what is covered, what covers it, and how the two meet. +#[derive(Debug, Clone, PartialEq)] +pub struct Spot { + /// Stable across devices — see [`Spot::derive_id`]. + pub id: String, + /// What is being covered, in normalised source coordinates. + pub centre: (f32, f32), + /// Where the replacement comes from, as a displacement from `centre` in + /// frame units. + /// + /// A vector rather than a second point, so that nudging a spot half a pixel + /// carries its source along instead of asking the photographer to place it + /// again. Moving the source alone is an edit to this. + pub offset: (f32, f32), + /// The radius of the disc, in frame units. + pub radius: f32, + /// The fraction of the radius over which the edge falls away, `0.0..=1.0`. + /// Zero is a hard disc. + pub feather: f32, + /// How much of the patch is laid down, `0.0..=1.0`. + /// + /// Below one the repair is partial, which is how a mark is *reduced* rather + /// than removed — worth having for a blemish that is part of the subject + /// rather than dirt on the sensor. + pub opacity: f32, + pub mode: SpotMode, + /// Whether this spot draws. + /// + /// Kept rather than deleted so a photographer can see what a repair was + /// doing without losing it, exactly as [`crate::mask::MaskLayer::enabled`] + /// does for a layer. + pub enabled: bool, +} + +impl Spot { + /// A spot covering `centre`, sourced `offset` away, clamped to what the + /// renderer can express. + /// + /// The id is derived from the position — see [`Spot::derive_id`]. A caller + /// adding to a set should go through [`SpotSet::place`], which is what + /// resolves the case of two spots landing on the same point. + pub fn new(centre: (f32, f32), offset: (f32, f32), radius: f32) -> Self { + let centre = (snap(centre.0), snap(centre.1)); + Self { + id: Self::derive_id(centre), + centre, + offset: clamp_offset(offset), + radius: snap(radius.clamp(MIN_RADIUS, MAX_RADIUS)), + feather: DEFAULT_FEATHER, + opacity: 1.0, + mode: SpotMode::default(), + enabled: true, + } + } + + /// TRACES: FR-NC-9 + /// The id a spot at `centre` is given: a short base-36 hash of the position + /// it was placed at. + /// + /// **Derived rather than counted**, which is the opposite of what + /// [`crate::mask::MaskStack::next_id`] does, and the difference is worth + /// stating. A layer is a thing a user names and reorders, so a sequence is + /// natural. A spot is not named, and two devices editing the same + /// photograph offline would each mint `spot3` for different marks — after + /// which the merge in [`crate::sidecar`] would treat two repairs as one and + /// quietly keep whichever revision was higher. + /// + /// From the position, two devices that removed *the same piece of dust* + /// agree on the id and the merge resolves them as one spot — which is + /// exactly right, because it is one spot. Two devices that removed + /// different marks disagree, and both survive. + /// + /// The id is minted once, at placement, and never re-derived: dragging a + /// spot moves the repair, it does not make a different one. + pub fn derive_id(centre: (f32, f32)) -> String { + let mut h = FNV_OFFSET; + h = mix(h, u64::from(canonical_bits(snap(centre.0)))); + h = mix(h, u64::from(canonical_bits(snap(centre.1)))); + base36(h) + } + + /// Where this spot reads from, in normalised source coordinates. + /// + /// `aspect` is the source's width over its height. The offset is in frame + /// units and the answer is in normalised ones, and this is the only place + /// that conversion happens on this side — a caller doing it itself would be + /// the second place, and the two would eventually disagree about which axis + /// carries the aspect. + pub fn source(&self, aspect: f32) -> (f32, f32) { + let aspect = if aspect > 0.0 { aspect } else { 1.0 }; + ( + self.centre.0 + self.offset.0 / aspect, + self.centre.1 + self.offset.1, + ) + } + + /// This spot's centre in frame units, where a disc is a disc. + pub fn frame_centre(&self, aspect: f32) -> (f32, f32) { + (self.centre.0 * aspect, self.centre.1) + } + + /// How far the source is from what it repairs, in frame units. + pub fn distance(&self) -> f32 { + self.offset.0.hypot(self.offset.1) + } + + /// Whether this spot changes the photograph. + /// + /// A spot with no offset reads the pixel it is writing: a clone copies a + /// pixel onto itself and a heal interpolates a boundary difference that is + /// zero everywhere, so both are the identity and both would cost a pass. A + /// spot just placed and not yet given a source is in exactly that state, + /// which is why this is asked per spot rather than per set. + pub fn is_active(&self) -> bool { + self.enabled && self.radius > 0.0 && self.opacity > 0.0 && self.distance() > f32::EPSILON + } + + /// Move the whole repair, source and all, to a new centre. + /// + /// The id does not move with it — see [`Spot::derive_id`]. + pub fn set_centre(&mut self, centre: (f32, f32)) { + self.centre = (snap(centre.0), snap(centre.1)); + } + + /// Move the source, leaving what is being repaired where it is. + pub fn set_offset(&mut self, offset: (f32, f32)) { + self.offset = clamp_offset(offset); + } + + pub fn set_radius(&mut self, radius: f32) { + self.radius = snap(radius.clamp(MIN_RADIUS, MAX_RADIUS)); + } + + pub fn set_feather(&mut self, feather: f32) { + self.feather = snap(feather.clamp(0.0, 1.0)); + } + + pub fn set_opacity(&mut self, opacity: f32) { + self.opacity = snap(opacity.clamp(0.0, 1.0)); + } + + /// Fold this spot into a running hash, for the detail stage's cache key. + /// + /// Every value is a parameter — a position a finger left, a number from a + /// sidecar — never a float that came back from the GPU, which is what makes + /// hashing the bit patterns sound rather than reckless (ARCH §6.13). The id + /// is in it too: two spots that swapped ids are a different edit to sync + /// even though they draw the same picture. + pub(crate) fn hash(&self, h: u64) -> u64 { + let mut h = hash_bytes(h, self.id.as_bytes()); + for v in [ + self.centre.0, + self.centre.1, + self.offset.0, + self.offset.1, + self.radius, + self.feather, + self.opacity, + ] { + h = mix(h, u64::from(canonical_bits(v))); + } + h = hash_bytes(h, self.mode.name().as_bytes()); + mix(h, u64::from(self.enabled)) + } +} + +/// An offset clamped to what the halo bound allows, snapped to the grid. +/// +/// Clamped along its own direction rather than per axis, so a drag towards a +/// corner stops at the bound instead of sliding along it — a per-axis clamp +/// would turn a diagonal drag into an L-shaped one under the finger. +fn clamp_offset(offset: (f32, f32)) -> (f32, f32) { + let distance = offset.0.hypot(offset.1); + if distance > MAX_SOURCE_DISTANCE { + let scale = MAX_SOURCE_DISTANCE / distance; + (snap(offset.0 * scale), snap(offset.1 * scale)) + } else { + (snap(offset.0), snap(offset.1)) + } +} + +/// A hash as base-36 digits. +/// +/// Short because it becomes a sidecar key that a human reads while debugging an +/// edit that went wrong. Six digits is two thousand million ids against the +/// sixty-four an edit may hold, so a collision is not a thing that happens by +/// accident — and [`SpotSet::place`] resolves it anyway when it does. +fn base36(mut h: u64) -> String { + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut out = String::with_capacity(6); + for _ in 0..6 { + out.push(DIGITS[(h % 36) as usize] as char); + h /= 36; + } + out +} + +/// TRACES: FR-DEV-8 +/// Every repair on one photograph, in the order they were made. +/// +/// The order is not decoration: it decides which spots may share a pass +/// ([`Self::rounds`]) and which repair sits on top where two overlap. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SpotSet { + spots: Vec, +} + +impl SpotSet { + pub fn new() -> Self { + Self::default() + } + + pub fn spots(&self) -> &[Spot] { + &self.spots + } + + pub fn len(&self) -> usize { + self.spots.len() + } + + pub fn is_empty(&self) -> bool { + self.spots.is_empty() + } + + /// Whether this set draws nothing, and the detail stage may skip it + /// entirely. + pub fn is_neutral(&self) -> bool { + !self.spots.iter().any(Spot::is_active) + } + + pub fn get(&self, id: &str) -> Option<&Spot> { + self.spots.iter().find(|s| s.id == id) + } + + pub fn get_mut(&mut self, id: &str) -> Option<&mut Spot> { + self.spots.iter_mut().find(|s| s.id == id) + } + + /// The spots that draw, in order. + pub fn active(&self) -> impl Iterator { + self.spots.iter().filter(|s| s.is_active()) + } + + /// Add a spot, returning its id, or `None` if the set is full. + /// + /// Full **refuses** rather than dropping the oldest: sixty-four repairs are + /// sixty-four decisions, and silently discarding the first to make room for + /// the sixty-fifth would undo work the photographer can see on screen. + /// + /// A spot placed on top of an existing one is given a distinct id by + /// salting the hash, so the set never holds two spots under one name. This + /// is rare by construction — the same point to a ten-thousandth of the + /// frame — and it is the one case [`Spot::derive_id`]'s determinism cannot + /// resolve on its own. + pub fn place(&mut self, mut spot: Spot) -> Option { + if self.spots.len() >= MAX_SPOTS { + log::warn!("spots: {MAX_SPOTS} is the limit; refusing to place another"); + return None; + } + let mut salt: u64 = 0; + while self.spots.iter().any(|s| s.id == spot.id) { + salt += 1; + let mut h = FNV_OFFSET; + h = mix(h, u64::from(canonical_bits(spot.centre.0))); + h = mix(h, u64::from(canonical_bits(spot.centre.1))); + spot.id = base36(mix(h, salt)); + } + let id = spot.id.clone(); + self.spots.push(spot); + Some(id) + } + + /// Remove one repair. + pub fn remove(&mut self, id: &str) -> Option { + let index = self.spots.iter().position(|s| s.id == id)?; + Some(self.spots.remove(index)) + } + + pub fn clear(&mut self) { + self.spots.clear(); + } + + /// TRACES: FR-DEV-8 + /// The active spots grouped into passes, as indices into the order + /// [`Self::active`] yields. + /// + /// # Why grouping is needed at all + /// + /// A detail pass reads one texture and writes another, so every spot in one + /// pass reads the photograph as it stood *before* that pass. A spot whose + /// source sits on an earlier spot's destination therefore copies the very + /// mark the earlier spot was removing, and the mark reappears a few hundred + /// pixels away — which reads as the tool being broken rather than as two + /// repairs that disagree. + /// + /// The fix is not a pass per spot: sixty-four dispatches for a frame that + /// needs one is a frame budget spent on a case that almost never arises. + /// Instead a spot joins the round being built unless its source disc + /// intersects the destination disc of a spot already in that round, in + /// which case it opens a new one. Spots scattered over a sky with their + /// sources beside them — the overwhelming majority — come out as a single + /// round. + /// + /// Only the round being built is consulted. Earlier rounds have already + /// been applied by the time a later one runs, so reading their destinations + /// is not a hazard: it is the repaired photograph, which is exactly what a + /// source should see. + /// + /// Destinations overlapping destinations is not a hazard either — both + /// write the same output and the later spot lands on top, which is the + /// order the photographer made them in. + pub fn rounds(&self, aspect: f32) -> Vec> { + let active: Vec<&Spot> = self.active().collect(); + let mut rounds: Vec> = Vec::new(); + let mut current: Vec = Vec::new(); + + for (index, spot) in active.iter().enumerate() { + let source = spot.source(aspect); + let source_frame = (source.0 * aspect, source.1); + let conflicts = current.iter().any(|&earlier| { + let other = active[earlier]; + let dest = other.frame_centre(aspect); + let reach = spot.radius + other.radius; + let dx = source_frame.0 - dest.0; + let dy = source_frame.1 - dest.1; + dx * dx + dy * dy < reach * reach + }); + if conflicts { + rounds.push(std::mem::take(&mut current)); + } + current.push(index); + } + if !current.is_empty() { + rounds.push(current); + } + rounds + } + + /// Fold the set into a running hash, for the detail stage's cache key. + /// + /// The order is in it: two spots swapped is a different grouping in + /// [`Self::rounds`] and a different picture where they overlap. + pub(crate) fn hash(&self, mut h: u64) -> u64 { + for spot in &self.spots { + h = spot.hash(h); + } + mix(h, self.spots.len() as u64) + } +} diff --git a/core/dr-pipeline/tests/spots.rs b/core/dr-pipeline/tests/spots.rs new file mode 100644 index 0000000..ba8806c --- /dev/null +++ b/core/dr-pipeline/tests/spots.rs @@ -0,0 +1,241 @@ +//! TRACES: FR-DEV-8 +//! The spot model: identity, bounds, and which repairs may share a pass. +//! +//! Everything here is arithmetic and bookkeeping, which is exactly why it is +//! tested without a device: the three ways this model can be wrong — an id that +//! is not stable, a bound that drops work silently, a grouping that lets a +//! source read a destination — all produce a *picture* that is subtly wrong and +//! no error anywhere. + +use dr_pipeline::spot::{ + Spot, SpotMode, SpotSet, DEFAULT_RADIUS, MAX_SOURCE_DISTANCE, MAX_SPOTS, MIN_RADIUS, +}; +use dr_pipeline::EditGraph; + +/// A 3:2 frame, which is the shape that catches a unit confusion. On a square +/// one every wrong answer happens to be right. +const ASPECT: f32 = 1.5; + +fn spot_at(centre: (f32, f32), offset: (f32, f32)) -> Spot { + Spot::new(centre, offset, DEFAULT_RADIUS) +} + +/// Two devices that remove the same piece of dust must agree on its id, or the +/// sidecar merge treats one repair as two and both survive — a spot drawn +/// twice, which is visible. +#[test] +fn the_same_placement_mints_the_same_id() { + let a = spot_at((0.25, 0.75), (0.05, 0.0)); + let b = spot_at((0.25, 0.75), (-0.02, 0.03)); + assert_eq!(a.id, b.id, "the id is the position, not the whole spot"); + + let elsewhere = spot_at((0.26, 0.75), (0.05, 0.0)); + assert_ne!(a.id, elsewhere.id); +} + +/// And the id must not move when the repair does: dragging a spot is an edit to +/// a spot, not the deletion of one and the creation of another. If the id +/// followed the centre, a drag on one device and a radius change on the other +/// would merge as two unrelated spots. +#[test] +fn dragging_a_spot_keeps_its_id() { + let mut spot = spot_at((0.25, 0.75), (0.05, 0.0)); + let id = spot.id.clone(); + spot.set_centre((0.9, 0.1)); + spot.set_offset((0.1, 0.1)); + spot.set_radius(0.2); + assert_eq!(spot.id, id); +} + +/// Two spots placed on the same point are still two repairs, and a set that +/// held them both under one id would lose one of them at the next save. +#[test] +fn a_second_spot_on_the_same_point_gets_its_own_id() { + let mut set = SpotSet::new(); + let first = set.place(spot_at((0.5, 0.5), (0.05, 0.0))).unwrap(); + let second = set.place(spot_at((0.5, 0.5), (0.0, 0.05))).unwrap(); + + assert_ne!(first, second); + assert_eq!(set.len(), 2); + assert!(set.get(&first).is_some() && set.get(&second).is_some()); +} + +/// The bound refuses rather than dropping. A set that quietly discarded the +/// oldest repair would remove work already on screen, with nothing said. +#[test] +fn the_limit_refuses_and_keeps_what_is_there() { + let mut set = SpotSet::new(); + for i in 0..MAX_SPOTS { + let y = i as f32 / MAX_SPOTS as f32; + assert!(set.place(spot_at((0.5, y), (0.05, 0.0))).is_some()); + } + let first = set.spots()[0].clone(); + + assert!(set.place(spot_at((0.1, 0.1), (0.05, 0.0))).is_none()); + assert_eq!(set.len(), MAX_SPOTS); + assert_eq!( + set.spots()[0], + first, + "the oldest repair survives the refusal" + ); +} + +/// The offset bound is what keeps the detail pass's halo finite, so it has to +/// hold along the diagonal and not merely per axis — and it must keep the +/// direction the photographer dragged in. +#[test] +fn a_source_dragged_too_far_stops_in_the_direction_it_was_going() { + let spot = spot_at((0.5, 0.5), (3.0, 4.0)); + let distance = spot.distance(); + assert!( + (distance - MAX_SOURCE_DISTANCE).abs() < 1e-3, + "clamped to the bound, got {distance}" + ); + // 3:4 in, 3:4 out. + assert!((spot.offset.0 / spot.offset.1 - 0.75).abs() < 1e-3); +} + +#[test] +fn a_radius_cannot_be_dragged_to_nothing() { + let mut spot = spot_at((0.5, 0.5), (0.05, 0.0)); + spot.set_radius(0.0); + assert!(spot.radius >= MIN_RADIUS); +} + +/// The offset is in frame units and the source is in normalised ones, and the +/// aspect goes on exactly one of the two axes. Getting this backwards puts the +/// source somewhere the photographer did not drag it, by a third of the frame +/// on a 3:2 — visible, and easy to write. +#[test] +fn the_source_converts_frame_units_to_normalised_ones() { + let spot = spot_at((0.5, 0.5), (0.15, 0.15)); + let (sx, sy) = spot.source(ASPECT); + + assert!((sx - (0.5 + 0.15 / ASPECT)).abs() < 1e-4); + assert!((sy - 0.65).abs() < 1e-4); + + // The displacement is equal on both axes in frame units, so it must be + // *unequal* in normalised ones on a frame that is not square. + assert!((sx - 0.5) < (sy - 0.5)); +} + +/// A spot with no source reads the pixel it writes: the identity, at the cost +/// of a dispatch. A freshly placed spot is in that state until a source is +/// found for it, which is why the question is asked per spot. +#[test] +fn a_spot_with_no_offset_draws_nothing() { + let mut set = SpotSet::new(); + let id = set.place(spot_at((0.5, 0.5), (0.0, 0.0))).unwrap(); + assert!(set.is_neutral()); + assert_eq!(set.rounds(ASPECT).len(), 0); + + set.get_mut(&id).unwrap().set_offset((0.08, 0.0)); + assert!(!set.is_neutral()); + assert_eq!(set.rounds(ASPECT), vec![vec![0]]); +} + +#[test] +fn a_disabled_spot_draws_nothing_but_is_kept() { + let mut set = SpotSet::new(); + let id = set.place(spot_at((0.5, 0.5), (0.08, 0.0))).unwrap(); + set.get_mut(&id).unwrap().enabled = false; + + assert!(set.is_neutral()); + assert_eq!(set.len(), 1, "disabling is not deleting"); +} + +/// Spots scattered over a sky with their sources beside them are the +/// overwhelming majority, and they must cost one dispatch. +#[test] +fn repairs_that_do_not_interfere_share_one_pass() { + let mut set = SpotSet::new(); + for i in 0..8 { + let y = 0.1 + 0.1 * i as f32; + set.place(spot_at((0.5, y), (0.04, 0.0))); + } + assert_eq!(set.rounds(ASPECT), vec![(0..8).collect::>()]); +} + +/// The case the grouping exists for: the second spot reads from where the first +/// one is repairing. In one pass it would copy the mark the first spot is +/// removing, and the mark would reappear somewhere else in the frame. +#[test] +fn a_source_over_an_earlier_repair_opens_a_new_pass() { + let mut set = SpotSet::new(); + // Repairs (0.30, 0.50) from (0.40, 0.50) — both in frame units on x. + set.place(spot_at((0.2, 0.5), (0.1, 0.0))); + // Repairs (0.60, 0.50) by reading (0.30, 0.50): exactly the first + // destination. + set.place(spot_at((0.4, 0.5), (-0.3, 0.0))); + + assert_eq!(set.rounds(ASPECT), vec![vec![0], vec![1]]); +} + +/// Two repairs landing on top of each other is not a hazard — they write the +/// same output and the later one lands on top, which is the order they were +/// made in. Splitting a pass for it would cost a dispatch for nothing. +#[test] +fn overlapping_destinations_stay_in_one_pass() { + let mut set = SpotSet::new(); + set.place(spot_at((0.5, 0.5), (0.2, 0.0))); + set.place(spot_at((0.505, 0.5), (0.2, 0.05))); + + assert_eq!(set.rounds(ASPECT).len(), 1); +} + +/// A spot is an edit like any other, so a graph holding one is not clean — and +/// a graph whose only spot draws nothing is. +#[test] +fn a_placed_repair_makes_the_graph_dirty() { + let mut graph = EditGraph::default_chain(); + assert!(graph.is_neutral()); + + graph.spots_mut().place(spot_at((0.5, 0.5), (0.0, 0.0))); + assert!(graph.is_neutral(), "a spot with no source is not an edit"); + + graph.spots_mut().place(spot_at((0.2, 0.2), (0.08, 0.0))); + assert!(!graph.is_neutral()); +} + +/// Moving a spot must re-run the neighbourhood passes and nothing before them. +/// If it moved the colour key, dragging a spot would re-run the fused dispatch +/// and every mask on the frame with it (FR-DEV-3d). +#[test] +fn a_repair_moves_the_detail_key_alone() { + use dr_pipeline::Affects; + + let mut graph = EditGraph::default_chain(); + let before = graph.invalidation(); + + let id = graph + .spots_mut() + .place(spot_at((0.5, 0.5), (0.08, 0.0))) + .unwrap(); + let after = graph.invalidation(); + + assert_eq!( + before.through(Affects::Colour), + after.through(Affects::Colour), + "a repair is not a colour change" + ); + assert_ne!( + before.through(Affects::Detail), + after.through(Affects::Detail) + ); + + // And a change *to* a spot moves it again. + let placed = graph.invalidation(); + graph.spots_mut().get_mut(&id).unwrap().set_radius(0.05); + assert_ne!( + placed.through(Affects::Detail), + graph.invalidation().through(Affects::Detail) + ); +} + +#[test] +fn modes_survive_their_own_names() { + for mode in [SpotMode::Heal, SpotMode::Clone] { + assert_eq!(SpotMode::from_name(mode.name()), Some(mode)); + } + assert_eq!(SpotMode::from_name("smudge"), None); +} diff --git a/docs/spot-removal.md b/docs/spot-removal.md index ec5af6b..624b1fc 100644 --- a/docs/spot-removal.md +++ b/docs/spot-removal.md @@ -73,7 +73,7 @@ pub struct Spot { /// What covers it, as an offset from `centre` in **frame units** /// (y spans 0..1, x spans 0..aspect — the mask convention). pub offset: (f32, f32), - /// Fraction of the source frame's shorter edge. + /// The radius of the disc, in frame units. pub radius: f32, /// Fraction of `radius` over which the edge falls away. 0 is hard. pub feather: f32, @@ -88,10 +88,20 @@ pub struct Spot { is a length that means something different in the preview and in the export ([`RenderScale`]'s whole documentation is this argument). `centre` is normalised source, so a crop, a zoom, a pan and a rotation move the spot with the -photograph and no arithmetic is needed to keep it there. `radius` is a fraction -of the **source** frame's shorter edge rather than of the rendered region, so -cropping does not resize a spot that was already placed — a dust mark is a fact -about the sensor, not about the composition. +photograph and no arithmetic is needed to keep it there. + +Every *length* — radius, feather, offset — is in the frame's **isotropic +units**, `MaskSource::Radial`'s convention, where y spans `0..1` and x spans +`0..aspect`. Only in those units is a disc a disc: normalised coordinates would +make a spot on a 3:2 frame an ellipse half again wider than it is tall. They are +lengths against the *source* frame rather than the rendered region, so cropping +does not resize a spot already placed — a dust mark is a fact about the sensor, +not about the composition. + +One unit for all three, deliberately. A radius in shorter-edge fractions beside +an offset in frame units agrees on a landscape frame and silently disagrees on a +portrait one, which is a bug that stays invisible until somebody rotates a +photograph. **`offset` is a vector, not a second point.** Dragging the destination moves the source with it, which is what a photographer expects when they nudge a spot half @@ -130,7 +140,8 @@ Bounds, following `mask.rs`'s example of bounding what a sidecar can grow to: |---|---|---| | `MAX_SPOTS` | 64 | Beyond a few dozen the answer is to clean the sensor. Refuses rather than dropping, as `MaskStack::push` does. | | `MAX_SOURCE_DISTANCE` | 0.5 | Frame units. Bounds the halo in §5.3, which is otherwise unbounded. | -| `DEFAULT_RADIUS` | 0.012 | About 25 px on a 24 MP frame's short edge — a dust mark. | +| `DEFAULT_RADIUS` | 0.012 | Frame units — about 25 px on a 24 MP frame's short edge, which is a dust mark. | +| `MIN_RADIUS` / `MAX_RADIUS` | 0.001 / 0.5 | Not zero, because a spot that repairs nothing reads as a broken tool; not larger, because the halo bound has to mean something. | | `DEFAULT_FEATHER` | 0.35 | Fraction of the radius. Soft enough that a heal on a gradient sky has no visible boundary. | ## 5. Where it runs diff --git a/docs/traceability.md b/docs/traceability.md index 5b25898..1a813a7 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 | 227 | -| TRACES tags found | 621 | +| Source files scanned | 229 | +| TRACES tags found | 631 | | Requirements defined | 177 | | Requirements covered | 91 | | **Coverage** | **51.4%** (91/177) | @@ -52,23 +52,23 @@ _None._ | 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), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:154`](../core/dr-pipeline/src/graph.rs#L154), [`core/dr-pipeline/src/graph.rs:457`](../core/dr-pipeline/src/graph.rs#L457), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:473`](../core/dr-pipeline/src/operation.rs#L473), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:1399`](../core/dr-pipeline/src/sidecar.rs#L1399), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2762`](../ui/dr-ui/src/develop.rs#L2762), [`ui/dr-ui/src/develop.rs:3194`](../ui/dr-ui/src/develop.rs#L3194), [`ui/dr-ui/src/develop.rs:3248`](../ui/dr-ui/src/develop.rs#L3248), [`ui/dr-ui/src/develop.rs:3292`](../ui/dr-ui/src/develop.rs#L3292), [`ui/dr-ui/src/develop.rs:3342`](../ui/dr-ui/src/develop.rs#L3342), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1275`](../ui/dr-ui/src/lib.rs#L1275), [`ui/dr-ui/src/lib.rs:1934`](../ui/dr-ui/src/lib.rs#L1934), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1915`](../ui/dr-ui/ui/app.slint#L1915), [`ui/dr-ui/ui/app.slint:913`](../ui/dr-ui/ui/app.slint#L913) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:218`](../core/dr-pipeline/src/graph.rs#L218), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:579`](../ui/dr-ui/src/lib.rs#L579) | -| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:218`](../core/dr-pipeline/src/graph.rs#L218), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3921`](../ui/dr-ui/src/develop.rs#L3921) | -| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:433`](../core/dr-gpu/tests/capture_sharpen.rs#L433), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:486`](../core/dr-pipeline/src/graph.rs#L486), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:165`](../core/dr-pipeline/src/graph.rs#L165), [`core/dr-pipeline/src/graph.rs:479`](../core/dr-pipeline/src/graph.rs#L479), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:473`](../core/dr-pipeline/src/operation.rs#L473), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:1399`](../core/dr-pipeline/src/sidecar.rs#L1399), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2762`](../ui/dr-ui/src/develop.rs#L2762), [`ui/dr-ui/src/develop.rs:3194`](../ui/dr-ui/src/develop.rs#L3194), [`ui/dr-ui/src/develop.rs:3248`](../ui/dr-ui/src/develop.rs#L3248), [`ui/dr-ui/src/develop.rs:3292`](../ui/dr-ui/src/develop.rs#L3292), [`ui/dr-ui/src/develop.rs:3342`](../ui/dr-ui/src/develop.rs#L3342), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1275`](../ui/dr-ui/src/lib.rs#L1275), [`ui/dr-ui/src/lib.rs:1934`](../ui/dr-ui/src/lib.rs#L1934), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1915`](../ui/dr-ui/ui/app.slint#L1915), [`ui/dr-ui/ui/app.slint:913`](../ui/dr-ui/ui/app.slint#L913) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:239`](../core/dr-pipeline/src/graph.rs#L239), [`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:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:579`](../ui/dr-ui/src/lib.rs#L579) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:239`](../core/dr-pipeline/src/graph.rs#L239), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3921`](../ui/dr-ui/src/develop.rs#L3921) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:433`](../core/dr-gpu/tests/capture_sharpen.rs#L433), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:508`](../core/dr-pipeline/src/graph.rs#L508), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:1491`](../core/dr-pipeline/src/operation.rs#L1491), [`core/dr-pipeline/src/operation.rs:1516`](../core/dr-pipeline/src/operation.rs#L1516), [`core/dr-pipeline/src/operation.rs:1531`](../core/dr-pipeline/src/operation.rs#L1531), [`core/dr-pipeline/src/operation.rs:1555`](../core/dr-pipeline/src/operation.rs#L1555), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:413`](../core/dr-pipeline/src/operation.rs#L413), [`core/dr-pipeline/src/operation.rs:549`](../core/dr-pipeline/src/operation.rs#L549) | -| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:88`](../core/dr-film/src/grain.rs#L88), [`core/dr-film/src/lib.rs:158`](../core/dr-film/src/lib.rs#L158), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:110`](../core/dr-pipeline/src/graph.rs#L110), [`core/dr-pipeline/src/graph.rs:292`](../core/dr-pipeline/src/graph.rs#L292), [`core/dr-pipeline/src/graph.rs:96`](../core/dr-pipeline/src/graph.rs#L96), [`core/dr-pipeline/src/operation.rs:1014`](../core/dr-pipeline/src/operation.rs#L1014), [`core/dr-pipeline/src/operation.rs:1043`](../core/dr-pipeline/src/operation.rs#L1043), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:102`](../core/dr-pipeline/src/ops/film_sim.rs#L102), [`core/dr-pipeline/src/ops/film_sim.rs:126`](../core/dr-pipeline/src/ops/film_sim.rs#L126), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:300`](../core/dr-pipeline/src/ops/film_sim.rs#L300), [`core/dr-pipeline/src/ops/film_sim.rs:68`](../core/dr-pipeline/src/ops/film_sim.rs#L68), [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:1647`](../core/dr-pipeline/src/sidecar.rs#L1647), [`core/dr-pipeline/src/sidecar.rs:169`](../core/dr-pipeline/src/sidecar.rs#L169), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:415`](../core/dr-pipeline/src/sidecar.rs#L415), [`core/dr-pipeline/src/sidecar.rs:555`](../core/dr-pipeline/src/sidecar.rs#L555), [`core/dr-pipeline/src/sidecar.rs:678`](../core/dr-pipeline/src/sidecar.rs#L678), [`ui/dr-ui/src/develop.rs:2340`](../ui/dr-ui/src/develop.rs#L2340), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/develop.rs:2388`](../ui/dr-ui/src/develop.rs#L2388), [`ui/dr-ui/src/develop.rs:2485`](../ui/dr-ui/src/develop.rs#L2485), [`ui/dr-ui/src/develop.rs:2799`](../ui/dr-ui/src/develop.rs#L2799), [`ui/dr-ui/src/lib.rs:1908`](../ui/dr-ui/src/lib.rs#L1908), [`ui/dr-ui/src/lib.rs:512`](../ui/dr-ui/src/lib.rs#L512), [`ui/dr-ui/src/lib.rs:570`](../ui/dr-ui/src/lib.rs#L570), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:879`](../ui/dr-ui/ui/adjust.slint#L879), [`ui/dr-ui/ui/adjust.slint:949`](../ui/dr-ui/ui/adjust.slint#L949), [`ui/dr-ui/ui/app.slint:2370`](../ui/dr-ui/ui/app.slint#L2370), [`ui/dr-ui/ui/app.slint:707`](../ui/dr-ui/ui/app.slint#L707) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:88`](../core/dr-film/src/grain.rs#L88), [`core/dr-film/src/lib.rs:158`](../core/dr-film/src/lib.rs#L158), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:120`](../core/dr-pipeline/src/graph.rs#L120), [`core/dr-pipeline/src/graph.rs:313`](../core/dr-pipeline/src/graph.rs#L313), [`core/dr-pipeline/src/graph.rs:97`](../core/dr-pipeline/src/graph.rs#L97), [`core/dr-pipeline/src/operation.rs:1014`](../core/dr-pipeline/src/operation.rs#L1014), [`core/dr-pipeline/src/operation.rs:1043`](../core/dr-pipeline/src/operation.rs#L1043), [`core/dr-pipeline/src/operation.rs:1410`](../core/dr-pipeline/src/operation.rs#L1410), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:102`](../core/dr-pipeline/src/ops/film_sim.rs#L102), [`core/dr-pipeline/src/ops/film_sim.rs:126`](../core/dr-pipeline/src/ops/film_sim.rs#L126), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:300`](../core/dr-pipeline/src/ops/film_sim.rs#L300), [`core/dr-pipeline/src/ops/film_sim.rs:68`](../core/dr-pipeline/src/ops/film_sim.rs#L68), [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:1647`](../core/dr-pipeline/src/sidecar.rs#L1647), [`core/dr-pipeline/src/sidecar.rs:169`](../core/dr-pipeline/src/sidecar.rs#L169), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:415`](../core/dr-pipeline/src/sidecar.rs#L415), [`core/dr-pipeline/src/sidecar.rs:555`](../core/dr-pipeline/src/sidecar.rs#L555), [`core/dr-pipeline/src/sidecar.rs:678`](../core/dr-pipeline/src/sidecar.rs#L678), [`ui/dr-ui/src/develop.rs:2340`](../ui/dr-ui/src/develop.rs#L2340), [`ui/dr-ui/src/develop.rs:2357`](../ui/dr-ui/src/develop.rs#L2357), [`ui/dr-ui/src/develop.rs:2388`](../ui/dr-ui/src/develop.rs#L2388), [`ui/dr-ui/src/develop.rs:2485`](../ui/dr-ui/src/develop.rs#L2485), [`ui/dr-ui/src/develop.rs:2799`](../ui/dr-ui/src/develop.rs#L2799), [`ui/dr-ui/src/lib.rs:1908`](../ui/dr-ui/src/lib.rs#L1908), [`ui/dr-ui/src/lib.rs:512`](../ui/dr-ui/src/lib.rs#L512), [`ui/dr-ui/src/lib.rs:570`](../ui/dr-ui/src/lib.rs#L570), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:879`](../ui/dr-ui/ui/adjust.slint#L879), [`ui/dr-ui/ui/adjust.slint:949`](../ui/dr-ui/ui/adjust.slint#L949), [`ui/dr-ui/ui/app.slint:2370`](../ui/dr-ui/ui/app.slint#L2370), [`ui/dr-ui/ui/app.slint:707`](../ui/dr-ui/ui/app.slint#L707) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | | FR-DEV-5 | [`core/dr-pipeline/src/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:2817`](../ui/dr-ui/src/develop.rs#L2817), [`ui/dr-ui/src/develop.rs:2827`](../ui/dr-ui/src/develop.rs#L2827), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1267`](../ui/dr-ui/src/lib.rs#L1267) | | FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:2756`](../ui/dr-ui/src/develop.rs#L2756), [`ui/dr-ui/src/develop.rs:2777`](../ui/dr-ui/src/develop.rs#L2777), [`ui/dr-ui/src/lib.rs:1239`](../ui/dr-ui/src/lib.rs#L1239), [`ui/dr-ui/src/library.rs:1545`](../ui/dr-ui/src/library.rs#L1545), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:535`](../ui/dr-ui/src/settings_ui.rs#L535), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1305`](../ui/dr-ui/ui/library.slint#L1305), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:912`](../ui/dr-ui/ui/library.slint#L912), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | -| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2090`](../core/dr-gpu/src/adjust.rs#L2090), [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:2252`](../core/dr-gpu/src/adjust.rs#L2252), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:427`](../core/dr-pipeline/src/graph.rs#L427), [`core/dr-pipeline/src/graph.rs:457`](../core/dr-pipeline/src/graph.rs#L457), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2983`](../ui/dr-ui/src/develop.rs#L2983), [`ui/dr-ui/src/develop.rs:3466`](../ui/dr-ui/src/develop.rs#L3466), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:720`](../ui/dr-ui/src/lib.rs#L720) | +| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/graph.rs:109`](../core/dr-pipeline/src/graph.rs#L109), [`core/dr-pipeline/src/graph.rs:187`](../core/dr-pipeline/src/graph.rs#L187), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/spot.rs:107`](../core/dr-pipeline/src/spot.rs#L107), [`core/dr-pipeline/src/spot.rs:143`](../core/dr-pipeline/src/spot.rs#L143), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:341`](../core/dr-pipeline/src/spot.rs#L341), [`core/dr-pipeline/src/spot.rs:426`](../core/dr-pipeline/src/spot.rs#L426), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2090`](../core/dr-gpu/src/adjust.rs#L2090), [`core/dr-gpu/src/adjust.rs:2167`](../core/dr-gpu/src/adjust.rs#L2167), [`core/dr-gpu/src/adjust.rs:2252`](../core/dr-gpu/src/adjust.rs#L2252), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:449`](../core/dr-pipeline/src/graph.rs#L449), [`core/dr-pipeline/src/graph.rs:479`](../core/dr-pipeline/src/graph.rs#L479), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2983`](../ui/dr-ui/src/develop.rs#L2983), [`ui/dr-ui/src/develop.rs:3466`](../ui/dr-ui/src/develop.rs#L3466), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:720`](../ui/dr-ui/src/lib.rs#L720) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | | FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4568`](../ui/dr-ui/src/develop.rs#L4568), [`ui/dr-ui/src/develop.rs:4600`](../ui/dr-ui/src/develop.rs#L4600), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1326`](../ui/dr-ui/src/lib.rs#L1326), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2400`](../core/dr-gpu/src/adjust.rs#L2400), [`core/dr-pipeline/src/graph.rs:417`](../core/dr-pipeline/src/graph.rs#L417), [`core/dr-pipeline/src/graph.rs:470`](../core/dr-pipeline/src/graph.rs#L470), [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2400`](../core/dr-gpu/src/adjust.rs#L2400), [`core/dr-pipeline/src/graph.rs:439`](../core/dr-pipeline/src/graph.rs#L439), [`core/dr-pipeline/src/graph.rs:492`](../core/dr-pipeline/src/graph.rs#L492), [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-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) | @@ -91,7 +91,7 @@ _None._ | FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1002`](../ui/dr-ui/src/lib.rs#L1002), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1002`](../ui/dr-ui/src/lib.rs#L1002) | | FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1579`](../ui/dr-ui/src/lib.rs#L1579), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:332`](../core/dr-pipeline/src/sidecar.rs#L332), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:332`](../core/dr-pipeline/src/sidecar.rs#L332), [`core/dr-pipeline/src/spot.rs:199`](../core/dr-pipeline/src/spot.rs#L199), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:789`](../ui/dr-ui/src/lib.rs#L789), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |