diff --git a/core/dr-decode/src/preview.rs b/core/dr-decode/src/preview.rs index ecc164e..0bce976 100644 --- a/core/dr-decode/src/preview.rs +++ b/core/dr-decode/src/preview.rs @@ -39,27 +39,14 @@ impl Preview { /// not: a quarter turn is a permutation, so it costs the same either way, /// and doing it on the smaller buffer moves a fraction of the bytes. /// - /// Allocates a second buffer rather than rotating in place. An in-place - /// quarter turn on a non-square image is a cycle-following permutation - /// that is both slower per pixel and far harder to get right, for a saving - /// that a thumbnail-sized buffer does not need. + /// The turn itself is [`dr_types::Orientation::into_shown`], which every + /// other consumer of an orientation in this codebase also goes through. + /// That is deliberate: a hand-written permutation per caller is how two of + /// them come to disagree, and a disagreement here shows as a thumbnail + /// facing the other way from the develop view. pub fn apply_orientation(&mut self, orientation: dr_types::Orientation) { - if orientation.is_normal() || self.width == 0 || self.height == 0 { - return; - } - let (dw, dh) = orientation.oriented_size(self.width, self.height); - - let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4]; - for y in 0..dh { - for x in 0..dw { - let (sx, sy) = orientation.source_pixel(x, y, dw, dh); - let s = ((sy * self.width + sx) * 4) as usize; - let d = ((y * dw + x) * 4) as usize; - out[d..d + 4].copy_from_slice(&self.rgba[s..s + 4]); - } - } - - self.rgba = out; + let (rgba, dw, dh) = orientation.into_shown(&self.rgba, self.width, self.height, 4); + self.rgba = rgba; self.width = dw; self.height = dh; } diff --git a/core/dr-face/src/naming.rs b/core/dr-face/src/naming.rs index 03f6669..87a6ef2 100644 --- a/core/dr-face/src/naming.rs +++ b/core/dr-face/src/naming.rs @@ -283,159 +283,3 @@ mod tests { } } -/// Map a box from the **displayed** (upright) image back into the **stored** -/// (sensor) one. -/// -/// # Why this is needed at all -/// -/// Faces are found on the thumbnail, which is cached the right way up — the -/// grid would lie on its side otherwise. Segmentation runs on a proxy rendered -/// through a *neutral* edit graph, which carries no orientation, so it is in -/// sensor order. For any photograph shot in portrait the two spaces differ by a -/// quarter turn, and matching a face against an instance without undoing that -/// finds nothing — or worse, finds the wrong person, since a rotated box can -/// still land inside some other instance. -/// -/// The transform is applied to the face rather than to the segmentation proxy -/// on purpose. Instance masks are defined in the proxy's space and sampled long -/// afterwards; turning that space would be a far larger change than naming a -/// region warrants. -/// -/// `displayed` is the size of the upright image in the same units as `bbox`. -/// Orientation is `(quarter_turns clockwise, flip_h, flip_v)`, applied by the -/// renderer in that order — so undoing it means undoing the flips first. -pub fn to_sensor_space( - bbox: (f32, f32, f32, f32), - displayed: (f32, f32), - quarter_turns: u8, - flip_h: bool, - flip_v: bool, -) -> (f32, f32, f32, f32) { - let (dw, dh) = displayed; - let (mut x0, mut y0, mut x1, mut y1) = bbox; - - // Undo the mirrors, which the renderer applied last. - if flip_h { - let (a, b) = (dw - x1, dw - x0); - x0 = a; - x1 = b; - } - if flip_v { - let (a, b) = (dh - y1, dh - y0); - y0 = a; - y1 = b; - } - - // Undo the turn. Each step rotates the box a quarter turn anticlockwise - // within the frame it currently occupies, swapping the frame's extents as - // it goes — which is why `w` and `h` are tracked rather than assumed. - let (mut w, mut h) = (dw, dh); - for _ in 0..(quarter_turns % 4) { - // Clockwise forward is (x, y) -> (h_before - y, x); anticlockwise back - // is (x, y) -> (y, w - x). - let (nx0, ny0) = (y0, w - x1); - let (nx1, ny1) = (y1, w - x0); - x0 = nx0; - y0 = ny0; - x1 = nx1; - y1 = ny1; - std::mem::swap(&mut w, &mut h); - } - - (x0, y0, x1, y1) -} - -#[cfg(test)] -mod orientation_tests { - use super::*; - - /// A landscape frame with a face near the top left. - const DISPLAYED: (f32, f32) = (1000.0, 600.0); - const FACE: (f32, f32, f32, f32) = (100.0, 50.0, 200.0, 150.0); - - #[test] - fn an_upright_image_needs_no_transform() { - assert_eq!(to_sensor_space(FACE, DISPLAYED, 0, false, false), FACE); - } - - /// The case that motivated this: a portrait photograph, stored sideways - /// and displayed with one clockwise quarter turn. - #[test] - fn a_quarter_turn_round_trips() { - let sensor = to_sensor_space(FACE, DISPLAYED, 1, false, false); - // Sensor frame is 600 × 1000 — the displayed extents swapped. - assert!(sensor.0 >= 0.0 && sensor.2 <= 600.0, "{sensor:?}"); - assert!(sensor.1 >= 0.0 && sensor.3 <= 1000.0, "{sensor:?}"); - // And the box keeps its size, only turned. - let (w, h) = (sensor.2 - sensor.0, sensor.3 - sensor.1); - assert!((w - 100.0).abs() < 1e-3, "width {w}"); - assert!((h - 100.0).abs() < 1e-3, "height {h}"); - } - - /// Four quarter turns is the identity, which is the cheapest possible - /// check that the rotation step is self-consistent. - #[test] - fn four_quarter_turns_return_the_original() { - let mut b = FACE; - let mut frame = DISPLAYED; - for _ in 0..4 { - b = to_sensor_space(b, frame, 1, false, false); - frame = (frame.1, frame.0); - } - assert!((b.0 - FACE.0).abs() < 1e-3, "{b:?}"); - assert!((b.1 - FACE.1).abs() < 1e-3, "{b:?}"); - assert!((b.2 - FACE.2).abs() < 1e-3, "{b:?}"); - assert!((b.3 - FACE.3).abs() < 1e-3, "{b:?}"); - } - - #[test] - fn a_horizontal_mirror_reflects_across_the_width() { - let s = to_sensor_space(FACE, DISPLAYED, 0, true, false); - assert_eq!(s, (800.0, 50.0, 900.0, 150.0)); - } - - #[test] - fn a_vertical_mirror_reflects_across_the_height() { - let s = to_sensor_space(FACE, DISPLAYED, 0, false, true); - assert_eq!(s, (100.0, 450.0, 200.0, 550.0)); - } - - #[test] - fn a_half_turn_maps_a_corner_to_the_opposite_corner() { - let corner = (0.0, 0.0, 100.0, 100.0); - let s = to_sensor_space(corner, DISPLAYED, 2, false, false); - assert!((s.0 - 900.0).abs() < 1e-3, "{s:?}"); - assert!((s.1 - 500.0).abs() < 1e-3, "{s:?}"); - } - - /// The boxes must stay well-formed whatever the transform: `x0 <= x1` and - /// `y0 <= y1`, or every containment test downstream silently returns zero. - #[test] - fn every_orientation_produces_a_well_formed_box() { - for turns in 0..4u8 { - for &fh in &[false, true] { - for &fv in &[false, true] { - let s = to_sensor_space(FACE, DISPLAYED, turns, fh, fv); - assert!(s.0 <= s.2, "turns={turns} fh={fh} fv={fv}: {s:?}"); - assert!(s.1 <= s.3, "turns={turns} fh={fh} fv={fv}: {s:?}"); - } - } - } - } - - /// A face that was inside the frame must stay inside it, whichever way the - /// frame is turned. - #[test] - fn a_face_inside_the_frame_stays_inside_it() { - for turns in 0..4u8 { - let s = to_sensor_space(FACE, DISPLAYED, turns, false, false); - let (fw, fh) = if turns % 2 == 1 { - (DISPLAYED.1, DISPLAYED.0) - } else { - DISPLAYED - }; - assert!(s.0 >= -1e-3 && s.2 <= fw + 1e-3, "turns={turns}: {s:?}"); - assert!(s.1 >= -1e-3 && s.3 <= fh + 1e-3, "turns={turns}: {s:?}"); - } - } -} diff --git a/core/dr-film/src/boolean_grain.rs b/core/dr-film/src/boolean_grain.rs new file mode 100644 index 0000000..54e4c61 --- /dev/null +++ b/core/dr-film/src/boolean_grain.rs @@ -0,0 +1,294 @@ +//! TRACES: FR-DEV-3f +//! Grain as a Boolean model — grains that overlap, rather than noise that does +//! not. +//! +//! # Why the counting model was not enough +//! +//! [`crate::grain`] gets the *variance* of a developed density right: a count +//! of independent yes/no events, `D(Dmax − uD)/N`, calibrated from published +//! granularity. What it cannot get right is the **structure**, because it +//! treats every pixel as an independent draw. +//! +//! Measured at 35 mm, a pixel of a 5472-wide frame covers about 6.6 µm and +//! holds some 300 crystals of ~370 nm. Three hundred independent events per +//! pixel average almost flat, and the little that survives has no spatial +//! extent — which is exactly why it reads as sensor noise rather than as film. +//! +//! Real grain is visible because it *clumps*. A crystal is far smaller than a +//! pixel, but crystals overlap into structures that are not, and those survive +//! the filtering that averages independent noise away. +//! +//! # The model +//! +//! Newson, Delon & Galerne, *A Stochastic Film Grain Model for +//! Resolution-Independent Rendering* (Computer Graphics Forum, 2017). +//! +//! Grain centres are a Poisson process of local intensity `λ(y)`; each centre +//! carries a disc. The developed film is the **union** of those discs, and a +//! point is opaque exactly when some disc covers it. Because a disc covers a +//! whole neighbourhood, nearby points are *correlated* — and that correlation +//! is the clumping, which arrives for free rather than being added. +//! +//! # Why it couples to density and not to a grey level +//! +//! The paper drives `λ` from an image's grey level, because it renders grain +//! onto a finished picture. We are not doing that: we have a *density* per +//! layer, from a measured characteristic curve, and a dye that absorbs through +//! it. +//! +//! The two meet exactly. In a Boolean model the chance a point is left +//! uncovered is +//! +//! ```text +//! P(uncovered) = exp(−λ · E[A]) +//! ``` +//! +//! which is Beer–Lambert. So the model's coverage *is* optical density, and +//! +//! ```text +//! λ = D · ln(10) / E[A] +//! ``` +//! +//! puts our measured densities straight into it — with `E[A]`, the mean grain +//! area, already computed from published RMS granularity in +//! [`crate::grain`]. Nothing here is tuned by eye. +//! +//! # What it costs +//! +//! Monte Carlo per pixel, against the counting model's two hashes and a square +//! root. The shader form stores no grains: space is cut into cells, a +//! generator is seeded from each cell's index, and only the cells a sample +//! could reach are visited. That keeps it inside the fused pass — no +//! neighbouring *pixel* is read — but it is emphatically not free, and +//! [`BooleanGrain::samples_for`] is where that trade is made explicit. + +use crate::grain::Grain; + +/// Mean grain area, in µm², recovered from the counting model's calibration. +/// +/// The two models are the same emulsion seen two ways, so they must not +/// disagree about how big a crystal is: `Grain` already inverts published RMS +/// granularity for exactly this number, and taking it from there is what stops +/// a Boolean render and a counting render describing different films. +pub fn mean_grain_area_um2(grain: &Grain, pixel_size_um: f32) -> [f32; 3] { + let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6); + grain.particles.map(|n| pixel_area / n.max(1e-6)) +} + +/// TRACES: FR-DEV-3f +/// What the shader needs to render the Boolean model. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BooleanGrain { + /// Grain radius per layer, in *pixels* at the current sampling scale. + /// + /// In pixels rather than micrometres because that is the unit the shader + /// works in, and converting once here keeps the conversion out of the + /// inner loop. + pub radius_px: [f32; 3], + /// `ln(10) / E[A]`, per layer: the factor taking a density to a Poisson + /// intensity. Precomputed because it is constant per bake and the shader + /// would otherwise recompute a logarithm per pixel per layer. + pub lambda_per_density: [f32; 3], + /// The density each layer saturates at, as in the counting model. + pub density_max: [f32; 3], + /// Monte Carlo samples per pixel. + pub samples: u32, + /// Standard deviation of the sampling kernel, in pixels. + /// + /// The pixel's own footprint: what a scanner or an eye integrates over. + /// Too small and the render is binary salt and pepper; too large and the + /// grain is blurred out of existence. + pub sigma_px: f32, +} + +impl BooleanGrain { + /// Derive the parameters for a stock at a given sampling scale. + pub fn new(grain: &Grain, pixel_size_um: f32, samples: u32) -> Self { + let area = mean_grain_area_um2(grain, pixel_size_um); + let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6); + + let mut radius_px = [0.0f32; 3]; + let mut lambda_per_density = [0.0f32; 3]; + for l in 0..3 { + // A disc of this area, expressed as a fraction of a pixel. + let area_px = area[l] / pixel_area; + radius_px[l] = (area_px / std::f32::consts::PI).sqrt(); + // Beer-Lambert, read backwards: coverage exp(-lambda*E[A]) is + // transmittance 10^-D, so lambda = D * ln(10) / E[A]. + lambda_per_density[l] = std::f32::consts::LN_10 / area_px.max(1e-9); + } + + Self { + radius_px, + lambda_per_density, + density_max: grain.density_max, + samples: samples.max(1), + // Half a pixel: the footprint of one sample of a sensor whose + // pixels abut. Wider would be a soft scanner, narrower a sharper + // one than exists. + sigma_px: 0.5, + } + } + + /// How many Monte Carlo samples a given quality asks for. + /// + /// The estimator's own noise falls as `1/sqrt(N)`, so this trades one kind + /// of grain against another: too few samples and the *sampling* shows as a + /// second, wrong texture on top of the film's. + pub fn samples_for(quality: Quality) -> u32 { + match quality { + Quality::Preview => 16, + Quality::Export => 64, + } + } + + /// Expected coverage at a density — what the render must average to. + /// + /// The Boolean model's mean is analytic even though its texture is not, + /// which is what makes it testable without rendering anything: whatever + /// the grain does locally, across a flat patch it has to come back to the + /// density the characteristic curve asked for. + pub fn expected_coverage(&self, layer: usize, density: f32) -> f32 { + let d = density.clamp(0.0, self.density_max[layer]); + 1.0 - 10f32.powf(-d) + } +} + +/// How hard to work at the Monte Carlo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Quality { + /// Interactive. Some sampling noise, which at preview scale is hidden + /// under the grain it is sampling. + Preview, + /// Final render, where the sampling noise must be well below the grain. + Export, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::Profile; + + fn portra() -> Profile { + Profile::parse(include_str!("../profiles/kodak_portra_400.yaml")).unwrap() + } + + fn model(pixel_size_um: f32) -> BooleanGrain { + let g = Grain::for_pixel_size(&portra(), pixel_size_um); + BooleanGrain::new(&g, pixel_size_um, 16) + } + + #[test] + fn coverage_is_beer_lambert() { + // The identity the whole coupling rests on: a Boolean model's uncovered + // fraction is exp(-lambda E[A]), and transmittance is 10^-D, so the + // model's coverage *is* the film's opacity. If this drifts, the grain + // is no longer rendering the density the curve asked for. + let m = model(6.6); + // Inside the layer's own range. Past its Dmax the coverage clamps — + // correctly, since a film cannot develop denser than its maximum — and + // an earlier version of this test probed 2.0 against a layer that + // reaches 1.798, then blamed the model for the clamp. + for d in [0.0f32, 0.3, 1.0, 1.7] { + let coverage = m.expected_coverage(1, d); + let transmittance = 1.0 - coverage; + assert!( + (transmittance - 10f32.powf(-d)).abs() < 1e-5, + "at density {d}: transmittance {transmittance}, expected {}", + 10f32.powf(-d) + ); + } + } + + #[test] + fn clear_film_has_no_grains_and_fully_developed_film_is_nearly_solid() { + let m = model(6.6); + assert!(m.expected_coverage(1, 0.0) < 1e-6); + // At its own maximum, not at some density it never reaches: Portra's + // green layer tops out near 1.8, which transmits about 1.6% — dense, + // and not opaque. A film that went fully black would be one whose + // shadows carried no detail at all. + let dmax = m.density_max[1]; + assert!( + m.expected_coverage(1, dmax) > 0.98, + "{}", + m.expected_coverage(1, dmax) + ); + } + + #[test] + fn coverage_clamps_at_the_layers_own_maximum() { + // The property the two tests above tripped over, asserted directly: + // asking for more density than the emulsion has gives the emulsion's + // own ceiling rather than extrapolating one. + let m = model(6.6); + let dmax = m.density_max[1]; + assert_eq!( + m.expected_coverage(1, dmax), + m.expected_coverage(1, dmax + 5.0) + ); + } + + #[test] + fn the_two_models_describe_the_same_crystal() { + // The counting model and this one are one emulsion seen two ways. If + // they disagreed about grain size they would render as different + // films, and the difference would look like a modelling choice rather + // than the bug it is. + let px = 6.6; + let g = Grain::for_pixel_size(&portra(), px); + let area = mean_grain_area_um2(&g, px); + // Portra's green layer: ~0.14 um^2, about 370 nm across. + assert!( + (0.10..0.20).contains(&area[1]), + "grain area {} um^2 is not what the counting model calibrated", + area[1] + ); + let m = BooleanGrain::new(&g, px, 16); + // And the radius in pixels must match that area at this scale. + let area_px = area[1] / (px * px); + let expect_r = (area_px / std::f32::consts::PI).sqrt(); + assert!((m.radius_px[1] - expect_r).abs() < 1e-6); + } + + #[test] + fn zooming_in_makes_the_grains_bigger_in_pixels() { + // Resolution independence, which is the paper's headline claim and the + // thing the counting model can only approximate: a grain is a fixed + // size *on the film*, so looking closer must resolve it, not merely + // reduce the variance. + let close = model(2.0); + let far = model(12.0); + assert!( + close.radius_px[1] > far.radius_px[1] * 3.0, + "close {} far {}", + close.radius_px[1], + far.radius_px[1] + ); + } + + #[test] + fn a_finer_stock_has_smaller_grains() { + let mut fine = portra(); + let mut coarse = portra(); + fine.rms_granularity = [4.0; 3]; + coarse.rms_granularity = [16.0; 3]; + + let f = BooleanGrain::new(&Grain::for_pixel_size(&fine, 6.6), 6.6, 16); + let c = BooleanGrain::new(&Grain::for_pixel_size(&coarse, 6.6), 6.6, 16); + assert!( + c.radius_px[1] > f.radius_px[1], + "coarse {} is not larger than fine {}", + c.radius_px[1], + f.radius_px[1] + ); + } + + #[test] + fn export_samples_more_than_preview() { + assert!( + BooleanGrain::samples_for(Quality::Export) + > BooleanGrain::samples_for(Quality::Preview) + ); + } +} diff --git a/core/dr-film/src/grain.rs b/core/dr-film/src/grain.rs index be33b64..0569f1a 100644 --- a/core/dr-film/src/grain.rs +++ b/core/dr-film/src/grain.rs @@ -76,13 +76,65 @@ const RMS_APERTURE_AREA_UM2: f32 = std::f32::consts::PI * 24.0 * 24.0; /// The net density the granularity figure is quoted at. const RMS_REFERENCE_NET_DENSITY: f32 = 1.0; -/// A 35 mm frame's width, in micrometres. +/// TRACES: FR-DEV-3f +/// The frame a photograph is being simulated on. /// -/// What turns a pixel count into a grain size. A photograph has no inherent -/// film format, so simulating one means choosing what the frame *would have -/// been*; 35 mm is the choice that makes the numbers mean what a photographer -/// expects, since published granularity and every intuition about how grainy a -/// stock looks come from 35 mm. +/// **Grain is a function of enlargement, and this is the half of it the +/// photograph cannot supply.** A crystal is a fixed size in micrometres, so +/// how grainy a picture looks depends entirely on how much the frame was +/// magnified to make it — and that is film size against output size. +/// +/// The same emulsion on 4x5 packs about 3,800 crystals into the pixel that +/// holds 300 on 35 mm, so it renders roughly 3.5 times smoother at the same +/// output size. Treating everything as 35 mm, as this did, made every +/// photograph as grainy as the smallest common format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Mm35, + Format645, + Format6x6, + Format6x7, + Sheet4x5, + Sheet8x10, +} + +impl Format { + /// The formats, in the order the picker offers them. + /// + /// Smallest first, so index zero is 35 mm — the commonest frame, and the + /// one whose grain every published figure and every photographer's + /// intuition is calibrated against. + pub const ALL: [Format; 6] = [ + Format::Mm35, + Format::Format645, + Format::Format6x6, + Format::Format6x7, + Format::Sheet4x5, + Format::Sheet8x10, + ]; + + /// The frame's width in micrometres — the *image* area, not the sheet. + pub fn width_um(self) -> f32 { + match self { + Format::Mm35 => 36_000.0, + // 6x4.5 and 6x6 share a 56 mm gate; only the other axis differs, + // and grain scales with the linear magnification of the axis being + // enlarged. + Format::Format645 | Format::Format6x6 => 56_000.0, + Format::Format6x7 => 70_000.0, + // The image area of a sheet, which is smaller than the nominal + // inches: a "4x5" exposes about 121 x 97 mm. + Format::Sheet4x5 => 121_000.0, + Format::Sheet8x10 => 248_000.0, + } + } + + pub fn from_index(i: usize) -> Self { + Self::ALL.get(i).copied().unwrap_or(Format::Mm35) + } +} + +/// A 35 mm frame's width, in micrometres. The default format. pub const FRAME_WIDTH_UM: f32 = 36_000.0; /// TRACES: FR-DEV-3f @@ -245,6 +297,41 @@ mod tests { ); } + #[test] + fn a_larger_format_is_less_grainy_at_the_same_output_size() { + // TRACES: FR-DEV-3f + // The point of the whole control, and a fact about photography rather + // than about this code: enlarge 35 mm and 4x5 to the same print and the + // sheet is visibly smoother, because each of its pixels averages far + // more crystals. Treating every frame as 35 mm made a large-format + // photograph as grainy as a small one. + let p = portra(); + let out_px = 5472.0; + let small = Grain::for_pixel_size(&p, Format::Mm35.width_um() / out_px); + let large = Grain::for_pixel_size(&p, Format::Sheet4x5.width_um() / out_px); + + let d = small.density_max[1] * 0.5; + let ratio = small.sigma(1, d) / large.sigma(1, d); + // Linear magnification is 121/36, so the crystal count per pixel goes + // as its square and sigma as its reciprocal: about 3.4x. + assert!( + (2.5..4.5).contains(&ratio), + "35mm is {ratio:.2}x grainier than 4x5, which is not the enlargement" + ); + } + + #[test] + fn the_formats_are_ordered_smallest_first() { + // Index zero has to be the neutral choice — 35 mm, which is what every + // published granularity figure is calibrated against. + let widths: Vec = Format::ALL.iter().map(|f| f.width_um()).collect(); + assert_eq!(widths[0], FRAME_WIDTH_UM); + assert!( + widths.windows(2).all(|w| w[0] <= w[1]), + "formats are not ordered by size: {widths:?}" + ); + } + #[test] fn a_pixel_never_holds_less_than_one_grain() { // Past this the model describes a pixel smaller than a crystal, where diff --git a/core/dr-film/src/lib.rs b/core/dr-film/src/lib.rs index 109d3af..41a3ead 100644 --- a/core/dr-film/src/lib.rs +++ b/core/dr-film/src/lib.rs @@ -38,6 +38,7 @@ //! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019). pub mod bake; +pub mod boolean_grain; mod built_in; pub mod grain; pub mod profile; @@ -45,7 +46,8 @@ pub mod spectrum; pub mod tables; pub use bake::{bake, Baked, Recipe}; -pub use grain::Grain; +pub use boolean_grain::BooleanGrain; +pub use grain::{Format, Grain}; pub use profile::{Kind, Profile, Stage, Support}; use built_in::BUILT_IN; diff --git a/core/dr-gpu/examples/local.rs b/core/dr-gpu/examples/local.rs index ff58bb6..3385ca4 100644 --- a/core/dr-gpu/examples/local.rs +++ b/core/dr-gpu/examples/local.rs @@ -30,6 +30,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, Subj use dr_pipeline::descriptor::ParamId; use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology}; use dr_pipeline::operation::compose_full; +use dr_pipeline::spot::SpotSet; use dr_pipeline::{ops, EditGraph, Framing}; use dr_segment::{SemanticModel, SemanticOptions, Shaped}; use dr_types::ColourSpace; @@ -55,7 +56,13 @@ fn main() { // ---- the photograph --------------------------------------------------- let bytes = std::fs::read(&path).expect("read file"); let raw = dr_decode::decode(&bytes).expect("decode"); + // The tag, because the model reads photographs and the sensor stores + // scanlines. See `stand_up` below: this example exists to be the shipping + // path with pictures attached, so it has to make the same turn the + // develop session makes. + let orientation = dr_decode::orientation(&bytes).unwrap_or_default(); println!("source {} × {}", raw.crop.width, raw.crop.height); + println!("turns {}", orientation.quarter_turns); let source = Demosaicer::new(&ctx) .expect("demosaicer") .run(&raw) @@ -93,10 +100,16 @@ fn main() { .collect(); // ---- find the subject ------------------------------------------------- + // + // Stood up first. A model trained on upright photographs is very bad at + // sideways ones, and the proxy above is in the sensor's own orientation + // — see `stand_up`. + let (upright, uw, uh) = stand_up(&rgb, pw as usize, ph as usize, orientation); + let t = std::time::Instant::now(); let mut model = SemanticModel::embedded().expect("model"); let instances = model - .detect(&rgb, pw as usize, ph as usize, &SemanticOptions::default()) + .detect(&upright, uw, uh, &SemanticOptions::default()) .expect("detect"); println!( "detect {} found in {:.0} ms", @@ -118,10 +131,11 @@ fn main() { subject.class_name, subject.score ); - // Quantised exactly as the develop session does, so this example exercises - // the shipping path rather than a shortcut around it. - let alpha: Vec = subject - .mask + // Laid back down, then quantised exactly as the develop session does, so + // this example exercises the shipping path rather than a shortcut around + // it. The mask has to end up in *source* space: the composed shader + // samples the mask array after the framing map. + let alpha: Vec = lay_down(&subject.mask, uw, uh, orientation) .iter() .map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8) .collect(); @@ -315,12 +329,42 @@ fn render_stack( .render(stack, None, Some(&subjects), pw, ph) .expect("rasterise masks"); - let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack); + let shader = compose_full( + &ops::chain(), + &Framing::new(), + ColourSpace::Srgb, + stack, + &SpotSet::new(), + ); adjust .render_masked(source, &shader, ow, oh, Some(array)) .expect("render"); } +/// Turn the proxy the way the photographer is looking at it, so the model +/// reads a photograph rather than a scanline order — and turn the mask it +/// answers with back again, because the composed shader samples masks in +/// source space, after the framing map. +/// +/// Both are `dr_types::Orientation`, which is the one place the permutation +/// is written: the grid's thumbnails, the develop session's segmentation and +/// this example all go through it, so "upright" means one thing across the +/// application. A local copy here would be a fourth opinion, and this example +/// exists to be the shipping path rather than an imitation of it. +fn stand_up( + rgb: &[f32], + width: usize, + height: usize, + o: dr_types::Orientation, +) -> (Vec, usize, usize) { + let (out, w, h) = o.into_shown(rgb, width as u32, height as u32, 3); + (out, w as usize, h as usize) +} + +fn lay_down(mask: &[f32], dw: usize, dh: usize, o: dr_types::Orientation) -> Vec { + o.into_stored(mask, dw as u32, dh as u32, 1).0 +} + fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) { let s = (longest as f32 / w.max(h) as f32).min(1.0); ( diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index 1e3bac7..90109bc 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -1367,8 +1367,7 @@ mod tests { // instead of the kernel under test. The chain still // carries the resolve pass that finishes the render, and // that generated source is worth compiling too. - let scale = g.render_scale(img.size(), (16, 16)); - let detail = g.compose_detail(scale); + let detail = g.compose_detail(img.size(), (16, 16)); let key = g.invalidation().through(dr_pipeline::Affects::Colour); pass.render_detailed(&img, &shader, 16, 16, None, &detail, key) .unwrap_or_else(|e| { @@ -1774,8 +1773,7 @@ mod tests { // find those operations in neither stage and fail for a reason that is // not a defect. Shadows the smaller size deliberately. let (w, h) = g.output_size(512, 512); - let scale = g.render_scale((512, 512), (w, h)); - let detail = g.compose_detail_for(scale, dr_types::ColourSpace::Srgb); + let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb); assert!( !detail.is_empty(), "the detail half composed nothing, so nothing of it was compiled" diff --git a/core/dr-gpu/src/detail.rs b/core/dr-gpu/src/detail.rs index a9d7cab..1c417ff 100644 --- a/core/dr-gpu/src/detail.rs +++ b/core/dr-gpu/src/detail.rs @@ -149,6 +149,8 @@ pub(crate) struct DetailRunner { /// Compiled pipelines by pass structure hash. cache: HashMap, pool: Intermediates, + /// See [`placeholder_instances`]. + no_instances: wgpu::Buffer, } struct Layout { @@ -156,6 +158,22 @@ struct Layout { pipeline: wgpu::PipelineLayout, } +/// What binding 3 holds for a pass that declared no instance list. +/// +/// One zeroed element, allocated once. Zero-length storage buffers cannot be +/// bound, and the passes that read this binding are exactly the ones that +/// uploaded something of their own, so nothing ever reads the placeholder's +/// contents — it exists to keep one bind group layout serving both kinds of +/// pass. +fn placeholder_instances(ctx: &GpuContext) -> wgpu::Buffer { + ctx.device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("detail-instances-placeholder"), + contents: bytemuck::cast_slice(&[[0.0f32; 4]]), + usage: wgpu::BufferUsages::STORAGE, + }) +} + impl DetailRunner { pub(crate) fn new(ctx: &GpuContext) -> Self { Self { @@ -164,6 +182,7 @@ impl DetailRunner { to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"), cache: HashMap::new(), pool: Intermediates::new(), + no_instances: placeholder_instances(ctx), } } @@ -230,6 +249,24 @@ impl DetailRunner { usage: wgpu::BufferUsages::UNIFORM, }); + // TRACES: FR-DEV-8 + // The instance list, uploaded only by the passes that have one. A + // kernel pass — which is every pass that is a convolution — is + // handed the placeholder allocated once in `new`, because a storage + // buffer of length zero is not bindable and allocating a fresh + // sixteen bytes per pass per frame is the per-frame allocation this + // module's documentation exists to refuse. + let instances = (!pass.storage.is_empty()).then(|| { + self.ctx + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("detail-instances"), + contents: bytemuck::cast_slice(pass.storage.as_slice()), + usage: wgpu::BufferUsages::STORAGE, + }) + }); + let instances = instances.as_ref().unwrap_or(&self.no_instances); + let bind_group = self .ctx .device @@ -249,6 +286,10 @@ impl DetailRunner { binding: 2, resource: wgpu::BindingResource::TextureView(destination), }, + wgpu::BindGroupEntry { + binding: 3, + resource: instances.as_entire_binding(), + }, ], }); @@ -338,6 +379,20 @@ impl DetailRunner { } } +/// A read-only storage buffer entry, as `mask.rs` declares its strokes. +fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + } +} + impl Layout { fn new(ctx: &GpuContext, format: wgpu::TextureFormat, label: &str) -> Self { let bind_group = ctx @@ -376,6 +431,14 @@ impl Layout { }, count: None, }, + // TRACES: FR-DEV-8 + // The instance list, for a pass whose work is a list rather + // than a kernel (`DetailPass::storage`). Every other pass + // gets `Intermediates`' placeholder here — one entry on both + // layouts rather than two more layouts, since a convolution + // that never reads the buffer costs nothing for it being + // bound. + storage_entry(3), ], }); diff --git a/core/dr-gpu/tests/capture_sharpen.rs b/core/dr-gpu/tests/capture_sharpen.rs index e9bbb83..eebf7e8 100644 --- a/core/dr-gpu/tests/capture_sharpen.rs +++ b/core/dr-gpu/tests/capture_sharpen.rs @@ -87,7 +87,8 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph { fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec { let shader = graph.compose_for(ColourSpace::Srgb); let scale = graph.render_scale(source.size(), (out, out)); - let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); + let detail = + graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb); let key = graph.invalidation().through(Affects::Colour); pass.render_detailed(source, &shader, out, out, None, &detail, key) .expect("render"); diff --git a/core/dr-gpu/tests/detail_instances.rs b/core/dr-gpu/tests/detail_instances.rs new file mode 100644 index 0000000..ec4d6a8 --- /dev/null +++ b/core/dr-gpu/tests/detail_instances.rs @@ -0,0 +1,169 @@ +//! TRACES: FR-DEV-8 +//! The instance binding: a detail pass whose work is a list, not a kernel. +//! +//! Spot removal needs a detail pass to read a variable number of records — +//! sixty-four repairs and one repair are the same shader with a different +//! buffer behind it. That is binding 3, and this file proves the three things +//! about it that a picture would not tell you clearly: +//! +//! - the data uploaded is the data the shader reads, in order; +//! - a pass that declares no list still runs, bound to the placeholder; +//! - the same shader with a *different* list does not recompile, which is what +//! keeps placing a spot as cheap as moving a slider. +//! +//! The passes here are synthetic on purpose. `spot_removal.rs` asserts the +//! repair; this asserts the plumbing, so a failure in one does not have to be +//! read to work out which of the two broke. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::detail::{ComposedDetail, ComposedDetailPass}; +use dr_pipeline::{Affects, EditGraph}; +use dr_types::ColourSpace; + +const SIZE: u32 = 8; + +fn ctx() -> Option { + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } +} + +/// A flat mid-grey frame, so anything the pass adds is the whole answer. +fn grey(ctx: &GpuContext) -> DemosaicedImage { + let data: Vec = (0..SIZE * SIZE).flat_map(|_| [0u8, 0, 0, 255]).collect(); + DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") +} + +/// A pass that sums the instance list into the red channel and writes the +/// output. Deliberately trivial: the value on screen is then a direct readout +/// of what arrived in the buffer. +fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass { + let source = " +@group(0) @binding(0) var source: texture_2d; +struct Params { detail_base: vec4 } +@group(0) @binding(1) var u: Params; +@group(0) @binding(2) var output: texture_storage_2d; +@group(0) @binding(3) var instances: array>; + +@compute @workgroup_size(8, 8, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + let dims = textureDimensions(output); + if (gid.x >= dims.x || gid.y >= dims.y) { return; } + + // Weighted by index, so a buffer read back to front fails this rather + // than passing by symmetry. + var total = 0.0; + let n = arrayLength(&instances); + for (var i = 0u; i < n; i = i + 1u) { + total = total + instances[i].x * f32(i + 1u); + } + textureStore(output, vec2(gid.xy), vec4(total, f32(n) / 255.0, 0.0, 1.0)); +} +" + .to_string(); + + ComposedDetailPass { + label: "test/instances".to_string(), + source, + uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0], + storage, + radius: 0, + writes_output: true, + // Any distinct number: the hash is a cache key, and these tests are + // what decide whether two chains share a pipeline. + structure_hash: structure, + } +} + +fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec { + // The fused half has to be composed knowing a detail stage follows it, or + // it encodes its own output and the chain would quantise twice — a mismatch + // `render_detailed` refuses outright. The probe is the graph that says so; + // its own passes are not used, since the chain here is hand-built. + let mut graph = EditGraph::with_detail_probe(); + graph.set_param( + dr_pipeline::descriptor::OpId("detail_probe"), + dr_pipeline::descriptor::ParamId("radius"), + 0.05, + ); + let shader = graph.compose_for(ColourSpace::Srgb); + let key = graph.invalidation().through(Affects::Colour); + pass.render_detailed(source, &shader, SIZE, SIZE, None, chain, key) + .expect("render"); + pass.export_pixels().expect("readback").0 +} + +/// The list arrives whole, in order, and the shader can tell how long it is. +#[test] +fn a_pass_reads_the_list_it_was_given() { + let Some(ctx) = ctx() else { return }; + let source = grey(&ctx); + let mut pass = AdjustPass::new(&ctx); + + // 0.1·1 + 0.2·2 + 0.3·3 = 1.4, which clips to 1.0 — so instead: values + // chosen to land at a quarter, unambiguously distinguishable from both the + // "read nothing" answer of 0 and the "read them unweighted" answer of 0.15. + let chain = ComposedDetail { + passes: vec![summing_pass( + vec![[0.05, 0.0, 0.0, 0.0], [0.1, 0.0, 0.0, 0.0]], + 1, + )], + }; + + let pixels = render(&mut pass, &source, &chain); + let (red, green) = (pixels[0], pixels[1]); + + // 0.05·1 + 0.1·2 = 0.25, written straight to an rgba8 target. + assert!( + red.abs_diff((0.25 * 255.0) as u8) <= 1, + "the shader summed {red}, not the list it was handed" + ); + assert_eq!(green, 2, "arrayLength saw both entries"); +} + +/// A convolution declares no list and must still run: it is bound to the +/// placeholder rather than to nothing, because a zero-length storage buffer +/// cannot be bound at all and a second bind group layout for the difference +/// would be two layouts to keep in step. +#[test] +fn a_pass_with_no_list_still_runs() { + let Some(ctx) = ctx() else { return }; + let source = grey(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let chain = ComposedDetail { + passes: vec![summing_pass(Vec::new(), 2)], + }; + + let pixels = render(&mut pass, &source, &chain); + assert_eq!(pixels[0], 0, "the placeholder is zeroed"); + assert_eq!(pixels[1], 1, "and is exactly one element long"); +} + +/// The property that makes placing the tenth spot as cheap as moving a slider: +/// the list is in the buffer, not in the source, so the pipeline is compiled +/// once however many entries arrive. +#[test] +fn changing_the_list_does_not_recompile() { + let Some(ctx) = ctx() else { return }; + let source = grey(&ctx); + let mut pass = AdjustPass::new(&ctx); + + for count in 1..=6 { + let list = (0..count).map(|_| [0.01, 0.0, 0.0, 0.0]).collect(); + let chain = ComposedDetail { + passes: vec![summing_pass(list, 3)], + }; + render(&mut pass, &source, &chain); + } + + assert_eq!( + pass.cached_detail_pipelines(), + 1, + "six different lists, one compiled pipeline" + ); +} diff --git a/core/dr-gpu/tests/detail_stage.rs b/core/dr-gpu/tests/detail_stage.rs index 36e67e3..ab18ef5 100644 --- a/core/dr-gpu/tests/detail_stage.rs +++ b/core/dr-gpu/tests/detail_stage.rs @@ -101,7 +101,8 @@ fn render( let _ = ctx; let shader = graph.compose_for(ColourSpace::Srgb); let scale = graph.render_scale(source.size(), (out, out)); - let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); + let detail = + graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb); let key = graph.invalidation().through(Affects::Colour); pass.render_detailed(source, &shader, out, out, None, &detail, key) .expect("render"); @@ -402,7 +403,8 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() { let shader = graph.compose_for(ColourSpace::Srgb); let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE)); - let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); + let detail = + graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb); assert!(detail.is_empty()); pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0) diff --git a/core/dr-gpu/tests/local_adjustments.rs b/core/dr-gpu/tests/local_adjustments.rs index 2f06301..35aa88c 100644 --- a/core/dr-gpu/tests/local_adjustments.rs +++ b/core/dr-gpu/tests/local_adjustments.rs @@ -14,6 +14,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass}; use dr_pipeline::descriptor::ParamId; use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack}; use dr_pipeline::operation::compose_full; +use dr_pipeline::spot::SpotSet; use dr_pipeline::{ops, EditGraph, Framing}; use dr_types::ColourSpace; @@ -65,7 +66,13 @@ fn render_at( h: u32, ) -> Vec { let source = grey_at(ctx, w, h); - let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack); + let shader = compose_full( + &ops::chain(), + &Framing::new(), + ColourSpace::Srgb, + stack, + &SpotSet::new(), + ); let mut masks = MaskPass::new(ctx).expect("mask pass"); let array = masks.render(stack, field, None, w, h).expect("rasterise"); diff --git a/core/dr-gpu/tests/local_contrast.rs b/core/dr-gpu/tests/local_contrast.rs index 3feffdf..ce491c3 100644 --- a/core/dr-gpu/tests/local_contrast.rs +++ b/core/dr-gpu/tests/local_contrast.rs @@ -108,7 +108,8 @@ fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> { fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec { let shader = graph.compose_for(ColourSpace::Srgb); let scale = graph.render_scale(source.size(), (out, out)); - let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); + let detail = + graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb); let key = graph.invalidation().through(Affects::Colour); pass.render_detailed(source, &shader, out, out, None, &detail, key) .expect("render"); @@ -596,7 +597,8 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() { // description of "a two-pixel surface structure is not present in a // 128-pixel rendering". let scale = graph.render_scale(source.size(), (128, 128)); - let composed = graph.compose_detail_for(scale, ColourSpace::Srgb); + let composed = + graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb); assert_eq!( composed.len(), 1, diff --git a/core/dr-gpu/tests/masked_outputs.rs b/core/dr-gpu/tests/masked_outputs.rs index 53c0b0f..28e6e5d 100644 --- a/core/dr-gpu/tests/masked_outputs.rs +++ b/core/dr-gpu/tests/masked_outputs.rs @@ -14,6 +14,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass, SubjectMasks}; use dr_pipeline::descriptor::ParamId; use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack}; use dr_pipeline::operation::compose_full; +use dr_pipeline::spot::SpotSet; use dr_pipeline::{ops, Framing}; use dr_segment::Shaped; use dr_types::ColourSpace; @@ -83,7 +84,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec { .render(stack, None, Some(&subjects), PROXY, PROXY) .expect("rasterise"); - let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack); + let shader = compose_full( + &ops::chain(), + &Framing::new(), + ColourSpace::Srgb, + stack, + &SpotSet::new(), + ); let mut adjust = AdjustPass::new(ctx); adjust .render_masked(&source, &shader, out, out, Some(array)) @@ -95,7 +102,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec { /// thumbnail were doing. fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec { let source = grey(ctx, 64); - let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack); + let shader = compose_full( + &ops::chain(), + &Framing::new(), + ColourSpace::Srgb, + stack, + &SpotSet::new(), + ); let mut adjust = AdjustPass::new(ctx); adjust.render(&source, &shader, out, out).expect("render"); adjust.export_pixels().expect("readback").0 @@ -211,7 +224,13 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec Vec { let shader = graph.compose_for(ColourSpace::Srgb); - let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); + let detail = + graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb); let key = graph.invalidation().through(Affects::Colour); pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key) .expect("render"); diff --git a/core/dr-gpu/tests/spot_removal.rs b/core/dr-gpu/tests/spot_removal.rs new file mode 100644 index 0000000..dba189e --- /dev/null +++ b/core/dr-gpu/tests/spot_removal.rs @@ -0,0 +1,389 @@ +//! TRACES: FR-DEV-8 +//! Repairs, drawn on a real device. +//! +//! `dr-pipeline`'s tests assert the model and the record packing; nothing there +//! can say whether the disc lands where the photographer put it. That is what +//! this file is for, and the cases it covers are the ones where a repair goes +//! wrong *quietly*: +//! +//! - the mark is still there, because the disc landed beside it; +//! - the repair works on screen and not in the export, because a length was +//! converted in the wrong units; +//! - the repair works until the photograph is cropped or rotated, because the +//! framing was applied to the pixels and not to the spot. +//! +//! The frame is a flat grey field with one black mark on it, which makes every +//! assertion here countable: a repair either leaves dark pixels or it does not. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::spot::{Spot, SpotMode}; +use dr_pipeline::{Affects, EditGraph}; +use dr_types::ColourSpace; + +const SIZE: u32 = 128; +const GREY: u8 = 128; + +/// Where the mark is, in normalised coordinates, and how big it is in frame +/// units. Off-centre on both axes so that a repair landing on a mirrored or +/// transposed position fails rather than passing by symmetry. +const MARK: (f32, f32) = (0.3, 0.65); +const MARK_RADIUS: f32 = 0.03; + +fn ctx() -> Option { + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } +} + +/// A flat grey frame with one black mark on it — a dust spot, idealised. +fn marked_frame(ctx: &GpuContext) -> DemosaicedImage { + let data: Vec = (0..SIZE * SIZE) + .flat_map(|i| { + let (x, y) = ((i % SIZE) as f32, (i / SIZE) as f32); + let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32); + let r = MARK_RADIUS * SIZE as f32; + let v = if (x - cx).hypot(y - cy) <= r { + 0u8 + } else { + GREY + }; + [v, v, v, 255] + }) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") +} + +/// A repair covering the mark, reading from clean grey to its right. +/// +/// The disc is twice the mark, so the mark sits entirely inside the solid core +/// and none of it falls in the feathered rim — otherwise this file would be +/// asserting a blend rather than a repair. +fn repair(mode: SpotMode) -> Spot { + let mut spot = Spot::new(MARK, (0.3, 0.0), MARK_RADIUS * 2.0); + spot.mode = mode; + spot +} + +fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec { + let shader = graph.compose_for(ColourSpace::Srgb); + let (w, h) = graph.output_size(source.size().0, source.size().1); + let (w, h) = (w.min(out), h.min(out)); + let detail = graph.compose_detail_for(source.size(), (w, h), ColourSpace::Srgb); + let key = graph.invalidation().through(Affects::Colour); + pass.render_detailed(source, &shader, w, h, None, &detail, key) + .expect("render"); + pass.export_pixels().expect("readback").0 +} + +/// How many pixels are darker than anything a grey field contains. +/// +/// The mark is the only dark thing in the frame, so this counts what is left of +/// it — and counts it wherever it ended up, which is what makes the same +/// assertion work after a crop or a rotation. +fn dark_pixels(pixels: &[u8]) -> usize { + pixels.chunks_exact(4).filter(|p| p[0] < GREY - 24).count() +} + +/// The whole feature in one assertion: the mark is there, and then it is not. +#[test] +fn a_clone_removes_the_mark() { + let Some(ctx) = ctx() else { return }; + let source = marked_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let before = dark_pixels(&render( + &mut pass, + &EditGraph::default_chain(), + &source, + SIZE, + )); + assert!(before > 20, "the frame is supposed to have a mark on it"); + + let mut graph = EditGraph::default_chain(); + graph.spots_mut().place(repair(SpotMode::Clone)); + + let after = dark_pixels(&render(&mut pass, &graph, &source, SIZE)); + assert_eq!(after, 0, "{before} dark pixels before, {after} after"); +} + +/// The grey the repair lays down has to be the *photograph's* grey. A repair +/// that removes the mark by darkening or brightening the disc passes the count +/// above and is still visibly a disc. +#[test] +fn the_patch_is_the_photograph_and_not_an_approximation_of_it() { + let Some(ctx) = ctx() else { return }; + let source = marked_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = EditGraph::default_chain(); + graph.spots_mut().place(repair(SpotMode::Clone)); + let pixels = render(&mut pass, &graph, &source, SIZE); + + let centre = + ((MARK.1 * SIZE as f32) as u32 * SIZE + (MARK.0 * SIZE as f32) as u32) as usize * 4; + assert!( + pixels[centre].abs_diff(GREY) <= 2, + "the repaired centre reads {}, the field is {GREY}", + pixels[centre] + ); +} + +/// A repair is stored as a fraction of the frame, so it must land in the same +/// *place* whatever size the frame is drawn at — which is the difference +/// between a preview that tells the truth and an export that does not +/// (FR-DSP-1). +#[test] +fn a_proxy_and_an_export_repair_the_same_thing() { + let Some(ctx) = ctx() else { return }; + let source = marked_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = EditGraph::default_chain(); + graph.spots_mut().place(repair(SpotMode::Clone)); + + assert_eq!(dark_pixels(&render(&mut pass, &graph, &source, SIZE)), 0); + assert_eq!( + dark_pixels(&render(&mut pass, &graph, &source, SIZE / 4)), + 0, + "the repair missed the mark at a quarter size" + ); +} + +/// The framing is applied to the spot, not to the pixels afterwards. If the +/// centre were mapped and the offset were not, this is the test that fails: the +/// disc would land on the mark and read from the wrong side of the frame. +#[test] +fn a_rotated_photograph_carries_its_repairs_round_with_it() { + let Some(ctx) = ctx() else { return }; + let source = marked_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = EditGraph::default_chain(); + graph.spots_mut().place(repair(SpotMode::Clone)); + graph.rotate_quarters(1); + + assert_eq!( + dark_pixels(&render(&mut pass, &graph, &source, SIZE)), + 0, + "the mark came back when the frame was turned" + ); +} + +/// And a crop, which moves the origin and the scale at once. +#[test] +fn a_crop_carries_its_repairs_with_it() { + let Some(ctx) = ctx() else { return }; + let source = marked_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = EditGraph::default_chain(); + graph.spots_mut().place(repair(SpotMode::Clone)); + graph.set_crop(dr_pipeline::CropRect { + x: 0.1, + y: 0.4, + width: 0.5, + height: 0.5, + }); + + assert_eq!( + dark_pixels(&render(&mut pass, &graph, &source, SIZE)), + 0, + "the mark is inside this crop and the repair no longer covers it" + ); +} + +/// Opacity is a real control: at zero the repair is off, and the mark is +/// exactly as it was. +#[test] +fn a_transparent_repair_draws_nothing() { + let Some(ctx) = ctx() else { return }; + let source = marked_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let bare = dark_pixels(&render( + &mut pass, + &EditGraph::default_chain(), + &source, + SIZE, + )); + + let mut graph = EditGraph::default_chain(); + let mut spot = repair(SpotMode::Clone); + spot.set_opacity(0.0); + graph.spots_mut().place(spot); + + assert_eq!(dark_pixels(&render(&mut pass, &graph, &source, SIZE)), bare); +} + +/// Placing repairs must not recompile: the list is in a storage buffer and the +/// shader never learns how long it is, so a photographer working through a +/// dusty sky pays one compilation. +#[test] +fn placing_repairs_compiles_one_pipeline() { + let Some(ctx) = ctx() else { return }; + let source = marked_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = EditGraph::default_chain(); + for i in 0..6 { + let y = 0.1 + 0.1 * i as f32; + graph + .spots_mut() + .place(Spot::new((0.5, y), (0.1, 0.0), 0.02)); + render(&mut pass, &graph, &source, SIZE); + } + + assert_eq!( + pass.cached_detail_pipelines(), + 1, + "six repairs, one compiled pipeline" + ); +} + +// --------------------------------------------------------------------------- +// Heal (FR-DEV-8) +// --------------------------------------------------------------------------- + +/// A frame whose brightness ramps across it, with one black mark on it. +/// +/// This is the case that separates the two modes. A clone copies a patch from +/// somewhere else on the ramp, so it arrives at the wrong level and leaves a +/// disc of the right texture and the wrong tone; a heal carries the difference +/// across from the boundary and leaves nothing. +fn ramped_frame(ctx: &GpuContext) -> DemosaicedImage { + let data: Vec = (0..SIZE * SIZE) + .flat_map(|i| { + let (x, y) = ((i % SIZE) as f32, (i / SIZE) as f32); + let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32); + let r = MARK_RADIUS * SIZE as f32; + // 64 at the left edge to 192 at the right: a ramp steep enough that + // a clone from a third of a frame away is unmistakably wrong, and + // shallow enough to stay well inside the range. + let ramp = 64.0 + 128.0 * x / SIZE as f32; + let v = if (x - cx).hypot(y - cy) <= r { + 0u8 + } else { + ramp as u8 + }; + [v, v, v, 255] + }) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload") +} + +/// What the ramp says at a column, as the byte the renderer should produce. +fn ramp_at(x: u32) -> u8 { + (64.0 + 128.0 * x as f32 / SIZE as f32) as u8 +} + +/// The worst a repair is wrong by, over the disc it covers. +/// +/// Measured against the ramp the photograph would have had if the mark had +/// never been there, which is the only definition of "repaired" worth +/// asserting: a repair that removes the mark and leaves the wrong tone has not +/// repaired anything, it has drawn a different mark. +fn worst_error(pixels: &[u8]) -> u8 { + let (cx, cy) = (MARK.0 * SIZE as f32, MARK.1 * SIZE as f32); + let r = MARK_RADIUS * SIZE as f32; + let mut worst = 0u8; + for y in 0..SIZE { + for x in 0..SIZE { + if (x as f32 - cx).hypot(y as f32 - cy) > r { + continue; + } + let got = pixels[((y * SIZE + x) * 4) as usize]; + worst = worst.max(got.abs_diff(ramp_at(x))); + } + } + worst +} + +/// The measurement the mode exists for, and the one that decides +/// `RIM_SAMPLES`: on a gradient, a heal is right and a clone is not. +#[test] +fn a_heal_takes_the_tone_from_the_hole_it_fills() { + let Some(ctx) = ctx() else { return }; + let source = ramped_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut cloned = EditGraph::default_chain(); + cloned.spots_mut().place(repair(SpotMode::Clone)); + let clone_error = worst_error(&render(&mut pass, &cloned, &source, SIZE)); + + let mut healed = EditGraph::default_chain(); + healed.spots_mut().place(repair(SpotMode::Heal)); + let heal_error = worst_error(&render(&mut pass, &healed, &source, SIZE)); + + // The clone is wrong by roughly the ramp across the source offset — about + // 38 levels here — and it is wrong across the whole disc. + assert!( + clone_error > 20, + "the clone was supposed to be visibly wrong, and is off by {clone_error}" + ); + // Measured at 0 on the reference device — the ramp is linear, so the + // boundary difference is constant all the way round and the membrane + // reproduces it exactly. The tolerance is for the rounding a different + // driver may do, not for the method being approximate here. + assert!( + heal_error <= 1, + "the heal is off by {heal_error} levels; the clone it has to beat is off by {clone_error}" + ); +} + +/// And the repair still has to remove the mark: a membrane that matched the +/// boundary while leaving the black disc underneath would pass the measurement +/// above by averaging its way past it. +#[test] +fn a_heal_removes_the_mark_as_well_as_matching_the_tone() { + let Some(ctx) = ctx() else { return }; + let source = ramped_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = EditGraph::default_chain(); + graph.spots_mut().place(repair(SpotMode::Heal)); + let pixels = render(&mut pass, &graph, &source, SIZE); + + let mark = (MARK.0 * SIZE as f32) as u32; + for x in mark - 2..=mark + 2 { + let got = pixels[(((MARK.1 * SIZE as f32) as u32 * SIZE + x) * 4) as usize]; + assert!( + got.abs_diff(ramp_at(x)) <= 3, + "column {x} reads {got}, the ramp says {}", + ramp_at(x) + ); + } +} + +/// A heal on a flat field is a clone: the boundary difference is zero all the +/// way round, so the membrane is zero and neither mode has anything to add. +/// Worth pinning, because a membrane that quietly tinted a flat repair would +/// be invisible in the gradient test above. +#[test] +fn a_heal_on_a_flat_field_changes_nothing_a_clone_would_not() { + let Some(ctx) = ctx() else { return }; + let source = marked_frame(&ctx); + let mut pass = AdjustPass::new(&ctx); + + let mut cloned = EditGraph::default_chain(); + cloned.spots_mut().place(repair(SpotMode::Clone)); + let a = render(&mut pass, &cloned, &source, SIZE); + + let mut healed = EditGraph::default_chain(); + healed.spots_mut().place(repair(SpotMode::Heal)); + let b = render(&mut pass, &healed, &source, SIZE); + + let worst = a + .iter() + .zip(&b) + .map(|(x, y)| x.abs_diff(*y)) + .max() + .unwrap_or(0); + assert!( + worst <= 1, + "heal and clone differ by {worst} on a flat field" + ); +} diff --git a/core/dr-pipeline/src/detail.rs b/core/dr-pipeline/src/detail.rs index 24c3be2..c07dfda 100644 --- a/core/dr-pipeline/src/detail.rs +++ b/core/dr-pipeline/src/detail.rs @@ -359,6 +359,29 @@ pub struct DetailPass { /// Uniform values this pass's body reads. pub uniforms: Vec, + + /// TRACES: FR-DEV-8 + /// Per-instance data, for a pass whose work is a *list* rather than a + /// kernel. + /// + /// Reaches the body as `instances: array>`, with + /// `instance_count` in scope as a `u32`. Empty for every pass that is a + /// convolution, which is every pass that existed before spot removal. + /// + /// # Why not the uniform block + /// + /// Because the uniform block is fixed by the pass's *structure*, and this + /// is not: sixty-four repairs and one repair are the same shader with a + /// different buffer behind it. Packing the list into uniforms would need a + /// fixed maximum, paid for on every frame whether the photograph carries + /// one spot or none, and it would need the composer to emit `vec4` fields — + /// a WGSL uniform array has a stride of 16 whatever it holds. + /// + /// The property that matters more: with the list in storage the generated + /// source does not mention how many there are, so placing the tenth spot + /// uploads 512 bytes and reuses the compiled pipeline, exactly as moving a + /// slider does for the fused pass. + pub storage: Vec<[f32; 4]>, } /// TRACES: FR-DEV-3 | FR-DEV-8 @@ -396,6 +419,9 @@ pub struct ComposedDetailPass { pub source: String, /// Uniform values in the order the generated struct declares them. pub uniforms: Vec, + /// TRACES: FR-DEV-8 + /// The instance list, if this pass declared one. See [`DetailPass::storage`]. + pub storage: Vec<[f32; 4]>, /// See [`DetailPass::radius`]. pub radius: u32, /// Whether this pass writes the display/export texture rather than another @@ -463,10 +489,45 @@ pub fn compose_detail( ops: &[Box], scale: RenderScale, output: ColourSpace, +) -> ComposedDetail { + compose_detail_with(ops, &[], scale, output) +} + +/// TRACES: FR-DEV-8 +/// The detail stage with a set of repairs ahead of the operations. +/// +/// `spots` are already-built passes, from [`crate::SpotSet::passes`], and they +/// go **first** — before sharpening, before noise reduction, before every +/// kernel in `ops`. +/// +/// That placement is a decision rather than an ordering convenience. A +/// sharpening kernel reads a neighbourhood, so sharpening a dust mark before +/// removing it smears the mark's edge into pixels the repair's own disc does +/// not cover: what is left afterwards is a faint over-sharpened ring around an +/// otherwise perfect patch, which is exactly the artefact that reads as broken +/// software. Removing the mark first means every later pass sees the +/// photograph the photographer thinks they are sharpening. +/// +/// It also means ARCH §5.2's stage list, which draws spot removal after +/// texture and clarity, is not what this does — see `docs/spot-removal.md` +/// §5.1, which is where the disagreement is written down. +pub fn compose_detail_with( + ops: &[Box], + spots: &[DetailPass], + scale: RenderScale, + output: ColourSpace, ) -> ComposedDetail { // Every pass of every active detail operation, flattened, carrying the // operation it came from for the uniform prefix and the helper set. let mut planned: Vec<(&'static str, &'static [Helper], DetailPass, usize)> = Vec::new(); + for (index, pass) in spots.iter().enumerate() { + planned.push(( + crate::spot::SPOT_ID, + crate::spot::SPOT_HELPERS, + pass.clone(), + index, + )); + } for op in ops { if !op.is_active() { continue; @@ -509,6 +570,7 @@ pub fn compose_detail( radius: 0, wgsl: String::new(), uniforms: Vec::new(), + storage: Vec::new(), }, 0, scale, @@ -651,6 +713,10 @@ struct Params {{ @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var u: Params; @group(0) @binding(2) var output: texture_storage_2d<{store_format}, write>; +// A pass whose work is a list rather than a kernel reads it here; every other +// pass leaves this bound to a single empty element and never looks at it. See +// `DetailPass::storage` for why the list is not in the uniform block. +@group(0) @binding(3) var instances: array>; // A neighbour, clamped to the edge of the image. // @@ -681,6 +747,10 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ // What this render is, relative to the export it has to match. let render_dims = u.detail_base.xy; let render_scale = u.detail_base.z; + // How many entries `instances` actually holds, read from the buffer itself + // rather than from a uniform so the two cannot disagree. A pass that + // declared no list is bound to a one-element placeholder and never asks. + let instance_count = arrayLength(&instances); var c = tap(coord, vec2(0)); // One scalar per pixel that survives the hand-off from one pass to the @@ -708,6 +778,7 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ label, source, uniforms: uniform_values, + storage: pass.storage.clone(), radius: pass.radius, writes_output, structure_hash, @@ -793,6 +864,7 @@ mod tests { &crate::Framing::new(), dr_types::ColourSpace::Srgb, &crate::mask::MaskStack::new(), + &crate::spot::SpotSet::new(), ) } diff --git a/core/dr-pipeline/src/detail/probe.rs b/core/dr-pipeline/src/detail/probe.rs index e9a6039..361fb4b 100644 --- a/core/dr-pipeline/src/detail/probe.rs +++ b/core/dr-pipeline/src/detail/probe.rs @@ -141,6 +141,8 @@ impl DetailStage for BoxBlur { .map(|(axis, _)| DetailPass { label: if axis == 0 { "horizontal" } else { "vertical" }, radius: r, + // A convolution, not a list: nothing to bind at binding 3. + storage: Vec::new(), uniforms: vec![ Uniform { name: "radius", diff --git a/core/dr-pipeline/src/framing.rs b/core/dr-pipeline/src/framing.rs index accde58..a92cf89 100644 --- a/core/dr-pipeline/src/framing.rs +++ b/core/dr-pipeline/src/framing.rs @@ -359,6 +359,7 @@ impl Framing { self.baseline = orientation; } + /// TRACES: FR-DEV-3 | FR-DEV-3h /// The baseline and the user's turns and mirrors, collapsed into one. /// /// Everything that renders or measures the frame goes through here; only @@ -370,7 +371,16 @@ impl Framing { /// axes when that turn is odd — which is exactly the case that a naive /// "add the turns, or the flags" gets wrong, and gets wrong silently, /// since the result is still a valid-looking orientation. - fn effective(&self) -> (u8, bool, bool) { + /// + /// Returned as an [`dr_types::Orientation`] because that is what it *is* + /// — a quarter turn and two mirrors — and because saying so lets a + /// caller outside the render reuse + /// [`dr_types::Orientation::source_pixel`] rather than write the + /// permutation out a second time. `dr-ui`'s segmentation is that caller: + /// the detector has to read the photograph the way the photographer does, + /// and the thumbnail path already turns its pixels with the same + /// function. + pub fn effective_orientation(&self) -> dr_types::Orientation { let b = self.baseline; // The user's mirrors, seen from the far side of the baseline's turn. let (ux, uy) = if b.swaps_axes() { @@ -378,11 +388,16 @@ impl Framing { } else { (self.flip_h, self.flip_v) }; - ( - (b.quarter_turns + self.quarter_turns) % 4, - b.flip_h != ux, - b.flip_v != uy, - ) + dr_types::Orientation { + quarter_turns: (b.quarter_turns + self.quarter_turns) % 4, + flip_h: b.flip_h != ux, + flip_v: b.flip_v != uy, + } + } + + fn effective(&self) -> (u8, bool, bool) { + let o = self.effective_orientation(); + (o.quarter_turns, o.flip_h, o.flip_v) } /// Whether this stage currently changes the image. @@ -906,6 +921,61 @@ mod tests { /// turn combined with a user flip — a phone portrait that the user then /// mirrors. Naive flag-ORing renders it mirrored about the wrong axis, /// which still looks like a photograph. + /// TRACES: FR-DEV-3h + /// The render's map and `dr_types::Orientation`'s must be the same map. + /// + /// There are two ways to ask "where does this output pixel come from": + /// the shader prologue (via [`Framing::source_at`], its CPU twin), and + /// [`Orientation::source_pixel`], which the grid's thumbnails and the + /// segmentation both go through. They are written independently and they + /// have to agree, or the photograph and everything drawn over it turn + /// different ways. + /// + /// A wrong direction is what this catches, and it is worth stating what + /// that looks like: a quarter turn applied backwards is 180° from right, + /// which reads as a deliberate transform rather than as a mistake. Checked + /// over every EXIF tag and every user rotation on top of it, because the + /// composition is where the two could agree singly and disagree together. + #[test] + fn the_render_and_the_orientation_map_agree() { + const SW: u32 = 8; + const SH: u32 = 5; + + for tag in 1..=8u16 { + for user_turns in 0..4u8 { + for user_flip_h in [false, true] { + let mut f = Framing::new(); + f.set_baseline(Orientation::from_exif(tag)); + f.rotate_quarters(i32::from(user_turns)); + f.set_param(FLIP_H, f32::from(u8::from(user_flip_h))); + + let effective = f.effective_orientation(); + let (dw, dh) = effective.oriented_size(SW, SH); + assert_eq!(f.output_size(SW, SH), (dw, dh), "tag {tag}/{user_turns}"); + + for y in 0..dh { + for x in 0..dw { + let want = effective.source_pixel(x, y, dw, dh); + + let out = ((x as f32 + 0.5) / dw as f32, (y as f32 + 0.5) / dh as f32); + let (u, v) = f.source_at(out, SW, SH); + let got = ( + (u * SW as f32).floor().clamp(0.0, (SW - 1) as f32) as u32, + (v * SH as f32).floor().clamp(0.0, (SH - 1) as f32) as u32, + ); + + assert_eq!( + want, got, + "tag {tag}, user {user_turns} turn(s), flip_h {user_flip_h}: \ + output ({x},{y}) — orientation says {want:?}, the render says {got:?}" + ); + } + } + } + } + } + } + #[test] fn a_baseline_and_a_user_rotation_compose_into_one_permutation() { // Non-square and coprime, so no accidental symmetry hides an error. @@ -931,12 +1001,10 @@ mod tests { f.set_param(FLIP_H, f32::from(u8::from(user_flip_h))); f.set_param(FLIP_V, f32::from(u8::from(user_flip_v))); - let (t, fh, fv) = f.effective(); - let combined = Orientation { - quarter_turns: t, - flip_h: fh, - flip_v: fv, - }; + // The public accessor, not the private tuple: this is + // the permutation `dr-ui` turns the detector's input + // by, so it is the one that has to be right. + let combined = f.effective_orientation(); // The output size the composed transform produces must // be the one the two stages produce in sequence. diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 1cb9b41..900f25f 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -7,6 +7,8 @@ //! Order is data, not code: operations run in the sequence this holds them, //! so reordering the pipeline needs no code change. +use std::sync::Arc; + use crate::descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation, }; @@ -14,6 +16,9 @@ use crate::framing::{CropRect, Framing}; use crate::mask::MaskStack; use crate::operation::{compose_full, ComposedShader, Operation}; use crate::ops; +use crate::preset::{Preset, Scope}; +use crate::spot::SpotSet; +use crate::state::{EditState, FilmRebake, FilmRef}; /// TRACES: FR-DEV-3a /// What one operation offers, as plain data. @@ -92,7 +97,7 @@ pub struct EditGraph { /// layer *contains* a chain of its own. Folding the stack into the global /// list would make the list recursive and every consumer that walks it /// have to know that some entries are really sub-graphs. - masks: MaskStack, + masks: Arc, /// TRACES: FR-DEV-3f /// The film stock this edit renders through, if any. /// @@ -105,6 +110,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 @@ -146,8 +160,9 @@ impl EditGraph { Self { ops: ops::chain(), framing: Framing::new(), - masks: MaskStack::new(), + masks: Arc::new(MaskStack::new()), film: None, + spots: SpotSet::new(), } } @@ -173,13 +188,30 @@ 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 } + /// The stack, to modify. + /// + /// Clones on write. The stack is shared with every [`EditState`] snapshot + /// taken since it last changed — the undo stack holds a run of them — so + /// this is where a shared stack becomes this graph's own again. Callers + /// see no difference; what it buys is that recording a slider drag does + /// not deep-copy a painted mask once a frame. pub fn masks_mut(&mut self) -> &mut MaskStack { - &mut self.masks + Arc::make_mut(&mut self.masks) } /// The framing — crop, straighten, rotation and flips. @@ -310,6 +342,89 @@ impl EditGraph { self.film.as_ref() } + /// TRACES: FR-DEV-5 | FR-CAT-8 + /// The whole edit, as data — what an undo step and a sidecar are both + /// made of. + /// + /// **The pattern below is exhaustive on purpose.** It is the only thing + /// standing between a new kind of graph state and an undo that quietly + /// ignores it, which is exactly how the mask stack came to be missing + /// from the history for as long as it was. Never add `..` to it: a field + /// added to this struct should fail to compile here until somebody has + /// decided whether stepping backwards has to put it back. See + /// [`crate::state`]. + pub fn state(&self) -> EditState { + let Self { + // Both reached through `capabilities`, which is the one walk the + // develop panel, the clipboard and the sidecar already make — so + // an operation is undoable by virtue of being in the chain, with + // nothing to register (FR-DEV-3c). + ops: _, + framing: _, + masks, + film, + spots, + } = self; + + EditState { + params: Preset::capture(self), + // A refcount bump. See `EditState::masks` for why that matters on + // a path called once a frame. + masks: Arc::clone(masks), + // The names travel; the tables do not. They are derived, and this + // crate cannot rebuild them — hence `FilmRebake`. + film: film.as_ref().map(|f| FilmRef { + stock: f.stock.clone(), + print: f.print.clone(), + }), + spots: spots.clone(), + } + } + + /// TRACES: FR-DEV-5 | FR-CAT-8 + /// Put `state` back, replacing whatever this graph held. + /// + /// A *replacement*, not an overlay: a parameter absent from the state + /// means default, and a state with no mask blocks means an edit with no + /// local adjustments rather than an edit that keeps whatever was on + /// screen. That is the same rule [`Preset::apply`] and + /// [`crate::Version::apply`] keep, and for the same reason — "the + /// photograph is now as it was" is the whole claim the call makes. + /// + /// The **viewport survives**, because [`Preset::apply`] preserves it. Zoom + /// says where the user is looking rather than what the picture is, and an + /// undo that refitted the frame would read as having navigated somewhere. + /// + /// The pattern below is exhaustive for the reason [`Self::state`]'s is. + pub fn set_state(&mut self, state: &EditState) -> FilmRebake { + let EditState { + params, + masks, + film, + spots, + } = state; + + // At full scope. `Scope` is a question about what a paste carries + // *between* photographs; this is one photograph's own edit being put + // back, so there is nothing to leave behind. + params.apply(self, Scope::Everything); + + self.masks = Arc::clone(masks); + self.spots = spots.clone(); + + // Cleared either way, and when a stock is named the caller bakes it. + // Left standing, the tables now in the graph would be the ones baked + // from the film node's *previous* exposure sliders — and those sliders + // were just replaced, so the restored state would render through the + // film of the state it replaced. Clearing is the conservative half of + // that; `Wanted` is the half that gets it back. + self.set_film(None); + match film { + None => FilmRebake::NotNeeded, + Some(want) => FilmRebake::Wanted(want.clone()), + } + } + pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) { if op == crate::framing::ID { let Some(desc) = self.framing.descriptor().param(param) else { @@ -362,7 +477,7 @@ impl EditGraph { // Masks go too, and this is why `apply` can be a replacement rather // than an overlay: a sidecar with no mask blocks means an edit with no // local adjustments, not an edit that keeps whatever was on screen. - self.masks = MaskStack::new(); + self.masks = Arc::new(MaskStack::new()); // The film goes too, for the reason the masks do. Restoring it is the // *caller's* job rather than `Version::apply`'s: a sidecar names a // stock, and turning a name into tables needs the profile database, @@ -402,6 +517,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. @@ -421,7 +537,7 @@ impl EditGraph { /// graph renders to the screen and to a file in the same breath, and the /// two want different answers. pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader { - compose_full(&self.ops, &self.framing, output, &self.masks) + compose_full(&self.ops, &self.framing, output, &self.masks, &self.spots) } /// TRACES: FR-DSP-1 @@ -462,9 +578,10 @@ impl EditGraph { /// single encoded dispatch it always has. pub fn compose_detail( &self, - scale: crate::detail::RenderScale, + source: (u32, u32), + render: (u32, u32), ) -> crate::detail::ComposedDetail { - self.compose_detail_for(scale, dr_types::ColourSpace::Srgb) + self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb) } /// TRACES: FR-EXP-2 @@ -475,12 +592,21 @@ impl EditGraph { /// transform — the fused pass stops at linear working values. Composing /// the two halves for different spaces would encode the edit twice, or /// not at all. + /// `source` is the demosaiced image's size and `render` the size being + /// drawn. The scale is worked out here rather than handed in, because the + /// repairs need the *source* size as well — a spot is stored in normalised + /// source coordinates and has to be put through the framing to find out + /// where it lands on this render, and a [`crate::detail::RenderScale`] + /// describes the region on screen rather than the photograph. pub fn compose_detail_for( &self, - scale: crate::detail::RenderScale, + source: (u32, u32), + render: (u32, u32), output: dr_types::ColourSpace, ) -> crate::detail::ComposedDetail { - crate::detail::compose_detail(&self.ops, scale, output) + let scale = self.render_scale(source, render); + let spots = self.spots.passes(&self.framing, source, scale); + crate::detail::compose_detail_with(&self.ops, &spots, scale, output) } /// TRACES: FR-DEV-3d @@ -552,6 +678,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/history.rs b/core/dr-pipeline/src/history.rs index cb5a9e3..226c3fe 100644 --- a/core/dr-pipeline/src/history.rs +++ b/core/dr-pipeline/src/history.rs @@ -3,10 +3,9 @@ //! //! # A stack of snapshots, not a stack of commands //! -//! The edit graph is plain data, and [`Preset::capture`] already reduces it to -//! the values that differ from default. So a history is a list of those, and -//! undo is [`Preset::apply`] at full scope — the same two calls the clipboard -//! and the sidecar are built from. +//! The edit graph is plain data, and [`EditGraph::state`] reduces it to +//! exactly what an edit is. So a history is a list of those, and undo is +//! [`EditGraph::set_state`] — the same two calls the sidecar is built from. //! //! The alternative, a command per action with an inverse beside it, would be a //! second thing every operation had to register. Operations are *declared* @@ -15,6 +14,34 @@ //! A snapshot knows nothing about which operations exist, so it cannot fall //! behind them. //! +//! # The snapshot has to be of the whole edit +//! +//! That last claim held for the operations and stopped holding for everything +//! else. This stack used to snapshot a [`Preset`](crate::Preset), which is the +//! parameter map — and a mask layer is not a parameter, a film stock is not a +//! parameter, and a repair is not a parameter, all three deliberately so. +//! +//! It went wrong three times, in two different ways, which is the argument for +//! not leaving it to anyone's memory: +//! +//! - **The masks and the stock went missing quietly.** Drawing a mask changed +//! nothing a `Preset` could see, so [`History::record`] returned `false`, no +//! step was opened, and the interface went on calling it in good faith. The +//! layer a photographer had just painted had no way back, and nothing +//! panicked, no test failed, and the only signal available was a `bool` +//! nobody was reading. +//! +//! - **The repairs would have gone missing loudly.** Undo is the single most +//! expected thing to do with a spot — place it, dislike it, take it back — +//! and a snapshot that could not carry the spot set would have stepped some +//! unrelated slider instead and left the repair on the photograph. That is +//! worse than no undo at all, because it looks like undo is *broken* rather +//! than absent. It did not happen: [`EditState`] refused to compile when the +//! spot set arrived in the graph, which is what that type is for. +//! +//! So the snapshot is an [`EditState`], and what makes that stay true is a +//! compiler error rather than a habit — see [`crate::state`]. +//! //! # What makes forty events one step //! //! A drag emits a change per frame. Recorded naively that is forty undo steps, @@ -42,15 +69,19 @@ //! # What this is not, yet //! //! FR-DEV-5 also asks for history persisted with the catalog and named -//! snapshots. This is per-session and in memory: closing the image forgets it. -//! The gap that mattered was that an automatically saved mis-drag had no way -//! back at all, and that is what this closes. +//! snapshots, and FR-DEV-7 for comparing against a chosen history state. +//! This is per-session and in memory, with no way to name a step or to jump +//! to one: closing the image forgets it. The gap that mattered was that an +//! automatically saved mis-drag had no way back at all, and that is what this +//! closes. +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; -use crate::descriptor::{OpId, ParamId}; +use crate::descriptor::{LocalizedKey, OpId, ParamId}; use crate::graph::EditGraph; -use crate::preset::{Preset, Scope}; +use crate::graph::OpCapability; +use crate::state::{EditState, FilmRebake}; /// TRACES: FR-DEV-5 | NFR-RES-1 /// How many states are held, the current one included. @@ -77,22 +108,52 @@ pub const DEPTH: usize = 64; pub const COALESCE_WINDOW: Duration = Duration::from_millis(700); /// TRACES: FR-DEV-5 -/// Which control a change came from, for deciding whether two changes are one -/// gesture. +/// What the state a photograph opened in is called. /// -/// Not "what changed" — the snapshot already carries that. This exists purely -/// so that consecutive changes can be told apart from one continuing change. +/// A step like any other in the list, because it is one: it is where undo +/// stops, and a list whose first row is blank would leave the floor looking +/// like a missing entry rather than the beginning. +pub const OPENED: LocalizedKey = LocalizedKey("history.opened"); + +/// The fallback for a step naming an operation or parameter this build no +/// longer has. +/// +/// Reachable only across a version skew, and it resolves to a readable word +/// rather than to nothing: a row the user cannot identify is still better than +/// a row that is not there, since the step exists and undo will pass through +/// it either way. +pub const UNNAMED: LocalizedKey = LocalizedKey("history.edit"); + +/// TRACES: FR-DEV-5 +/// Which control a change came from. +/// +/// Two jobs, and they used to be one. Deciding whether consecutive changes are +/// one continuing gesture needs only an *identity* — that was the whole of +/// this type, and `Discrete` was a perfectly good name for "some control, no +/// gesture". Listing the steps needs each one to say what it **was**, and +/// seventeen rows reading "Discrete" is not a history. So every variant now +/// carries enough to name itself, and the compiler enumerated the call sites +/// that had to start saying so. +/// +/// It is still not "what changed" — the snapshot carries that. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Edit { - /// One parameter's own control, dragged. + /// One parameter's own control, dragged. Named by its descriptor. Param(OpId, ParamId), /// A whole operation, where one gesture moves several of its parameters at - /// once — a curve point, or a crop rectangle's four edges. + /// once — a curve point, or a crop rectangle's four edges. Named by its + /// descriptor. Op(OpId), + /// A dragged control the graph does not own: a mask layer's feather, its + /// opacity, a gradient handle. Coalesces like a slider, because it is one + /// — but there is no descriptor to ask for a name, so it carries its own. + Control(LocalizedKey), /// A change with no gesture behind it: a reset, a paste, a flip, a quarter - /// turn. Never coalesces, not even with an identical one, because two - /// clicks are two decisions however quickly they follow each other. - Discrete, + /// turn, a layer added. Never coalesces, not even with an identical one, + /// because two clicks are two decisions however quickly they follow each + /// other — which is also why the key is not enough to tell two of them + /// apart and does not have to be. + Action(LocalizedKey), } impl Edit { @@ -117,8 +178,116 @@ impl Edit { /// Whether a second change to this same control continues the first. fn is_gesture(self) -> bool { - !matches!(self, Self::Discrete) + !matches!(self, Self::Action(_)) } + + /// TRACES: FR-DEV-5 + /// What to call this step in a list of them. + /// + /// A [`LocalizedKey`], never a string: resolving one needs a localiser and + /// `core/` must not depend on one (NFR-A11Y-1). The frontend has the + /// catalogue, and an uncatalogued key derives a readable fallback — so an + /// operation added as a YAML declaration appears in the history under a + /// sensible name with no code written for it, on the same terms it appears + /// in the panel (FR-DEV-3c). + /// + /// Takes the capabilities rather than the graph so that listing a whole + /// stack walks the chain once instead of once per step. + pub fn label(self, caps: &[OpCapability]) -> LocalizedKey { + match self { + Self::Param(op, param) => caps + .iter() + .find(|c| c.id == op) + .and_then(|c| c.params.iter().find(|p| p.id == param)) + .map(|p| p.label) + .unwrap_or(UNNAMED), + Self::Op(op) => caps + .iter() + .find(|c| c.id == op) + .map(|c| c.label) + .unwrap_or(UNNAMED), + Self::Control(key) | Self::Action(key) => key, + } + } +} + +/// TRACES: FR-DEV-5 | FR-DEV-7 +/// One row of the history, for a frontend that lists them. +/// +/// Carries its own [`Self::index`] rather than leaving the caller to infer one +/// from a position in the returned list. The list a photographer reads runs +/// newest-first — the same order the trash is reviewed in, and for the same +/// reason: it is consulted to undo a recent mistake rather than browsed +/// chronologically — so the row's position and its position in the stack are +/// deliberately not the same number, and a frontend doing that arithmetic +/// itself is a frontend that will one day jump to the wrong state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// Where this state sits in the stack. What [`History::go_to`] takes. + pub index: usize, + /// What to call it. A localisation key; the frontend resolves it. + pub label: LocalizedKey, + /// Whether this is the state the graph is showing now. + pub current: bool, +} + +/// TRACES: FR-DEV-5 +/// What one press of undo or redo did. +/// +/// Not a `bool`, because stepping is not the only outcome a caller has to act +/// on: a step that crosses a change of film leaves the graph without its +/// tables, and only the caller can bake them back (see [`FilmRebake`]). A +/// `bool` would let that be dropped by writing nothing at all, which is the +/// shape of mistake this module has already made once. +#[derive(Debug, Clone, PartialEq)] +#[must_use = "a step that is not acted on leaves the panel showing the old values"] +pub enum Step { + /// Nowhere to go — the stack is at its floor, or at its head. + Nowhere, + /// The graph now holds the neighbouring state, and this is what it is + /// still owed. + Took(FilmRebake), +} + +impl Step { + /// Whether the graph moved. + /// + /// The panel has to be rebuilt when it did: undo replaces the values the + /// controls are showing and nothing here pushes them. + pub fn moved(&self) -> bool { + matches!(self, Self::Took(_)) + } + + /// The film this step needs baked back, if any. + pub fn rebake(&self) -> Option<&crate::state::FilmRef> { + match self { + Self::Nowhere => None, + Self::Took(film) => film.wanted(), + } + } +} + +/// Hands out revisions, shared by every [`History`] in the process. +/// +/// See [`History::revision`]. `Relaxed` because the only thing required of +/// these values is that they differ: nothing is published through the counter, +/// and a frontend comparing two of them is doing so on its own thread. +static REVISIONS: AtomicU64 = AtomicU64::new(0); + +fn next_revision() -> u64 { + REVISIONS.fetch_add(1, Ordering::Relaxed) +} + +/// A state, and what put the graph into it. +/// +/// The edit is kept for the *list*, not for the stepping: undo restores by +/// replacing the whole state, so it never needs to know what the change was. +/// It needs to know only in order to say so. +#[derive(Debug, Clone, PartialEq)] +struct Snapshot { + state: EditState, + /// `None` for the floor, which nothing in this session produced. + edit: Option, } /// TRACES: FR-DEV-5 @@ -129,8 +298,36 @@ impl Edit { /// where redo goes. `states` is never empty — the state the history was opened /// on is the floor, and undo stops there rather than at nothing. pub struct History { - states: Vec, + states: Vec, cursor: usize, + /// A value that changes whenever [`Self::entries`] would read differently, + /// and that **no other history will ever report**. + /// + /// For a frontend that draws the list. Rebuilding it costs a walk of the + /// chain, sixty-odd label lookups and — the part that actually hurts — a + /// model reset that makes the toolkit tear down and rebuild every row. A + /// drag emits a change per frame and coalesces into the step already on + /// top, so during the one gesture where that cost would be paid sixty + /// times a second, the list is not changing at all. + /// + /// A counter rather than the `(depth, cursor)` pair it is tempting to + /// compare instead: stepping back one and then editing truncates the tail + /// and pushes a replacement, which can land on the same depth and the same + /// cursor with a different step on top. The pair would call that unchanged + /// and the panel would name the branch the photographer just abandoned. + /// + /// **Drawn from a counter shared by every history in the process**, which + /// is the part that is easy to leave out and expensive to add back. A + /// per-instance counter starts each photograph at the same number, so a + /// frontend holding "the revision I last drew" sees a *stale* value match + /// a *fresh* history and keeps the previous photograph's list on screen. + /// Today that is invisible, because a freshly opened image always has the + /// same one row — and it stops being invisible the moment FR-DEV-5's + /// persisted history means an image opens with steps already in it, at + /// which point the panel shows another photograph's work. A shared counter + /// is two lines and the failure cannot occur; the alternative is every + /// frontend remembering to invalidate on open. + revision: u64, /// What produced `states[cursor]`, and when — the pair that decides /// whether the next change amends it. `None` means the current step is /// closed: nothing may be folded into it. @@ -141,8 +338,12 @@ impl History { /// Start from the state `graph` is in. pub fn new(graph: &EditGraph) -> Self { Self { - states: vec![Preset::capture(graph)], + states: vec![Snapshot { + state: graph.state(), + edit: None, + }], cursor: 0, + revision: next_revision(), last: None, } } @@ -168,15 +369,22 @@ impl History { /// [`Self::record`] with the clock supplied, so coalescing can be tested /// without sleeping. pub fn record_at(&mut self, graph: &EditGraph, edit: Edit, at: Instant) -> bool { - let state = Preset::capture(graph); - if self.states.get(self.cursor) == Some(&state) { + let state = graph.state(); + if self.states.get(self.cursor).map(|s| &s.state) == Some(&state) { return false; } if self.coalesces(edit, at) { if let Some(top) = self.states.get_mut(self.cursor) { - *top = state; + // The label stays whatever opened the step. It is the same + // control by definition — that is what coalescing decided — + // and rewriting it every frame of a drag would be work to + // arrive back at the word already there. + top.state = state; self.last = Some((edit, at)); + // No bump. The row is the same row with the same name; only + // the state behind it moved, and nothing drawing the list can + // tell. This is the case the counter exists for. return true; } } @@ -185,7 +393,10 @@ impl History { // is no longer reachable from here, and keeping it would let redo jump // to a state this one was never derived from. self.states.truncate(self.cursor + 1); - self.states.push(state); + self.states.push(Snapshot { + state, + edit: Some(edit), + }); self.cursor = self.states.len().saturating_sub(1); // Oldest first, so what is lost is the part furthest from where the @@ -197,6 +408,7 @@ impl History { } self.last = Some((edit, at)); + self.revision = next_revision(); true } @@ -222,36 +434,38 @@ impl History { self.cursor + 1 < self.states.len() } - /// Step `graph` back one, returning whether there was anywhere to go. + /// Step `graph` back one. /// - /// Applied at [`Scope::Everything`]: framing is as undoable as colour, and - /// a crop drag the user wants back is one of the likelier reasons to - /// reach for this. The view survives, because [`Preset::apply`] preserves - /// it — an undo that also jumped the viewport would read as navigation. - pub fn undo(&mut self, graph: &mut EditGraph) -> bool { + /// The whole edit goes back, not merely its colour: framing is as undoable + /// as exposure, a mask as undoable as either, and a crop drag the user + /// wants back is one of the likelier reasons to reach for this. The view + /// survives, because [`EditGraph::set_state`] preserves it — an undo that + /// also jumped the viewport would read as navigation. + pub fn undo(&mut self, graph: &mut EditGraph) -> Step { let Some(target) = self.cursor.checked_sub(1) else { - return false; + return Step::Nowhere; }; self.restore(graph, target) } - /// Step `graph` forward one, returning whether there was anywhere to go. - pub fn redo(&mut self, graph: &mut EditGraph) -> bool { + /// Step `graph` forward one. + pub fn redo(&mut self, graph: &mut EditGraph) -> Step { self.restore(graph, self.cursor + 1) } - fn restore(&mut self, graph: &mut EditGraph, target: usize) -> bool { - let Some(state) = self.states.get(target) else { - return false; + fn restore(&mut self, graph: &mut EditGraph, target: usize) -> Step { + let Some(snapshot) = self.states.get(target).cloned() else { + return Step::Nowhere; }; - state.apply(graph, Scope::Everything); + let rebake = graph.set_state(&snapshot.state); self.cursor = target; // Closes the current step. Without this, a slider moved immediately // after an undo would fold into the step it was just undone out of — // and the state the user had just recovered would be overwritten by // the very edit they made from it. self.last = None; - true + self.revision = next_revision(); + Step::Took(rebake) } /// How many states are held, the current one included. @@ -261,15 +475,119 @@ impl History { pub fn depth(&self) -> usize { self.states.len() } + + /// TRACES: FR-DEV-5 + /// Where in the stack the graph currently stands. + /// + /// Everything below it is undo; everything above it is redo. Exposed so a + /// frontend can mark the row rather than infer it from a run of + /// [`Self::can_undo`] calls. + pub fn cursor(&self) -> usize { + self.cursor + } + + /// TRACES: FR-DEV-5 + /// A number that changes exactly when [`Self::entries`] would. + /// + /// What a frontend compares against the last list it drew, so that a drag + /// — which amends the step on top sixty times a second without changing + /// a single row — does not rebuild the panel once a frame. + pub fn revision(&self) -> u64 { + self.revision + } + + /// TRACES: FR-DEV-5 | FR-DEV-7 + /// Every step, oldest first, each saying what it was. + /// + /// **Oldest first, though a photographer reads the list the other way + /// round.** The order a list is *displayed* in is a frontend decision — it + /// depends on where the panel is and which end has the room — whereas the + /// order the stack is in is a fact. Reversing here would bake one + /// interface's choice into the core and leave every [`Entry::index`] + /// counting backwards for everyone else. + /// + /// Walks the chain once for the whole list rather than once per step: + /// resolving a label asks the descriptors, and sixty-four steps against a + /// twenty-operation chain is otherwise a thousand lookups for one panel + /// refresh. + pub fn entries(&self, graph: &EditGraph) -> Vec { + let caps = graph.capabilities(); + self.states + .iter() + .enumerate() + .map(|(index, snapshot)| Entry { + index, + label: snapshot.edit.map_or(OPENED, |edit| edit.label(&caps)), + current: index == self.cursor, + }) + .collect() + } + + /// TRACES: FR-DEV-5 | FR-DEV-7 + /// Jump straight to one step, however far away it is. + /// + /// The same restore undo and redo make — a step is a whole state, so + /// arriving at one from six steps away costs exactly what arriving from + /// one does, and there is no sequence of intermediate states to replay. + /// That is the property that makes a clickable list worth having rather + /// than a decoration over the buttons. + /// + /// Jumping to where the graph already is reports [`Step::Nowhere`], so a + /// frontend can click the current row without paying for a redraw. + pub fn go_to(&mut self, graph: &mut EditGraph, index: usize) -> Step { + if index == self.cursor { + return Step::Nowhere; + } + self.restore(graph, index) + } } #[cfg(test)] mod tests { use super::*; use crate::framing; + use crate::graph::Film; + use crate::mask::{MaskLayer, MaskSource}; use crate::ops::{curve, exposure, saturation}; + use crate::state::FilmRef; use crate::CropRect; + /// A radial layer, the cheapest thing a photographer can put on a picture + /// that is not a number. + fn layer(id: &str) -> MaskLayer { + MaskLayer::new( + id, + MaskSource::Radial { + centre: (0.5, 0.5), + radii: (0.25, 0.25), + angle: 0.0, + feather: 0.2, + }, + ) + } + + /// A stock with tables well-formed enough for the film node to keep them. + /// The numbers are not a real emulsion and do not need to be — what is + /// under test is whether the *choice* survives a step. + fn film(stock: &str) -> Film { + Film { + stock: stock.to_string(), + print: None, + tables: crate::ops::FilmTables { + exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES], + curve_log_min: -3.0, + curve_log_max: 1.0, + lut: vec![[0.5, 0.5, 0.5]; 8], + density_max: 2.0, + lut_size: 2, + grain_particles: [0.0; 3], + grain_density_max: [2.0; 3], + grain_uniformity: 1.0, + }, + } + } + fn at(base: Instant, ms: u64) -> Instant { base + Duration::from_millis(ms) } @@ -288,6 +606,359 @@ mod tests { } } + #[test] + fn two_photographs_never_report_the_same_revision() { + // A frontend holds the revision it last drew and rebuilds when it + // moves. Per-instance counters start every photograph at the same + // number, so a stale value matches a fresh history and the panel keeps + // the previous image's steps on screen — invisible while every image + // opens with one identical row, and a wrong-photograph bug the moment + // FR-DEV-5's persisted history means it does not. + let g = EditGraph::default_chain(); + let first = History::new(&g); + let second = History::new(&g); + assert_ne!(first.revision(), second.revision()); + + // Including across the reset that opening an image makes. + let mut reused = History::new(&g); + let before = reused.revision(); + reused.reset(&g); + assert_ne!(reused.revision(), before); + assert_ne!(reused.revision(), first.revision()); + assert_ne!(reused.revision(), second.revision()); + } + + #[test] + fn a_drag_does_not_make_the_list_look_different() { + // The whole reason the counter exists. A drag emits a change per frame + // and folds every one into the step already on top: the rows do not + // move, and a panel that rebuilt itself anyway would tear down and + // recreate sixty rows sixty times a second for no visible change. + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + let base = Instant::now(); + + g.set_param(exposure::ID, exposure::EXPOSURE, 0.1); + h.record_at( + &g, + Edit::for_param(&g, exposure::ID, exposure::EXPOSURE), + base, + ); + let opened = h.revision(); + + drag(&mut h, &mut g, base, 0.1, 1.5, 40); + assert_eq!( + h.revision(), + opened, + "coalescing into one row still moved the counter" + ); + assert_eq!(h.entries(&g).len(), 2); + } + + #[test] + fn the_counter_moves_for_everything_that_shows() { + // The complement, and each of these is a case that would otherwise + // leave the panel drawing a list the photograph is no longer in. + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + + let mut seen = vec![h.revision()]; + let note = |h: &History, seen: &mut Vec, what: &str| { + assert!( + !seen.contains(&h.revision()), + "{what} did not move the counter" + ); + seen.push(h.revision()); + }; + + g.set_param(exposure::ID, exposure::EXPOSURE, 0.1); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); + note(&h, &mut seen, "recording a step"); + + assert!(h.undo(&mut g).moved()); + note(&h, &mut seen, "an undo"); + + assert!(h.redo(&mut g).moved()); + note(&h, &mut seen, "a redo"); + + assert!(h.go_to(&mut g, 0).moved()); + note(&h, &mut seen, "a jump"); + + h.reset(&EditGraph::default_chain()); + note(&h, &mut seen, "opening another photograph"); + } + + #[test] + fn a_step_that_moves_nothing_leaves_the_counter_alone() { + // `record` returning false means no row appeared, so nothing that + // draws rows has anything to do. + let g = EditGraph::default_chain(); + let mut h = History::new(&g); + let before = h.revision(); + assert!(!h.record(&g, Edit::Action(LocalizedKey("history.test")))); + assert_eq!(h.revision(), before); + } + + #[test] + fn every_step_says_what_it_was() { + // The reason `Discrete` had to go. A list is only worth drawing if the + // rows are distinguishable, and a parameter names itself out of the + // descriptor rather than out of a table here — so an operation added + // as a YAML declaration appears in the history with no code written + // for it, exactly as it appears in the panel (FR-DEV-3c). + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + + g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); + h.record(&g, Edit::for_param(&g, exposure::ID, exposure::EXPOSURE)); + g.set_param(curve::ID, curve::P1_X, 0.4); + h.record(&g, Edit::for_param(&g, curve::ID, curve::P1_X)); + g.masks_mut().push(layer("l1")); + h.record(&g, Edit::Action(LocalizedKey("history.mask_added"))); + + let labels: Vec<&str> = h.entries(&g).iter().map(|e| e.label.0).collect(); + assert_eq!( + labels, + vec![ + "history.opened", + "param.exposure", + // The curve point is one widget over two parameters, so the + // step is the operation and it is named as one. + "op.tone_curve", + "history.mask_added", + ] + ); + } + + #[test] + fn the_list_marks_where_the_photograph_stands() { + // What a panel draws the highlight from. Everything below the mark is + // undo and everything above it is redo, so a mark in the wrong place + // is a list that lies about which way the photograph will move. + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + for v in [0.1, 0.2, 0.3] { + g.set_param(exposure::ID, exposure::EXPOSURE, v); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); + } + + assert_eq!(current(&h, &g), 3); + assert!(h.undo(&mut g).moved()); + assert_eq!(current(&h, &g), 2); + assert_eq!(h.cursor(), 2); + + // Exactly one row is ever marked. + assert_eq!(h.entries(&g).iter().filter(|e| e.current).count(), 1); + } + + #[test] + fn a_row_can_be_jumped_to_from_any_distance() { + // The property that makes the list clickable rather than decorative: + // a step is a whole state, so arriving from six steps away costs what + // arriving from one does and there is nothing to replay in between. + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + for v in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] { + g.set_param(exposure::ID, exposure::EXPOSURE, v); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); + } + assert_eq!(h.depth(), 7, "the floor and one step per value"); + + assert!(h.go_to(&mut g, 1).moved()); + assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.1)); + + // And forward again, over the same distance. + assert!(h.go_to(&mut g, 6).moved()); + assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.6)); + } + + #[test] + fn jumping_to_the_row_already_showing_does_nothing() { + // A panel highlights the current row, and a row is a thing people + // click. Reported as `Nowhere` so the frontend can skip the redraw + // rather than re-rendering the frame it is already looking at. + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); + + assert!(!h.go_to(&mut g, h.cursor()).moved()); + assert!( + !h.go_to(&mut g, 99).moved(), + "a row off the end is not a step" + ); + assert_eq!(h.cursor(), 1, "and neither moved the cursor"); + } + + #[test] + fn editing_from_a_row_in_the_middle_drops_the_rows_above_it() { + // Jumping back and then working is the branch the user chose. The + // steps that were above are gone from the list as well as from redo, + // because a row that cannot be reached is a row that must not be + // offered. + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + for v in [0.1, 0.2, 0.3] { + g.set_param(exposure::ID, exposure::EXPOSURE, v); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); + } + + assert!(h.go_to(&mut g, 1).moved()); + g.set_param(saturation::ID, saturation::SATURATION, 20.0); + h.record(&g, Edit::Action(LocalizedKey("history.other"))); + + let entries = h.entries(&g); + assert_eq!(entries.len(), 3, "the abandoned branch is still listed"); + assert!(entries.last().is_some_and(|e| e.current)); + assert!(!h.can_redo()); + } + + #[test] + fn a_drag_stays_one_row_named_once() { + // Coalescing has to hold for the list as well as for the stepping: a + // drag that amended its step must not also rewrite its own label + // forty times, and must not appear forty times. + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + let base = Instant::now(); + drag(&mut h, &mut g, base, 0.0, 1.5, 40); + + let entries = h.entries(&g); + assert_eq!(entries.len(), 2); + assert_eq!(entries[1].label.0, "param.exposure"); + } + + /// The index the list says the photograph is standing on. + fn current(h: &History, g: &EditGraph) -> usize { + h.entries(g) + .into_iter() + .find(|e| e.current) + .expect("some row is always current") + .index + } + + #[test] + fn a_mask_the_user_just_drew_can_be_taken_back() { + // The bug this module was rewritten for. A snapshot used to be a + // `Preset` — the parameter map — and a mask layer is deliberately not + // a parameter, so drawing one changed nothing the history could see: + // `record` returned `false`, no step opened, and the layer had no way + // back. Nothing failed; the undo simply was not there. + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + + g.masks_mut().push(layer("l1")); + assert!( + h.record(&g, Edit::Action(LocalizedKey("history.test"))), + "drawing a mask has to open a step" + ); + + assert!(h.undo(&mut g).moved()); + assert!( + g.masks().is_empty(), + "the layer survived an undo: {} left", + g.masks().len() + ); + assert!(h.redo(&mut g).moved()); + assert_eq!(g.masks().len(), 1, "and redo has to put it back"); + } + + #[test] + fn editing_a_layer_is_a_step_of_its_own() { + // Not merely adding and removing: the settings *inside* a layer are + // the part a photographer works at, and they are as far from being a + // graph parameter as the layer itself. + let mut g = EditGraph::default_chain(); + g.masks_mut().push(layer("l1")); + let mut h = History::new(&g); + + if let Some(l) = g.masks_mut().get_mut("l1") { + l.opacity = 0.4; + } + assert!(h.record(&g, Edit::Op(OpId("mask-opacity")))); + + assert!(h.undo(&mut g).moved()); + let back = g.masks().get("l1").expect("the layer itself must remain"); + assert!( + (back.opacity - 1.0).abs() < 1e-6, + "the opacity did not come back: {}", + back.opacity + ); + } + + #[test] + fn undoing_across_a_change_of_film_asks_for_the_stock_back() { + // A stock is not a scalar either, so it went missing the same way. It + // needs the extra half-step because this crate cannot bake tables — + // the step reports what it owes rather than leaving the caller to + // remember, which `Version::update` is the standing evidence for. + let mut g = EditGraph::default_chain(); + g.set_film(Some(film("kodak_portra_400"))); + let mut h = History::new(&g); + + g.set_film(Some(film("ilford_hp5"))); + assert!( + h.record(&g, Edit::Action(LocalizedKey("history.test"))), + "a change of stock is a step" + ); + + let step = h.undo(&mut g); + assert!(step.moved()); + assert_eq!( + step.rebake(), + Some(&FilmRef { + stock: "kodak_portra_400".into(), + print: None, + }), + "undo must name the stock it needs baked back" + ); + } + + #[test] + fn clearing_the_film_is_undoable_and_the_floor_has_none_to_restore() { + let mut g = EditGraph::default_chain(); + let mut h = History::new(&g); + + g.set_film(Some(film("kodak_portra_400"))); + assert!(h.record(&g, Edit::Action(LocalizedKey("history.test")))); + + let step = h.undo(&mut g); + assert!(step.moved()); + assert_eq!( + step.rebake(), + None, + "there was no film to go back to, so nothing is owed" + ); + assert!(g.film().is_none()); + } + + #[test] + fn a_step_that_leaves_the_masks_alone_does_not_copy_them() { + // The reason `EditState` shares its stack rather than owning it. + // `record` runs on every parameter change, which during a drag is once + // a frame; a painted brush is thousands of stroke points, and + // deep-copying it sixty times a second to note an exposure move would + // be a cost paid for nothing at all. + let mut g = EditGraph::default_chain(); + g.masks_mut().push(layer("l1")); + + let before = g.state(); + g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); + let after = g.state(); + + assert!( + std::sync::Arc::ptr_eq(&before.masks, &after.masks), + "an exposure move deep-copied the mask stack" + ); + + // And the sharing ends the moment a layer is actually touched, or the + // two snapshots would be the same object and undo would restore + // nothing. + g.masks_mut().push(layer("l2")); + assert!(!std::sync::Arc::ptr_eq(&before.masks, &g.state().masks)); + assert_eq!(before.masks.len(), 1, "the older snapshot was mutated"); + } + #[test] fn a_whole_drag_of_one_slider_is_a_single_undo_step() { // The reason this type is not a plain stack. A drag emits an event per @@ -300,7 +971,7 @@ mod tests { drag(&mut h, &mut g, base, 0.0, 1.5, 40); assert_eq!(h.depth(), 2, "the drag should have added exactly one state"); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0)); assert!(!h.can_undo(), "undo went behind the state it opened in"); } @@ -326,7 +997,7 @@ mod tests { ); assert_eq!(h.depth(), 3); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0)); } @@ -349,7 +1020,7 @@ mod tests { at(base, 16), ); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!(g.param(saturation::ID, saturation::SATURATION), Some(0.0)); assert_eq!( g.param(exposure::ID, exposure::EXPOSURE), @@ -406,7 +1077,7 @@ mod tests { } assert_eq!(h.depth(), 2); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert!( g.crop().is_full(), "the frame did not come back: {:?}", @@ -425,9 +1096,9 @@ mod tests { let base = Instant::now(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); - h.record_at(&g, Edit::Discrete, base); + h.record_at(&g, Edit::Action(LocalizedKey("history.test")), base); g.set_param(saturation::ID, saturation::SATURATION, 20.0); - h.record_at(&g, Edit::Discrete, at(base, 5)); + h.record_at(&g, Edit::Action(LocalizedKey("history.test")), at(base, 5)); assert_eq!(h.depth(), 3); } @@ -442,10 +1113,10 @@ mod tests { let mut h = History::new(&g); g.set_param(exposure::ID, exposure::EXPOSURE, 99.0); - assert!(h.record(&g, Edit::Discrete)); + assert!(h.record(&g, Edit::Action(LocalizedKey("history.test")))); // Clamped at the descriptor's ceiling, so this asks for no movement. g.set_param(exposure::ID, exposure::EXPOSURE, 120.0); - assert!(!h.record(&g, Edit::Discrete)); + assert!(!h.record(&g, Edit::Action(LocalizedKey("history.test")))); assert_eq!(h.depth(), 2); } @@ -461,12 +1132,12 @@ mod tests { g.set_param(exposure::ID, exposure::EXPOSURE, 1.25); g.set_param(saturation::ID, saturation::SATURATION, -40.0); g.set_param(framing::ID, framing::ANGLE, 3.0); - h.record(&g, Edit::Discrete); - let after = Preset::capture(&g); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); + let after = g.state(); - assert!(h.undo(&mut g)); - assert!(h.redo(&mut g)); - assert_eq!(Preset::capture(&g), after); + assert!(h.undo(&mut g).moved()); + assert!(h.redo(&mut g).moved()); + assert_eq!(g.state(), after); assert!(!h.can_redo()); } @@ -480,11 +1151,11 @@ mod tests { let mut h = History::new(&g); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); - h.record(&g, Edit::Discrete); - assert!(h.undo(&mut g)); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); + assert!(h.undo(&mut g).moved()); g.set_param(saturation::ID, saturation::SATURATION, 25.0); - h.record(&g, Edit::Discrete); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); assert!(!h.can_redo()); assert_eq!(h.depth(), 2); @@ -509,7 +1180,7 @@ mod tests { at(base, 2000), ); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0)); g.set_param(exposure::ID, exposure::EXPOSURE, 1.1); @@ -519,7 +1190,7 @@ mod tests { at(base, 2010), ); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert_eq!( g.param(exposure::ID, exposure::EXPOSURE), Some(1.0), @@ -539,14 +1210,18 @@ mod tests { for i in 1..=(DEPTH as u32 * 2) { g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.01); - h.record_at(&g, Edit::Discrete, at(base, u64::from(i) * 1000)); + h.record_at( + &g, + Edit::Action(LocalizedKey("history.test")), + at(base, u64::from(i) * 1000), + ); } assert_eq!(h.depth(), DEPTH); // Every step still present is reachable, and the walk stops at the // floor rather than running off the end. let mut steps = 0; - while h.undo(&mut g) { + while h.undo(&mut g).moved() { steps += 1; } assert_eq!(steps, DEPTH - 1); @@ -560,7 +1235,7 @@ mod tests { let mut g = EditGraph::default_chain(); let mut h = History::new(&g); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); - h.record(&g, Edit::Discrete); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); let mut next = EditGraph::default_chain(); next.set_param(saturation::ID, saturation::SATURATION, 10.0); @@ -579,7 +1254,7 @@ mod tests { let mut g = EditGraph::default_chain(); let mut h = History::new(&g); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); - h.record(&g, Edit::Discrete); + h.record(&g, Edit::Action(LocalizedKey("history.test"))); g.framing_mut().set_view(CropRect { x: 0.25, @@ -587,7 +1262,7 @@ mod tests { width: 0.25, height: 0.25, }); - assert!(h.undo(&mut g)); + assert!(h.undo(&mut g).moved()); assert!(g.framing().is_zoomed(), "the undo threw away the viewport"); } diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index 38f7ea4..4b64e36 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -42,6 +42,8 @@ pub mod operation; pub mod ops; pub mod preset; pub mod sidecar; +pub mod spot; +pub mod state; pub use descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, @@ -52,7 +54,7 @@ pub use detail::{ }; pub use framing::{CropRect, Framing}; pub use graph::{EditGraph, OpCapability, ParamCapability}; -pub use history::{Edit, History}; +pub use history::{Edit, Entry as HistoryEntry, History, Step}; pub use lens::{compose_warps, ComposedWarp, Warp}; pub use operation::{ compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation, @@ -60,6 +62,8 @@ pub use operation::{ }; pub use preset::{Preset, Scope}; pub use sidecar::{Sidecar, Version}; +pub use spot::{Spot, SpotMode, SpotSet}; +pub use state::{EditState, FilmRebake, FilmRef}; #[cfg(test)] mod tests { @@ -150,8 +154,7 @@ mod tests { // source pixels, so on a proxy it may honestly decline to draw at all // (`RenderScale::resolves`) — which would put it in neither half and // make the assertion fail for a reason that is not a defect. - let scale = g.render_scale((4000, 3000), (4000, 3000)); - let detail = g.compose_detail(scale); + let detail = g.compose_detail((4000, 3000), (4000, 3000)); let mut fused_blocks = 0; for desc in g.descriptors() { let id = desc.id.0; diff --git a/core/dr-pipeline/src/operation.rs b/core/dr-pipeline/src/operation.rs index 1ba1764..35978bc 100644 --- a/core/dr-pipeline/src/operation.rs +++ b/core/dr-pipeline/src/operation.rs @@ -467,7 +467,13 @@ pub fn compose_with_framing( framing: &Framing, output: ColourSpace, ) -> ComposedShader { - compose_full(ops, framing, output, &MaskStack::new()) + compose_full( + ops, + framing, + output, + &MaskStack::new(), + &crate::spot::SpotSet::new(), + ) } /// TRACES: FR-DEV-3 @@ -487,6 +493,7 @@ pub fn compose_full( framing: &Framing, output: ColourSpace, masks: &MaskStack, + spots: &crate::spot::SpotSet, ) -> ComposedShader { // Active *point* operations. A neighbourhood operation is filtered out // here rather than asked for a fragment it cannot write: it reads pixels @@ -506,11 +513,18 @@ pub fn compose_full( // than from a flag the caller sets, because a caller that got the flag // wrong would produce a shader whose storage format does not match the // texture bound to it. - let output_mode = if ops.iter().any(|o| o.is_active() && o.detail().is_some()) { - OutputMode::LinearWorking - } else { - OutputMode::Encoded - }; + // + // TRACES: FR-DEV-8 + // The repairs count too, and they are the reason this takes a spot set at + // all: a photograph with a spot on it and no sharpening still has a detail + // stage, and a fused pass that encoded its own output there would quantise + // twice and be bound to a texture of the wrong format. + let output_mode = + if ops.iter().any(|o| o.is_active() && o.detail().is_some()) || !spots.is_neutral() { + OutputMode::LinearWorking + } else { + OutputMode::Encoded + }; // Whether an operation has taken over the rendering. Decided from the // operations for the same reason `output_mode` is: a caller that got it diff --git a/core/dr-pipeline/src/ops/capture_sharpen.rs b/core/dr-pipeline/src/ops/capture_sharpen.rs index 4f01908..20e4909 100644 --- a/core/dr-pipeline/src/ops/capture_sharpen.rs +++ b/core/dr-pipeline/src/ops/capture_sharpen.rs @@ -358,6 +358,8 @@ impl DetailStage for CaptureSharpen { .map(|label| DetailPass { label, radius: extent, + // A convolution, not a list: nothing to bind at binding 3. + storage: Vec::new(), uniforms: vec![ Uniform { // −100…100 as a gain around zero. A hundred percent is @@ -410,6 +412,8 @@ fn nothing_to_sharpen() -> DetailPass { label: "unresolved", // Reads only the pixel it writes, so a tile needs no halo at all. radius: 0, + // A convolution, not a list: nothing to bind at binding 3. + storage: Vec::new(), uniforms: Vec::new(), wgsl: "// The chosen radius is finer than one pixel of this render, so the detail // it would act on is not in this texture — it was lost to the downscale @@ -537,7 +541,10 @@ mod tests { } fn chain_at(graph: &EditGraph, scale: RenderScale) -> crate::detail::ComposedDetail { - graph.compose_detail_for(scale, ColourSpace::Srgb) + // The scale is what these tests vary, so it is rebuilt into the two + // sizes it stands for rather than handed over: a render of the full + // frame at `render_size`, from a source of `full_size`. + graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb) } #[test] diff --git a/core/dr-pipeline/src/ops/film_sim.rs b/core/dr-pipeline/src/ops/film_sim.rs index a4c64f5..49b8a16 100644 --- a/core/dr-pipeline/src/ops/film_sim.rs +++ b/core/dr-pipeline/src/ops/film_sim.rs @@ -37,6 +37,23 @@ pub const ID: OpId = OpId("film_sim"); pub const EXPOSURE: ParamId = ParamId("exposure"); pub const PRINT_EXPOSURE: ParamId = ParamId("print_exposure"); pub const PUSH: ParamId = ParamId("push"); +pub const FORMAT: ParamId = ParamId("format"); + +/// TRACES: FR-DEV-3f +/// The frames a photograph can be simulated on, smallest first. +/// +/// A genuinely fixed list, unlike the stocks: nobody invents a film format, so +/// this is a declared `enum` parameter and gets its control, its place in the +/// sidecar and its undo step for free. The *sizes* live in `dr_film::Format`; +/// this crate carries only the names, in the same order. +static FORMATS: [LocalizedKey; 6] = [ + LocalizedKey("param.film_sim.format.35mm"), + LocalizedKey("param.film_sim.format.645"), + LocalizedKey("param.film_sim.format.6x6"), + LocalizedKey("param.film_sim.format.6x7"), + LocalizedKey("param.film_sim.format.4x5"), + LocalizedKey("param.film_sim.format.8x10"), +]; /// How many samples a characteristic curve carries. /// @@ -70,6 +87,13 @@ static DESCRIPTOR: OpDescriptor = OpDescriptor { // actually published: Double-X's measured axis spans about -1 to +2, // and beyond a range like that a curve would have to be invented. ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0), + // TRACES: FR-DEV-3f + // Which frame this was taken on — the half of the enlargement a + // photograph cannot supply. A crystal is a fixed size in micrometres, + // so how grainy a picture looks is film size against output size, and + // the same emulsion on 4x5 renders about three times smoother than on + // 35mm at the same print. + ParamDescriptor::choice("format", "param.film_sim.format", &FORMATS), ], }; @@ -129,6 +153,8 @@ pub struct FilmSim { exposure: f32, print_exposure: f32, push: f32, + /// Index into `FORMATS`. Zero is 35 mm, which is the neutral choice. + format: f32, tables: Option, } @@ -173,6 +199,7 @@ impl Operation for FilmSim { EXPOSURE => self.exposure = value, PRINT_EXPOSURE => self.print_exposure = value, PUSH => self.push = value, + FORMAT => self.format = value, _ => log::warn!("film_sim: unknown parameter {id}"), } } @@ -182,6 +209,7 @@ impl Operation for FilmSim { EXPOSURE => self.exposure, PRINT_EXPOSURE => self.print_exposure, PUSH => self.push, + FORMAT => self.format, _ => 0.0, } } diff --git a/core/dr-pipeline/src/ops/local_contrast.rs b/core/dr-pipeline/src/ops/local_contrast.rs index 63cbd31..fe25ab5 100644 --- a/core/dr-pipeline/src/ops/local_contrast.rs +++ b/core/dr-pipeline/src/ops/local_contrast.rs @@ -475,12 +475,16 @@ impl DetailStage for LocalContrast { DetailPass { label: "base", radius, + // A convolution, not a list: nothing to bind at binding 3. + storage: Vec::new(), uniforms: shape, wgsl: BASE_X.to_string(), }, DetailPass { label: "combine", radius, + // A convolution, not a list: nothing to bind at binding 3. + storage: Vec::new(), uniforms: combine, wgsl: combine_body(B::RECIPE.midtone_taper), }, diff --git a/core/dr-pipeline/src/ops/noise_reduction.rs b/core/dr-pipeline/src/ops/noise_reduction.rs index 3006587..b57a828 100644 --- a/core/dr-pipeline/src/ops/noise_reduction.rs +++ b/core/dr-pipeline/src/ops/noise_reduction.rs @@ -436,6 +436,8 @@ impl DetailStage for NoiseReduction { passes.push(DetailPass { label: "luminance", radius: luma, + // A convolution, not a list: nothing to bind at binding 3. + storage: Vec::new(), uniforms: vec![ Uniform { name: "radius", @@ -474,6 +476,8 @@ impl DetailStage for NoiseReduction { "chroma-vertical" }, radius: chroma, + // A convolution, not a list: nothing to bind at binding 3. + storage: Vec::new(), uniforms: vec![ Uniform { name: "radius", diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index 16e0ec0..c36ad2e 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -68,7 +68,9 @@ use std::fmt::Write as _; use crate::graph::EditGraph; use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER}; -use crate::preset::{resolve, Preset}; +use crate::preset::Preset; +use crate::spot::{Spot, SpotMode, SpotSet}; +use crate::state::{EditState, FilmRebake}; /// Format version of the document itself. /// @@ -108,14 +110,10 @@ pub struct Sidecar { /// TRACES: FR-DEV-3f /// The stock and paper a version names, without the tables they bake to. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct FilmRef { - pub stock: String, - /// The paper, if the negative is printed. Absent means the film is viewed - /// as it comes — which for a colour negative is the scan, orange and - /// inverted, and is a legitimate thing to ask for. - pub print: Option, -} +/// +/// Re-exported rather than defined here: a sidecar is one of the things an +/// edit is written to, not where an edit is defined. See [`crate::state`]. +pub use crate::state::FilmRef; /// TRACES: FR-CAT-12 | FR-NC-8 /// One named edit variant. @@ -182,6 +180,15 @@ pub struct Version { /// database, which this crate does not link, so [`Self::apply`] leaves the /// graph's film cleared and the caller re-bakes — see `EditGraph::set_film`. pub film: Option, + /// TRACES: FR-DEV-8 | FR-NC-9 + /// The repairs (`docs/spot-removal.md`). + /// + /// A line per spot, keyed `spot.`, rather than a block per spot as a + /// mask gets: a spot is eight numbers, and sixty-four blocks would bury the + /// rest of the file. A line *per spot* rather than one line for the set, + /// because the line is the unit of merge and of a readable diff — the same + /// reasoning [`write_strokes`] gives for a line per stroke. + pub spots: SpotSet, /// Keys this build did not recognise, kept verbatim. /// /// An operation this build lacks would otherwise be deleted the moment an @@ -191,8 +198,22 @@ pub struct Version { } impl Version { - /// A new version holding the non-default parameters of `graph`. + /// A new version holding everything `graph`'s edit consists of. + /// + /// The destructuring is exhaustive on purpose — see [`crate::state`]. A + /// new part of an edit must not reach the file only by somebody + /// remembering to add a line here, which is how [`Self::update`] came to + /// write the masks and forget the film. pub fn from_graph(uuid: impl Into, name: impl Into, graph: &EditGraph) -> Self { + let EditState { + params, + masks, + film, + spots, + } = graph.state(); + let params = params.into_params(); + let masks = (*masks).clone(); + Self { uuid: uuid.into(), name: name.into(), @@ -206,40 +227,40 @@ impl Version { // it in the "not yet looked at" state a cull resumes from. rating: 0, flag: 0, - params: capture(graph), - masks: graph.masks().clone(), - film: graph.film().map(|f| FilmRef { - stock: f.stock.clone(), - print: f.print.clone(), - }), + params, + masks, + film, + spots, unknown: BTreeMap::new(), } } - /// Apply this version's parameters to a graph. + /// Apply this version's edit to a graph, returning the film it still owes. /// - /// The graph is reset first, so loading is a *replacement* rather than an - /// overlay: a parameter absent from the file means default, and would - /// otherwise silently inherit whatever the graph happened to hold. + /// Loading is a *replacement* rather than an overlay: a parameter absent + /// from the file means default, and would otherwise silently inherit + /// whatever the graph happened to hold. /// /// Unknown operations and parameters are skipped with a warning by /// [`EditGraph::set_param`], and values are clamped there, so a corrupt /// or newer file cannot reach a shader. - pub fn apply(&self, graph: &mut EditGraph) { + /// + /// The [`FilmRebake`] is not a new obligation — restoring a stock always + /// needed the profile database this crate does not link (ARCH §6.5a), and + /// callers were already doing it from a comment. It is the same debt made + /// impossible to walk past. + pub fn apply(&self, graph: &mut EditGraph) -> FilmRebake { + // Reset first for the *viewport's* sake, and only that: `set_state` + // deliberately preserves the view so an undo does not read as + // navigation, whereas opening a photograph should show it fitted + // rather than at the zoom the previous one was inspected at. graph.reset(); - for ((op, param), value) in &self.params { - // `OpId` and `ParamId` hold `&'static str` because descriptors - // are statics, and a sidecar's strings are not. `resolve` matches - // the file's names against the descriptors and hands back the - // static ids, so no string read from disk is ever leaked to get - // a lifetime it did not earn. - let Some((op, param)) = resolve(graph, op, param) else { - log::warn!("sidecar: unknown parameter {op}.{param}; ignoring"); - continue; - }; - graph.set_param(op, param, *value); - } - *graph.masks_mut() = self.masks.clone(); + graph.set_state(&EditState { + params: Preset::from_params(self.params.clone()), + masks: std::sync::Arc::new(self.masks.clone()), + film: self.film.clone(), + spots: self.spots.clone(), + }) } /// Record `graph` into this version, bumping the revision. @@ -248,8 +269,22 @@ impl Version { /// setter: FR-NC-9 resolves conflicts by revision, so a local edit that /// did not bump it is a local edit a remote one will silently win. pub fn update(&mut self, graph: &EditGraph, device: &str, now: i64) { - self.params = capture(graph); - self.masks = graph.masks().clone(); + // Exhaustive, and this is the call site that proves why it has to be: + // this method wrote the parameters, the masks and the repairs and + // silently dropped the film, so saving an edit developed on a stock + // lost the stock. Nothing here can be forgotten now without failing to + // compile. + let EditState { + params, + masks, + film, + spots, + } = graph.state(); + self.params = params.into_params(); + self.masks = (*masks).clone(); + self.film = film; + self.spots = spots; + self.revision = self.revision.saturating_add(1); self.device = device.to_string(); self.modified = now; @@ -314,6 +349,89 @@ impl Version { conflicts } + /// TRACES: FR-DEV-8 | FR-NC-9 + /// Merge the spot sets, returning the repairs that genuinely conflicted. + /// + /// By id, exactly as [`Self::merge_masks`] does, and for the same reason + /// one level down: a repair made on the phone and a repair made on the + /// desktop are different ids, so both survive and neither is a conflict. + /// That is most of why [`crate::spot::Spot::derive_id`] hashes the position + /// rather than counting — with counted ids the two would collide here and + /// one would be lost. + /// + /// A spot *both* sides moved resolves wholesale to the higher revision. + /// Half of one device's offset with the other's radius is a repair neither + /// photographer made, and unlike a mask there is not even a case for + /// interleaving: eight numbers describe one disc. + fn merge_spots( + &mut self, + remote: &Version, + base: Option<&Version>, + remote_wins: bool, + ) -> Vec<(String, String)> { + let empty = SpotSet::new(); + let base_spots = base.map(|b| &b.spots).unwrap_or(&empty); + let mut conflicts = Vec::new(); + + let ids: Vec = self + .spots + .spots() + .iter() + .chain(remote.spots.spots()) + .map(|s| s.id.clone()) + .collect::>() + .into_iter() + .collect(); + + for id in ids { + let ours = self.spots.get(&id); + let theirs = remote.spots.get(&id); + let was = base_spots.get(&id); + + let we_changed = ours != was; + let they_changed = theirs != was; + + match (we_changed, they_changed) { + // Only they touched it: take theirs, a deletion included. + (false, true) => match theirs { + Some(spot) => self.put_spot(spot.clone()), + None => { + self.spots.remove(&id); + } + }, + (true, true) if ours != theirs => { + conflicts.push(("spot".to_string(), id.clone())); + if remote_wins { + match theirs { + Some(spot) => self.put_spot(spot.clone()), + None => { + self.spots.remove(&id); + } + } + } + } + _ => {} + } + } + + conflicts + } + + /// Replace a repair of the same id, or append it. + /// + /// Position is not merged, for the reason [`Self::put_mask`] gives and one + /// more: the order only decides which of two *overlapping* repairs lands on + /// top, and repairs that overlap are already a case the photographer will + /// look at. + fn put_spot(&mut self, spot: Spot) { + match self.spots.get_mut(&spot.id) { + Some(existing) => *existing = spot, + None => { + self.spots.place(spot); + } + } + } + /// Replace a layer of the same id, or append it. /// /// Position is not merged. Two devices that reordered the same stack have @@ -438,6 +556,7 @@ impl Version { // plus half of another's opacity is a layer neither of them made — // so the layer is the unit, exactly as the value is for a parameter. conflicts.extend(self.merge_masks(remote, base, remote_wins)); + conflicts.extend(self.merge_spots(remote, base, remote_wins)); // Unknown keys follow the same rule, so an operation neither side // understands is not dropped by the merge either. @@ -490,20 +609,6 @@ fn merge_judgement(ours: u8, theirs: u8, remote_wins: bool) -> u8 { } } -/// Every non-default parameter in the graph, keyed by `(op, param)`. -/// -/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls -/// from — so an operation is persisted by virtue of being in the chain, with -/// nothing to register and nothing to forget. -/// -/// Delegated to [`Preset::capture`] rather than reimplemented: a version's -/// parameters and a copied preset are the same values taken from the same -/// list, and two routines building the same map would be two places for the -/// non-default rule to drift. -fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> { - Preset::capture(graph).into_params() -} - impl Sidecar { pub fn new() -> Self { Self::default() @@ -564,6 +669,15 @@ impl Sidecar { for ((op, param), value) in &v.params { let _ = writeln!(out, "{op}.{param} = {}", format_value(*value)); } + // TRACES: FR-DEV-8 + // In the order they were made, which is the order they are drawn + // in and the order that decides which repairs share a pass + // (`SpotSet::rounds`). A `BTreeMap` here — sorting them by id — + // would look tidier in the file and would silently reorder the + // photograph. + for spot in v.spots.spots() { + write_spot(&mut out, spot); + } for (k, raw) in &v.unknown { let _ = writeln!(out, "{k} = {raw}"); } @@ -691,6 +805,21 @@ impl Sidecar { Some(value.to_string()); } "flag" => version.flag = value.parse::().unwrap_or(0).min(MAX_FLAG), + // TRACES: FR-DEV-8 + // Ahead of the `op.param` arm below, which would otherwise try + // to read eight numbers as one float and drop the repair with a + // warning about a corrupt value. The prefix is safe because a + // spot is deliberately *not* an operation (see `crate::spot`), + // so no `ops/` declaration can ever claim the name. + k if k.starts_with("spot.") => { + let id = &k["spot.".len()..]; + match parse_spot(id, value) { + Some(spot) => { + version.spots.place(spot); + } + None => log::warn!("sidecar: unreadable spot {id}; ignoring it"), + } + } _ => match key.split_once('.') { // An `op.param` line whose value does not parse is a // corrupt number, not an unknown key; dropping it lets @@ -730,6 +859,78 @@ impl Sidecar { } } +/// TRACES: FR-DEV-8 +/// Write one repair as one line. +/// +/// Fields in order: centre, radius, feather, source offset, opacity, mode — +/// and the word `disabled` when it is switched off, which is rare enough that +/// it is a trailing marker rather than a ninth number every line carries. +/// +/// Values are written at the precision they are held at (see `crate::spot`'s +/// grid), so a round trip is exact and two devices that placed the same repair +/// produce the same line rather than a diff of noise in the sixth decimal. +fn write_spot(out: &mut String, spot: &Spot) { + let _ = write!( + out, + "spot.{} = {} {} {} {} {} {} {} {}", + spot.id, + format_value(spot.centre.0), + format_value(spot.centre.1), + format_value(spot.radius), + format_value(spot.feather), + format_value(spot.offset.0), + format_value(spot.offset.1), + format_value(spot.opacity), + spot.mode.name(), + ); + if !spot.enabled { + let _ = write!(out, " disabled"); + } + let _ = writeln!(out); +} + +/// TRACES: FR-DEV-8 +/// Read one `spot. = …` line, or nothing if it cannot be trusted. +/// +/// A malformed line costs that repair and not the file, which is the rule +/// [`parse_stroke`] follows and for the sharper version of its reason: a spot +/// read half-way is a patch of one part of the photograph copied over another +/// part at random. A missing repair is noticed and re-made in a second; a +/// repair in the wrong place looks like the file is damaged. +fn parse_spot(id: &str, value: &str) -> Option { + if id.is_empty() { + return None; + } + let mut tokens = value.split_whitespace(); + let mut number = || { + tokens + .next() + .and_then(|t| t.parse::().ok()) + .filter(|v| v.is_finite()) + }; + + let centre = (number()?, number()?); + let radius = number()?; + let feather = number()?; + let offset = (number()?, number()?); + let opacity = number()?; + + let mode = SpotMode::from_name(tokens.next()?)?; + // Anything after the mode that is not the one marker this format defines + // is a newer build's business. Ignored rather than refused: the repair is + // complete without it, and refusing would drop work over a field this + // build simply does not know about yet. + let enabled = !tokens.any(|t| t == "disabled"); + + let mut spot = Spot::new(centre, offset, radius); + spot.id = id.to_string(); + spot.set_feather(feather); + spot.set_opacity(opacity); + spot.mode = mode; + spot.enabled = enabled; + Some(spot) +} + /// Write one mask layer as its own block. /// /// The version uuid is repeated in the header rather than relying on the @@ -1168,6 +1369,81 @@ mod tests { Version::from_graph("uuid-1", "Default", graph) } + /// A graph developing on a stock, with tables well-formed enough for the + /// film node to keep them. The emulsion is invented; what is under test + /// is whether the *choice* reaches the file. + fn on_film(stock: &str) -> EditGraph { + let mut g = edited(); + g.set_film(Some(crate::graph::Film { + stock: stock.to_string(), + print: None, + tables: crate::ops::FilmTables { + exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES], + curve_log_min: -3.0, + curve_log_max: 1.0, + lut: vec![[0.5, 0.5, 0.5]; 8], + density_max: 2.0, + lut_size: 2, + grain_particles: [0.0; 3], + grain_density_max: [2.0; 3], + grain_uniformity: 1.0, + }, + })); + g + } + + #[test] + fn saving_an_edit_keeps_the_film_it_was_developed_on() { + // `update` is the write path — the one an automatic save goes through + // — and it used to copy the parameters and the masks and say nothing + // about the film. A photograph developed on a stock was written back + // without it, so the next time it opened, the emulsion was gone and + // nothing had reported a failure. + // + // It reads as an oversight because it was one, and that is the point: + // three routines captured "the edit" and each captured a different + // subset. All three now destructure one `EditState`, so the next part + // of an edit cannot be forgotten by anybody writing a line too few. + let mut v = version_of(&on_film("kodak_portra_400")); + v.film = None; + + v.update(&on_film("kodak_portra_400"), "device-a", 1000); + + assert_eq!( + v.film, + Some(FilmRef { + stock: "kodak_portra_400".into(), + print: None, + }), + "the stock did not survive the save" + ); + } + + #[test] + fn a_film_written_by_update_comes_back_off_the_disk() { + // End to end, because the field being set is only half of it: the + // stock has to reach the text and parse back out of it. + let mut v = version_of(&EditGraph::default_chain()); + v.update(&on_film("ilford_hp5"), "device-a", 1000); + + let mut sidecar = Sidecar::new(); + sidecar.put(v); + let parsed = Sidecar::parse(&sidecar.to_text()).expect("re-read"); + + let mut graph = EditGraph::default_chain(); + let rebake = parsed + .default_version() + .expect("a version") + .apply(&mut graph); + + assert_eq!( + rebake.wanted().map(|f| f.stock.as_str()), + Some("ilford_hp5"), + "reopening the photograph has to ask for its stock back" + ); + } + #[test] fn only_non_default_values_are_written() { // The property the whole format rests on: a neutral operation is @@ -1197,7 +1473,8 @@ mod tests { parsed .default_version() .expect("a version") - .apply(&mut restored); + .apply(&mut restored) + .expect_no_film(); assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75)); assert_eq!( @@ -1231,7 +1508,8 @@ mod tests { parsed .default_version() .expect("a version") - .apply(&mut restored); + .apply(&mut restored) + .expect_no_film(); for cap in g.capabilities() { for p in &cap.params { @@ -1268,7 +1546,8 @@ mod tests { parsed .default_version() .expect("a version") - .apply(&mut restored); + .apply(&mut restored) + .expect_no_film(); assert_eq!(restored.crop(), g.crop()); assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5)); @@ -1308,7 +1587,8 @@ mod tests { .expect("valid") .default_version() .expect("a version") - .apply(&mut sideways); + .apply(&mut sideways) + .expect_no_film(); assert_eq!( sideways.framing().baseline(), @@ -1326,7 +1606,11 @@ mod tests { let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let mut g = edited(); - parsed.default_version().expect("a version").apply(&mut g); + parsed + .default_version() + .expect("a version") + .apply(&mut g) + .expect_no_film(); assert!(g.is_neutral(), "a neutral version must clear the graph"); } @@ -1353,7 +1637,11 @@ mod tests { tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n"; let parsed = Sidecar::parse(text).expect("valid"); let mut g = EditGraph::default_chain(); - parsed.default_version().expect("a version").apply(&mut g); + parsed + .default_version() + .expect("a version") + .apply(&mut g) + .expect_no_film(); // The S-curve the file describes, on the master curve and nowhere // else. @@ -1420,7 +1708,8 @@ mod tests { parsed .default_version() .expect("a version") - .apply(&mut restored); + .apply(&mut restored) + .expect_no_film(); assert_eq!(restored.param(curve::ID, blue), Some(0.08)); assert_eq!(restored.param(curve::ID, red), Some(0.92)); @@ -1449,7 +1738,11 @@ mod tests { time_machine.year = 1994\n"; let parsed = Sidecar::parse(text).expect("valid"); let mut g = EditGraph::default_chain(); - parsed.default_version().expect("a version").apply(&mut g); + parsed + .default_version() + .expect("a version") + .apply(&mut g) + .expect_no_film(); assert!(g.is_neutral()); } @@ -1459,7 +1752,11 @@ mod tests { exposure.exposure = NaN\nwhite_balance.temperature = 20\n"; let parsed = Sidecar::parse(text).expect("valid"); let mut g = EditGraph::default_chain(); - parsed.default_version().expect("a version").apply(&mut g); + parsed + .default_version() + .expect("a version") + .apply(&mut g) + .expect_no_film(); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0)); assert_eq!( @@ -1476,7 +1773,11 @@ mod tests { exposure.exposure = 99\n"; let parsed = Sidecar::parse(text).expect("valid"); let mut g = EditGraph::default_chain(); - parsed.default_version().expect("a version").apply(&mut g); + parsed + .default_version() + .expect("a version") + .apply(&mut g) + .expect_no_film(); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0)); } @@ -1510,7 +1811,7 @@ mod tests { assert_eq!(parsed.default_version().expect("default").name, "Colour"); let mut g = EditGraph::default_chain(); - parsed.versions["u2"].apply(&mut g); + parsed.versions["u2"].apply(&mut g).expect_no_film(); assert_eq!( g.param(saturation::ID, saturation::SATURATION), Some(-100.0) @@ -1555,7 +1856,7 @@ mod tests { assert!(conflicts.is_empty(), "disjoint edits must not conflict"); let mut g = EditGraph::default_chain(); - local.apply(&mut g); + local.apply(&mut g).expect_no_film(); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0)); assert_eq!(g.crop().width, 0.5); } @@ -1579,7 +1880,7 @@ mod tests { assert_eq!(conflicts.len(), 1, "the same parameter, two values"); let mut g = EditGraph::default_chain(); - local.apply(&mut g); + local.apply(&mut g).expect_no_film(); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(2.0)); } @@ -1604,7 +1905,7 @@ mod tests { local.merge(&remote, Some(&base)); let mut g = EditGraph::default_chain(); - local.apply(&mut g); + local.apply(&mut g).expect_no_film(); assert_eq!( g.param(exposure::ID, exposure::EXPOSURE), Some(1.0), @@ -1626,7 +1927,7 @@ mod tests { local.merge(&remote, Some(&base)); let mut g = EditGraph::default_chain(); - local.apply(&mut g); + local.apply(&mut g).expect_no_film(); assert!(g.is_neutral(), "the remote reset must survive the merge"); } @@ -1935,7 +2236,7 @@ mod tests { assert_eq!(local.rating, 4, "the tablet's cull arrived"); let mut g = EditGraph::default_chain(); - local.apply(&mut g); + local.apply(&mut g).expect_no_film(); assert_eq!( g.param(exposure::ID, exposure::EXPOSURE), Some(1.5), diff --git a/core/dr-pipeline/src/spot.rs b/core/dr-pipeline/src/spot.rs new file mode 100644 index 0000000..0a96031 --- /dev/null +++ b/core/dr-pipeline/src/spot.rs @@ -0,0 +1,796 @@ +//! 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, Helper, 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; + +/// How far a new repair's source starts from what it repairs, in radii. +/// +/// Clear of the disc it is replacing — a source overlapping its own +/// destination would copy the mark it is removing — and close enough that on a +/// smoothly varying background it is the same background. Two and a half puts +/// a full radius of untouched photograph between the two edges. +const SOURCE_ARM: f32 = 2.5; + +/// 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-DEV-8 + /// Where a new repair reads from, before anybody has looked at it. + /// + /// **FR-DEV-8 asks for automatic source placement, and this is the cheap + /// half of it.** The good half searches the photograph for a patch whose + /// surroundings match — a compute dispatch scoring candidate offsets, and + /// one small readback when the spot is created (`docs/spot-removal.md` + /// §8). This is what stands in for it, and it is worth having on its own + /// terms rather than as a placeholder: dust sits on skies, skies are + /// smooth, and a patch two and a half radii away is nearly always the same + /// sky. + /// + /// Towards the centre of the frame, because that is the direction with the + /// most photograph in it: a mark near an edge sourced outwards reads from + /// the border, or from outside it, where `spot_tap` clamps and the repair + /// smears. A mark *at* the centre has no such direction and is sent right, + /// which is as good as any other bearing and is at least predictable. + /// + /// The result is what gets stored, and it is never recomputed: a repair + /// whose source moved on its own when the file was reopened would be an + /// edit changing itself, and non-destructive editing means the sidecar + /// decides what the picture is. + pub fn default_offset(centre: (f32, f32), radius: f32, aspect: f32) -> (f32, f32) { + let aspect = if aspect > 0.0 { aspect } else { 1.0 }; + // In frame units, where a direction is a direction: normalised + // coordinates would bend the bearing by the aspect ratio and send a + // source off at an angle nobody chose. + let to_centre = ((0.5 - centre.0) * aspect, 0.5 - centre.1); + let length = to_centre.0.hypot(to_centre.1); + let direction = if length > 1e-4 { + (to_centre.0 / length, to_centre.1 / length) + } else { + (1.0, 0.0) + }; + let distance = radius * SOURCE_ARM; + (direction.0 * distance, direction.1 * distance) + } + + /// 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) + } +} + +/// The id the generated passes are labelled and prefixed with. +/// +/// Not an operation id — no `ops/*.yaml` declares it and nothing in the chain +/// answers to it — for the same reason [`crate::detail`]'s resolve pass has one +/// of its own: a label reading `spot/round0` sends a reader to this module +/// rather than to whichever operation happened to lend its name. +pub const SPOT_ID: &str = "spot"; + +/// Bilinear sampling, which the generated preamble does not offer. +/// +/// `tap` takes an integer offset from the pixel being written, and a repair +/// reads from wherever its source is — a fractional position in render space, +/// because the offset was stored as a fraction of the frame and multiplied up. +/// Sampling it nearest-neighbour would make a repair jitter by a pixel as the +/// view is zoomed, which on a face is the difference between a repair and a +/// smudge. +pub const SPOT_HELPERS: &[Helper] = &[Helper { + name: "spot_tap", + source: "\ +// A bilinear sample at an arbitrary position, clamped to the edge. +// +// Clamped rather than zero-filled, exactly as `tap` is: a source dragged partly +// off the frame must read the pixels that exist rather than fade into black, +// which would draw a dark crescent inside the repair. +fn spot_tap(p: vec2) -> vec3 { + let last = vec2(textureDimensions(source)) - vec2(1); + // Pixel centres sit at half-integers, so the texel below and left of a + // position is `floor(p - 0.5)`. Getting this wrong shifts every repair by + // half a pixel — invisible in a test that checks a mean, obvious on a face. + let q = p - vec2(0.5); + let base = floor(q); + let f = q - base; + let i0 = clamp(vec2(base), vec2(0), last); + let i1 = clamp(i0 + vec2(1), vec2(0), last); + let s00 = textureLoad(source, vec2(i0.x, i0.y), 0).rgb; + let s10 = textureLoad(source, vec2(i1.x, i0.y), 0).rgb; + let s01 = textureLoad(source, vec2(i0.x, i1.y), 0).rgb; + let s11 = textureLoad(source, vec2(i1.x, i1.y), 0).rgb; + return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y); +}", +}]; + +/// TRACES: FR-DEV-8 +/// How many points around a disc's rim a heal samples. +/// +/// The membrane in [`SPOT_BODY`] is an interpolation of the boundary +/// difference, so this is the resolution of the boundary it sees. Twenty-four +/// puts a sample every fifteen degrees, which on a disc of any size a +/// photographer draws is finer than the tone it is interpolating. +/// +/// A uniform rather than a constant in the source, so tuning it uploads a +/// buffer instead of recompiling — and so a future control could trade it for +/// speed on a large repair without a second shader. +pub const RIM_SAMPLES: f32 = 24.0; + +/// The WGSL every spot pass runs. See [`SpotSet::passes`] for the record layout +/// it reads, which is where the meaning of each lane is written down. +const SPOT_BODY: &str = "\ +// Each repair is two records: the disc it covers, and where it reads from. +let repairs = instance_count / 2u; +// The centre of this pixel. Half-integer, because a disc of radius 1.5 centred +// on a pixel should cover that pixel whole rather than half of it. +let here = vec2(f32(coord.x) + 0.5, f32(coord.y) + 0.5); + +for (var i = 0u; i < repairs; i = i + 1u) { + let disc = instances[i * 2u]; + let src = instances[i * 2u + 1u]; + + // The rejection test. It is what every pixel outside every repair pays, + // and a repair covers a few thousand pixels of a few million. + let delta = here - disc.xy; + let dist = length(delta); + if (dist >= disc.z) { + continue; + } + + // One inside the solid core, falling to zero at the rim. `disc.w` is where + // the fall begins, worked out on the CPU so the shader never divides by a + // feather that might be zero. + let cover = (1.0 - smoothstep(disc.w, disc.z, dist)) * src.z; + if (cover <= 0.0) { + continue; + } + + // The same displacement within the disc, read from beside it: the patch is + // a translation of the photograph, so its texture arrives unrotated and + // unscaled. + // + // `replacement`, not `patch`: WGSL reserves that word, and a reserved + // keyword in generated code is a compile error a long way from its cause. + var replacement = spot_tap(src.xy + delta); + + // Heal: carry the source's texture, but the destination's tone. + // + // What a clone gets wrong is not the texture, it is the level. Dust on a + // gradient sky is cloned from a patch a little lighter or darker than the + // hole it fills, and the repair reads as a disc even though every grain in + // it is right. The fix is the difference between the two neighbourhoods, + // interpolated across the disc — a membrane, in the sense the Poisson + // literature means, approximated here in closed form rather than solved. + // + // Solving it properly is tens of Jacobi iterations, and an iteration in + // this architecture is a dispatch: sixty dispatches to remove a dust spot + // is not a frame budget. Interpolating the boundary difference by inverse + // square distance costs one loop over the rim and no state at all, and on + // the case that actually matters — a smooth background, where the + // difference around the rim is near enough constant — it lands on the same + // answer the solve would. + if (src.w > 0.5) { + var weighted = vec3(0.0); + var total = 0.0; + let samples = i32(rim_samples); + for (var k = 0; k < samples; k = k + 1) { + // Offset by half a step so no sample sits exactly on an axis, + // where a rim that crosses a hard edge would align with it. + let angle = (f32(k) + 0.5) * 6.283185307 / rim_samples; + let arm = vec2(cos(angle), sin(angle)) * disc.z; + // What the photograph says here, minus what the source says at the + // matching point of its own rim. + let boundary = spot_tap(disc.xy + arm) - spot_tap(src.xy + arm); + // Inverse square distance, floored so a pixel that lands on a + // sample is a large weight rather than an infinite one. + let w = 1.0 / max(dot(here - (disc.xy + arm), here - (disc.xy + arm)), 1.0); + weighted = weighted + boundary * w; + total = total + w; + } + replacement = replacement + weighted / max(total, 1e-6); + } + + c = mix(c, replacement, cover); +}"; + +impl SpotSet { + /// TRACES: FR-DEV-8 | FR-DSP-1 + /// The passes that draw these repairs at this size. + /// + /// Shaped like [`crate::detail::DetailStage::passes`] and called in the + /// same place for the same reason, but deliberately not an implementation + /// of it: that trait belongs to operations, and a spot set is not one. + /// + /// # Everything the shader sees is in render pixels + /// + /// The conversion happens here, where the [`crate::Framing`] is in scope, + /// and never in WGSL. That is what keeps the shader ignorant of crops, + /// zooms, rotations and flips: a repair's centre and its source both go + /// through [`crate::Framing::output_at`] — the same map the fused pass + /// applies to every pixel — so a rotated photograph rotates the offset with + /// no trigonometry here at all, and a repair panned off screen lands + /// outside the target and draws nothing. + /// + /// The radius goes through the same map rather than being multiplied by a + /// ratio: a point one radius above the centre is mapped too, and the + /// distance between the two answers *is* the radius in render pixels. + /// Anything cheaper would need this function to know that the framing is a + /// similarity, which is not its business to know. + /// + /// # The record layout + /// + /// Two `vec4`s per repair, because a repair does not fit in one: + /// + /// | | x | y | z | w | + /// |---|---|---|---|---| + /// | 0 | centre x | centre y | radius | where the edge starts falling | + /// | 1 | source x | source y | opacity | 1 for heal, 0 for clone | + pub fn passes( + &self, + framing: &crate::Framing, + source: (u32, u32), + scale: crate::detail::RenderScale, + ) -> Vec { + use crate::detail::DetailPass; + + let (sw, sh) = (source.0.max(1), source.1.max(1)); + let aspect = sw as f32 / sh as f32; + let (rw, rh) = scale.render_size(); + let (rw, rh) = (rw as f32, rh as f32); + let to_px = |uv: (f32, f32)| (uv.0 * rw, uv.1 * rh); + + let active: Vec<&Spot> = self.active().collect(); + let mut passes = Vec::new(); + + for (round, group) in self.rounds(aspect).into_iter().enumerate() { + let mut storage: Vec<[f32; 4]> = Vec::with_capacity(group.len() * 2); + let mut reach: f32 = 0.0; + + for index in group { + let spot = active[index]; + let centre = to_px(framing.output_at(spot.centre, sw, sh)); + let from = to_px(framing.output_at(spot.source(aspect), sw, sh)); + + // One radius along y in frame units is one radius along y in + // normalised coordinates, which is why the probe point is built + // this way rather than from the offset. + let rim = (spot.centre.0, spot.centre.1 + spot.radius); + let rim_px = to_px(framing.output_at(rim, sw, sh)); + let radius = (rim_px.0 - centre.0).hypot(rim_px.1 - centre.1); + + // Where the edge begins to fall away. Always at least half a + // pixel inside the rim: a disc with a genuinely hard edge + // aliases into a visible polygon, and half a pixel of ramp is + // finer than any feather control can ask for anyway. + let inner = (radius * (1.0 - spot.feather)).min(radius - 0.5).max(0.0); + + storage.push([centre.0, centre.1, radius, inner]); + storage.push([ + from.0, + from.1, + spot.opacity, + match spot.mode { + SpotMode::Heal => 1.0, + SpotMode::Clone => 0.0, + }, + ]); + + // How far this pass reads from a pixel it writes: across to the + // source, plus the disc it reads there. Stated honestly even + // though it is large — an understated radius shows as a seam at + // every tile boundary, which reads as a driver bug (ARCH §5.3). + let across = (from.0 - centre.0).hypot(from.1 - centre.1); + reach = reach.max(across + radius); + } + + if storage.is_empty() { + continue; + } + + passes.push(DetailPass { + label: round_label(round), + radius: reach.ceil() as u32, + wgsl: SPOT_BODY.to_string(), + uniforms: vec![crate::operation::Uniform { + name: "rim_samples", + value: RIM_SAMPLES, + }], + storage, + }); + } + + passes + } +} + +/// A static label for round `n`. +/// +/// Static because a pass label is a `&'static str`, and rounds past the few +/// named here are rare enough — each one needs a source deliberately placed +/// over an earlier repair — that sharing a label between them costs nothing but +/// a slightly vaguer line in a profiler. +fn round_label(round: usize) -> &'static str { + match round { + 0 => "round0", + 1 => "round1", + 2 => "round2", + 3 => "round3", + _ => "round", + } +} diff --git a/core/dr-pipeline/src/state.rs b/core/dr-pipeline/src/state.rs new file mode 100644 index 0000000..928c690 --- /dev/null +++ b/core/dr-pipeline/src/state.rs @@ -0,0 +1,313 @@ +//! TRACES: FR-DEV-5 | FR-CAT-8 +//! The whole of an edit, gathered so that nothing can be left out of it. +//! +//! # Why this exists +//! +//! An edit used to be a bag of scalars, and [`Preset`] is that bag. It was a +//! true description while the graph held only operations and a framing, and it +//! stopped being true the moment a mask stack and a film stock arrived — +//! because a layer is not a scalar and a stock is not a scalar, which is +//! precisely why [`EditGraph`] holds both of them apart from `ops`. +//! +//! Nothing announced the change. What happened instead is that three places +//! each captured "the edit" and each captured a different subset of it: +//! [`Preset::capture`] took the parameters, `Version::from_graph` took the +//! parameters and the masks and the film, and `Version::update` took the +//! parameters and the masks and dropped the film on the floor. The visible +//! symptom was quieter still — the history snapshots a `Preset`, so drawing a +//! mask opened no undo step at all, and `record` returned `false` while the +//! interface went on calling it in good faith. +//! +//! # What keeps it complete +//! +//! Not vigilance, and not a checklist in a comment. [`EditGraph::state`] +//! destructures the graph **exhaustively** and [`EditGraph::set_state`] +//! destructures this type exhaustively — no `..` in either pattern, and this +//! type's fields are public so that every construction site is a struct +//! literal naming all of them. A fifth kind of state added to the graph does +//! not compile until somebody has decided whether an undo has to put it back. +//! +//! FR-DEV-8's spot removal was named here as the one already asked for, and it +//! arrived. It did not compile, which is the whole of what this was for: the +//! repairs are in [`Self::spots`] because the build refused to proceed without +//! an answer about them, rather than because anyone remembered to look. +//! +//! That is the whole mechanism. It is deliberately a compiler error rather +//! than a runtime check, because the failure being prevented is *silence*: the +//! mask bug produced no panic, no warning and no failing test, and only a +//! diagnostic that fires before the code runs at all could have caught it. +//! +//! # What is deliberately not in here +//! +//! - **The viewport.** Zoom and pan say where the photographer is looking, not +//! what the photograph becomes. An undo that also moved the view would read +//! as navigation — the rule [`Preset::apply`] already keeps for a paste. +//! - **The orientation baseline.** How the camera stored its rows is part of +//! reading the file, not a decision anyone made (see [`crate::framing`]). +//! - **The film tables.** Derived from the stock, the paper and the film +//! node's own exposure sliders, and re-baked in milliseconds. Turning a name +//! back into tables needs the profile database this crate does not link +//! (ARCH §6.5a), which is why [`FilmRebake`] exists. +//! - **The rating and the flag.** Judgements about the photograph rather than +//! edits to it; they change no pixel, and `sidecar::Version` keeps them for +//! that reason. + +use crate::graph::EditGraph; +use crate::mask::MaskStack; +use crate::preset::Preset; +use crate::spot::SpotSet; + +/// TRACES: FR-DEV-3f +/// The stock and paper an edit names, without the tables they bake to. +/// +/// The **id, not an index**. Stocks are files that users add +/// (`core/dr-film/profiles`), so an index would mean installing a profile +/// silently changed which film every existing photograph was developed on. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct FilmRef { + pub stock: String, + /// The paper, if the negative is printed. Absent means the film is viewed + /// as it comes — which for a colour negative is the scan, orange and + /// inverted, and is a legitimate thing to ask for. + pub print: Option, +} + +/// TRACES: FR-DEV-5 | FR-CAT-8 +/// Everything about a photograph that an edit decides. +/// +/// Fields are public on purpose: a struct literal is exhaustive, so every +/// place that builds one has to account for every part of an edit. See the +/// module note — that is the entire safety mechanism, and hiding these behind +/// a constructor with positional arguments would trade it for two `String`s +/// that can be passed in the wrong order. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct EditState { + /// Every non-default parameter — the operations' and the framing's alike, + /// since the framing is an entry in [`EditGraph::capabilities`] like any + /// other. + pub params: Preset, + /// The local adjustments (FR-DEV-3). + /// + /// Shared rather than owned, and that is a decision about the *drag path* + /// rather than about memory. `state` is called on every parameter change, + /// which during a slider drag is once a frame; a mask stack holding a + /// painted brush is thousands of stroke points, and deep-copying it sixty + /// times a second to record an exposure move that did not touch it would + /// be a cost paid for nothing. [`EditGraph::masks_mut`] is the single door + /// through which a stack is modified, and it clones on write, so snapshots + /// share until one of them is actually edited. + pub masks: std::sync::Arc, + /// TRACES: FR-DEV-3f + /// The stock this edit develops on, named. + pub film: Option, + /// TRACES: FR-DEV-8 + /// The repairs (see [`crate::spot`]). + /// + /// Owned rather than shared, where [`Self::masks`] is shared, and the + /// difference is a fact about the two rather than an inconsistency: a + /// repair is a handful of numbers and a photographer places tens of them, + /// while a painted mask is an unbounded path of stroke points. Copying the + /// first once a frame is nothing; copying the second is the reason + /// `masks_mut` clones on write. + pub spots: SpotSet, +} + +/// TRACES: FR-DEV-3f +/// What a caller still owes the graph after its state was replaced. +/// +/// `dr-pipeline` can hold a film but cannot bake one: the tables come from the +/// profile database, and this crate does not link it (ARCH §6.5a). So +/// restoring an edit that names a stock is necessarily two steps, and this is +/// the second one made impossible to forget rather than left in a comment. +#[derive(Debug, Clone, PartialEq)] +#[must_use = "an ignored rebake leaves the photograph rendering without its film"] +pub enum FilmRebake { + /// The graph's film is already right — either the state named none, in + /// which case it has been cleared, or there was nothing to restore. + NotNeeded, + /// Bake this stock and hand the result back through + /// [`EditGraph::set_film`]. + Wanted(FilmRef), +} + +impl FilmRebake { + /// The stock to bake, if one is owed. + pub fn wanted(&self) -> Option<&FilmRef> { + match self { + Self::NotNeeded => None, + Self::Wanted(film) => Some(film), + } + } + + /// Assert that nothing is owed, for a caller that knows this edit names + /// no film — a test fixture, in practice. + /// + /// It *checks* rather than discards, and that is the point of it existing + /// at all. `let _ = …` would make the same claim silently and go on making + /// it the day the fixture grows a stock, at which point the test would + /// pass while rendering the wrong picture — which is precisely the failure + /// this type was introduced to stop. + #[track_caller] + pub fn expect_no_film(self) { + if let Self::Wanted(film) = self { + panic!( + "this edit develops on {:?}, which has to be baked back before \ + the photograph can be rendered", + film.stock + ); + } + } +} + +impl EditState { + /// The state `graph` is in. The same call as [`EditGraph::state`], for a + /// caller that reads better this way round. + pub fn capture(graph: &EditGraph) -> Self { + graph.state() + } + + /// Whether this edit does anything to the photograph at all. + /// + /// A neutral state is a *decision* rather than a missing one — applying it + /// returns the photograph to its defaults — so this answers "is there + /// anything to show", not "is there anything to store". + pub fn is_neutral(&self) -> bool { + self.params.is_empty() + && self.masks.is_empty() + && self.film.is_none() + && self.spots.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::framing; + use crate::graph::Film; + use crate::mask::{MaskLayer, MaskSource}; + use crate::ops::{curve, exposure, saturation}; + use crate::spot::Spot; + use crate::CropRect; + + /// A graph with one of *every* kind of state moved off its default: an + /// operation's parameter, a framing, a mask layer, a film, a repair. One + /// of each is the point — a round trip that only exercises the scalars is + /// the test that was already passing while the masks went missing. + fn thoroughly_edited() -> EditGraph { + let mut g = EditGraph::default_chain(); + + g.set_param(exposure::ID, exposure::EXPOSURE, 0.75); + g.set_param(saturation::ID, saturation::SATURATION, -30.0); + g.set_param(curve::ID, curve::P1_X, 0.3); + g.set_param(framing::ID, framing::ANGLE, 2.5); + g.set_crop(CropRect { + x: 0.1, + y: 0.1, + width: 0.6, + height: 0.6, + }); + + let mut layer = MaskLayer::new( + "l1", + MaskSource::Radial { + centre: (0.4, 0.6), + radii: (0.25, 0.3), + angle: 0.2, + feather: 0.15, + }, + ); + layer.opacity = 0.6; + layer.invert = true; + g.masks_mut().push(layer); + + g.spots_mut() + .place(Spot::new((0.3, 0.7), (0.05, 0.0), 0.02)) + .expect("the repair was placed"); + + g.set_film(Some(Film { + stock: "kodak_portra_400".into(), + print: Some("kodak_endura".into()), + tables: crate::ops::FilmTables { + exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES], + curve_log_min: -3.0, + curve_log_max: 1.0, + lut: vec![[0.5, 0.5, 0.5]; 8], + density_max: 2.0, + lut_size: 2, + grain_particles: [0.0; 3], + grain_density_max: [2.0; 3], + grain_uniformity: 1.0, + }, + })); + + g + } + + #[test] + fn an_edit_survives_being_taken_off_a_graph_and_put_back() { + // The property every undo rests on. Checked over the whole state + // rather than the values that were set, because the interesting + // failure is not a value coming back wrong — it is a *kind* of value + // that was never carried at all, and a narrower assertion is exactly + // what missed the masks. + let edited = thoroughly_edited(); + let state = edited.state(); + + let mut fresh = EditGraph::default_chain(); + let rebake = fresh.set_state(&state); + + assert_eq!( + rebake, + FilmRebake::Wanted(FilmRef { + stock: "kodak_portra_400".into(), + print: Some("kodak_endura".into()), + }), + "the stock has to be asked for, since this crate cannot bake it" + ); + + // The film is the one part `set_state` deliberately does not restore, + // so it is put back the way a caller would before comparing. + fresh.set_film(edited.film().cloned()); + + assert_eq!( + fresh.state(), + state, + "something about the edit did not survive the round trip" + ); + } + + #[test] + fn putting_a_state_back_replaces_rather_than_overlays() { + // A part absent from the state means *default*, not "leave whatever + // was there". Otherwise undoing to the floor would leave the last + // mask standing, which is the shape of the bug this type exists for. + let mut g = EditGraph::default_chain(); + let floor = g.state(); + + let edited = thoroughly_edited(); + assert!(g.set_state(&edited.state()).wanted().is_some()); + // A caller bakes at this point. Standing in for it here is what makes + // the assertion below mean anything: without a film on the graph, + // "the film was cleared" would be true of a graph that never had one. + g.set_film(edited.film().cloned()); + assert!(g.film().is_some() && !g.masks().is_empty() && !g.spots().is_empty()); + + g.set_state(&floor).expect_no_film(); + + assert!( + g.masks().is_empty(), + "a layer outlived the state holding it" + ); + assert!(g.film().is_none(), "the film outlived the state holding it"); + assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0)); + assert!(g.crop().is_full(), "the crop outlived it: {:?}", g.crop()); + assert_eq!(g.state(), floor); + } + + #[test] + fn a_neutral_state_is_a_decision_rather_than_a_missing_one() { + assert!(EditGraph::default_chain().state().is_neutral()); + assert!(!thoroughly_edited().state().is_neutral()); + } +} diff --git a/core/dr-pipeline/tests/mask_sidecar.rs b/core/dr-pipeline/tests/mask_sidecar.rs index 962755b..80596a8 100644 --- a/core/dr-pipeline/tests/mask_sidecar.rs +++ b/core/dr-pipeline/tests/mask_sidecar.rs @@ -39,7 +39,8 @@ fn round_trip(graph: &EditGraph) -> EditGraph { .versions .get("default") .expect("version survived") - .apply(&mut restored); + .apply(&mut restored) + .expect_no_film(); restored } @@ -239,7 +240,7 @@ fn applying_a_maskless_version_clears_existing_masks() { assert_eq!(graph.masks().len(), 1); let plain = Version::from_graph("clean", "Clean", &EditGraph::default_chain()); - plain.apply(&mut graph); + plain.apply(&mut graph).expect_no_film(); assert!(graph.masks().is_empty()); } diff --git a/core/dr-pipeline/tests/spot_sidecar.rs b/core/dr-pipeline/tests/spot_sidecar.rs new file mode 100644 index 0000000..c05b695 --- /dev/null +++ b/core/dr-pipeline/tests/spot_sidecar.rs @@ -0,0 +1,298 @@ +//! TRACES: FR-DEV-8 | FR-NC-9 +//! Repairs must survive the sidecar, and survive two devices. +//! +//! The sidecar is the authoritative store (ARCH §6.12), so a spot that does not +//! round-trip is not a persistence bug but lost work — and a spot that +//! round-trips *nearly* is worse than one that fails, because a disc copied +//! from slightly the wrong place looks like a damaged file rather than like a +//! feature that did not run. + +use dr_pipeline::spot::{Spot, SpotMode, DEFAULT_RADIUS}; +use dr_pipeline::{EditGraph, Sidecar, Version}; + +fn spot_at(centre: (f32, f32), offset: (f32, f32)) -> Spot { + Spot::new(centre, offset, DEFAULT_RADIUS) +} + +/// A graph carrying three repairs: the default kind, a clone, and one switched +/// off — which between them cover every field the line format carries. +fn graph_with_spots() -> EditGraph { + let mut graph = EditGraph::default_chain(); + + graph + .spots_mut() + .place(spot_at((0.25, 0.75), (0.08, -0.02))); + + let mut cloned = spot_at((0.6, 0.4), (-0.12, 0.05)); + cloned.mode = SpotMode::Clone; + cloned.set_feather(0.0); + cloned.set_opacity(0.5); + graph.spots_mut().place(cloned); + + let mut off = spot_at((0.1, 0.1), (0.2, 0.2)); + off.enabled = false; + graph.spots_mut().place(off); + + graph +} + +fn round_trip(graph: &EditGraph) -> EditGraph { + let mut sidecar = Sidecar::new(); + sidecar.put(Version::from_graph("default", "Default", graph)); + + let text = sidecar.to_text(); + let parsed = Sidecar::parse(&text).expect("reparse"); + + let mut restored = EditGraph::default_chain(); + parsed + .versions + .get("default") + .expect("version survived") + .apply(&mut restored) + .expect_no_film(); + restored +} + +#[test] +fn every_field_of_every_repair_comes_back() { + let graph = graph_with_spots(); + let restored = round_trip(&graph); + + assert_eq!( + restored.spots().spots(), + graph.spots().spots(), + "a repair is eight numbers and every one of them matters" + ); +} + +/// The order is the order they were made in, which decides which repair lands +/// on top where two overlap and which of them may share a pass. Sorting by id +/// on the way out would look tidier and would reorder the photograph. +#[test] +fn the_order_they_were_made_in_survives() { + let graph = graph_with_spots(); + let ids: Vec<&str> = graph + .spots() + .spots() + .iter() + .map(|s| s.id.as_str()) + .collect(); + let restored = round_trip(&graph); + let back: Vec<&str> = restored + .spots() + .spots() + .iter() + .map(|s| s.id.as_str()) + .collect(); + + assert_eq!(ids, back); +} + +/// What lets a caller skip an upload by comparing content: the same edit must +/// produce the same bytes, or every save looks like a change to sync. +#[test] +fn writing_the_same_repairs_twice_is_byte_identical() { + let graph = graph_with_spots(); + + let mut a = Sidecar::new(); + a.put(Version::from_graph("default", "Default", &graph)); + let mut b = Sidecar::new(); + b.put(Version::from_graph("default", "Default", &graph)); + + assert_eq!(a.to_text(), b.to_text()); +} + +/// A frame nobody has repaired must not grow a line, in the same way an +/// operation at neutral contributes nothing. +#[test] +fn an_unrepaired_frame_writes_no_spot_lines() { + let mut sidecar = Sidecar::new(); + sidecar.put(Version::from_graph( + "default", + "Default", + &EditGraph::default_chain(), + )); + + assert!(!sidecar.to_text().contains("spot.")); +} + +/// The file is meant to be readable by a human debugging an edit that went +/// wrong, and hand-editable by one who knows what they are doing. +#[test] +fn a_hand_written_line_loads() { + let text = "drsc 1\n\ + \n\ + [version default]\n\ + name = Default\n\ + default = 1\n\ + revision = 1\n\ + spot.abc123 = 0.4 0.6 0.02 0.5 0.1 -0.05 1 heal\n"; + + let sidecar = Sidecar::parse(text).expect("parse"); + let spots = &sidecar.versions["default"].spots; + + assert_eq!(spots.len(), 1); + let spot = &spots.spots()[0]; + assert_eq!(spot.id, "abc123"); + assert_eq!(spot.centre, (0.4, 0.6)); + assert_eq!(spot.radius, 0.02); + assert_eq!(spot.feather, 0.5); + assert_eq!(spot.offset, (0.1, -0.05)); + assert_eq!(spot.mode, SpotMode::Heal); + assert!(spot.enabled); +} + +/// A truncated or hand-mangled line costs that repair and not the file. The +/// alternative — refusing the version — throws away every other repair, the +/// crop and the exposure over one bad line. +#[test] +fn a_malformed_line_costs_one_repair() { + let text = "drsc 1\n\ + \n\ + [version default]\n\ + revision = 1\n\ + exposure.exposure = 0.75\n\ + spot.good11 = 0.4 0.6 0.02 0.5 0.1 -0.05 1 heal\n\ + spot.short1 = 0.4 0.6 0.02\n\ + spot.nomode = 0.4 0.6 0.02 0.5 0.1 -0.05 1 smudge\n\ + spot.notnum = x y 0.02 0.5 0.1 -0.05 1 heal\n"; + + let sidecar = Sidecar::parse(text).expect("parse"); + let version = &sidecar.versions["default"]; + + assert_eq!(version.spots.len(), 1); + assert_eq!(version.spots.spots()[0].id, "good11"); + assert_eq!( + version.params.get(&("exposure".into(), "exposure".into())), + Some(&0.75), + "the rest of the edit still loaded" + ); +} + +/// A field a newer build added must not cost the repair. The disc is complete +/// without it, and refusing would be a device running behind deleting work it +/// merely does not understand. +#[test] +fn a_trailing_field_from_a_newer_build_is_ignored_not_refused() { + let text = "drsc 1\n\ + \n\ + [version default]\n\ + revision = 1\n\ + spot.abc123 = 0.4 0.6 0.02 0.5 0.1 -0.05 1 heal rotation=0.5\n"; + + let sidecar = Sidecar::parse(text).expect("parse"); + assert_eq!(sidecar.versions["default"].spots.len(), 1); +} + +/// A spot switched off is state the photographer set, not an absence. +#[test] +fn a_disabled_repair_stays_disabled() { + let graph = graph_with_spots(); + let restored = round_trip(&graph); + let off = restored.spots().spots().last().expect("three repairs"); + + assert!(!off.enabled); +} + +// --------------------------------------------------------------------------- +// Sync merge (FR-NC-9) +// --------------------------------------------------------------------------- + +fn version_with(uuid: &str, revision: u64, build: impl FnOnce(&mut EditGraph)) -> Version { + let mut graph = EditGraph::default_chain(); + build(&mut graph); + let mut v = Version::from_graph(uuid, "Default", &graph); + v.revision = revision; + v +} + +/// The case the id derivation exists for. Two devices, offline, each removing a +/// different mark: with counted ids both would be `spot3` and one would be +/// lost here without a word. +#[test] +fn repairs_from_two_devices_both_survive() { + let base = version_with("default", 1, |_| {}); + + let mut ours = version_with("default", 2, |g| { + g.spots_mut().place(spot_at((0.2, 0.2), (0.05, 0.0))); + }); + let theirs = version_with("default", 2, |g| { + g.spots_mut().place(spot_at((0.8, 0.8), (-0.05, 0.0))); + }); + + let conflicts = ours.merge(&theirs, Some(&base)); + + assert!(conflicts.is_empty(), "different marks are not a conflict"); + assert_eq!(ours.spots.len(), 2); +} + +/// And the other half of that: two devices that removed *the same* piece of +/// dust agree on the id, so the merge sees one repair — which is right, because +/// it is one repair, and the alternative is the same disc drawn twice. +#[test] +fn the_same_mark_removed_on_both_devices_stays_one_repair() { + let base = version_with("default", 1, |_| {}); + let mut ours = version_with("default", 2, |g| { + g.spots_mut().place(spot_at((0.5, 0.5), (0.06, 0.0))); + }); + let theirs = version_with("default", 3, |g| { + g.spots_mut().place(spot_at((0.5, 0.5), (0.06, 0.0))); + }); + + let conflicts = ours.merge(&theirs, Some(&base)); + + assert!( + conflicts.is_empty(), + "the same repair is not a disagreement" + ); + assert_eq!(ours.spots.len(), 1); +} + +/// Both devices dragging one repair's source is genuinely ambiguous: the two +/// offsets cannot be averaged into a third that either photographer wanted, so +/// the higher revision takes the spot whole. +#[test] +fn both_moving_one_repair_is_a_conflict_resolved_by_revision() { + let placed = spot_at((0.5, 0.5), (0.06, 0.0)); + let id = placed.id.clone(); + + let base = version_with("default", 1, |g| { + g.spots_mut().place(placed.clone()); + }); + let mut ours = version_with("default", 2, |g| { + g.spots_mut().place(placed.clone()); + g.spots_mut().get_mut(&id).unwrap().set_offset((0.2, 0.0)); + }); + let theirs = version_with("default", 5, |g| { + g.spots_mut().place(placed.clone()); + g.spots_mut().get_mut(&id).unwrap().set_offset((0.0, -0.2)); + }); + + let conflicts = ours.merge(&theirs, Some(&base)); + + assert_eq!(conflicts, vec![("spot".to_string(), id.clone())]); + assert_eq!( + ours.spots.get(&id).map(|s| s.offset), + Some((0.0, -0.2)), + "the higher revision wins the repair whole" + ); +} + +/// A repair deleted on one device and untouched on the other stays deleted — +/// the disjoint case again, in the direction that is easy to get backwards. +#[test] +fn a_deletion_propagates() { + let placed = spot_at((0.5, 0.5), (0.06, 0.0)); + let id = placed.id.clone(); + + let base = version_with("default", 1, |g| { + g.spots_mut().place(placed.clone()); + }); + let mut ours = version_with("default", 2, |g| { + g.spots_mut().place(placed.clone()); + }); + let theirs = version_with("default", 3, |_| {}); + + assert!(ours.merge(&theirs, Some(&base)).is_empty()); + assert!(ours.spots.get(&id).is_none()); +} diff --git a/core/dr-pipeline/tests/spots.rs b/core/dr-pipeline/tests/spots.rs new file mode 100644 index 0000000..b35ab4c --- /dev/null +++ b/core/dr-pipeline/tests/spots.rs @@ -0,0 +1,303 @@ +//! 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); +} + +// --------------------------------------------------------------------------- +// Undo (FR-DEV-5) +// --------------------------------------------------------------------------- + +/// The press a photographer reaches for first: place a repair, dislike it, +/// take it back. Before the history carried the spot set this stepped some +/// unrelated slider and left the repair on the photograph, which reads as undo +/// being broken rather than absent. +#[test] +fn undo_takes_a_repair_back() { + use dr_pipeline::history::{Edit, History}; + + let mut graph = EditGraph::default_chain(); + let mut history = History::new(&graph); + + graph.spots_mut().place(spot_at((0.5, 0.5), (0.08, 0.0))); + assert!(history.record( + &graph, + Edit::Action(dr_pipeline::LocalizedKey("history.spot")) + )); + assert_eq!(graph.spots().len(), 1); + + assert!(history.undo(&mut graph).moved()); + assert_eq!(graph.spots().len(), 0, "the repair is still on the frame"); + + assert!(history.redo(&mut graph).moved()); + assert_eq!(graph.spots().len(), 1, "and redo could not put it back"); +} + +/// Moving a repair is undoable too, and separately from placing it: the two +/// are different decisions and a photographer who nudges a source expects one +/// press to return it, not to lose the spot entirely. +#[test] +fn undo_steps_back_through_a_moved_source() { + use dr_pipeline::history::{Edit, History}; + + let mut graph = EditGraph::default_chain(); + let id = graph + .spots_mut() + .place(spot_at((0.5, 0.5), (0.08, 0.0))) + .unwrap(); + let mut history = History::new(&graph); + + graph + .spots_mut() + .get_mut(&id) + .unwrap() + .set_offset((0.2, 0.1)); + history.record( + &graph, + Edit::Action(dr_pipeline::LocalizedKey("history.spot")), + ); + + assert!(history.undo(&mut graph).moved()); + assert_eq!( + graph.spots().get(&id).map(|s| s.offset), + Some((0.08, 0.0)), + "the source did not go back where it was" + ); + assert_eq!(graph.spots().len(), 1, "and the repair itself survived"); +} diff --git a/core/dr-pipeline/tests/tone_curve.rs b/core/dr-pipeline/tests/tone_curve.rs index db58cde..8f59903 100644 --- a/core/dr-pipeline/tests/tone_curve.rs +++ b/core/dr-pipeline/tests/tone_curve.rs @@ -26,7 +26,8 @@ fn apply(text: &str) -> EditGraph { sidecar .default_version() .expect("a default version") - .apply(&mut graph); + .apply(&mut graph) + .expect_no_film(); graph } diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index cb6ab21..bda1ee1 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -441,6 +441,214 @@ impl Orientation { } } +/// TRACES: FR-DEV-3h +/// A rectangle in the image **as stored** — the sensor's own scanline order. +/// +/// Normalised: fractions of the stored image, origin top-left. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct StoredRect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// TRACES: FR-DEV-3h +/// A rectangle in the image **as shown** — the photograph the right way up. +/// +/// Normalised, like [`StoredRect`], and deliberately a different type. The two +/// are the same four numbers and mean different things, which is precisely why +/// mixing them up is silent: a stored rect used against shown dimensions +/// produces a plausible rectangle in the wrong place. See [`Orientation`]'s +/// module note on why nothing here is named "clockwise". +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ShownRect { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// TRACES: FR-DEV-3h +/// Moving pixels, rectangles and points between the stored image and the shown +/// one. +/// +/// # Why these are named after spaces and not after rotations +/// +/// Every orientation bug this codebase has had was somebody applying a turn +/// the correct size in the wrong direction — and a quarter turn applied +/// backwards lands 180° from right, which looks like a deliberate transform +/// rather than a mistake. "Rotate 90° clockwise" cannot be checked by reading +/// it, because the reader has to hold in their head which image is being +/// rotated and which way the y axis points. +/// +/// So no function below says clockwise, anticlockwise, horizontal or vertical. +/// They say **which space they take and which space they return**, the two +/// spaces are different Rust types where a mistake would otherwise be silent, +/// and the direction is then something the compiler checks rather than +/// something the author remembers. +/// +/// The one primitive underneath all of it is +/// [`Orientation::source_pixel`] — the backward map the shader prologue and +/// the thumbnail path already share. Everything here is that map read forwards +/// or backwards, so there is one permutation in the codebase and no second +/// opinion to drift. +impl Orientation { + /// The transform that puts a shown image back into stored order. + /// + /// Not simply `4 - turns`: the mirrors are applied *after* the turn, so + /// undoing means undoing them first, and a mirror seen from the far side + /// of a turn may be about the other axis. Getting this wrong is the + /// diagonal-mirror case (tags 5 and 7), which renders as a 180° error. + pub fn inverse(self) -> Self { + // Mirrors are involutions, so the inverse's mirrors are the same two; + // what changes is the turn they are read against. + let (flip_h, flip_v) = if self.quarter_turns % 2 == 1 { + (self.flip_v, self.flip_h) + } else { + (self.flip_h, self.flip_v) + }; + Self { + quarter_turns: (4 - self.quarter_turns) % 4, + flip_h, + flip_v, + } + } + + /// Where a **stored** pixel lands in the **shown** image. + /// + /// [`Self::source_pixel`] run the other way, and stated the same way: + /// each takes the dimensions of the space it *reads from*. So this one is + /// handed the stored size and `source_pixel` is handed the shown size, + /// and neither caller has to work out which pair it is holding. + pub fn shown_pixel(self, sx: u32, sy: u32, width: u32, height: u32) -> (u32, u32) { + self.inverse().source_pixel(sx, sy, width, height) + } + + /// Read a **stored** buffer into **shown** order. + /// + /// `channels` values per pixel, tightly packed. Generic because the same + /// permutation serves an RGB proxy of floats, an RGBA overlay of bytes and + /// a single-channel mask, and three hand-written copies of one loop is how + /// two of them come to disagree. + /// + /// Exact. A quarter turn and its mirrors are a permutation of the pixel + /// grid, so nothing is filtered, nothing is resampled, and the round trip + /// through [`Self::into_stored`] returns what went in. + pub fn into_shown( + self, + stored: &[T], + width: u32, + height: u32, + channels: usize, + ) -> (Vec, u32, u32) { + if self.is_normal() || width == 0 || height == 0 { + return (stored.to_vec(), width, height); + } + let (dw, dh) = self.oriented_size(width, height); + let mut out = vec![T::default(); (dw as usize) * (dh as usize) * channels]; + for y in 0..dh { + for x in 0..dw { + let (sx, sy) = self.source_pixel(x, y, dw, dh); + let s = (sy as usize * width as usize + sx as usize) * channels; + let d = (y as usize * dw as usize + x as usize) * channels; + out[d..d + channels].copy_from_slice(&stored[s..s + channels]); + } + } + (out, dw, dh) + } + + /// Read a **shown** buffer back into **stored** order. + /// + /// `(dw, dh)` are the shown dimensions. The exact inverse of + /// [`Self::into_shown`], and written as the same map rather than as a + /// second one: a bijection read as a scatter fills every stored pixel once + /// and leaves no hole. + pub fn into_stored( + self, + shown: &[T], + dw: u32, + dh: u32, + channels: usize, + ) -> (Vec, u32, u32) { + if self.is_normal() || dw == 0 || dh == 0 { + return (shown.to_vec(), dw, dh); + } + let (sw, sh) = self.oriented_size(dw, dh); + let mut out = vec![T::default(); (sw as usize) * (sh as usize) * channels]; + for y in 0..dh { + for x in 0..dw { + let (sx, sy) = self.source_pixel(x, y, dw, dh); + let s = (y as usize * dw as usize + x as usize) * channels; + let d = (sy as usize * sw as usize + sx as usize) * channels; + out[d..d + channels].copy_from_slice(&shown[s..s + channels]); + } + } + (out, sw, sh) + } + + /// [`Self::source_pixel`] in normalised continuous coordinates. + /// + /// The pixel map says `dw - 1 - x` where this says `1 - x`, because a + /// pixel *centre* at `x + 0.5` has to land at `dw - x - 0.5`. Everything + /// else is the same transform in the same order — turn first, mirrors + /// after, in the stored image's own axes — and it is written next to its + /// integer twin so the two cannot drift apart unnoticed. + fn source_point(self, x: f32, y: f32) -> (f32, f32) { + let (mut sx, mut sy) = match self.quarter_turns { + 1 => (y, 1.0 - x), + 2 => (1.0 - x, 1.0 - y), + 3 => (1.0 - y, x), + _ => (x, y), + }; + if self.flip_h { + sx = 1.0 - sx; + } + if self.flip_v { + sy = 1.0 - sy; + } + (sx, sy) + } + + /// A normalised rectangle, **stored** to **shown**. + /// + /// Through the *inverse* map, because [`Self::source_point`] runs shown to + /// stored — and that asymmetry is written once, here, rather than being + /// rediscovered at each call site. The corners are mapped and the extremes + /// taken afterwards, since a turn exchanges which corner is which and a + /// rectangle has to keep its width positive. + pub fn into_shown_rect(self, r: StoredRect) -> ShownRect { + let [x, y, x1, y1] = self + .inverse() + .corners([r.x, r.y, r.x + r.width, r.y + r.height]); + ShownRect { + x, + y, + width: x1 - x, + height: y1 - y, + } + } + + /// A normalised rectangle, **shown** to **stored**. + pub fn into_stored_rect(self, r: ShownRect) -> StoredRect { + let [x, y, x1, y1] = self.corners([r.x, r.y, r.x + r.width, r.y + r.height]); + StoredRect { + x, + y, + width: x1 - x, + height: y1 - y, + } + } + + /// Both corners of a normalised rect through [`Self::source_point`], left + /// in `(x0, y0, x1, y1)` order. + fn corners(self, [ax, ay, bx, by]: [f32; 4]) -> [f32; 4] { + let (p0x, p0y) = self.source_point(ax, ay); + let (p1x, p1y) = self.source_point(bx, by); + [p0x.min(p1x), p0y.min(p1y), p0x.max(p1x), p0y.max(p1y)] + } +} + /// TRACES: FR-EXP-8 /// Where a photograph was taken. /// @@ -778,4 +986,137 @@ mod tests { assert!(Availability::Original > Availability::Preview); assert!(Availability::Preview > Availability::MetadataOnly); } + + // ---- the orientation space maps (FR-DEV-3h) -------------------------- + + /// Every pixel its own value, non-square and coprime, so no symmetry can + /// hide a permutation that is not the intended one. + fn ramp(w: u32, h: u32, channels: usize) -> Vec { + (0..(w * h) as usize) + .flat_map(|i| (0..channels).map(move |c| (i * 10 + c) as u32)) + .collect() + } + + /// The property everything else rests on. A quarter turn applied backwards + /// lands 180° from right and still looks like a transform, so "it came + /// back" is the only check worth having. + #[test] + fn a_buffer_survives_the_round_trip_through_shown_space() { + for tag in 1..=8u16 { + let o = Orientation::from_exif(tag); + for channels in [1usize, 3, 4] { + let src = ramp(5, 3, channels); + let (shown, dw, dh) = o.into_shown(&src, 5, 3, channels); + assert_eq!((dw, dh), o.oriented_size(5, 3), "tag {tag}"); + let (back, bw, bh) = o.into_stored(&shown, dw, dh, channels); + assert_eq!((bw, bh), (5, 3), "tag {tag}: stored size"); + assert_eq!(back, src, "tag {tag}, {channels}ch: did not come back"); + } + } + } + + /// `shown_pixel` must be the true inverse of `source_pixel`, not merely + /// something that looks like it. This is where a wrong direction shows up + /// as a fixed point that should have moved. + #[test] + fn the_two_pixel_maps_invert_each_other() { + const W: u32 = 7; + const H: u32 = 4; + for tag in 1..=8u16 { + let o = Orientation::from_exif(tag); + let (dw, dh) = o.oriented_size(W, H); + for y in 0..dh { + for x in 0..dw { + let (sx, sy) = o.source_pixel(x, y, dw, dh); + assert!(sx < W && sy < H, "tag {tag}: ({x},{y}) left the image"); + assert_eq!( + o.shown_pixel(sx, sy, W, H), + (x, y), + "tag {tag}: ({x},{y}) did not survive the return trip" + ); + } + } + } + } + + /// An inverse that is not a group inverse is the diagonal-mirror bug: tags + /// 5 and 7 differ only in which axis the mirror is about, and getting it + /// wrong renders as a 180° error rather than as anything obviously broken. + #[test] + fn the_inverse_undoes_the_transform() { + for tag in 1..=8u16 { + let o = Orientation::from_exif(tag); + assert_eq!(o.inverse().inverse(), o, "tag {tag}: not an involution"); + + // Composed on the grid, the pair must be the identity. + let (dw, dh) = o.oriented_size(6, 4); + let src = ramp(6, 4, 1); + let (shown, _, _) = o.into_shown(&src, 6, 4, 1); + let (back, _, _) = o.inverse().into_shown(&shown, dw, dh, 1); + assert_eq!(back, src, "tag {tag}: inverse did not undo it"); + } + } + + /// The rect maps have to agree with the pixel map, or a clip lands + /// somewhere the pixels did not — which is exactly the fault that put the + /// segmentation overlay on its side over an upright photograph. + #[test] + fn a_rect_lands_where_its_pixels_land() { + const W: u32 = 8; + const H: u32 = 4; + for tag in 1..=8u16 { + let o = Orientation::from_exif(tag); + let (dw, dh) = o.oriented_size(W, H); + + // A stored rect covering a known block of pixels. + let r = StoredRect { + x: 0.25, + y: 0.0, + width: 0.5, + height: 0.5, + }; + let shown = o.into_shown_rect(r); + + // Every stored pixel inside `r` must map into `shown`, and the + // rect must not have grown: a permutation moves a block, it does + // not resize one. + assert!( + (shown.width * shown.height - r.width * r.height).abs() < 1e-5, + "tag {tag}: the rect changed size" + ); + for sy in 0..H { + for sx in 0..W { + let inside = (sx as f32 + 0.5) / W as f32 >= r.x + && (sx as f32 + 0.5) / W as f32 <= r.x + r.width + && (sy as f32 + 0.5) / H as f32 >= r.y + && (sy as f32 + 0.5) / H as f32 <= r.y + r.height; + if !inside { + continue; + } + let (x, y) = o.shown_pixel(sx, sy, W, H); + let (u, v) = ((x as f32 + 0.5) / dw as f32, (y as f32 + 0.5) / dh as f32); + assert!( + u >= shown.x - 1e-4 + && u <= shown.x + shown.width + 1e-4 + && v >= shown.y - 1e-4 + && v <= shown.y + shown.height + 1e-4, + "tag {tag}: stored pixel ({sx},{sy}) -> shown ({x},{y}) fell outside {shown:?}" + ); + } + } + + // And back again. + let round = o.into_stored_rect(shown); + assert!( + (round.x - r.x).abs() < 1e-5, + "tag {tag}: x {round:?} vs {r:?}" + ); + assert!( + (round.y - r.y).abs() < 1e-5, + "tag {tag}: y {round:?} vs {r:?}" + ); + assert!((round.width - r.width).abs() < 1e-5, "tag {tag}: w"); + assert!((round.height - r.height).abs() < 1e-5, "tag {tag}: h"); + } + } } diff --git a/docker/ci-preflight.sh b/docker/ci-preflight.sh new file mode 100755 index 0000000..3b9257c --- /dev/null +++ b/docker/ci-preflight.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Check that every command the workflows invoke exists in the image that will +# run it. +# +# This exists because two CI failures in a row were the same shape: the image +# was missing a command, and finding out took a 28-minute cross-compile each +# time because the step that would fail ran last. git-lfs and file(1) were both +# absent for as long as the build panicked before ever reaching them. +# +# Nothing here runs the workflow. It answers one question -- is the toolchain +# the steps assume actually installed -- in about ten seconds. +# +# ./docker/ci-preflight.sh # every job +# ./docker/ci-preflight.sh android # one job +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "${HERE}/.." && pwd)" +WANT="${1:-}" +FAILED=0 + +# Commands a `run:` block invokes that are worth asserting: the ones a minimal +# image plausibly lacks. Shell builtins and coreutils are not interesting; a +# missing `cd` is not the failure mode anybody has. +readonly INTERESTING='^(git|git-lfs|file|zip|unzip|keytool|curl|cargo|rustup|apt-get|find|sed|awk|base64|shred|adb|python3|node|jq)$' + +jobs_and_images() { + python3 - "$REPO" <<'PY' +import sys, pathlib, yaml +root = pathlib.Path(sys.argv[1]) +for wf in sorted((root / ".gitea/workflows").glob("*.yml")): + doc = yaml.safe_load(wf.read_text()) or {} + for job, spec in (doc.get("jobs") or {}).items(): + image = ((spec.get("container") or {}).get("image")) + if not image: + continue + cmds = set() + for step in (spec.get("steps") or []): + run = step.get("run") + if not run: + continue + for line in run.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + # first word of the line, and of anything after a pipe + for part in line.split("|"): + word = part.strip().split(" ")[0].strip() + if word.isidentifier() or "-" in word: + cmds.add(word) + # `git lfs` is a subcommand, so the first word is `git` and the + # thing that can actually be absent never appears. This is the + # exact bug that shipped an image with no git-lfs in it. + if "git lfs" in line: + cmds.add("git-lfs") + print(f"{job}\t{image}\t{' '.join(sorted(cmds))}") +PY +} + +while IFS=$'\t' read -r job image cmds; do + [[ -n "${WANT}" && "${job}" != "${WANT}" ]] && continue + checked=() + for c in ${cmds}; do + [[ "${c}" =~ ${INTERESTING} ]] && checked+=("${c}") + done + # `git lfs` is a git subcommand, not a binary on PATH — ask git about it. + printf '\n== %s (%s)\n' "${job}" "${image}" + if [[ ${#checked[@]} -eq 0 ]]; then + echo " nothing to check" + continue + fi + docker image inspect "${image}" >/dev/null 2>&1 || { + echo " image not present locally — docker pull ${image}" + FAILED=1 + continue + } + out=$(docker run --rm --entrypoint bash "${image}" -c ' + for c in '"${checked[*]}"'; do + if [ "$c" = git-lfs ]; then + git lfs version >/dev/null 2>&1 && echo "ok git lfs" || echo "MISSING git lfs" + elif command -v "$c" >/dev/null 2>&1; then + echo "ok $c" + else + echo "MISSING $c" + fi + done' 2>&1) + echo "${out}" | sed 's/^/ /' + grep -q MISSING <<<"${out}" && FAILED=1 +done < <(jobs_and_images) + +echo +if [[ ${FAILED} -eq 0 ]]; then + echo "preflight: every command the workflows invoke is present" +else + echo "preflight: something the workflows invoke is not in the image — fix the Dockerfile before pushing" +fi +exit ${FAILED} diff --git a/docs/architecture.md b/docs/architecture.md index 67e10ae..78a8a15 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,8 +10,10 @@ and records the decisions and constraints behind the design. ## 1. Overview -DarkRoom is a Rust application with a Slint interface, rendering through wgpu to Vulkan on both -Linux and Android. The design is organised around four ideas, each of which the rest of this +DarkRoom is a Rust application with a Slint interface. On Linux it renders through wgpu to Vulkan. +On Android it renders through Skia to OpenGL — not by preference but because wgpu's Vulkan +swapchain cannot pre-rotate, which tears a portrait window on a landscape-mounted panel +([technical-debt.md TD-1](technical-debt.md)). The compute passes are wgpu on both. The design is organised around four ideas, each of which the rest of this document elaborates: 1. **Pixels stay on the GPU.** From decode to display, image data never round-trips through the @@ -980,6 +982,13 @@ At 4K the shader finishes in 0.28 ms and then 7.15 ms is spent moving pixels thr 26× overhead that scales with area, which is why an uncapped window resize falls off a cliff. The constraint is not a stylistic preference; it is the dominant cost in the frame. +**One exception, on Android only, and it is debt rather than a revision.** The develop view there +reads the frame back rather than handing over a texture, because zero-copy requires Slint to draw +with wgpu and wgpu's Android swapchain tears a portrait window. The reasoning, the measurements +that forced it and what would remove it are in [technical-debt.md TD-1](technical-debt.md). The +constraint above still governs every other path, including the desktop develop view and the export +pipeline, and the Android exception is expected to be temporary. + ### 6.2 Tiling from day one Mobile GPUs have far less memory. Retrofitting tiling into a whole-image pipeline is a rewrite. diff --git a/docs/spot-removal.md b/docs/spot-removal.md new file mode 100644 index 0000000..0ebd07d --- /dev/null +++ b/docs/spot-removal.md @@ -0,0 +1,569 @@ +# Spot removal + +**Status:** Draft · 2026-08-26 +**Companion to:** [requirements.md](requirements.md) §3.3 FR-DEV-8 · [architecture.md](architecture.md) §5.2 + +The last develop feature the requirements ask for that nothing in the tree +implements. FR-DEV-8 states the shape — "non-destructive clone and heal spots +stored as parameters in the edit graph (target, radius, feather, source offset, +opacity, mode), with automatic source placement and manual override, plus a +visualise-spots mode" — and this document is how that lands on the pipeline +that exists now. + +--- + +## 1. Why it is worth the work + +Sensor dust is unavoidable with interchangeable lenses, and a dust spot is the +most common reason a photographer leaves a RAW editor for a pixel editor +mid-workflow. Every other develop operation in this application can be the best +one in its class and the workflow still breaks at the first frame with a mark on +the sky. + +It is also, unusually, a feature whose cost has already been paid twice over. +The neighbourhood stage exists ([`crate::detail`](../core/dr-pipeline/src/detail.rs)), +the convention for storing geometry in normalised source coordinates exists +([`mask.rs`](../core/dr-pipeline/src/mask.rs)), the canvas-drag pattern exists +([`gradient.rs`](../ui/dr-ui/src/gradient.rs)), and the merge-by-id rule exists +([`sidecar.rs`](../core/dr-pipeline/src/sidecar.rs)). What is genuinely new is +small and is named in §3. + +## 2. Non-goals + +- **Not layer-based pixel editing.** §1.3 of the requirements excludes that and + this does not reopen it. A spot is a handful of numbers in the edit graph; no + pixels are stored, and the original file is never touched. +- **Not content-aware fill.** The source is a patch from the same photograph, + chosen by an offset. Synthesising texture that is not in the frame is a + different problem with a different budget. +- **Not a general clone brush.** A spot is a disc, not a stroke. A dragged + clone brush is expressible on top of this (a stroke *is* a run of discs) and + is deliberately left until the disc is finished and used. +- **Not automatic dust detection.** Finding spots without being asked is a + reasonable later feature and a bad first one: a false positive silently alters + a photograph, which is the failure this application must not have. + +## 3. What is new, precisely + +Four things, and it is worth being blunt about them because everything else in +this document is assembly of parts that already work: + +1. **A detail pass with variable-length data.** Every [`DetailPass`] today + carries a `Vec` of uniforms fixed by its own structure. A spot list is + neither fixed nor small. §7. +2. **A neighbourhood operation whose reach is not a small kernel.** Every + existing pass declares a halo of a few pixels. A spot reads from wherever its + source is, which may be a third of the frame away. §5.3. +3. **Undo over something that is not a parameter.** [`History`] snapshots a + [`Preset`], which is a map of scalars — so mask edits are already outside + undo, and spots must not be. §10.4. +4. **A canvas mode that *creates* objects.** Crop edits one rect; local selects + a region; gradient drags an existing shape. Nothing yet makes a new thing + where the pointer went down. §10. + +## 4. The model + +```rust +/// TRACES: FR-DEV-8 +pub struct Spot { + /// Stable across devices; see below. + pub id: String, + /// What is being covered, in normalised **source** coordinates. + pub centre: (f32, f32), + /// 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), + /// 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, + /// How much of the patch is laid down. 1.0 is opaque. + pub opacity: f32, + pub mode: SpotMode, // Heal | Clone + pub enabled: bool, +} +``` + +**Units follow the mask rule, for the mask reason.** A length stored in pixels +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. + +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 +a pixel and do not want to re-place the source. Moving the source alone is +editing `offset`. + +**The id is derived, not counted.** `MaskStack::next_id` numbers layers, which +is fine for a stack a user names, and wrong here: two devices that each place a +spot offline would both produce `spot3`, and the merge in §11 would treat two +different marks as one. So the id is a short base-36 hash of the centre at +creation, and two devices that place a spot in the same place produce the same +id — which is the correct outcome, because they removed the same piece of dust. + +```rust +pub struct SpotSet { + spots: Vec, // in creation order; the order matters, see §5.2 +} +``` + +### 4.1 Why it lives beside `ops`, not in it + +The [`Operation`] trait takes a `ParamId` and returns an `f32`, and the whole +generic machinery above it — the panel, the sidecar, the presets, the history — +is built on that being true. A spot list is not scalars, and the trait says so +explicitly where it refuses a downcast for film tables. + +[`EditGraph`] already holds three things that are not operations for exactly +this reason: `framing`, `masks` and `film`. `spots` is the fourth, and the +argument is the same one `masks` makes — a stack of layers is not a slider, and +folding it into the list would make every consumer that walks `ops` know that +some entries are not really operations. + +Bounds, following `mask.rs`'s example of bounding what a sidecar can grow to: + +| Constant | Value | Why | +|---|---|---| +| `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 | 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 + +### 5.1 First in the detail chain + +ARCH §5.2 draws spot removal *after* texture and clarity and *before* sharpen +and NR. That diagram is already out of step with the operation set — the +`order:` keys in `core/dr-pipeline/ops/` put noise reduction at 110 and capture +sharpening at 120, ahead of clarity at 130 and texture at 140 — so it needs a +correction anyway, and the correction should put spot removal **first among the +neighbourhood passes**, at a notional order of 105. + +The reason is the halo. Sharpening a dust spot before removing it amplifies its +edge, and the amplified edge is wider than the spot: the sharpening kernel has +already smeared a dark ring into pixels that the spot's own disc does not cover, +so the heal leaves a faint circle of over-sharpened background around a patch +that is otherwise perfect. Removing the mark first means every later pass sees a +photograph with no mark in it, which is also the photograph the photographer +thinks they are sharpening. + +Since the spot set is not in `ops`, `EditGraph::compose_detail_for` splices its +passes in front of the ops' passes rather than sorting by a declared order. That +is a two-line change and it is stated here so nobody looks for a `spots.yaml`. + +### 5.2 Rounds, because sources can read destinations + +Every pass reads one texture and writes another. So within a single pass, every +spot reads the *unhealed* image — and a spot whose source overlaps an earlier +spot's destination copies the mark the earlier spot was removing. + +The fix is not to run one pass per spot (64 dispatches for a frame that needs +one). It is to group: walking the spots in creation order, a spot joins the +current round unless its source disc intersects the destination disc of a spot +already in that round, in which case it opens a new one. One pass per round, and +the common case — spots scattered over a sky, sources near their own +destinations — is a single round. The grouping is plain CPU code over at most 64 +discs and belongs in `SpotSet`, with a test that says an overlapping pair +produces two rounds and a disjoint pair produces one. + +### 5.3 The halo, honestly + +[`DetailPass::radius`] is "the furthest this pass reads from the pixel it +writes", and it exists so that ARCH §5.3's tile scheduler knows how far to grow +a tile. For a spot pass that is `max(|offset| + radius)` over the pass's spots, +in render pixels — which with `MAX_SOURCE_DISTANCE` at 0.5 can approach half the +frame. + +That is a real cost and it should be written down rather than discovered: a +frame with a long-armed spot is close to untileable for that one pass, so the +tiled path will compute it whole-frame. Two things keep it affordable. The pass +is cheap per pixel (§6.4), and it is only the *spot* passes that carry the halo +— the sharpening pass after it still declares its three pixels and still tiles. +The alternative, clamping the source distance to something tile-sized, would +make the tool useless exactly where it is most needed: a mark on a face is +healed from the other cheek, and that is a long way. + +## 6. What a spot does to the pixels + +Both modes work on the same disc. For a pixel at render coordinate `p` inside a +spot centred at `d` with radius `r`, with the source at `s = d + offset`: + +``` +w = falloff(|p - d| / r) // 1 at the centre, 0 at the rim +patch = bilinear(source_texture, p - d + s) +c = mix(c, patch + membrane, w * opacity) +``` + +`falloff` is a smoothstep over the outer `feather` fraction of the radius; a +feather of 0 is a hard disc. `bilinear` is four `tap`s and two lerps, because +the generated preamble offers `textureLoad` only and the offset is fractional in +render space — it becomes a [`Helper`], deduplicated across passes like the +existing luminance helper. + +`membrane` is what separates the two modes, and it is zero for `Clone`. + +### 6.1 Heal, without a Poisson solve — **implemented** + +The classic heal is Poisson blending: copy the *gradients* of the source and +solve for the image whose gradients they are, subject to matching the +destination on the boundary. Solved properly that is an iterative linear system +— tens of Jacobi passes over the disc — and each iteration is a dispatch in this +architecture. Sixty dispatches to remove a dust spot is not a frame budget. + +What that solve produces is a smooth membrane interpolating the boundary +difference, and a membrane can be interpolated directly instead of solved. The +shipped form samples the difference between destination and source at `K` points +around the rim and interpolates them into the interior by inverse square +distance: + +``` +for k in 0..K: + b_k = tap(rim_k) - tap(rim_k + offset) // boundary difference + w_k = 1 / max(|p - rim_k|², 1) +membrane = Σ w_k·b_k / Σ w_k +``` + +`K = 24` — `RIM_SAMPLES` in `core/dr-pipeline/src/spot.rs`, a uniform rather +than a constant in the source, so tuning it uploads a buffer instead of +recompiling. The cost is `2K` bilinear samples per pixel *inside a disc*, and +nothing at all outside one. + +**What was originally specified here was mean-value seamless cloning** (Farbman +et al., 2009), whose weights are half-angle tangents over the rim rather than +inverse squares. The difference matters when the boundary difference varies +sharply around the rim; on the case that actually arises — a repair on a +smoothly varying background — both reduce to the same answer, and the inverse +square form costs two transcendentals per sample fewer. The measurement in +`core/dr-gpu/tests/spot_removal.rs` is what decides whether that trade stays +good: on a linear ramp steep enough to make a clone wrong by 38 levels out of +255, the heal is wrong by **0**. If a case turns up where it is not, the weights +are four lines and the tests are already written. + +### 6.2 Why not compute the boundary statistics on the CPU + +Because that means reading the rendered image back, and FR-DEV-4 forbids it in +the render path for reasons ARCH §6.1 spends a page on. A per-frame readback to +find out what colour a sky is would reintroduce exactly the stall the whole +architecture exists to avoid. The mean-value form needs no reduction at all, +which is most of why it is the right answer here. + +### 6.3 Clone + +`membrane = 0`. Kept because heal is wrong on a boundary: a spot straddling a +horizon healed by mean-value blending smears the horizon's contrast into the +disc, and the honest tool then is a straight copy from a matching part of the +frame. This is why FR-DEV-8 asks for both, and it costs one branch in the shader +and one segmented control in the panel. + +### 6.4 Cost + +Per pixel, per pass: a rejection test per spot in the pass (a squared distance +and a compare), and for the pixels actually inside a disc, `4 + 2K` taps. With +64 spots the rejection cost is the dominant term and it is about 64 × 4 ALU ops +on every pixel of the frame — call it a millisecond at 2 MP on integrated +graphics, which is affordable but not free. + +If it proves not to be, the fix is the one `mask.rs` already uses for strokes: a +bounding box per spot and a dispatch sized to it. That needs the detail runner +to dispatch something other than the whole frame, which is a change to its +shape, and it is deliberately not being made until a measurement asks for it. + +## 7. Getting a spot list to the GPU + +[`DetailPass`] gains one field: + +```rust +/// Per-instance data too large or too variable for the uniform block. +pub storage: Option>, +``` + +and the generated preamble gains one binding: + +```wgsl +@group(0) @binding(3) var instances: array>; +``` + +In `dr-gpu`, both bind group layouts gain a read-only storage entry at binding +3, and a pass that declares no storage binds a shared one-element dummy buffer. +wgpu permits a layout entry the shader does not use, so the two layouts stay two +rather than four, and no existing pass changes at all. + +**A spot is two `vec4`s**: `(centre.x, centre.y, radius, feather)` and +`(offset.x, offset.y, opacity, flags)`, all in render pixels except `flags`, +converted on the CPU in `compose_detail_for` where the framing is in scope. This +matters: the shader never sees a normalised coordinate and never has to know +about crop, rotation or zoom — `Framing::output_at` does that map on the way in, +exactly as `gradient.rs` does it for handles. The framing is an affine +similarity, so a disc stays a disc and one radius scales by one factor: +`radius_px = radius × min(source_w, source_h) × scale.ratio()`. + +**The source list does not recompile anything.** The WGSL is identical for one +spot and for sixty-four — the count is a uniform and the loop is over the +buffer — so `structure_hash` is unchanged as spots are placed, and placing the +tenth spot re-uploads a 512-byte buffer. This is the same property the fused +pass has for slider movement and it is worth a test that asserts +`cached_pipelines()` does not grow while spots are added. + +**Rejected: packing spots into the uniform block.** It would touch no bind group +layout, which is genuinely attractive. It also requires the composer to emit +`vec4` uniform fields (it emits scalars), forces a fixed `MAX_SPOTS`-sized array +and its fixed upload cost into every spot pass, and gives the next operation +that wants a table — a LUT, a curve, a lens grid — nothing to build on. The +storage buffer is a few more lines once and useful again later. + +**Invalidation.** The spot set folds into the `detail` key in +`EditGraph::invalidation`, alongside the detail operations. Dragging a spot +therefore re-runs the detail chain and *not* the fused colour pass or the +demosaic, which is exactly the reuse FR-DEV-3d asks for and is the difference +between a spot that follows the finger and one that stutters. + +## 8. Automatic source placement + +FR-DEV-8 asks for automatic placement with manual override. Two stages, because +the useful half is much cheaper than the good half. + +**Stage one — a placed default.** A new spot's source is offset by `2.5 × radius` +in the direction that keeps it furthest inside the frame, biased towards the +frame centre. For dust on a sky, which is the overwhelming majority of spots, +this is right often enough to be worth having, and it is wrong in a way that is +immediately visible and one drag from fixed. + +**Stage two — a scored search.** A compute dispatch per new spot scores candidate +offsets on two rings around the destination (say 32 candidates), each scored by +the sum of squared differences over the annulus just outside the destination +disc — the ring is what has to match, since the disc's interior is being +replaced anyway. Penalise candidates whose disc overlaps another spot's +destination, or the frame edge. The winner's offset is read back **once**, when +the spot is created, through `readback.rs` — a few hundred bytes, which is the +size the histogram already moves and completes in well under a frame — and +written into the spot. + +Two rules about that readback, both of which are the difference between a +feature and a bug: + +- It is **not** in the render loop. `readback.rs` blocks with a deadline, and + that is tolerable exactly once per placement and intolerable per frame. It + happens on the gesture, and the render that follows uses whatever the spot + currently holds. +- The result is **stored**, and the search is never re-run behind the user. A + spot whose source moved on its own when the file was reopened would be an edit + changing itself, and non-destructive editing means the sidecar decides what + the picture is. + +If the readback fails, the stage-one default stands. There is no state in which +a spot has no source. + +## 9. Visualising spots + +FR-DEV-8's "visualise-spots mode" is two different things, and conflating them +is how one of them ends up missing: + +**The overlay** — where the spots *are*. Circles for the destination, a fainter +circle for the source, a line between them for the selected spot. Drawn in Slint +over the canvas, alongside the gradient handles and by the same coordinate map, +so it costs the render path nothing and cannot leak into an export. + +**The reveal** — where the spots *should be*. Lightroom's "Visualize Spots": a +high-contrast, desaturated view of the frame's high-frequency content, in which +sensor dust on a smooth sky is obvious and in a normal view is nearly invisible. +It is a detail pass appended to the chain: + +``` +c = abs(c - blur(c)) stretched by a threshold, greyscale, inverted +``` + +It is a **view**, not an edit. So it is not in the graph and not in the sidecar: +`compose_detail_for` takes a `DetailView` (`Normal` | `RevealSpots`) and the +export path passes `Normal`. A flag on the session would work until the day +somebody exports while the mode is on, and then it would produce a black-and- +white file that looks like corruption. Making the export call site name it is +what stops that from ever being possible. + +## 10. Interaction + +### 10.1 The mode + +A third chip in `ModeStrip`, beside Crop and Local, and a `ViewMode::spots`. +The strip's own documentation already predicted this shape for the brush; the +spot tool is the same shape and arrives first. + +### 10.2 Gestures + +| Gesture | Effect | +|---|---| +| Tap / click on the photograph | Place a spot at the current radius, source auto-placed (§8), and select it | +| Drag from a spot's centre | Move the destination; the source follows | +| Drag from the source circle | Change the offset | +| Drag *out* from a fresh placement | Set the source directly, without the auto-placement | +| Scroll / pinch on a selected spot | Radius | +| Tap a spot | Select it; the panel scopes to it | +| `Delete` / `Backspace` | Remove the selected spot | +| Alt-click a spot | Remove it without selecting first | +| `Esc` | Leave the mode | + +A drag is a displacement from the press, not a snap to the pointer — the rule +`gradient.rs` states and for the same reason: a finger-sized touch target snapped +to the pointer jumps by half a target the instant it is grabbed. + +### 10.3 The panel + +While a spot is selected, the adjust column shows radius, feather, opacity and +a Heal/Clone control for *that* spot, exactly as selecting a mask layer +re-scopes the column today. With nothing selected it shows the defaults new +spots will be created with, plus the reveal toggle. + +### 10.4 Undo + +[`History`] snapshots a [`Preset`], which is a parameter map — so today mask +edits are not undoable, and spot placement must not inherit that. `History` +should hold `(Preset, SpotSet)` and restore both. + +That is a narrow change with a wide benefit: the same door lets the mask stack +join later, which closes a gap FR-DEV-5 has open right now. The coalescing rule +needs one addition — a drag of one spot's handle is one step, keyed by the spot +id in the same way a slider drag is keyed by its control — and placement, +deletion and mode changes each open a step of their own. + +### 10.5 Touch + +Every handle is a `Theme.touch-target`, per FR-UI-3. On a phone the destination +and source circles of a small spot overlap at that size, so the source handle is +drawn at a minimum arm length from the centre while the stored offset is +untouched — the trick `gradient.rs` uses with `MIN_ARM`, for the identical +reason: a handle that cannot be grabbed again is a one-way edit. + +## 11. Persistence + +One line per spot in the version block: + +``` +[version 8f04c0e2-…] +exposure.exposure = 0.75 +spot.3f9k = 0.4213 0.2871 0.0120 0.35 0.0310 -0.0180 1 heal +``` + +Fields in order: `centre.x centre.y radius feather offset.x offset.y opacity +mode`. A line rather than a block because a spot is eight numbers and sixty-four +blocks would bury the rest of the file; a line *per spot* rather than one line +for the set because the line is the unit of merge and of a readable diff — the +same reasoning `write_strokes` gives for a line per stroke. + +Coordinates are written at the same precision they are held at, as strokes are, +so a round trip is exact and two devices do not generate a diff of noise in the +sixth decimal. + +**A malformed line costs that spot and not the file.** A truncated line is +dropped with a warning, exactly as `parse_stroke` drops a bad stroke: a spot +that silently lands somewhere the user never put it is worse than a spot that is +missing, because only one of the two is noticeable. + +**Merge** follows `merge_masks` precisely: by id, disjoint survives, a spot both +sides edited resolves wholesale to the higher revision. Half of one device's +offset with the other's radius is a repair neither photographer made. Deletion +propagates through the base comparison exactly as a layer's does. + +**Presets do not carry spots** in the first version — a preset is a look, and a +look does not include where the dust was. But dust is in the *same place on +every frame from that body*, which makes "copy spot removal to the selection" +genuinely valuable, and it is a stage of its own (§12, S7) rather than a +surprise inside the existing paste. + +## 12. Stages + +Each stage is shippable and each has something to look at. Test names are the +files they belong in. + +**S1 — The model.** *(Done.)* `spot.rs` in `dr-pipeline`: `Spot`, `SpotMode`, `SpotSet`, +the bounds, id derivation, round grouping (§5.2). No GPU, no UI. +*Tests:* `core/dr-pipeline/tests/spots.rs` — id stability across two identical +placements, `MAX_SPOTS` refuses rather than drops, overlapping sources produce +two rounds, disjoint produce one. + +**S2 — Persistence.** *(Done.)* Sidecar write, parse, round trip, merge. +*Acceptance:* a hand-written sidecar with three spots survives a load/save round +trip byte-identically, and two devices that each add a spot offline end with +both. + +**S3 — The storage binding.** *(Done.)* `DetailPass::storage`, the preamble's binding 3, +the dummy buffer, the two layouts. +*Acceptance:* every existing detail test still passes untouched, and a synthetic +pass reading the buffer gets what was uploaded. + +**S4 — Clone.** *(Done.)* The disc, the feather, the bilinear helper, the pass grouping, +the halo declaration, spliced first into the chain. +*Acceptance:* `core/dr-gpu/tests/spot_removal.rs` — a synthetic frame with a +black disc on a flat grey field is clean to within a tolerance after one clone +spot; the same edit at a one-quarter proxy and at full size land the disc in the +same *normalised* place; adding spots does not grow `cached_pipelines()`. + +**S5 — Heal.** The membrane, the mode switch. **Done** — §6.1, and the measurement came out at 0 levels of error against a clone's 38. +*Acceptance:* a dark spot on a linear grey **gradient** — the case clone fails — +is clean to within a tolerance, and the residual at the disc boundary is below +the residual a clone leaves by an order of magnitude. This is the measurement +that decides `K`. + +**S6 — The tool.** `ViewMode::spots`, the chip, placement, handles, selection, +the panel scope, deletion, history carrying the spot set, the stage-one source +default. **Done, except the reveal view** — §9's second half is the one piece +of S6 not built, and it is separable: it is a view mode over the detail chain +rather than part of the tool. +*Acceptance:* a dust mark on a real frame is gone in one click, the edit survives +a restart, and undo takes it back. + +*What was verified, and how.* Everything below the interface is under test — +the model, the sidecar, the merge, the passes, both blend modes, and undo. The +interface itself was compiled, laid out and photographed: the strip renders +`Crop | Local | Repair` and the column re-scopes. It was **not** driven, because +synthetic clicks do not reach this application (the compositor refuses them), +so the gestures in §10 are as-written rather than as-felt. A first pass with a +real pointer is the outstanding work on this stage. + +**S7 — The rest of FR-DEV-8.** The scored source search (§8 stage two), and +copying a spot set across a selection. + +S1–S6 is the requirement met in the sense a photographer would recognise; S7 is +the sentence in FR-DEV-8 about automatic placement met in the sense the document +means it. + +## 13. Documents to amend + +- **requirements.md** — FR-DEV-8 has no *Acceptance:* line; every other + requirement of its weight does. Proposed: *"a dust mark on a smooth sky is + removed in one click with no visible boundary at 1:1, the spot survives a + crop, a rotation and an export at another size, and the exported file matches + the preview."* +- **architecture.md §5.2** — the stage list is out of step with the `order:` + keys in `ops/` and does not show spot removal first among the neighbourhood + passes. §5.1 above is the correction. +- **traceability.md** — regenerated, as ever, rather than edited. FR-DEV-8's row + currently points only at two comments that mention it. + +## 14. Open questions + +1. ~~**`K = 24`?**~~ Settled by S5's measurement: 24 samples, inverse square weights, zero error on the case the mode exists for. +2. **Does the reveal view belong to spot mode only,** or is it a view mode of + its own that a photographer can turn on while doing something else? It is + cheap to allow both; the risk is a mode nobody remembers turning on. Still + open, and now the only part of §9 unbuilt. +3. **Should a spot be clamped inside the crop?** A spot outside the current crop + costs nothing to render and is invisible, and re-cropping should bring it + back rather than find it deleted. Leaning strongly towards no clamp. +4. **One radius, or an ellipse?** Lightroom's spot tool is circular and its + users cope. An ellipse doubles the handle count for a case a second spot + already covers. diff --git a/docs/technical-debt.md b/docs/technical-debt.md new file mode 100644 index 0000000..69c76e5 --- /dev/null +++ b/docs/technical-debt.md @@ -0,0 +1,150 @@ +# DarkRoom — Technical debt + +**Status:** Living document · first written 2026-08-26 +**Companion to:** [architecture.md](architecture.md) + +Deliberate compromises: things the code does knowing they are wrong, because the alternative was +worse at the time. Each entry says what the debt is, what it cost to take on, what it would take to +pay off, and how you would know it had been paid. + +Not a bug list. A bug is something nobody chose. Everything here was chosen, and the point of +writing it down is that the reasoning outlives whoever chose it — so the next person can tell a +constraint from an accident, and does not "fix" something load-bearing or preserve something that +has quietly stopped being necessary. + +--- + +## TD-1 — The Android develop view reads pixels back through the CPU + +**Breaks:** [architecture.md §12 / 6.1](architecture.md) — GPU results never round-trip through the +CPU — and AC-8, on Android only. Desktop is unaffected and keeps the zero-copy path. + +### What it does + +`DevelopSession::render` on Android runs the compute passes on the GPU as usual, then calls +`AdjustPass::export_pixels` and hands the frame to Slint as a `SharedPixelBuffer`. That is exactly +the GPU→CPU→GPU transfer §6.1 exists to forbid, and it is on the frame path. + +### Why + +Zero-copy needs Slint to draw with wgpu. On Android that means wgpu's Vulkan swapchain, which +hardcodes `preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR` +([gfx-rs/wgpu#3345](https://github.com/gfx-rs/wgpu/issues/3345)) — wgpu-hal says so in a comment +beside the line. + +On a tablet whose panel is mounted landscape, a portrait window then hands Android an unrotated +buffer, every present returns `VK_SUBOPTIMAL_KHR`, and frames arrive torn. Measured on the device, +same build, only the tablet rotated: + +| orientation | `bufferTransform` | composition | result | +|---|---|---|---| +| landscape | `ROT_180` | `DEVICE (2)` | clean | +| portrait | `ROT_270` | `CLIENT (1)` | torn | + +Setting `preTransform` is not a fix available to us: the field is a *promise* that the content is +already rotated, so honouring it needs the renderer to rotate what it draws, which wgpu cannot do +on Skia's behalf. + +So the choice was never fast-develop against slow-develop. It was a develop view that costs a +readback against a grid that tears in the orientation a tablet is mostly held in. + +### What it costs + +Less than §6.1's headline numbers, because `render` fits the pass to the canvas before it runs — the +readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in §12 is the ceiling, +not the bill. **It has not been measured on the device**, which is the first thing to do if the +develop view feels heavy on the tablet; do not assume this is the cause without a number. + +### Paying it off + +Any one of these removes it: + +- wgpu implements pre-rotation (#3345), and Android goes back on `unstable-wgpu-29`. +- Slint's Skia Vulkan surface handles `preTransform` and Android uses that instead of OpenGL. +- Skia over OpenGL grows a way to sample an external texture that wgpu can write. + +**Done when:** `ui/dr-ui/Cargo.toml` no longer scopes `renderer-femtovg-wgpu` and +`unstable-wgpu-29` to non-Android, the `#[cfg(target_os = "android")]` arm of +`DevelopSession::render` is gone, and the tablet is clean in portrait. + +--- + +## TD-2 — Thumbnails are fetched one at a time + +**Where:** `library::spawn_thumbnails` — the `for req in to_fetch` loop. + +### What it does + +The interactive thumbnail batch fetches serially: one image at a time, and two HTTP round trips +each (a header read, then the preview's byte range). A window of a few hundred cells is that many +sequential round trips against the server. + +### Why it is debt rather than a bug + +It is correct, and it was fast enough when a window was one screenful. It is the *ordering* that +kept it survivable: since `fetch_rank`, on-screen cells are requested first, so the cells a person +is looking at arrive first even though the queue as a whole is slow. + +Portrait makes it worse by construction — a narrow window means smaller cells, more rows, and two +to three times as many cells on screen at once, all of them ahead of the ones below in a queue that +never runs more than one request. + +### Paying it off + +`spawn_thumbnail_sweep` already has the pattern: `SWEEP_LANES` disjoint lanes over a chunk, joined, +with the store written on the one thread that owns it. Striping a *priority-ordered* chunk across +lanes keeps `fetch_rank`'s ordering while running several requests at once. + +Not done yet because it multiplies concurrent requests against the user's Nextcloud during a +scroll, and that is a behaviour change worth deciding on deliberately rather than inheriting from a +performance fix. + +**Done when:** the interactive batch runs on more than one lane, priority order is preserved +across the lanes, and a slow server still cannot stall the visible cells behind offscreen ones. + +--- + +## TD-3 — The thumbnail drain applies an unbounded batch on the UI thread + +**Where:** `library_ui::drain_thumbnails` — the `loop` inside the timer callback. + +### What it does + +Every message queued when the timer fires is applied in that one callback, with no ceiling. On a +library whose thumbnails are already in the store, the worker delivers a whole window at once, so a +single callback can do hundreds of `to_slint_image` calls back to back — each an allocation and a +full RGBA copy — while the grid is mid-flick. + +The copy cannot move off the UI thread: `slint::SharedPixelBuffer` is not `Send`, so decoded bytes +can only become an `Image` on the thread that draws. Only the *amount done per wake* is ours to +choose, and right now it is "all of it". + +### Cost + +Measured with a temporary probe, **debug build**, so treat the shape rather than the size: + +| class | per thumbnail | × a 280-cell window | +|---|---|---| +| grid, 256 px | 1.93 ms | 539 ms | +| large, 512 px | 7.78 ms | 2.18 s | + +A release measurement was started and never completed — do not quote these as release figures. + +### Paying it off + +A time budget per wake and a shorter interval: apply for a few milliseconds, return without +stopping the timer, and finish on the next tick. A batch then lands in frame-sized slices rather +than one lump between two frames. Draft written and discarded during the investigation; it is a +small change. + +**Done when:** one wake of the drain cannot exceed a frame, and a fully-cached window still fills +in well under a second. + +--- + +## Related, and deliberately not here + +The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed* +rather than deferred — see the commits around `6d6ef8d`. They are mentioned only so that a reader +looking for "why was the grid slow" finds the answer in the code and its comments rather than +assuming it is still outstanding. diff --git a/docs/traceability.md b/docs/traceability.md index fcc63b2..c70e8ce 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 244 | -| TRACES tags found | 634 | +| Source files scanned | 254 | +| TRACES tags found | 732 | | Requirements defined | 177 | -| Requirements covered | 97 | -| **Coverage** | **54.8%** (97/177) | +| Requirements covered | 98 | +| **Coverage** | **55.4%** (98/177) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 77 | 122 | +| FR | 78 | 122 | | NFR | 18 | 49 | | R | 2 | 6 | @@ -34,88 +34,89 @@ _None._ | ID | Tagged in | |---|---| | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | -| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1051`](../ui/dr-ui/src/lib.rs#L1051), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1008`](../ui/dr-ui/ui/library.slint#L1008), [`ui/dr-ui/ui/library.slint:1170`](../ui/dr-ui/ui/library.slint#L1170), [`ui/dr-ui/ui/library.slint:857`](../ui/dr-ui/ui/library.slint#L857) | -| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1051`](../ui/dr-ui/src/lib.rs#L1051), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2122`](../ui/dr-ui/src/library.rs#L2122), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | -| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120) | +| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1097`](../ui/dr-ui/src/lib.rs#L1097), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1008`](../ui/dr-ui/ui/library.slint#L1008), [`ui/dr-ui/ui/library.slint:1170`](../ui/dr-ui/ui/library.slint#L1170), [`ui/dr-ui/ui/library.slint:857`](../ui/dr-ui/ui/library.slint#L857) | +| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1097`](../ui/dr-ui/src/lib.rs#L1097), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2122`](../ui/dr-ui/src/library.rs#L2122), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) | | FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) | | FR-CAT-15 | [`core/dr-catalog/src/schema.rs:583`](../core/dr-catalog/src/schema.rs#L583), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:198`](../ui/dr-ui/src/library.rs#L198), [`ui/dr-ui/src/library.rs:3037`](../ui/dr-ui/src/library.rs#L3037), [`ui/dr-ui/src/library.rs:3071`](../ui/dr-ui/src/library.rs#L3071), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:757`](../ui/dr-ui/src/library_ui.rs#L757), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | -| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`ui/dr-ui/ui/app.slint:527`](../ui/dr-ui/ui/app.slint#L527), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`ui/dr-ui/ui/app.slint:540`](../ui/dr-ui/ui/app.slint#L540), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:304`](../core/dr-catalog/src/schema.rs#L304), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1001`](../core/dr-catalog/src/schema.rs#L1001), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3085`](../ui/dr-ui/src/library.rs#L3085), [`ui/dr-ui/src/library_ui.rs:6271`](../ui/dr-ui/src/library_ui.rs#L6271), [`ui/dr-ui/src/library_ui.rs:6282`](../ui/dr-ui/src/library_ui.rs#L6282), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6295`](../ui/dr-ui/src/library_ui.rs#L6295), [`ui/dr-ui/src/library_ui.rs:6310`](../ui/dr-ui/src/library_ui.rs#L6310), [`ui/dr-ui/src/library_ui.rs:6319`](../ui/dr-ui/src/library_ui.rs#L6319), [`ui/dr-ui/ui/app.slint:472`](../ui/dr-ui/ui/app.slint#L472), [`ui/dr-ui/ui/app.slint:637`](../ui/dr-ui/ui/app.slint#L637), [`ui/dr-ui/ui/library.slint:1219`](../ui/dr-ui/ui/library.slint#L1219), [`ui/dr-ui/ui/library.slint:1222`](../ui/dr-ui/ui/library.slint#L1222), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:850`](../ui/dr-ui/ui/library.slint#L850), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:3331`](../ui/dr-ui/src/library.rs#L3331), [`ui/dr-ui/src/library_ui.rs:315`](../ui/dr-ui/src/library_ui.rs#L315), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5193`](../ui/dr-ui/src/library_ui.rs#L5193), [`ui/dr-ui/src/library_ui.rs:6411`](../ui/dr-ui/src/library_ui.rs#L6411), [`ui/dr-ui/ui/app.slint:475`](../ui/dr-ui/ui/app.slint#L475), [`ui/dr-ui/ui/app.slint:637`](../ui/dr-ui/ui/app.slint#L637), [`ui/dr-ui/ui/app.slint:876`](../ui/dr-ui/ui/app.slint#L876), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1288`](../ui/dr-ui/ui/library.slint#L1288), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/settings.slint:128`](../ui/dr-ui/ui/settings.slint#L128) | -| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3482`](../ui/dr-ui/src/library_ui.rs#L3482), [`ui/dr-ui/src/library_ui.rs:4180`](../ui/dr-ui/src/library_ui.rs#L4180), [`ui/dr-ui/ui/app.slint:632`](../ui/dr-ui/ui/app.slint#L632), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2528`](../ui/dr-ui/ui/library.slint#L2528), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) | -| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1343`](../core/dr-pipeline/src/sidecar.rs#L1343), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`core/dr-pipeline/tests/tone_curve.rs:33`](../core/dr-pipeline/tests/tone_curve.rs#L33), [`ui/dr-ui/src/develop.rs:2845`](../ui/dr-ui/src/develop.rs#L2845), [`ui/dr-ui/src/develop.rs:2873`](../ui/dr-ui/src/develop.rs#L2873), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1307`](../ui/dr-ui/src/lib.rs#L1307), [`ui/dr-ui/src/lib.rs:1647`](../ui/dr-ui/src/lib.rs#L1647), [`ui/dr-ui/src/lib.rs:1783`](../ui/dr-ui/src/lib.rs#L1783), [`ui/dr-ui/src/lib.rs:467`](../ui/dr-ui/src/lib.rs#L467), [`ui/dr-ui/src/lib.rs:852`](../ui/dr-ui/src/lib.rs#L852), [`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:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4882`](../ui/dr-ui/src/library_ui.rs#L4882), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2408`](../ui/dr-ui/src/develop.rs#L2408), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library.rs:1582`](../ui/dr-ui/src/library.rs#L1582), [`ui/dr-ui/src/library.rs:220`](../ui/dr-ui/src/library.rs#L220), [`ui/dr-ui/src/library.rs:3438`](../ui/dr-ui/src/library.rs#L3438), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library_ui.rs:1564`](../ui/dr-ui/src/library_ui.rs#L1564), [`ui/dr-ui/src/library_ui.rs:1590`](../ui/dr-ui/src/library_ui.rs#L1590), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1700`](../ui/dr-ui/src/library_ui.rs#L1700), [`ui/dr-ui/src/library_ui.rs:226`](../ui/dr-ui/src/library_ui.rs#L226), [`ui/dr-ui/src/library_ui.rs:2316`](../ui/dr-ui/src/library_ui.rs#L2316), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:2754`](../ui/dr-ui/src/library_ui.rs#L2754), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:3257`](../ui/dr-ui/src/library_ui.rs#L3257), [`ui/dr-ui/src/library_ui.rs:3345`](../ui/dr-ui/src/library_ui.rs#L3345), [`ui/dr-ui/src/library_ui.rs:3533`](../ui/dr-ui/src/library_ui.rs#L3533), [`ui/dr-ui/src/library_ui.rs:3651`](../ui/dr-ui/src/library_ui.rs#L3651), [`ui/dr-ui/src/library_ui.rs:440`](../ui/dr-ui/src/library_ui.rs#L440), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5238`](../ui/dr-ui/src/library_ui.rs#L5238), [`ui/dr-ui/src/library_ui.rs:5353`](../ui/dr-ui/src/library_ui.rs#L5353), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | -| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/develop.rs:1668`](../ui/dr-ui/src/develop.rs#L1668), [`ui/dr-ui/src/develop.rs:532`](../ui/dr-ui/src/develop.rs#L532), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1755`](../ui/dr-ui/src/lib.rs#L1755), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1001`](../core/dr-catalog/src/schema.rs#L1001), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3085`](../ui/dr-ui/src/library.rs#L3085), [`ui/dr-ui/src/library_ui.rs:6271`](../ui/dr-ui/src/library_ui.rs#L6271), [`ui/dr-ui/src/library_ui.rs:6282`](../ui/dr-ui/src/library_ui.rs#L6282), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6295`](../ui/dr-ui/src/library_ui.rs#L6295), [`ui/dr-ui/src/library_ui.rs:6310`](../ui/dr-ui/src/library_ui.rs#L6310), [`ui/dr-ui/src/library_ui.rs:6319`](../ui/dr-ui/src/library_ui.rs#L6319), [`ui/dr-ui/ui/app.slint:485`](../ui/dr-ui/ui/app.slint#L485), [`ui/dr-ui/ui/app.slint:650`](../ui/dr-ui/ui/app.slint#L650), [`ui/dr-ui/ui/library.slint:1219`](../ui/dr-ui/ui/library.slint#L1219), [`ui/dr-ui/ui/library.slint:1222`](../ui/dr-ui/ui/library.slint#L1222), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:850`](../ui/dr-ui/ui/library.slint#L850), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:3331`](../ui/dr-ui/src/library.rs#L3331), [`ui/dr-ui/src/library_ui.rs:315`](../ui/dr-ui/src/library_ui.rs#L315), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5193`](../ui/dr-ui/src/library_ui.rs#L5193), [`ui/dr-ui/src/library_ui.rs:6411`](../ui/dr-ui/src/library_ui.rs#L6411), [`ui/dr-ui/ui/app.slint:488`](../ui/dr-ui/ui/app.slint#L488), [`ui/dr-ui/ui/app.slint:650`](../ui/dr-ui/ui/app.slint#L650), [`ui/dr-ui/ui/app.slint:889`](../ui/dr-ui/ui/app.slint#L889), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1288`](../ui/dr-ui/ui/library.slint#L1288), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/settings.slint:128`](../ui/dr-ui/ui/settings.slint#L128) | +| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3482`](../ui/dr-ui/src/library_ui.rs#L3482), [`ui/dr-ui/src/library_ui.rs:4180`](../ui/dr-ui/src/library_ui.rs#L4180), [`ui/dr-ui/ui/app.slint:645`](../ui/dr-ui/ui/app.slint#L645), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2528`](../ui/dr-ui/ui/library.slint#L2528), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) | +| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1627`](../core/dr-pipeline/src/sidecar.rs#L1627), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3288`](../ui/dr-ui/src/develop.rs#L3288), [`ui/dr-ui/src/develop.rs:3317`](../ui/dr-ui/src/develop.rs#L3317), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1353`](../ui/dr-ui/src/lib.rs#L1353), [`ui/dr-ui/src/lib.rs:1726`](../ui/dr-ui/src/lib.rs#L1726), [`ui/dr-ui/src/lib.rs:1862`](../ui/dr-ui/src/lib.rs#L1862), [`ui/dr-ui/src/lib.rs:473`](../ui/dr-ui/src/lib.rs#L473), [`ui/dr-ui/src/lib.rs:898`](../ui/dr-ui/src/lib.rs#L898), [`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:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4882`](../ui/dr-ui/src/library_ui.rs#L4882), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2813`](../ui/dr-ui/src/develop.rs#L2813), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library.rs:1582`](../ui/dr-ui/src/library.rs#L1582), [`ui/dr-ui/src/library.rs:220`](../ui/dr-ui/src/library.rs#L220), [`ui/dr-ui/src/library.rs:3438`](../ui/dr-ui/src/library.rs#L3438), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library_ui.rs:1564`](../ui/dr-ui/src/library_ui.rs#L1564), [`ui/dr-ui/src/library_ui.rs:1590`](../ui/dr-ui/src/library_ui.rs#L1590), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1700`](../ui/dr-ui/src/library_ui.rs#L1700), [`ui/dr-ui/src/library_ui.rs:226`](../ui/dr-ui/src/library_ui.rs#L226), [`ui/dr-ui/src/library_ui.rs:2316`](../ui/dr-ui/src/library_ui.rs#L2316), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:2754`](../ui/dr-ui/src/library_ui.rs#L2754), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:3257`](../ui/dr-ui/src/library_ui.rs#L3257), [`ui/dr-ui/src/library_ui.rs:3345`](../ui/dr-ui/src/library_ui.rs#L3345), [`ui/dr-ui/src/library_ui.rs:3533`](../ui/dr-ui/src/library_ui.rs#L3533), [`ui/dr-ui/src/library_ui.rs:3651`](../ui/dr-ui/src/library_ui.rs#L3651), [`ui/dr-ui/src/library_ui.rs:440`](../ui/dr-ui/src/library_ui.rs#L440), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5238`](../ui/dr-ui/src/library_ui.rs#L5238), [`ui/dr-ui/src/library_ui.rs:5353`](../ui/dr-ui/src/library_ui.rs#L5353), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | +| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1777`](../ui/dr-ui/src/develop.rs#L1777), [`ui/dr-ui/src/develop.rs:195`](../ui/dr-ui/src/develop.rs#L195), [`ui/dr-ui/src/develop.rs:590`](../ui/dr-ui/src/develop.rs#L590), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1834`](../ui/dr-ui/src/lib.rs#L1834), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | -| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs: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-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) | | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:343`](../core/dr-catalog/src/schema.rs#L343), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/ui/settings.slint:385`](../ui/dr-ui/ui/settings.slint#L385), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs: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:1178`](../ui/dr-ui/src/develop.rs#L1178), [`ui/dr-ui/src/develop.rs:143`](../ui/dr-ui/src/develop.rs#L143), [`ui/dr-ui/src/develop.rs:1651`](../ui/dr-ui/src/develop.rs#L1651), [`ui/dr-ui/src/develop.rs:1668`](../ui/dr-ui/src/develop.rs#L1668), [`ui/dr-ui/src/develop.rs:1682`](../ui/dr-ui/src/develop.rs#L1682), [`ui/dr-ui/src/develop.rs:1704`](../ui/dr-ui/src/develop.rs#L1704), [`ui/dr-ui/src/develop.rs:1836`](../ui/dr-ui/src/develop.rs#L1836), [`ui/dr-ui/src/develop.rs:1914`](../ui/dr-ui/src/develop.rs#L1914), [`ui/dr-ui/src/develop.rs:2845`](../ui/dr-ui/src/develop.rs#L2845), [`ui/dr-ui/src/develop.rs:289`](../ui/dr-ui/src/develop.rs#L289), [`ui/dr-ui/src/develop.rs:321`](../ui/dr-ui/src/develop.rs#L321), [`ui/dr-ui/src/develop.rs:3277`](../ui/dr-ui/src/develop.rs#L3277), [`ui/dr-ui/src/develop.rs:3331`](../ui/dr-ui/src/develop.rs#L3331), [`ui/dr-ui/src/develop.rs:3375`](../ui/dr-ui/src/develop.rs#L3375), [`ui/dr-ui/src/develop.rs:3425`](../ui/dr-ui/src/develop.rs#L3425), [`ui/dr-ui/src/develop.rs:571`](../ui/dr-ui/src/develop.rs#L571), [`ui/dr-ui/src/develop.rs:609`](../ui/dr-ui/src/develop.rs#L609), [`ui/dr-ui/src/lib.rs:1343`](../ui/dr-ui/src/lib.rs#L1343), [`ui/dr-ui/src/lib.rs:2033`](../ui/dr-ui/src/lib.rs#L2033), [`ui/dr-ui/src/lib.rs:293`](../ui/dr-ui/src/lib.rs#L293), [`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:2009`](../ui/dr-ui/ui/app.slint#L2009), [`ui/dr-ui/ui/app.slint:963`](../ui/dr-ui/ui/app.slint#L963) | -| 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:1075`](../ui/dr-ui/src/develop.rs#L1075), [`ui/dr-ui/src/lib.rs:582`](../ui/dr-ui/src/lib.rs#L582) | -| 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:4004`](../ui/dr-ui/src/develop.rs#L4004) | -| 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-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:2423`](../ui/dr-ui/src/develop.rs#L2423), [`ui/dr-ui/src/develop.rs:2440`](../ui/dr-ui/src/develop.rs#L2440), [`ui/dr-ui/src/develop.rs:2471`](../ui/dr-ui/src/develop.rs#L2471), [`ui/dr-ui/src/develop.rs:2568`](../ui/dr-ui/src/develop.rs#L2568), [`ui/dr-ui/src/develop.rs:2882`](../ui/dr-ui/src/develop.rs#L2882), [`ui/dr-ui/src/lib.rs:2007`](../ui/dr-ui/src/lib.rs#L2007), [`ui/dr-ui/src/lib.rs:515`](../ui/dr-ui/src/lib.rs#L515), [`ui/dr-ui/src/lib.rs:573`](../ui/dr-ui/src/lib.rs#L573), [`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:2464`](../ui/dr-ui/ui/app.slint#L2464), [`ui/dr-ui/ui/app.slint:757`](../ui/dr-ui/ui/app.slint#L757) | -| 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-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:362`](../core/dr-pipeline/src/framing.rs#L362), [`core/dr-pipeline/src/framing.rs:617`](../core/dr-pipeline/src/framing.rs#L617), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:573`](../core/dr-pipeline/src/graph.rs#L573), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:479`](../core/dr-pipeline/src/operation.rs#L479), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1627`](../core/dr-pipeline/src/sidecar.rs#L1627), [`core/dr-pipeline/src/sidecar.rs:1687`](../core/dr-pipeline/src/sidecar.rs#L1687), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1281`](../ui/dr-ui/src/develop.rs#L1281), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1759`](../ui/dr-ui/src/develop.rs#L1759), [`ui/dr-ui/src/develop.rs:1777`](../ui/dr-ui/src/develop.rs#L1777), [`ui/dr-ui/src/develop.rs:1791`](../ui/dr-ui/src/develop.rs#L1791), [`ui/dr-ui/src/develop.rs:1813`](../ui/dr-ui/src/develop.rs#L1813), [`ui/dr-ui/src/develop.rs:1959`](../ui/dr-ui/src/develop.rs#L1959), [`ui/dr-ui/src/develop.rs:2057`](../ui/dr-ui/src/develop.rs#L2057), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3288`](../ui/dr-ui/src/develop.rs#L3288), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3852`](../ui/dr-ui/src/develop.rs#L3852), [`ui/dr-ui/src/develop.rs:3906`](../ui/dr-ui/src/develop.rs#L3906), [`ui/dr-ui/src/develop.rs:3950`](../ui/dr-ui/src/develop.rs#L3950), [`ui/dr-ui/src/develop.rs:4000`](../ui/dr-ui/src/develop.rs#L4000), [`ui/dr-ui/src/develop.rs:629`](../ui/dr-ui/src/develop.rs#L629), [`ui/dr-ui/src/develop.rs:676`](../ui/dr-ui/src/develop.rs#L676), [`ui/dr-ui/src/lib.rs:1410`](../ui/dr-ui/src/lib.rs#L1410), [`ui/dr-ui/src/lib.rs:2112`](../ui/dr-ui/src/lib.rs#L2112), [`ui/dr-ui/src/lib.rs:294`](../ui/dr-ui/src/lib.rs#L294), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:2091`](../ui/dr-ui/ui/app.slint#L2091), [`ui/dr-ui/ui/app.slint:976`](../ui/dr-ui/ui/app.slint#L976) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1178`](../ui/dr-ui/src/develop.rs#L1178), [`ui/dr-ui/src/lib.rs:588`](../ui/dr-ui/src/lib.rs#L588) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:4579`](../ui/dr-ui/src/develop.rs#L4579) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:612`](../core/dr-pipeline/src/graph.rs#L612), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | +| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1424`](../core/dr-pipeline/src/operation.rs#L1424), [`core/dr-pipeline/src/operation.rs:1505`](../core/dr-pipeline/src/operation.rs#L1505), [`core/dr-pipeline/src/operation.rs:1530`](../core/dr-pipeline/src/operation.rs#L1530), [`core/dr-pipeline/src/operation.rs:1545`](../core/dr-pipeline/src/operation.rs#L1545), [`core/dr-pipeline/src/operation.rs:1569`](../core/dr-pipeline/src/operation.rs#L1569), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:413`](../core/dr-pipeline/src/operation.rs#L413), [`core/dr-pipeline/src/operation.rs:563`](../core/dr-pipeline/src/operation.rs#L563) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1028`](../core/dr-pipeline/src/operation.rs#L1028), [`core/dr-pipeline/src/operation.rs:1057`](../core/dr-pipeline/src/operation.rs#L1057), [`core/dr-pipeline/src/operation.rs:1424`](../core/dr-pipeline/src/operation.rs#L1424), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:126`](../core/dr-pipeline/src/ops/film_sim.rs#L126), [`core/dr-pipeline/src/ops/film_sim.rs:150`](../core/dr-pipeline/src/ops/film_sim.rs#L150), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:328`](../core/dr-pipeline/src/ops/film_sim.rs#L328), [`core/dr-pipeline/src/ops/film_sim.rs:42`](../core/dr-pipeline/src/ops/film_sim.rs#L42), [`core/dr-pipeline/src/ops/film_sim.rs:85`](../core/dr-pipeline/src/ops/film_sim.rs#L85), [`core/dr-pipeline/src/ops/film_sim.rs:90`](../core/dr-pipeline/src/ops/film_sim.rs#L90), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1948`](../core/dr-pipeline/src/sidecar.rs#L1948), [`core/dr-pipeline/src/sidecar.rs:2024`](../core/dr-pipeline/src/sidecar.rs#L2024), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2828`](../ui/dr-ui/src/develop.rs#L2828), [`ui/dr-ui/src/develop.rs:2845`](../ui/dr-ui/src/develop.rs#L2845), [`ui/dr-ui/src/develop.rs:2857`](../ui/dr-ui/src/develop.rs#L2857), [`ui/dr-ui/src/develop.rs:2895`](../ui/dr-ui/src/develop.rs#L2895), [`ui/dr-ui/src/develop.rs:2904`](../ui/dr-ui/src/develop.rs#L2904), [`ui/dr-ui/src/develop.rs:3007`](../ui/dr-ui/src/develop.rs#L3007), [`ui/dr-ui/src/develop.rs:3325`](../ui/dr-ui/src/develop.rs#L3325), [`ui/dr-ui/src/develop.rs:3340`](../ui/dr-ui/src/develop.rs#L3340), [`ui/dr-ui/src/lib.rs:2086`](../ui/dr-ui/src/lib.rs#L2086), [`ui/dr-ui/src/lib.rs:521`](../ui/dr-ui/src/lib.rs#L521), [`ui/dr-ui/src/lib.rs:579`](../ui/dr-ui/src/lib.rs#L579), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:959`](../ui/dr-ui/ui/adjust.slint#L959), [`ui/dr-ui/ui/app.slint:2703`](../ui/dr-ui/ui/app.slint#L2703), [`ui/dr-ui/ui/app.slint:770`](../ui/dr-ui/ui/app.slint#L770) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-pipeline/src/framing.rs:362`](../core/dr-pipeline/src/framing.rs#L362), [`core/dr-pipeline/src/framing.rs:924`](../core/dr-pipeline/src/framing.rs#L924), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1976`](../ui/dr-ui/src/develop.rs#L1976), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | -| FR-DEV-5 | [`core/dr-pipeline/src/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:2900`](../ui/dr-ui/src/develop.rs#L2900), [`ui/dr-ui/src/develop.rs:2910`](../ui/dr-ui/src/develop.rs#L2910), [`ui/dr-ui/src/develop.rs:553`](../ui/dr-ui/src/develop.rs#L553), [`ui/dr-ui/src/lib.rs:1335`](../ui/dr-ui/src/lib.rs#L1335) | -| 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:2839`](../ui/dr-ui/src/develop.rs#L2839), [`ui/dr-ui/src/develop.rs:2860`](../ui/dr-ui/src/develop.rs#L2860), [`ui/dr-ui/src/lib.rs:1307`](../ui/dr-ui/src/lib.rs#L1307), [`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:547`](../ui/dr-ui/src/settings_ui.rs#L547), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1315`](../ui/dr-ui/ui/library.slint#L1315), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:913`](../ui/dr-ui/ui/library.slint#L913), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | -| FR-DEV-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:2244`](../ui/dr-ui/src/develop.rs#L2244), [`ui/dr-ui/src/develop.rs:3066`](../ui/dr-ui/src/develop.rs#L3066), [`ui/dr-ui/src/develop.rs:3549`](../ui/dr-ui/src/develop.rs#L3549), [`ui/dr-ui/src/develop.rs:3583`](../ui/dr-ui/src/develop.rs#L3583), [`ui/dr-ui/src/lib.rs:62`](../ui/dr-ui/src/lib.rs#L62), [`ui/dr-ui/src/lib.rs:723`](../ui/dr-ui/src/lib.rs#L723) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2857`](../ui/dr-ui/src/develop.rs#L2857), [`ui/dr-ui/src/develop.rs:3340`](../ui/dr-ui/src/develop.rs#L3340), [`ui/dr-ui/src/develop.rs:3370`](../ui/dr-ui/src/develop.rs#L3370), [`ui/dr-ui/src/develop.rs:3383`](../ui/dr-ui/src/develop.rs#L3383), [`ui/dr-ui/src/develop.rs:3395`](../ui/dr-ui/src/develop.rs#L3395), [`ui/dr-ui/src/develop.rs:3411`](../ui/dr-ui/src/develop.rs#L3411), [`ui/dr-ui/src/develop.rs:3443`](../ui/dr-ui/src/develop.rs#L3443), [`ui/dr-ui/src/develop.rs:3447`](../ui/dr-ui/src/develop.rs#L3447), [`ui/dr-ui/src/develop.rs:3466`](../ui/dr-ui/src/develop.rs#L3466), [`ui/dr-ui/src/develop.rs:3482`](../ui/dr-ui/src/develop.rs#L3482), [`ui/dr-ui/src/develop.rs:611`](../ui/dr-ui/src/develop.rs#L611), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1374`](../ui/dr-ui/src/lib.rs#L1374), [`ui/dr-ui/src/lib.rs:1388`](../ui/dr-ui/src/lib.rs#L1388), [`ui/dr-ui/src/lib.rs:1396`](../ui/dr-ui/src/lib.rs#L1396), [`ui/dr-ui/src/lib.rs:2282`](../ui/dr-ui/src/lib.rs#L2282), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3282`](../ui/dr-ui/src/develop.rs#L3282), [`ui/dr-ui/src/develop.rs:3303`](../ui/dr-ui/src/develop.rs#L3303), [`ui/dr-ui/src/lib.rs:1353`](../ui/dr-ui/src/lib.rs#L1353), [`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:547`](../ui/dr-ui/src/settings_ui.rs#L547), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1315`](../ui/dr-ui/ui/library.slint#L1315), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:913`](../ui/dr-ui/ui/library.slint#L913), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | +| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3411`](../ui/dr-ui/src/develop.rs#L3411), [`ui/dr-ui/src/develop.rs:3443`](../ui/dr-ui/src/develop.rs#L3443), [`ui/dr-ui/src/lib.rs:1396`](../ui/dr-ui/src/lib.rs#L1396), [`ui/dr-ui/src/lib.rs:2282`](../ui/dr-ui/src/lib.rs#L2282), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:681`](../core/dr-pipeline/src/graph.rs#L681), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:517`](../core/dr-pipeline/src/operation.rs#L517), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2128`](../ui/dr-ui/src/develop.rs#L2128), [`ui/dr-ui/src/develop.rs:2166`](../ui/dr-ui/src/develop.rs#L2166), [`ui/dr-ui/src/develop.rs:2231`](../ui/dr-ui/src/develop.rs#L2231), [`ui/dr-ui/src/develop.rs:2314`](../ui/dr-ui/src/develop.rs#L2314), [`ui/dr-ui/src/develop.rs:2328`](../ui/dr-ui/src/develop.rs#L2328), [`ui/dr-ui/src/develop.rs:659`](../ui/dr-ui/src/develop.rs#L659), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1417`](../ui/dr-ui/src/lib.rs#L1417), [`ui/dr-ui/src/lib.rs:2379`](../ui/dr-ui/src/lib.rs#L2379), [`ui/dr-ui/src/lib.rs:299`](../ui/dr-ui/src/lib.rs#L299), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:676`](../ui/dr-ui/ui/adjust.slint#L676), [`ui/dr-ui/ui/app.slint:1826`](../ui/dr-ui/ui/app.slint#L1826), [`ui/dr-ui/ui/app.slint:2343`](../ui/dr-ui/ui/app.slint#L2343), [`ui/dr-ui/ui/app.slint:2609`](../ui/dr-ui/ui/app.slint#L2609), [`ui/dr-ui/ui/app.slint:332`](../ui/dr-ui/ui/app.slint#L332), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:543`](../core/dr-pipeline/src/graph.rs#L543), [`core/dr-pipeline/src/graph.rs:573`](../core/dr-pipeline/src/graph.rs#L573), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:654`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L654), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:662`](../core/dr-pipeline/src/ops/local_contrast.rs#L662), [`core/dr-pipeline/src/ops/noise_reduction.rs:695`](../core/dr-pipeline/src/ops/noise_reduction.rs#L695), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2628`](../ui/dr-ui/src/develop.rs#L2628), [`ui/dr-ui/src/develop.rs:3641`](../ui/dr-ui/src/develop.rs#L3641), [`ui/dr-ui/src/develop.rs:4124`](../ui/dr-ui/src/develop.rs#L4124), [`ui/dr-ui/src/develop.rs:4158`](../ui/dr-ui/src/develop.rs#L4158), [`ui/dr-ui/src/lib.rs:63`](../ui/dr-ui/src/lib.rs#L63), [`ui/dr-ui/src/lib.rs:729`](../ui/dr-ui/src/lib.rs#L729), [`ui/dr-ui/src/lib.rs:788`](../ui/dr-ui/src/lib.rs#L788) | | 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:2289`](../ui/dr-ui/src/develop.rs#L2289), [`ui/dr-ui/src/develop.rs:4651`](../ui/dr-ui/src/develop.rs#L4651), [`ui/dr-ui/src/develop.rs:4683`](../ui/dr-ui/src/develop.rs#L4683), [`ui/dr-ui/src/develop.rs:564`](../ui/dr-ui/src/develop.rs#L564), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1394`](../ui/dr-ui/src/lib.rs#L1394), [`ui/dr-ui/src/lib.rs:288`](../ui/dr-ui/src/lib.rs#L288), [`ui/dr-ui/ui/app.slint:290`](../ui/dr-ui/ui/app.slint#L290), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-DSP-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:2694`](../ui/dr-ui/src/develop.rs#L2694), [`ui/dr-ui/src/develop.rs:5226`](../ui/dr-ui/src/develop.rs#L5226), [`ui/dr-ui/src/develop.rs:5258`](../ui/dr-ui/src/develop.rs#L5258), [`ui/dr-ui/src/develop.rs:622`](../ui/dr-ui/src/develop.rs#L622), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1473`](../ui/dr-ui/src/lib.rs#L1473), [`ui/dr-ui/src/lib.rs:289`](../ui/dr-ui/src/lib.rs#L289), [`ui/dr-ui/ui/app.slint:292`](../ui/dr-ui/ui/app.slint#L292), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs: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:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:533`](../core/dr-pipeline/src/graph.rs#L533), [`core/dr-pipeline/src/graph.rs:587`](../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-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:331`](../ui/dr-ui/src/lib.rs#L331), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:178`](../ui/dr-ui/src/lib.rs#L178), [`ui/dr-ui/src/lib.rs:1949`](../ui/dr-ui/src/lib.rs#L1949), [`ui/dr-ui/src/lib.rs:331`](../ui/dr-ui/src/lib.rs#L331), [`ui/dr-ui/src/lib.rs:366`](../ui/dr-ui/src/lib.rs#L366), [`ui/dr-ui/src/lib.rs:393`](../ui/dr-ui/src/lib.rs#L393), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/library_ui.rs:6253`](../ui/dr-ui/src/library_ui.rs#L6253), [`ui/dr-ui/src/library_ui.rs:6330`](../ui/dr-ui/src/library_ui.rs#L6330), [`ui/dr-ui/src/library_ui.rs:6342`](../ui/dr-ui/src/library_ui.rs#L6342), [`ui/dr-ui/src/library_ui.rs:660`](../ui/dr-ui/src/library_ui.rs#L660), [`ui/dr-ui/src/library_ui.rs:717`](../ui/dr-ui/src/library_ui.rs#L717), [`ui/dr-ui/ui/app.slint:1054`](../ui/dr-ui/ui/app.slint#L1054), [`ui/dr-ui/ui/app.slint:1476`](../ui/dr-ui/ui/app.slint#L1476), [`ui/dr-ui/ui/library.slint:1323`](../ui/dr-ui/ui/library.slint#L1323), [`ui/dr-ui/ui/library.slint:835`](../ui/dr-ui/ui/library.slint#L835), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) | -| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2367`](../ui/dr-ui/src/develop.rs#L2367), [`ui/dr-ui/src/lib.rs:331`](../ui/dr-ui/src/lib.rs#L331) | +| 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:337`](../ui/dr-ui/src/lib.rs#L337), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:179`](../ui/dr-ui/src/lib.rs#L179), [`ui/dr-ui/src/lib.rs:2028`](../ui/dr-ui/src/lib.rs#L2028), [`ui/dr-ui/src/lib.rs:337`](../ui/dr-ui/src/lib.rs#L337), [`ui/dr-ui/src/lib.rs:372`](../ui/dr-ui/src/lib.rs#L372), [`ui/dr-ui/src/lib.rs:399`](../ui/dr-ui/src/lib.rs#L399), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/library_ui.rs:6253`](../ui/dr-ui/src/library_ui.rs#L6253), [`ui/dr-ui/src/library_ui.rs:6330`](../ui/dr-ui/src/library_ui.rs#L6330), [`ui/dr-ui/src/library_ui.rs:6342`](../ui/dr-ui/src/library_ui.rs#L6342), [`ui/dr-ui/src/library_ui.rs:660`](../ui/dr-ui/src/library_ui.rs#L660), [`ui/dr-ui/src/library_ui.rs:717`](../ui/dr-ui/src/library_ui.rs#L717), [`ui/dr-ui/ui/app.slint:1112`](../ui/dr-ui/ui/app.slint#L1112), [`ui/dr-ui/ui/app.slint:1534`](../ui/dr-ui/ui/app.slint#L1534), [`ui/dr-ui/ui/library.slint:1323`](../ui/dr-ui/ui/library.slint#L1323), [`ui/dr-ui/ui/library.slint:835`](../ui/dr-ui/ui/library.slint#L835), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) | +| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2772`](../ui/dr-ui/src/develop.rs#L2772), [`ui/dr-ui/src/lib.rs:337`](../ui/dr-ui/src/lib.rs#L337) | | 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:393`](../ui/dr-ui/src/lib.rs#L393), [`ui/dr-ui/src/library.rs:1582`](../ui/dr-ui/src/library.rs#L1582), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:947`](../ui/dr-ui/src/library.rs#L947), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:399`](../ui/dr-ui/src/lib.rs#L399), [`ui/dr-ui/src/library.rs:1582`](../ui/dr-ui/src/library.rs#L1582), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:947`](../ui/dr-ui/src/library.rs#L947), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) | | 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.rs:2598`](../ui/dr-ui/src/library.rs#L2598), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`ui/dr-ui/ui/app.slint:527`](../ui/dr-ui/ui/app.slint#L527), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`ui/dr-ui/ui/app.slint:540`](../ui/dr-ui/ui/app.slint#L540), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/schema.rs:956`](../core/dr-catalog/src/schema.rs#L956), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1678`](../ui/dr-ui/src/lib.rs#L1678), [`ui/dr-ui/src/lib.rs:2516`](../ui/dr-ui/src/lib.rs#L2516), [`ui/dr-ui/src/library.rs:1324`](../ui/dr-ui/src/library.rs#L1324), [`ui/dr-ui/src/library.rs:1347`](../ui/dr-ui/src/library.rs#L1347), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1531`](../ui/dr-ui/src/library_ui.rs#L1531), [`ui/dr-ui/src/library_ui.rs:2058`](../ui/dr-ui/src/library_ui.rs#L2058), [`ui/dr-ui/src/library_ui.rs:267`](../ui/dr-ui/src/library_ui.rs#L267), [`ui/dr-ui/src/library_ui.rs:278`](../ui/dr-ui/src/library_ui.rs#L278), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:298`](../ui/dr-ui/src/library_ui.rs#L298), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:399`](../ui/dr-ui/src/library_ui.rs#L399), [`ui/dr-ui/src/library_ui.rs:409`](../ui/dr-ui/src/library_ui.rs#L409), [`ui/dr-ui/src/library_ui.rs:451`](../ui/dr-ui/src/library_ui.rs#L451), [`ui/dr-ui/src/library_ui.rs:514`](../ui/dr-ui/src/library_ui.rs#L514), [`ui/dr-ui/src/library_ui.rs:5255`](../ui/dr-ui/src/library_ui.rs#L5255), [`ui/dr-ui/src/library_ui.rs:5273`](../ui/dr-ui/src/library_ui.rs#L5273), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/src/library_ui.rs:545`](../ui/dr-ui/src/library_ui.rs#L545), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2514`](../ui/dr-ui/ui/app.slint#L2514), [`ui/dr-ui/ui/app.slint:626`](../ui/dr-ui/ui/app.slint#L626), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:974`](../ui/dr-ui/ui/library.slint#L974) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/schema.rs:956`](../core/dr-catalog/src/schema.rs#L956), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1757`](../ui/dr-ui/src/lib.rs#L1757), [`ui/dr-ui/src/lib.rs:2635`](../ui/dr-ui/src/lib.rs#L2635), [`ui/dr-ui/src/library.rs:1324`](../ui/dr-ui/src/library.rs#L1324), [`ui/dr-ui/src/library.rs:1347`](../ui/dr-ui/src/library.rs#L1347), [`ui/dr-ui/src/library.rs:1505`](../ui/dr-ui/src/library.rs#L1505), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1531`](../ui/dr-ui/src/library_ui.rs#L1531), [`ui/dr-ui/src/library_ui.rs:2058`](../ui/dr-ui/src/library_ui.rs#L2058), [`ui/dr-ui/src/library_ui.rs:267`](../ui/dr-ui/src/library_ui.rs#L267), [`ui/dr-ui/src/library_ui.rs:278`](../ui/dr-ui/src/library_ui.rs#L278), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:298`](../ui/dr-ui/src/library_ui.rs#L298), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:399`](../ui/dr-ui/src/library_ui.rs#L399), [`ui/dr-ui/src/library_ui.rs:409`](../ui/dr-ui/src/library_ui.rs#L409), [`ui/dr-ui/src/library_ui.rs:451`](../ui/dr-ui/src/library_ui.rs#L451), [`ui/dr-ui/src/library_ui.rs:514`](../ui/dr-ui/src/library_ui.rs#L514), [`ui/dr-ui/src/library_ui.rs:5255`](../ui/dr-ui/src/library_ui.rs#L5255), [`ui/dr-ui/src/library_ui.rs:5273`](../ui/dr-ui/src/library_ui.rs#L5273), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/src/library_ui.rs:545`](../ui/dr-ui/src/library_ui.rs#L545), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2782`](../ui/dr-ui/ui/app.slint#L2782), [`ui/dr-ui/ui/app.slint:639`](../ui/dr-ui/ui/app.slint#L639), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:974`](../ui/dr-ui/ui/library.slint#L974) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) | | FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | | FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2624`](../ui/dr-ui/src/library.rs#L2624), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353) | -| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1051`](../ui/dr-ui/src/lib.rs#L1051), [`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:1051`](../ui/dr-ui/src/lib.rs#L1051) | -| 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:1647`](../ui/dr-ui/src/lib.rs#L1647), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs: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-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:1097`](../ui/dr-ui/src/lib.rs#L1097), [`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:1097`](../ui/dr-ui/src/lib.rs#L1097) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1726`](../ui/dr-ui/src/lib.rs#L1726), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2024`](../core/dr-pipeline/src/sidecar.rs#L2024), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | | FR-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:792`](../ui/dr-ui/src/lib.rs#L792), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | +| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:838`](../ui/dr-ui/src/lib.rs#L838), [`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:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | -| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:178`](../ui/dr-ui/src/lib.rs#L178) | +| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:179`](../ui/dr-ui/src/lib.rs#L179) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2598`](../ui/dr-ui/src/lib.rs#L2598), [`ui/dr-ui/src/lib.rs:70`](../ui/dr-ui/src/lib.rs#L70), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:1040`](../ui/dr-ui/ui/library.slint#L1040) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:70`](../ui/dr-ui/src/lib.rs#L70), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/src/library_ui.rs:6361`](../ui/dr-ui/src/library_ui.rs#L6361), [`ui/dr-ui/ui/app.slint:2236`](../ui/dr-ui/ui/app.slint#L2236), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/app.slint:656`](../ui/dr-ui/ui/app.slint#L656), [`ui/dr-ui/ui/app.slint:663`](../ui/dr-ui/ui/app.slint#L663), [`ui/dr-ui/ui/library.slint:1196`](../ui/dr-ui/ui/library.slint#L1196), [`ui/dr-ui/ui/library.slint:1203`](../ui/dr-ui/ui/library.slint#L1203), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2278`](../ui/dr-ui/ui/library.slint#L2278), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106) | -| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1914`](../ui/dr-ui/src/develop.rs#L1914), [`ui/dr-ui/src/library_ui.rs:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/ui/app.slint:2009`](../ui/dr-ui/ui/app.slint#L2009), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | -| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`ui/dr-ui/src/library_ui.rs:4430`](../ui/dr-ui/src/library_ui.rs#L4430), [`ui/dr-ui/src/library_ui.rs:4533`](../ui/dr-ui/src/library_ui.rs#L4533), [`ui/dr-ui/src/library_ui.rs:4561`](../ui/dr-ui/src/library_ui.rs#L4561), [`ui/dr-ui/src/library_ui.rs:5273`](../ui/dr-ui/src/library_ui.rs#L5273), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/ui/app.slint:1705`](../ui/dr-ui/ui/app.slint#L1705), [`ui/dr-ui/ui/app.slint:632`](../ui/dr-ui/ui/app.slint#L632), [`ui/dr-ui/ui/app.slint:663`](../ui/dr-ui/ui/app.slint#L663), [`ui/dr-ui/ui/library.slint:1196`](../ui/dr-ui/ui/library.slint#L1196), [`ui/dr-ui/ui/library.slint:1203`](../ui/dr-ui/ui/library.slint#L1203), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2278`](../ui/dr-ui/ui/library.slint#L2278), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) | -| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2243`](../ui/dr-ui/src/lib.rs#L2243), [`ui/dr-ui/src/lib.rs:2632`](../ui/dr-ui/src/lib.rs#L2632), [`ui/dr-ui/src/lib.rs:2817`](../ui/dr-ui/src/lib.rs#L2817), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:311`](../ui/dr-ui/ui/app.slint#L311), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2717`](../ui/dr-ui/src/lib.rs#L2717), [`ui/dr-ui/src/lib.rs:71`](../ui/dr-ui/src/lib.rs#L71), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:1040`](../ui/dr-ui/ui/library.slint#L1040) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:71`](../ui/dr-ui/src/lib.rs#L71), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/src/library_ui.rs:6361`](../ui/dr-ui/src/library_ui.rs#L6361), [`ui/dr-ui/ui/app.slint:2449`](../ui/dr-ui/ui/app.slint#L2449), [`ui/dr-ui/ui/app.slint:278`](../ui/dr-ui/ui/app.slint#L278), [`ui/dr-ui/ui/app.slint:669`](../ui/dr-ui/ui/app.slint#L669), [`ui/dr-ui/ui/app.slint:676`](../ui/dr-ui/ui/app.slint#L676), [`ui/dr-ui/ui/library.slint:1196`](../ui/dr-ui/ui/library.slint#L1196), [`ui/dr-ui/ui/library.slint:1203`](../ui/dr-ui/ui/library.slint#L1203), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2278`](../ui/dr-ui/ui/library.slint#L2278), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2057`](../ui/dr-ui/src/develop.rs#L2057), [`ui/dr-ui/src/develop.rs:2166`](../ui/dr-ui/src/develop.rs#L2166), [`ui/dr-ui/src/library_ui.rs:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2091`](../ui/dr-ui/ui/app.slint#L2091), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | +| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`ui/dr-ui/src/library_ui.rs:4430`](../ui/dr-ui/src/library_ui.rs#L4430), [`ui/dr-ui/src/library_ui.rs:4533`](../ui/dr-ui/src/library_ui.rs#L4533), [`ui/dr-ui/src/library_ui.rs:4561`](../ui/dr-ui/src/library_ui.rs#L4561), [`ui/dr-ui/src/library_ui.rs:5273`](../ui/dr-ui/src/library_ui.rs#L5273), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/ui/app.slint:1763`](../ui/dr-ui/ui/app.slint#L1763), [`ui/dr-ui/ui/app.slint:645`](../ui/dr-ui/ui/app.slint#L645), [`ui/dr-ui/ui/app.slint:676`](../ui/dr-ui/ui/app.slint#L676), [`ui/dr-ui/ui/library.slint:1196`](../ui/dr-ui/ui/library.slint#L1196), [`ui/dr-ui/ui/library.slint:1203`](../ui/dr-ui/ui/library.slint#L1203), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2278`](../ui/dr-ui/ui/library.slint#L2278), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) | +| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/src/lib.rs:2751`](../ui/dr-ui/src/lib.rs#L2751), [`ui/dr-ui/src/lib.rs:2936`](../ui/dr-ui/src/lib.rs#L2936), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:313`](../ui/dr-ui/ui/app.slint#L313), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1983`](../ui/dr-ui/src/lib.rs#L1983), [`ui/dr-ui/ui/app.slint:1065`](../ui/dr-ui/ui/app.slint#L1065), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:2062`](../ui/dr-ui/src/lib.rs#L2062), [`ui/dr-ui/ui/app.slint:1123`](../ui/dr-ui/ui/app.slint#L1123), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | -| NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | +| NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | | NFR-P5 | [`core/dr-catalog/src/schema.rs:304`](../core/dr-catalog/src/schema.rs#L304), [`ui/dr-ui/src/library.rs:3171`](../ui/dr-ui/src/library.rs#L3171), [`ui/dr-ui/src/library.rs:3944`](../ui/dr-ui/src/library.rs#L3944), [`ui/dr-ui/src/library_ui.rs:4070`](../ui/dr-ui/src/library_ui.rs#L4070), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | | NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302) | @@ -124,7 +125,7 @@ _None._ | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:62`](../ui/dr-ui/src/lib.rs#L62) | +| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:63`](../ui/dr-ui/src/lib.rs#L63) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2598`](../ui/dr-ui/src/library.rs#L2598) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | @@ -133,7 +134,7 @@ _None._ ## Not yet tagged -80 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +79 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements @@ -144,7 +145,6 @@ _None._ - FR-CULL-7 - FR-DEV-1 - FR-DEV-3g -- FR-DEV-7 - FR-DSP-2 - FR-DSP-3 - FR-DSP-4 diff --git a/ui/dr-ui/Cargo.toml b/ui/dr-ui/Cargo.toml index 6f19d22..526794c 100644 --- a/ui/dr-ui/Cargo.toml +++ b/ui/dr-ui/Cargo.toml @@ -65,11 +65,14 @@ rusqlite.workspace = true # Consequence worth stating plainly: the desktop app now needs a working wgpu # adapter to open a window at all. Slint refuses a CPU adapter for this # renderer unless `SLINT_WGPU_CPU` is set in the environment. -slint = { workspace = true, features = [ - "compat-1-2", - "renderer-femtovg-wgpu", - "unstable-wgpu-29", -] } +# TEST BUILD: the wgpu renderer features have moved to the desktop-only +# dependency below. On Android they made `AndroidWindowAdapter` choose +# `SkiaRenderer::default_wgpu_29`, and so put the app on wgpu's Vulkan +# swapchain — which hardcodes `preTransform = IDENTITY` (gfx-rs/wgpu#3345). +# Without them the Android backend uses `SkiaRenderer::default`, which on +# Android resolves to Skia over OpenGL, where the driver owns the display +# rotation and there is no transform to get wrong. +slint = { workspace = true, features = ["compat-1-2"] } wgpu.workspace = true anyhow.workspace = true # `SettingsError` distinguishes an io failure from a malformed file, which the @@ -85,7 +88,11 @@ serde_norway = { workspace = true, optional = true } # under `cfg(target_os = "android")`, so this only has to name the feature; # cargo resolves it away entirely on desktop. [target.'cfg(not(target_os = "android"))'.dependencies] -slint = { workspace = true, features = ["backend-winit"] } +slint = { workspace = true, features = [ + "backend-winit", + "renderer-femtovg-wgpu", + "unstable-wgpu-29", +] } [target.'cfg(target_os = "android")'.dependencies] slint = { workspace = true, features = ["backend-android-activity-06"] } diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 857a9b2..759c653 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -116,16 +116,36 @@ pub struct SegmentationJob { source: Arc, session: SessionId, abandon: Abandon, + /// TRACES: FR-CULL-10 /// Confirmed faces in this photograph, **normalised to the long edge** of - /// the *upright* image. + /// the EXIF-upright image. /// /// Carried rather than looked up, because the job runs on a thread with no /// catalog in reach — the same reason it carries the pixels. Normalised /// rather than in pixels because the proxy size is only settled inside - /// `run`, and these have to survive being scaled to whatever it turns out - /// to be. + /// `run`. names: Vec, - /// Undone before matching, since the proxy is in sensor order. + /// TRACES: FR-CULL-10 + /// The file's EXIF turn alone, which is the space `names` is expressed in. + /// + /// **Deliberately not [`SegmentationJob::orientation`].** Faces are found + /// on the thumbnail, which is stood up by the EXIF tag and knows nothing + /// about the photographer's later turns; the segmentation below composes + /// both. Using the composed one here would turn the faces twice on any + /// photograph the user has rotated, and the failure would be silent — + /// names simply landing on nobody. + exif_orientation: dr_types::Orientation, + /// TRACES: FR-DEV-3h + /// How the sensor's pixels have to be turned to be the photograph. + /// + /// The file's EXIF tag and the photographer's own turns, composed into + /// one permutation by `Framing::effective_orientation`. Carried rather + /// than read from the session for the reason everything else here is: the + /// job runs on a worker and the session stays behind. + /// + /// A *snapshot*, so turning the photograph while a run is in flight + /// leaves that run answering the question it was asked. The next press + /// takes the new one, and the signature says the two are different runs. orientation: dr_types::Orientation, } @@ -169,41 +189,51 @@ impl SegmentationJob { return Ok(None); } - let mut seg = segmentation::compute(&self.ctx, &rgb, rw, rh, options)?; + let mut seg = + segmentation::compute(&self.ctx, &rgb, rw, rh, self.orientation, options)?; - // Put names on the people the segmenter found. Scaled here rather than - // at the call site because this is where the proxy size is finally - // known. + // TRACES: FR-CULL-10 + // Put names on the people the segmenter found. + // + // `compute` stands the frame up to detect and lays the instances back + // down, so `bbox` is in the sensor's space. The faces came off the + // thumbnail and are in the EXIF-upright one. Two different spaces, and + // on a portrait photograph they are a quarter turn apart — so the + // faces are turned down to meet the instances, through the same + // `Orientation` map every other consumer uses rather than a second + // copy of the arithmetic. + // + // The catalog normalises a face to the image's **long edge**, where + // `ShownRect` is normalised per axis; the conversions either side of + // the turn are that difference and nothing more. if !self.names.is_empty() { - // The proxy is in sensor order; the faces are upright. The long - // edge is the same number either way — `max` survives the swap — - // but the axes do not, so the boxes are turned back before they - // are compared with anything. let long_edge = rw.max(rh) as f32; - let displayed = if self.orientation.swaps_axes() { - (rh as f32, rw as f32) - } else { - (rw as f32, rh as f32) - }; + let (dw, dh) = self.exif_orientation.oriented_size(rw as u32, rh as u32); + let (dw, dh) = (dw as f32, dh as f32); + let boxes: Vec> = self .names .iter() - .map(|(x, y, w, h, name)| dr_face::NamedFace { - bbox: dr_face::naming::to_sensor_space( - ( - x * long_edge, - y * long_edge, - (x + w) * long_edge, - (y + h) * long_edge, + .map(|(x, y, w, h, name)| { + let shown = dr_types::ShownRect { + x: x * long_edge / dw, + y: y * long_edge / dh, + width: w * long_edge / dw, + height: h * long_edge / dh, + }; + let stored = self.exif_orientation.into_stored_rect(shown); + dr_face::NamedFace { + bbox: ( + stored.x * rw as f32, + stored.y * rh as f32, + (stored.x + stored.width) * rw as f32, + (stored.y + stored.height) * rh as f32, ), - displayed, - self.orientation.quarter_turns, - self.orientation.flip_h, - self.orientation.flip_v, - ), - name, + name, + } }) .collect(); + let named = seg.apply_names(&boxes); if named > 0 { log::info!("named {named} segmented region(s) from known faces"); @@ -229,6 +259,14 @@ impl SegmentationJob { /// segmentation must survive an exposure change, or every slider would /// invalidate the masks that depend on it (docs/segmentation.md §3). /// + /// **Still the sensor's orientation, deliberately.** Standing the picture + /// up is what `segmentation::compute` does to the buffer this returns, + /// and it is done there rather than here because a mask has to come back + /// in this space: the generated shader samples the mask array at `uv_src`, + /// after the framing map. Rendering an upright proxy would put every mask + /// a quarter turn away from the subject it was drawn around — a wrong + /// mask rather than a weak one, and nothing would announce it. + /// /// A throwaway [`AdjustPass`] with a neutral graph rather than the /// session's own — which this could not reach from here in any case, and /// must not: reusing it would overwrite the frame the histogram reads and @@ -307,6 +345,11 @@ pub struct RefineJob { class_name: Arc, bbox: (f32, f32, f32, f32), proxy: (usize, usize), + /// The same permutation, for the same reason — see + /// [`SegmentationJob::orientation`]. A refine pass that read the crop + /// sideways would hand back a worse mask than the one it was asked to + /// improve, on the subject the photographer had just pointed at. + orientation: dr_types::Orientation, } impl RefineJob { @@ -370,20 +413,30 @@ impl RefineJob { return Ok(None); } + // Stood up before the model reads it and laid back down after, the + // same way the whole-frame pass does it — `segmentation::upright` is + // the one place that permutation is written. A crop rendered in + // sensor space is exactly as sideways as the frame it came from. + let (upright, uw, uh) = segmentation::upright(&rgb, rw, rh, self.orientation); + let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?; let options = dr_segment::SemanticOptions { tiling: dr_segment::Tiling::Whole, ..dr_segment::SemanticOptions::default() }; let found = model - .detect(&rgb, rw, rh, &options) + .detect(&upright, uw, uh, &options) .map_err(|e| e.to_string())?; // The crop was built around one subject, so the right answer among // whatever the model found in it is the same class closest to the // window's centre — not merely the highest score, which a second, // unrelated instance caught in the padding could win. - let (cx, cy) = (rw as f32 * 0.5, rh as f32 * 0.5); + // + // Measured in the upright frame, which is where the model's boxes are. + // The centre is the centre either way; the distances are not, once the + // window is not square. + let (cx, cy) = (uw as f32 * 0.5, uh as f32 * 0.5); let best = found .into_iter() .filter(|i| i.class_name.as_ref() == self.class_name.as_ref()) @@ -393,15 +446,20 @@ impl RefineJob { return Ok(None); }; + // Back into the crop's own sensor-space pixels, so everything below + // this line measures in the space `self.bbox` and `self.proxy` are in. + let (crop_mask, crop_bbox) = + segmentation::lay_down(&instance.mask, instance.bbox, uw, uh, self.orientation); + // Downsampled back onto the shared proxy grid like every other // instance's mask is, but built from a sharper source than the // whole-frame pass ever saw for this subject. - let mask = paste_into_proxy(&instance.mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px)); + let mask = paste_into_proxy(&crop_mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px)); let bbox = ( - x0 + instance.bbox.0 / scale, - y0 + instance.bbox.1 / scale, - x0 + instance.bbox.2 / scale, - y0 + instance.bbox.3 / scale, + x0 + crop_bbox.0 / scale, + y0 + crop_bbox.1 / scale, + x0 + crop_bbox.2 / scale, + y0 + crop_bbox.3 / scale, ); Ok(Some(RefinedInstance { @@ -598,6 +656,15 @@ pub struct DevelopSession { /// "make these three subjects a stop darker" is one gesture rather than /// three. Order is insertion order and nothing reads it, only membership. active_masks: Vec, + /// TRACES: FR-DEV-8 + /// Which repair the panel is describing, if any. + /// + /// Interface state and not part of the edit, exactly as `active_masks` is: + /// it changes no pixel, it is not in the sidecar, and it is not on the undo + /// stack. One at a time rather than a set — a repair is eight numbers and + /// there is no gesture that usefully moves several at once, where three + /// masked layers really can share a slider drag. + selected_spot: Option, /// Whether to draw the false-coloured region overlay. show_overlay: bool, /// Which attribute the panel is filtered to, or all of them. @@ -686,6 +753,7 @@ impl DevelopSession { subjects: None, subject_key: 0, active_masks: Vec::new(), + selected_spot: None, show_overlay: false, active_tab: None, curve_channel: 0, @@ -827,6 +895,41 @@ fn no_choices() -> slint::ModelRc { EMPTY.with(Clone::clone) } +/// The choices model for one enum parameter, built once per variant list. +/// +/// Memoised for exactly the reason [`no_choices`] is shared: `ModelRc` compares +/// by *identity*, so building a fresh one each call makes the row differ from +/// itself on every parameter event. `sync_rows` would then replace the row — +/// destroying the elements built from it, including whichever `TouchArea` is +/// holding the current gesture — and the enum's own control would fight every +/// slider drag elsewhere in the panel. +/// +/// Curve rows solve the same problem the other way, by writing new values +/// through the existing model. That is not available here: a variant list is +/// fixed at compile time, so the model never needs updating and can simply be +/// the same one every time. +/// +/// Keyed on the labels rather than the slice's address, because they are +/// resolved through the UI's catalogue and two operations offering the same +/// choices should share one model. +fn choices_model(labels: &[slint::SharedString]) -> slint::ModelRc { + use std::cell::RefCell; + use std::collections::HashMap; + + thread_local! { + static CACHE: RefCell>> = + RefCell::new(HashMap::new()); + } + let key = labels.join("\u{1f}"); + CACHE.with(|cache| { + cache + .borrow_mut() + .entry(key) + .or_insert_with(|| slint::ModelRc::new(slint::VecModel::from(labels.to_vec()))) + .clone() + }) +} + /// Whether this frontend has an implementation of `widget` **anywhere**. /// /// "Anywhere" is doing real work: a widget may be drawn in the panel, as the @@ -1046,7 +1149,7 @@ pub(crate) fn rows_filtered( choices: if choices.is_empty() { no_choices() } else { - slint::ModelRc::new(slint::VecModel::from(choices)) + choices_model(&choices) }, }); } @@ -1318,7 +1421,8 @@ impl DevelopSession { layer.set_param(id, param, default); } } - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_OP)); return; } for p in &cap.params { @@ -1326,7 +1430,8 @@ impl DevelopSession { } // One step, though it moved every parameter the operation has: the // user pressed one button. - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_OP)); } /// Reset a curve, which is to reset its operation. @@ -1382,12 +1487,14 @@ impl DevelopSession { .map(|p| p.default) .unwrap_or(0.0); self.graph.set_param(op, param, default); - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_PARAM)); } pub fn reset_all(&mut self) { self.graph.reset(); - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_ALL)); } /// Rasterise the current mask stack, if there is one. @@ -1455,8 +1562,9 @@ impl DevelopSession { // Empty for every edit with no active neighbourhood operation — which // is almost all of them — and `render_detailed` then falls straight // through to the single masked dispatch this used to call. - let scale = self.graph.render_scale(self.demosaiced.size(), (w, h)); - let detail = self.graph.compose_detail_for(scale, space); + let detail = self + .graph + .compose_detail_for(self.demosaiced.size(), (w, h), space); // Detail passes read what the colour pass wrote, so the key they are // cached against is the colour key: moving a sharpening slider re-runs // this stage and not the fused one (FR-DEV-3d). @@ -1661,7 +1769,8 @@ impl DevelopSession { session: self.id, abandon: Abandon::default(), names: self.face_names.clone(), - orientation: self.orientation, + exif_orientation: self.orientation, + orientation: self.graph.framing().effective_orientation(), } } @@ -1721,6 +1830,7 @@ impl DevelopSession { class_name: instance.class_name.clone(), bbox: instance.bbox, proxy: seg.proxy_size(), + orientation: self.graph.framing().effective_orientation(), }) } @@ -1820,7 +1930,20 @@ impl DevelopSession { let Some(seg) = self.segmentation.as_ref() else { return (0, 0, 0, 0); }; - let (w, h) = seg.proxy_size(); + // **Shown pixels, matching `overlay_image`.** The crop and the + // viewport are fractions of the photograph as the user sees it — the + // prologue maps an output pixel through `crop_rect` *before* it + // unturns the frame — so measuring them against the sensor's width + // and height puts the clip on the wrong axis the moment the two + // differ. That is the same confusion as the overlay itself had, one + // layer down, and it is silent for exactly the images where it is + // wrong: a landscape frame has nothing to notice. + let (sw, sh) = seg.proxy_size(); + let (w, h) = self + .graph + .framing() + .effective_orientation() + .oriented_size(sw as u32, sh as u32); let rect = self.graph.framing().visible_rect(); // Rounded outward, so half a pixel of rounding never shows as a strip @@ -1849,6 +1972,26 @@ impl DevelopSession { return None; } let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba(); + + // TRACES: FR-DEV-3h + // **Turned the right way up before it is drawn.** Instance masks live + // in sensor space, because the generated shader samples them after + // the framing map (`uv_src`) — but this is not sampled by that shader. + // It is a flat image handed to the compositor to lay over a + // photograph that *has* been through the framing map, so it has to + // arrive in the same space the photograph is in. + // + // Without this the outlines are drawn in the sensor's orientation over + // an upright picture: on a portrait frame the colour sits nowhere near + // the subject, which reads as the detector having failed rather than + // as the overlay being turned. Nothing announces it, and it is + // invisible on landscape frames, which is most of them. + let (rgba, w, h) = self + .graph + .framing() + .effective_orientation() + .into_shown(&rgba, w, h, 4); + let buffer = slint::SharedPixelBuffer::::clone_from_slice(&rgba, w, h); Some(slint::Image::from_rgba8(buffer)) } @@ -1976,7 +2119,241 @@ impl DevelopSession { /// Called on the pointer's release rather than on each move, which is what /// makes a drag one decision in the undo stack however many frames it took. pub fn commit_gradient_drag(&mut self) { - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_MOVED)); + } + + // --- repairs (FR-DEV-8) ------------------------------------------------ + + /// TRACES: FR-DEV-8 + /// Cover what is at `(x, y)`, in fractions of the shown image. + /// + /// The click arrives in *output* coordinates — where the photograph + /// currently sits on screen — and a repair is stored against the + /// photograph, so it goes through `Framing::source_at`: the same map the + /// shader applies, run backwards. Anything less would put the repair where + /// the pointer was rather than where the mark is, and the two agree only at + /// fit-to-window with no crop. + /// + /// Returns the new repair's id, or `None` when the set is full. Selecting + /// it is deliberate: the control that changes its size is in the column, + /// and a photographer who has just placed a spot too small should find that + /// control already pointed at it. + pub fn place_spot(&mut self, x: f32, y: f32) -> Option { + let (sw, sh) = self.demosaiced.size(); + let centre = self.graph.framing().source_at((x, y), sw, sh); + // Outside the photograph entirely — the letterbox margin, or a drag + // that ended off the edge. Placing a repair there would put a disc + // somewhere the user cannot see and cannot pick up again. + if !(0.0..=1.0).contains(¢re.0) || !(0.0..=1.0).contains(¢re.1) { + return None; + } + + let aspect = sw.max(1) as f32 / sh.max(1) as f32; + let radius = dr_pipeline::spot::DEFAULT_RADIUS; + let offset = dr_pipeline::Spot::default_offset(centre, radius, aspect); + + let id = self + .graph + .spots_mut() + .place(dr_pipeline::Spot::new(centre, offset, radius))?; + self.selected_spot = Some(id.clone()); + self.history + .record(&self.graph, Edit::Action(labels::step::SPOT_PLACED)); + Some(id) + } + + /// TRACES: FR-DEV-8 | FR-UI-3 + /// Every repair as a circle on the shown image, plus the source circle of + /// the selected one. + /// + /// # Why only the selected repair shows its source + /// + /// A dusty sky carries a dozen repairs. Two dozen circles with nothing + /// saying which source belongs to which disc is not more information, it is + /// less — and there is no room on a phone for a connector between each + /// pair. The selection is what disambiguates them, which is also why a + /// press on a repair selects it before the drag begins. + /// + /// Recomputed per redraw rather than cached, for the reason + /// [`Self::gradient_handles`] gives: the answer changes with the *view*, + /// and a pan moves every circle while touching no edit. + pub fn spot_handles(&self) -> Vec { + let (sw, sh) = self.demosaiced.size(); + let framing = self.graph.framing(); + let aspect = sw.max(1) as f32 / sh.max(1) as f32; + // The shown image's own shape, which is not the source's once the frame + // has been cropped or turned. A radius is reported against its height, + // so this is what converts the x half of the mapped offset. + let (ow, oh) = self.graph.output_size(sw, sh); + let shown_aspect = ow.max(1) as f32 / oh.max(1) as f32; + + let mut handles = Vec::new(); + for spot in self.graph.spots().spots() { + let selected = self.selected_spot.as_deref() == Some(spot.id.as_str()); + let centre = framing.output_at(spot.centre, sw, sh); + + // The radius, mapped rather than scaled: a point one radius above + // the centre goes through the same map, and the distance between + // the two answers is the radius as drawn. The x half is multiplied + // by the shown aspect because the two axes are normalised by + // different lengths, and a circle measured in mixed units is an + // ellipse. + let rim = framing.output_at((spot.centre.0, spot.centre.1 + spot.radius), sw, sh); + let radius = ((rim.0 - centre.0) * shown_aspect).hypot(rim.1 - centre.1); + + handles.push(crate::SpotHandle { + id: spot.id.clone().into(), + role: crate::SpotRole::Destination, + x: centre.0, + y: centre.1, + radius, + selected, + enabled: spot.enabled, + }); + + if selected { + let source = framing.output_at(spot.source(aspect), sw, sh); + handles.push(crate::SpotHandle { + id: spot.id.clone().into(), + role: crate::SpotRole::Source, + x: source.0, + y: source.1, + radius, + selected: true, + enabled: spot.enabled, + }); + } + } + handles + } + + /// TRACES: FR-DEV-8 + /// Drag one circle of one repair, from `press` to `now`, both in fractions + /// of the shown image. + /// + /// Dragging the disc moves the whole repair and carries its source along — + /// what a photographer means by nudging a spot. Dragging the source moves + /// the source alone, which is the override FR-DEV-8 asks for over the + /// automatic placement. + /// + /// `origin` is the repair as it stood when the gesture began; the caller + /// holds it for the duration and hands it back, so a drag is applied to + /// that rather than accumulated frame by frame — the rule + /// [`Self::drag_gradient_handle`] states, for the same reasons. + pub fn drag_spot( + &mut self, + id: &str, + role: crate::SpotRole, + origin: Option<&dr_pipeline::Spot>, + press: (f32, f32), + now: (f32, f32), + ) -> Option { + let (sw, sh) = self.demosaiced.size(); + let framing = *self.graph.framing(); + let aspect = sw.max(1) as f32 / sh.max(1) as f32; + + let start = match origin { + Some(spot) => spot.clone(), + None => self.graph.spots().get(id)?.clone(), + }; + + // The displacement in source coordinates. Affine, so a movement is a + // movement: the map may be run on the two endpoints and subtracted, + // which is what makes a drag on a rotated photograph move the repair in + // the direction the finger went. + let from = framing.source_at(press, sw, sh); + let to = framing.source_at(now, sw, sh); + let moved = (to.0 - from.0, to.1 - from.1); + + let spot = self.graph.spots_mut().get_mut(id)?; + match role { + crate::SpotRole::Destination => { + spot.set_centre((start.centre.0 + moved.0, start.centre.1 + moved.1)); + } + // In frame units, because that is what an offset is stored in — and + // the x half of a normalised displacement is short by the aspect. + crate::SpotRole::Source => { + spot.set_offset((start.offset.0 + moved.0 * aspect, start.offset.1 + moved.1)); + } + } + // Nothing recorded here: a drag delivers a pointer event a frame, and + // one history step apiece would make undo walk the gesture back pixel + // by pixel. Recorded once, on release. + Some(start) + } + + /// A repair's drag finished: one history step for the whole gesture. + pub fn commit_spot_drag(&mut self) { + self.history + .record(&self.graph, Edit::Action(labels::step::SPOT_MOVED)); + } + + /// Which repair the column is describing. + pub fn selected_spot(&self) -> Option<&dr_pipeline::Spot> { + let id = self.selected_spot.as_deref()?; + self.graph.spots().get(id) + } + + pub fn selected_spot_id(&self) -> Option<&str> { + self.selected_spot.as_deref() + } + + /// Choose a repair, or `None` to describe none. + /// + /// An id the graph no longer holds selects nothing rather than being kept: + /// the circle that offered it is stale by the time the press lands, and a + /// selection pointing at a deleted repair would leave the column describing + /// something that is not on the photograph. + pub fn select_spot(&mut self, id: Option<&str>) { + self.selected_spot = id + .filter(|id| self.graph.spots().get(id).is_some()) + .map(str::to_string); + } + + /// TRACES: FR-DEV-8 + /// Take a repair off the photograph, returning whether one went. + pub fn remove_spot(&mut self, id: &str) -> bool { + if self.graph.spots_mut().remove(id).is_none() { + return false; + } + if self.selected_spot.as_deref() == Some(id) { + self.selected_spot = None; + } + self.history + .record(&self.graph, Edit::Action(labels::step::SPOT_REMOVED)); + true + } + + /// TRACES: FR-DEV-8 + /// Change one of the selected repair's settings. + /// + /// Recorded as a named [`Edit::Action`] rather than under a parameter key: + /// a repair is not an operation and has no `OpId` to name or coalesce by, + /// so a slider drag over it records a step per movement unless the caller + /// debounces. `SliderRow` fires once per completed gesture, + /// which is what makes that acceptable here and is why this is the one + /// panel in the application built from that row rather than from a live + /// track. + pub fn set_selected_spot(&mut self, change: F) -> bool + where + F: FnOnce(&mut dr_pipeline::Spot), + { + let Some(id) = self.selected_spot.clone() else { + return false; + }; + let Some(spot) = self.graph.spots_mut().get_mut(&id) else { + return false; + }; + change(spot); + self.history + .record(&self.graph, Edit::Action(labels::step::SPOT)); + true + } + + /// How many repairs this photograph carries. + pub fn spot_count(&self) -> usize { + self.graph.spots().len() } /// The selected layer's mask rule, for a caller that has to remember what @@ -2056,7 +2433,8 @@ impl DevelopSession { return None; } self.active_masks = vec![id.clone()]; - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } @@ -2085,28 +2463,32 @@ impl DevelopSession { return None; } self.active_masks = vec![id.clone()]; - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_ADDED)); Some(id) } pub fn remove_mask(&mut self, id: &str) { if self.graph.masks_mut().remove(id).is_some() { self.active_masks.retain(|a| a != id); - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_REMOVED)); } } pub fn set_mask_enabled(&mut self, id: &str, enabled: bool) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.enabled = enabled; - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_TOGGLED)); } } pub fn set_mask_invert(&mut self, id: &str, invert: bool) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.invert = invert; - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_INVERTED)); } } @@ -2115,7 +2497,7 @@ impl DevelopSession { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.feather = feather.clamp(0.0, 1.0); self.history - .record(&self.graph, Edit::Op(OpId("mask-feather"))); + .record(&self.graph, Edit::Control(labels::step::MASK_FEATHER)); } } @@ -2126,7 +2508,8 @@ impl DevelopSession { }; if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.falloff = falloff; - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF)); } } @@ -2142,7 +2525,8 @@ impl DevelopSession { if morphology != Morphology::None && layer.morph_radius <= 0.0 { layer.morph_radius = 0.006; } - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY)); } } @@ -2150,7 +2534,7 @@ impl DevelopSession { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.morph_radius = radius.clamp(0.0, 1.0); self.history - .record(&self.graph, Edit::Op(OpId("mask-morph"))); + .record(&self.graph, Edit::Control(labels::step::MASK_MORPH)); } } @@ -2205,7 +2589,7 @@ impl DevelopSession { // one decision however many values it passes through. `Discrete` // would put every intermediate position on the undo stack. self.history - .record(&self.graph, Edit::Op(OpId("mask-opacity"))); + .record(&self.graph, Edit::Control(labels::step::MASK_OPACITY)); } } @@ -2282,8 +2666,29 @@ impl DevelopSession { // in `AdjustPass`'s texture descriptor — so a failure here is a // descriptor that drifted, not anything the caller did. Say that, // rather than surfacing "InvalidUsage" to a photographer. - slint::Image::try_from(texture.clone()) - .map_err(|e| format!("the render target is not importable by the compositor: {e}")) + #[cfg(not(target_os = "android"))] + { + slint::Image::try_from(texture.clone()) + .map_err(|e| format!("the render target is not importable by the compositor: {e}")) + } + + // Android draws with Skia over OpenGL and cannot sample a + // `wgpu::Texture`, so the frame comes back through memory. See + // `crate::shared_gpu`'s Android arm for why that is the trade on this + // platform. The pass still runs on the GPU; only this last hop does not. + #[cfg(target_os = "android")] + { + let _ = texture; + let (rgba, w, h) = self + .adjust + .export_pixels() + .map_err(|e| format!("reading the rendered frame back: {e}"))?; + let mut buf = slint::SharedPixelBuffer::::new(w, h); + let wanted = (w as usize) * (h as usize) * 4; + let src = &rgba[..wanted.min(rgba.len())]; + buf.make_mut_bytes()[..src.len()].copy_from_slice(src); + Ok(slint::Image::from_rgba8(buf)) + } } /// TRACES: FR-DSP-7 @@ -2449,6 +2854,25 @@ impl DevelopSession { /// is the sync case — a sidecar written on a device with a stock this one /// lacks — and rendering it as *some other* film would be worse than /// rendering it plainly. + /// TRACES: FR-DEV-3f | FR-DEV-5 + /// Choose a stock **as the photographer just did**, and record the step. + /// + /// Separate from [`Self::choose_film`] because that call has two very + /// different callers. Picking Portra from the list is an edit and belongs + /// in the history; the same call made while *restoring* an edit — opening + /// a photograph, or stepping to a history row that names a stock — is the + /// second half of putting a state back, and recording it would push a step + /// for the undo the photographer had just asked for. + /// + /// Choosing a stock was not undoable at all before this existed: the pick + /// went straight to `choose_film`, which nothing on the history's path + /// ever sees. + pub fn pick_film(&mut self, stock: Option<&str>, print: bool) { + self.choose_film(stock, print); + self.history + .record(&self.graph, Edit::Action(labels::step::FILM)); + } + pub fn choose_film(&mut self, stock: Option<&str>, print: bool) { let Some(stock) = stock else { self.set_film(None); @@ -2477,8 +2901,23 @@ impl DevelopSession { // stock looks comes from. The sensor's width in pixels then says how // much film one pixel covers, and the grain model needs nothing else // to be correct at any zoom. + // TRACES: FR-DEV-3f + // The frame this is being simulated on, against the pixels it is being + // rendered to: together they are the enlargement, and the enlargement + // is what decides how grainy the result looks. A crystal is a fixed + // size in micrometres — the same emulsion on a sheet averages far more + // of them into each pixel than it does on 35 mm. + let format = dr_film::Format::from_index( + self.graph + .param( + dr_pipeline::ops::film_sim::ID, + dr_pipeline::ops::film_sim::FORMAT, + ) + .unwrap_or(0.0) + .max(0.0) as usize, + ); let (source_width, _) = self.demosaiced.size(); - let pixel_size_um = dr_film::grain::FRAME_WIDTH_UM / source_width.max(1) as f32; + let pixel_size_um = format.width_um() / source_width.max(1) as f32; let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um); let baked = dr_film::bake(&dr_film::Recipe { @@ -2659,7 +3098,8 @@ impl DevelopSession { self.graph.set_crop(rotate_crop(crop, turns)); } self.graph.rotate_quarters(turns); - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::ROTATE)); } /// Straightening, in degrees. Positive turns the image clockwise. @@ -2684,7 +3124,8 @@ impl DevelopSession { dr_pipeline::framing::FLIP_H, f32::from(u8::from(!h)), ); - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::FLIP_H)); } pub fn toggle_flip_v(&mut self) { @@ -2694,7 +3135,8 @@ impl DevelopSession { dr_pipeline::framing::FLIP_V, f32::from(u8::from(!v)), ); - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::FLIP_V)); } /// Set the straightening angle, in degrees. @@ -2730,7 +3172,8 @@ impl DevelopSession { let view = self.graph.framing().view(); self.graph.framing_mut().reset(); self.graph.framing_mut().set_view(view); - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::RESET_FRAMING)); } /// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×. @@ -2867,7 +3310,8 @@ impl DevelopSession { // A paste is undoable, and is the action most in need of it: it // replaces everything in scope at once, so getting it wrong costs more // than any single control can. - self.history.record(&self.graph, Edit::Discrete); + self.history + .record(&self.graph, Edit::Action(labels::step::PASTE)); } /// TRACES: FR-CAT-8 @@ -2878,18 +3322,14 @@ impl DevelopSession { /// rather than leaving the previous image's exposure standing. The file's /// orientation survives it, since that was never an edit. pub fn apply_version(&mut self, version: &dr_pipeline::Version) { - version.apply(&mut self.graph); // TRACES: FR-DEV-3f // The film, which `apply` cleared and could not restore: a sidecar // names a stock, and turning a name into tables needs the profile // database that `dr-pipeline` deliberately does not link. So it is // re-baked here, after the parameters, because the bake reads the // film's own exposure sliders and they have just arrived. - if let Some(film) = &version.film { - let stock = film.stock.clone(); - let print = film.print.is_some(); - self.choose_film(Some(&stock), print); - } + let rebake = version.apply(&mut self.graph); + self.pay_film_debt(&rebake); // The stored edit becomes the floor rather than a step. It is not // something the user did in this sitting, and an undo that reached // behind it would discard a previous session's work in one press — @@ -2897,6 +3337,49 @@ impl DevelopSession { self.history.reset(&self.graph); } + /// TRACES: FR-DEV-3f | FR-DEV-5 + /// Pay what a restored edit owes the picture. + /// + /// `dr-pipeline` restores a stock's *name* and clears its tables, because + /// baking needs the profile database it does not link (ARCH §6.5a). This + /// side of the seam has it, so this is where the photograph gets its film + /// back. + /// + /// Both outcomes go through [`Self::set_film`], and the empty one is not + /// a no-op: `set_state` cleared the *graph*, and the adjust pass would go + /// on holding textures that nothing will sample. That is the two halves + /// disagreeing, which is the failure `set_film` exists to make + /// impossible — and it is silent in this direction, which is worse. + /// + /// Baked unconditionally rather than only when the stock changed: the + /// tables come from the film node's own exposure sliders as well as from + /// the stock, and restoring an edit replaces those sliders too. A bake is + /// milliseconds and this happens on a keypress, so the cheap correct rule + /// beats the clever one. + fn pay_film_debt(&mut self, rebake: &dr_pipeline::FilmRebake) { + match rebake.wanted() { + Some(film) => { + let stock = film.stock.clone(); + let print = film.print.is_some(); + self.choose_film(Some(&stock), print); + } + None => self.set_film(None), + } + } + + /// TRACES: FR-DEV-5 + /// [`Self::pay_film_debt`] for a history step, when the step went + /// anywhere. + /// + /// The guard is the whole difference between the two: a step that found + /// nowhere to go left the graph alone, and clearing the film because + /// undo hit the floor would take the picture's stock off it. + fn settle(&mut self, step: &dr_pipeline::Step) { + if let dr_pipeline::Step::Took(rebake) = step { + self.pay_film_debt(rebake); + } + } + /// TRACES: FR-DEV-5 /// Step the edit back one, returning whether anything moved. /// @@ -2904,13 +3387,17 @@ impl DevelopSession { /// reason a paste must: this moves values the controls are showing and /// nothing here pushes them. pub fn undo(&mut self) -> bool { - self.history.undo(&mut self.graph) + let step = self.history.undo(&mut self.graph); + self.settle(&step); + step.moved() } /// TRACES: FR-DEV-5 /// Step the edit forward one, returning whether anything moved. pub fn redo(&mut self) -> bool { - self.history.redo(&mut self.graph) + let step = self.history.redo(&mut self.graph); + self.settle(&step); + step.moved() } pub fn can_undo(&self) -> bool { @@ -2920,6 +3407,94 @@ impl DevelopSession { pub fn can_redo(&self) -> bool { self.history.can_redo() } + + /// TRACES: FR-DEV-5 | FR-DEV-7 + /// Every step this photograph has been through, newest first. + /// + /// Newest first because the list is consulted to take back something just + /// done, not browsed chronologically — the order `dr_catalog::trash` + /// settled on for the same question. It also keeps the interesting end + /// against the heading, so a stack sixty-four deep does not put the step + /// the photographer is looking for at the bottom of a long scroll. + /// + /// The reversal happens here rather than in the core, which returns the + /// stack in stack order and stamps each row with its own index — so + /// nothing on this side does arithmetic to turn a row back into a step. + pub fn history_rows(&self) -> Vec { + let mut rows: Vec<_> = self + .history + .entries(&self.graph) + .into_iter() + .map(|entry| crate::HistoryRow { + index: entry.index as i32, + label: labels::resolve(entry.label.0).into(), + current: entry.current, + // Everything past the mark is a future the photographer + // stepped out of. Still listed, because it is still reachable + // by redo and hiding it would make redo arrive somewhere the + // panel never mentioned — but drawn as the branch it is. + undone: entry.index > self.history.cursor(), + }) + .collect(); + rows.reverse(); + rows + } + + /// TRACES: FR-DEV-5 | FR-DEV-7 + /// Step straight to one row of [`Self::history_rows`]. + /// + /// Takes the row's own `index`, not its position in that list. + /// TRACES: FR-DEV-5 + /// A number that changes exactly when [`Self::history_rows`] would. + /// + /// The panel is rebuilt off this rather than every redraw: a drag ends in + /// a redraw per frame and changes no row, and pushing a fresh model makes + /// the toolkit tear down and recreate every one of them. + pub fn history_revision(&self) -> u64 { + self.history.revision() + } + + pub fn go_to_history(&mut self, index: i32) -> bool { + let Ok(index) = usize::try_from(index) else { + return false; + }; + let step = self.history.go_to(&mut self.graph, index); + self.settle(&step); + step.moved() + } + + /// TRACES: FR-DEV-5 + /// What undo would take back, and what redo would put back. + /// + /// Named on the buttons rather than left to the bare verb. "Undo" asks the + /// photographer to remember what they last did, which after a run of small + /// adjustments is exactly what they have stopped tracking — and it is the + /// moment they are least willing to press a button and find out. + /// + /// Empty when there is nowhere to go, so the caller falls back to the verb + /// alone rather than printing a label for a disabled control. + pub fn undo_label(&self) -> String { + // Undo takes back the step the graph is *standing on*, so the row to + // name is the current one — not the one it will land on. + self.step_name(self.history.cursor(), self.history.can_undo()) + } + + /// TRACES: FR-DEV-5 + pub fn redo_label(&self) -> String { + self.step_name(self.history.cursor() + 1, self.history.can_redo()) + } + + fn step_name(&self, index: usize, offered: bool) -> String { + if !offered { + return String::new(); + } + self.history + .entries(&self.graph) + .into_iter() + .find(|e| e.index == index) + .map(|e| labels::resolve(e.label.0)) + .unwrap_or_default() + } } /// Re-express a crop rect after the frame it is measured against turns. diff --git a/ui/dr-ui/src/labels.rs b/ui/dr-ui/src/labels.rs index 67ea829..d71a876 100644 --- a/ui/dr-ui/src/labels.rs +++ b/ui/dr-ui/src/labels.rs @@ -9,47 +9,153 @@ //! a derived label, before anyone writes a translation for it. That is the //! behaviour FR-DEV-3c promises: adding an operation needs no UI change. +/// TRACES: FR-DEV-5 +/// The history steps this interface records that no descriptor can name. +/// +/// Constants rather than string literals at the call sites, and [`ALL`] rather +/// than a list written out again in a test. A key spelled one way where the +/// step is recorded and another way where it is catalogued resolves through +/// `derive` to something *plausible* — "Mask Toggled" — so the mistake does +/// not look like one. Naming each key once removes the opportunity. +/// +/// A step that moved a parameter or an operation is deliberately not here: it +/// is named out of the descriptor, through the same `op.` and `param.` entries +/// the develop panel resolves. That is what lets an operation added as a YAML +/// declaration appear in the history correctly named with nothing written for +/// it here (FR-DEV-3c). +pub mod step { + use dr_pipeline::LocalizedKey; + + pub const PASTE: LocalizedKey = LocalizedKey("history.paste"); + pub const FILM: LocalizedKey = LocalizedKey("history.film"); + + pub const RESET_ALL: LocalizedKey = LocalizedKey("history.reset_all"); + pub const RESET_OP: LocalizedKey = LocalizedKey("history.reset_op"); + pub const RESET_PARAM: LocalizedKey = LocalizedKey("history.reset_param"); + pub const RESET_FRAMING: LocalizedKey = LocalizedKey("history.reset_framing"); + + pub const ROTATE: LocalizedKey = LocalizedKey("history.rotate"); + pub const FLIP_H: LocalizedKey = LocalizedKey("history.flip_h"); + pub const FLIP_V: LocalizedKey = LocalizedKey("history.flip_v"); + + pub const MASK_ADDED: LocalizedKey = LocalizedKey("history.mask_added"); + pub const MASK_REMOVED: LocalizedKey = LocalizedKey("history.mask_removed"); + pub const MASK_TOGGLED: LocalizedKey = LocalizedKey("history.mask_toggled"); + pub const MASK_INVERTED: LocalizedKey = LocalizedKey("history.mask_inverted"); + pub const MASK_MOVED: LocalizedKey = LocalizedKey("history.mask_moved"); + pub const MASK_FEATHER: LocalizedKey = LocalizedKey("history.mask_feather"); + pub const MASK_MORPH: LocalizedKey = LocalizedKey("history.mask_morph"); + pub const MASK_OPACITY: LocalizedKey = LocalizedKey("history.mask_opacity"); + pub const MASK_FALLOFF: LocalizedKey = LocalizedKey("history.mask_falloff"); + pub const MASK_MORPHOLOGY: LocalizedKey = LocalizedKey("history.mask_morphology"); + + /// TRACES: FR-DEV-8 + /// A repair's own settings changed — its radius, its source, its opacity. + pub const SPOT: LocalizedKey = LocalizedKey("history.spot"); + pub const SPOT_PLACED: LocalizedKey = LocalizedKey("history.spot_placed"); + pub const SPOT_MOVED: LocalizedKey = LocalizedKey("history.spot_moved"); + pub const SPOT_REMOVED: LocalizedKey = LocalizedKey("history.spot_removed"); + + /// Every step above, plus the two the core names for itself. + /// + /// Exists so the catalogue can be checked against the keys that are + /// actually recorded rather than against a second copy of them. A new step + /// left out of this list is a step with no test — which is the failure + /// this whole arrangement is guarding, so it is worth saying plainly: + /// **add the constant to this slice.** + /// + /// Test-only, because checking is the whole of what it is for. Resolving a + /// key at runtime goes through [`super::resolve`] one key at a time and + /// never needs the roll. + #[cfg(test)] + pub const ALL: &[LocalizedKey] = &[ + dr_pipeline::history::OPENED, + dr_pipeline::history::UNNAMED, + PASTE, + FILM, + RESET_ALL, + RESET_OP, + RESET_PARAM, + RESET_FRAMING, + ROTATE, + FLIP_H, + FLIP_V, + MASK_ADDED, + MASK_REMOVED, + MASK_TOGGLED, + MASK_INVERTED, + MASK_MOVED, + MASK_FEATHER, + MASK_MORPH, + MASK_OPACITY, + MASK_FALLOFF, + MASK_MORPHOLOGY, + SPOT, + SPOT_PLACED, + SPOT_MOVED, + SPOT_REMOVED, + ]; +} + /// Resolve a key, deriving a fallback where none is catalogued. pub fn resolve(key: &str) -> String { - match key { + catalogued(key).map_or_else(|| derive(key), str::to_string) +} + +/// The label this build has actually chosen for `key`, if it has chosen one. +/// +/// Split out from [`resolve`] so that "is this catalogued?" can be asked, and +/// it is asked by the test that guards the history rows. Those are read as a +/// column of short phrases naming decisions, and `derive` is not up to that +/// job even when it produces real words: it turns `history.mask_toggled` into +/// "Mask Toggled", which is a description of a field rather than of something +/// the photographer did — and, being perfectly readable, is a mistake nobody +/// would look at twice. +/// +/// Deriving stays right for `op.` and `param.`, where an operation added as a +/// YAML declaration must show up usable before anyone writes its translation +/// (FR-DEV-3c). The difference is that those keys name a thing and these name +/// an act. +fn catalogued(key: &str) -> Option<&'static str> { + Some(match key { // Operations // The attribute names. Short on purpose: these are read as a strip // of tabs, where a long word crowds out the next one. - "attr.tone" => "Light".into(), - "attr.colour" => "Colour".into(), - "attr.detail" => "Detail".into(), - "attr.optics" => "Optics".into(), - "attr.geometry" => "Geometry".into(), - "attr.effect" => "Effects".into(), + "attr.tone" => "Light", + "attr.colour" => "Colour", + "attr.detail" => "Detail", + "attr.optics" => "Optics", + "attr.geometry" => "Geometry", + "attr.effect" => "Effects", - "op.white_balance" => "White Balance".into(), - "op.exposure" => "Exposure".into(), - "op.highlights_shadows" => "Highlights & Shadows".into(), - "op.blacks_whites" => "Blacks & Whites".into(), - "op.brilliance" => "Brilliance".into(), - "op.vibrance" => "Vibrance".into(), - "op.saturation" => "Saturation".into(), - "op.colour_mixer" => "Colour Mixer".into(), + "op.white_balance" => "White Balance", + "op.exposure" => "Exposure", + "op.highlights_shadows" => "Highlights & Shadows", + "op.blacks_whites" => "Blacks & Whites", + "op.brilliance" => "Brilliance", + "op.vibrance" => "Vibrance", + "op.saturation" => "Saturation", + "op.colour_mixer" => "Colour Mixer", // "Sharpening" rather than what `derive` would make of the id. The id // says *capture* sharpening to separate it from the output sharpening // an export applies (FR-EXP-4), which is a distinction about where in // the pipeline it sits; in the develop panel there is only one, and // "Capture Sharpen" would name a distinction the photographer cannot // see from there. - "op.capture_sharpen" => "Sharpening".into(), - "op.framing" => "Crop & Rotate".into(), + "op.capture_sharpen" => "Sharpening", + "op.framing" => "Crop & Rotate", // Parameters - "param.temperature" => "Temperature".into(), - "param.tint" => "Tint".into(), - "param.exposure" => "Exposure".into(), - "param.highlights" => "Highlights".into(), - "param.shadows" => "Shadows".into(), - "param.blacks" => "Blacks".into(), - "param.whites" => "Whites".into(), - "param.brilliance" => "Brilliance".into(), - "param.vibrance" => "Vibrance".into(), - "param.saturation" => "Saturation".into(), + "param.temperature" => "Temperature", + "param.tint" => "Tint", + "param.exposure" => "Exposure", + "param.highlights" => "Highlights", + "param.shadows" => "Shadows", + "param.blacks" => "Blacks", + "param.whites" => "Whites", + "param.brilliance" => "Brilliance", + "param.vibrance" => "Vibrance", + "param.saturation" => "Saturation", // What a faceted parameter adjusts — the mixer's three channels. // @@ -57,9 +163,9 @@ pub fn resolve(key: &str) -> String { // "Lum" from the keys. These name a run of twelve rows apiece, and an // abbreviation at the head of a section is a word the reader has to // expand every time they scan past it. - "param.channel.hue" => "Hue".into(), - "param.channel.sat" => "Saturation".into(), - "param.channel.lum" => "Luminance".into(), + "param.channel.hue" => "Hue", + "param.channel.sat" => "Saturation", + "param.channel.lum" => "Luminance", // The tone curve's four curves, which its points are *subject* to. // @@ -69,10 +175,10 @@ pub fn resolve(key: &str) -> String { // colours would derive correctly and are written out beside it anyway, // since a list where one entry is translated and three are guessed is // the shape a half-finished translation takes. - "channel.rgb" => "RGB".into(), - "channel.red" => "Red".into(), - "channel.green" => "Green".into(), - "channel.blue" => "Blue".into(), + "channel.rgb" => "RGB", + "channel.red" => "Red", + "channel.green" => "Green", + "channel.blue" => "Blue", // The hue bands, which a faceted row is *subject* to. // @@ -82,32 +188,76 @@ pub fn resolve(key: &str) -> String { // nothing. These are also the only place a swatch's meaning is written // down in words, which is what a photographer who cannot separate the // squares by eye has to go on. - "band.red" => "Red".into(), - "band.orange" => "Orange".into(), - "band.yellow" => "Yellow".into(), - "band.chartreuse" => "Yellow-Green".into(), - "band.green" => "Green".into(), - "band.spring" => "Blue-Green".into(), - "band.cyan" => "Cyan".into(), - "band.azure" => "Azure".into(), - "band.blue" => "Blue".into(), - "band.violet" => "Violet".into(), - "band.magenta" => "Magenta".into(), - "band.rose" => "Rose".into(), + "band.red" => "Red", + "band.orange" => "Orange", + "band.yellow" => "Yellow", + "band.chartreuse" => "Yellow-Green", + "band.green" => "Green", + "band.spring" => "Blue-Green", + "band.cyan" => "Cyan", + "band.azure" => "Azure", + "band.blue" => "Blue", + "band.violet" => "Violet", + "band.magenta" => "Magenta", + "band.rose" => "Rose", // Framing. "Straighten" rather than "Angle" because that is the task // the control performs; the number it reports is still degrees. - "param.angle" => "Straighten".into(), - "param.rotation" => "Rotate".into(), - "param.flip_h" => "Flip Horizontal".into(), - "param.flip_v" => "Flip Vertical".into(), - "param.crop_x" => "Crop Left".into(), - "param.crop_y" => "Crop Top".into(), - "param.crop_w" => "Crop Width".into(), - "param.crop_h" => "Crop Height".into(), + "param.angle" => "Straighten", + "param.rotation" => "Rotate", + "param.flip_h" => "Flip Horizontal", + "param.flip_v" => "Flip Vertical", + "param.crop_x" => "Crop Left", + "param.crop_y" => "Crop Top", + "param.crop_w" => "Crop Width", + "param.crop_h" => "Crop Height", - other => derive(other), - } + // TRACES: FR-DEV-5 + // The steps that are not a parameter moving. + // + // Catalogued rather than derived because these are read as a *column* + // of short phrases, where `derive` would give "Mask Added" and "Reset + // Op" — the second of which names a function rather than an action, + // and the first of which reads as a label for a thing rather than for + // something the photographer did. A history is a list of decisions, so + // the rows are written as decisions. + // + // A step naming an operation or a parameter is *not* here: it resolves + // through the same `op.` and `param.` entries above that the panel + // uses, which is what lets an operation added as a YAML declaration + // appear in the history correctly named with no entry written for it + // (FR-DEV-3c). + "history.opened" => "Opened", + "history.edit" => "Edit", + "history.paste" => "Paste Settings", + "history.film" => "Film Stock", + + "history.reset_all" => "Reset Everything", + "history.reset_op" => "Reset Group", + "history.reset_param" => "Reset Control", + "history.reset_framing" => "Reset Crop & Rotate", + + "history.rotate" => "Rotate", + "history.flip_h" => "Flip Horizontal", + "history.flip_v" => "Flip Vertical", + + "history.mask_added" => "Add Mask", + "history.mask_removed" => "Delete Mask", + "history.mask_toggled" => "Mask On/Off", + "history.mask_inverted" => "Invert Mask", + "history.mask_moved" => "Move Mask", + "history.mask_feather" => "Mask Feather", + "history.mask_morph" => "Mask Edge", + "history.mask_opacity" => "Mask Opacity", + "history.mask_falloff" => "Mask Falloff", + "history.mask_morphology" => "Mask Grow/Shrink", + "history.spot" => "Adjust Repair", + "history.spot_placed" => "Add Repair", + "history.spot_moved" => "Move Repair", + "history.spot_removed" => "Delete Repair", + + _ => return None, + }) } /// Turn `op.some_new_thing` into `Some New Thing`. @@ -137,6 +287,43 @@ fn derive(key: &str) -> String { mod tests { use super::*; + #[test] + fn every_step_the_interface_records_has_a_name() { + // A history is only as useful as its rows are distinguishable, and + // these are the ones no descriptor can name — the resets, the flips, + // the mask actions. A key missing from the catalogue derives something + // readable rather than nothing, so the failure this guards is subtler: + // two different steps deriving the *same* word, or a word that names + // the function that ran rather than the decision the user made. + let mut seen: Vec = Vec::new(); + for key in step::ALL.iter().map(|k| k.0) { + let label = catalogued(key).unwrap_or_else(|| { + panic!( + "{key} is not catalogued; it would fall through to `derive` \ + and read as {:?}, which names a field rather than an act", + derive(key) + ) + }); + assert!(!label.is_empty(), "{key} resolved to nothing"); + assert!( + !seen.contains(&label.to_string()), + "{key} shares the label {label:?} with an earlier step" + ); + seen.push(label.to_string()); + } + } + + #[test] + fn a_step_naming_an_operation_borrows_the_panels_label() { + // What keeps the history honest as the pipeline grows: a step that + // moved a parameter is named by the same entry the slider beside it + // is, so the two can never disagree, and an operation nobody has + // catalogued yet still reads correctly in both places. + assert_eq!(resolve("op.white_balance"), "White Balance"); + assert_eq!(resolve("op.tone_curve"), "Tone Curve"); + assert_eq!(resolve("param.exposure"), "Exposure"); + } + #[test] fn catalogued_keys_resolve_to_their_label() { assert_eq!(resolve("op.white_balance"), "White Balance"); diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 51eca8f..b122100 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -43,9 +43,10 @@ mod segmentation; mod settings_store; mod settings_ui; mod sidecar_cache; +mod spots_ui; mod trash; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::path::{Path, PathBuf}; use std::rc::Rc; @@ -295,6 +296,11 @@ fn reset_view_state(window: &AppWindow) { // overlay or the crosshair to the next one would offer a selection of // regions that are not in the picture on screen. masks_ui::reset(window); + // TRACES: FR-DEV-8 + // The repairs belong to one photograph too. The next one's arrive with its + // sidecar a moment later, and until they do the canvas must not be showing + // the last one's. + spots_ui::reset(window); } /// Push the framing back to the geometry panel. @@ -738,6 +744,7 @@ enum PointsUpdate { /// /// `None` means develop is unavailable and the viewer falls back to embedded /// previews — the same degradation as a machine with no adapter at all. +#[cfg(not(target_os = "android"))] fn shared_gpu() -> Option { let shared = match pollster::block_on(dr_gpu::GpuContext::new_shared()) { Ok(shared) => shared, @@ -778,6 +785,45 @@ fn shared_gpu() -> Option { Some(ctx) } +/// TRACES: FR-DSP-1 | AC-8 +/// Open the compute device on Android — and do **not** give it to Slint. +/// +/// # Why this platform is different +/// +/// The desktop version above exists so one `wgpu::Texture` can be written by +/// the adjust pass and sampled by the compositor without a round-trip. That +/// requires Slint to be drawing with wgpu, and on Android drawing with wgpu +/// means drawing on wgpu's Vulkan swapchain — which hardcodes +/// `preTransform = IDENTITY` (gfx-rs/wgpu#3345). +/// +/// On a tablet whose panel is mounted landscape, portrait then presents an +/// unrotated buffer, every present returns `VK_SUBOPTIMAL_KHR`, and the frames +/// arrive torn. Measured on the device: portrait sits on `composition=CLIENT` +/// with `bufferTransform=ROT_270`, landscape on `composition=DEVICE` with +/// `ROT_180`, and only portrait tore. Taking Slint off wgpu — it then uses +/// Skia over OpenGL, where the driver owns the rotation — fixed it. +/// +/// # What it costs, and why that is the right trade here +/// +/// Without a shared device the display path needs a readback: +/// `AdjustPass::export_pixels` instead of `Image::try_from(texture)`. That is +/// the transfer ARCH §6.1 and AC-8 exist to avoid, and it is still the better +/// bargain on this platform — the alternative is not a faster develop view, it +/// is a torn one, in the orientation a tablet is mostly held in. +/// +/// The compute passes are untouched: demosaic and adjust still run on the GPU, +/// on this device. Only the last hop to the screen goes through memory. +#[cfg(target_os = "android")] +fn shared_gpu() -> Option { + match pollster::block_on(dr_gpu::GpuContext::new_headless()) { + Ok(ctx) => Some(ctx), + Err(e) => { + log::warn!("no GPU for the develop passes: {e}"); + None + } + } +} + /// TRACES: M-13 | M-14 /// Build and run the viewer. pub fn run(paths: Vec) -> Result<()> { @@ -1325,9 +1371,16 @@ pub fn run(paths: Vec) -> Result<()> { // // Called on every slider change, so it must do no more than run the // adjust pass — the demosaic is not repeated. + // TRACES: FR-DEV-5 + // The history revision the panel was last built from. See the use below: + // the list is rebuilt when it would read differently, not when the picture + // is redrawn, and those are very different rates. + let drawn_history: Rc>> = Rc::new(Cell::new(None)); + let render_now: Render = { let session = session.clone(); let viewport = viewport.clone(); + let drawn_history = drawn_history.clone(); Rc::new(move |window: &AppWindow, draft: bool| { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; @@ -1340,6 +1393,20 @@ pub fn run(paths: Vec) -> Result<()> { window.set_can_undo(s.can_undo()); window.set_can_redo(s.can_redo()); + // TRACES: FR-DEV-5 | FR-DEV-7 + // The list, on the same path and for the same reason — but only + // when it would read differently. A drag ends in a redraw per + // frame while folding into one step, so an unconditional rebuild + // here would tear down and recreate every row of the panel sixty + // times a second to arrive back at the list already on screen. + let revision = s.history_revision(); + if drawn_history.get() != Some(revision) { + drawn_history.set(Some(revision)); + window + .set_history_rows(slint::ModelRc::new(slint::VecModel::from(s.history_rows()))); + window.set_undo_label(s.undo_label().into()); + } + // TRACES: FR-DEV-3 // Which part of the region overlay the view is showing. Here // rather than in the panel's own sync because a pan or a zoom @@ -1347,6 +1414,18 @@ pub fn run(paths: Vec) -> Result<()> { // those ends in a redraw. masks_ui::sync_overlay_view(window, s); + // TRACES: FR-DEV-8 + // And where the repairs are drawn, for the same reason: a pan or a + // zoom moves every circle while touching no repair, and a circle + // left where the mark used to be is worse than no circle at all. + spots_ui::sync_handles(window, s); + // The column's own numbers, pushed from here as well so that a + // photograph opened with repairs already on it arrives with the + // panel describing them — the sidecar lands after the callbacks + // have all been installed, and a redraw is the one path every + // arrival takes. + spots_ui::sync_panel(window, s); + let (mut w, mut h) = *viewport.borrow(); // **Half resolution while the gesture is still moving.** @@ -2050,6 +2129,7 @@ pub fn run(paths: Vec) -> Result<()> { } masks_ui::wire(&window, &session, &rows, &redraw); + spots_ui::wire(&window, &session, redraw.clone()); // A way to land on the Identity Manager at startup, for looking at it // without a mouse. Off unless the variable is set, so it costs a getenv @@ -2139,7 +2219,7 @@ pub fn run(paths: Vec) -> Result<()> { .and_then(dr_film::find) .and_then(dr_film::default_print) .is_some(); - s.choose_film(*stock, print); + s.pick_film(*stock, print); } sync_rows(&w, &rows, &session); redraw(&w); @@ -2153,7 +2233,7 @@ pub fn run(paths: Vec) -> Result<()> { let Some(w) = weak.upgrade() else { return }; if let Some(s) = session.borrow_mut().as_mut() { if let Some((stock, _)) = s.film().map(|(a, b)| (a.to_string(), b)) { - s.choose_film(Some(&stock), print); + s.pick_film(Some(&stock), print); } } sync_film(&w, &session); @@ -2199,6 +2279,27 @@ pub fn run(paths: Vec) -> Result<()> { }); } + // TRACES: FR-DEV-5 | FR-DEV-7 + // Clicking a row. Arriving six steps away costs what arriving from one + // does, because a step is a whole state — see `History::go_to`. + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + let rows = rows.clone(); + window.on_history_picked(move |index| { + let Some(w) = weak.upgrade() else { return }; + let stepped = session + .borrow_mut() + .as_mut() + .is_some_and(|s| s.go_to_history(index)); + if stepped { + sync_rows(&w, &rows, &session); + redraw(&w); + } + }); + } + // ---- zoom, pan and crop --------------------------------------------- // // Zoom and pan are viewing state and touch no parameter, so unlike the @@ -2275,14 +2376,32 @@ pub fn run(paths: Vec) -> Result<()> { w.set_crop_h(c.height); } ViewMode::Local => s.set_overlay(true), + // TRACES: FR-DEV-8 + // Nothing to arm: the circles are drawn whenever there are + // repairs, and what the mode changes is whether a click on + // the photograph makes another one. Leaving the mask + // selection behind would re-point the column at a layer's + // chain while the canvas is showing repairs, which is the + // fault this whole strip exists to prevent. + ViewMode::Spots => { + s.set_overlay(false); + s.set_active_mask(None); + } ViewMode::Photo => { s.set_overlay(false); s.set_active_mask(None); + // A repair stays on the photograph; only the *selection* + // goes, so the source circle does not hang about over a + // frame nobody is repairing any more. + s.select_spot(None); } } } w.set_view_mode(mode); masks_ui::sync(&w, &session); + if let Some(s) = session.borrow().as_ref() { + spots_ui::sync_handles(&w, s); + } // The scope may have just changed, so the panel below is now // describing a different chain. sync_rows(&w, &rows, &session); diff --git a/ui/dr-ui/src/presets.rs b/ui/dr-ui/src/presets.rs index 507df69..c761389 100644 --- a/ui/dr-ui/src/presets.rs +++ b/ui/dr-ui/src/presets.rs @@ -606,7 +606,7 @@ mod tests { let version = sidecar.default_version().expect("a default version"); let mut reopened = EditGraph::default_chain(); - version.apply(&mut reopened); + version.apply(&mut reopened).expect_no_film(); assert_eq!( reopened.masks().len(), @@ -646,7 +646,8 @@ mod tests { sidecar .default_version() .expect("a version") - .apply(&mut restored); + .apply(&mut restored) + .expect_no_film(); assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(1.5)); } diff --git a/ui/dr-ui/src/segmentation.rs b/ui/dr-ui/src/segmentation.rs index 870c896..cb19c1d 100644 --- a/ui/dr-ui/src/segmentation.rs +++ b/ui/dr-ui/src/segmentation.rs @@ -34,6 +34,7 @@ use std::sync::Arc; use dr_gpu::GpuContext; use dr_pipeline::mask::segmentation_signature; +use dr_types::Orientation; /// One recognised object. #[derive(Debug, Clone)] @@ -243,27 +244,41 @@ impl Default for Options { /// Find what can be selected in this photograph. /// -/// `rgb` is the proxy the model reads — tightly packed RGB floats at -/// `(width, height)`. Passed in rather than derived here because the caller -/// already has the rendered proxy, and re-deriving it would mean a second -/// readback of something the CPU is holding. +/// `rgb` is the rendered proxy — tightly packed RGB floats at +/// `(width, height)`, in the **sensor's** own orientation. Passed in rather +/// than derived here because the caller already has it, and re-deriving it +/// would mean a second readback of something the CPU is holding. +/// +/// `orientation` is the file's EXIF tag composed with whatever turns the +/// photographer has since applied — `Framing::effective_orientation`, one +/// permutation covering both. The model is shown the picture through it and +/// its answers come back without it, so everything this returns is in sensor +/// space exactly as it was before the detector was taught to read. pub fn compute( _ctx: &GpuContext, rgb: &[f32], width: usize, height: usize, + orientation: Orientation, options: &Options, ) -> Result { - let found = detect(rgb, width, height, options.fine)?; + // The model reads the photograph; everything else here speaks sensor. + let (stood_up, uw, uh) = upright(rgb, width, height, orientation); + let found = detect(&stood_up, uw, uh, options.fine)?; let instances: Vec = found .iter() .filter(|i| i.score >= options.confidence) - .map(|i| InstanceSummary { - class_name: i.class_name.clone(), - score: i.score, - mask: quantise(&i.mask), - bbox: i.bbox, + .map(|i| { + let (mask, bbox) = lay_down(&i.mask, i.bbox, uw, uh, orientation); + InstanceSummary { + class_name: i.class_name.clone(), + score: i.score, + // Quantised after the permutation, so the byte stored is a + // rounding of the model's own coverage and not of a copy. + mask: quantise(&mask), + bbox, + } }) .collect(); @@ -278,21 +293,122 @@ pub fn compute( // a signature, a layer built against the coarse pass would be silently // reinterpreted against the fine one. That is a *wrong* mask, which is // far worse than a stale one, because nothing announces it. + // + // The orientation is in for the same reason and it is not hypothetical: + // turning the photograph changes what the model recognises, so a run + // before a quarter turn and a run after it are different instance + // lists. Two lists that happened to come out the same length would + // otherwise share a signature, and a layer built against the first + // would be silently re-indexed into the second. options.confidence.to_bits() as u64 ^ if options.fine { 0x9E37_79B9_7F4A_7C15 } else { 0 - }, + } + ^ orientation_key(orientation), ); Ok(Segmentation { instances, signature, + // **Sensor space, not the model's.** `lay_down` put every mask back, + // so the grid a stored layer indexes into is the one it always was — + // see `upright` for why the model saw a different one. proxy: (width, height), }) } +/// TRACES: FR-DEV-3 | FR-DEV-3h +/// Turn the proxy the way the photographer is looking at it. +/// +/// **Why this exists at all.** A camera held sideways writes its sensor rows +/// the way it always does, and the render puts them right by way of +/// `Framing`. The proxy the model reads is deliberately rendered through a +/// *neutral* graph — the detection has to survive an exposure change, or +/// every slider would invalidate the masks built on it — and neutral took the +/// orientation with it. So the detector was handed a portrait frame lying on +/// its side, and a model trained on upright photographs is very bad at those. +/// Measured end to end on one 22 MP frame of two people and a dog: `person +/// 0.36` and nothing else, against `dog 0.82, person 0.61, person 0.49` for +/// the same pixels stood up. +/// +/// One line, because the permutation belongs to +/// [`dr_types::Orientation`] and every other consumer goes through the same +/// one — the grid's thumbnails included, which is what makes "upright" mean +/// one thing across the application rather than one thing per caller. +pub(crate) fn upright( + rgb: &[f32], + width: usize, + height: usize, + orientation: Orientation, +) -> (Vec, usize, usize) { + let (out, w, h) = orientation.into_shown(rgb, width as u32, height as u32, 3); + (out, w as usize, h as usize) +} + +/// TRACES: FR-DEV-3 +/// Put what the model answered back onto the sensor's grid. +/// +/// The counterpart of [`upright`], and the two are always used as a pair: a +/// mask is only ever in the model's frame between those two calls. Returned +/// together rather than as two functions a caller composes, because calling +/// one and forgetting the other is silent — the mask lands a quarter turn off +/// the subject, which reads as a bad detection rather than as a bug. +/// +/// `dw`/`dh` are the *shown* dimensions, as [`upright`] returned them. +pub(crate) fn lay_down( + mask: &[f32], + bbox: (f32, f32, f32, f32), + dw: usize, + dh: usize, + orientation: Orientation, +) -> (Vec, (f32, f32, f32, f32)) { + let (out, sw, sh) = orientation.into_stored(mask, dw as u32, dh as u32, 1); + (out, lay_down_bbox(bbox, dw, dh, sw, sh, orientation)) +} + +/// [`lay_down`] for a box. +/// +/// Normalised on the way in and scaled on the way out, so the turn itself is +/// `Orientation::into_stored_rect` rather than a fourth copy of the corner +/// arithmetic. A box is the one place a permutation can be *nearly* right — +/// the corners land correctly and `x0 > x1` — so the shared map takes the +/// extremes and this only has to say what space it is in. +fn lay_down_bbox( + bbox: (f32, f32, f32, f32), + dw: usize, + dh: usize, + sw: u32, + sh: u32, + orientation: Orientation, +) -> (f32, f32, f32, f32) { + if dw == 0 || dh == 0 { + return bbox; + } + let (fw, fh) = (dw as f32, dh as f32); + let shown = dr_types::ShownRect { + x: bbox.0 / fw, + y: bbox.1 / fh, + width: (bbox.2 - bbox.0) / fw, + height: (bbox.3 - bbox.1) / fh, + }; + let stored = orientation.into_stored_rect(shown); + ( + stored.x * sw as f32, + stored.y * sh as f32, + (stored.x + stored.width) * sw as f32, + (stored.y + stored.height) * sh as f32, + ) +} + +/// One of eight transforms, as bits a signature can carry. +fn orientation_key(orientation: Orientation) -> u64 { + u64::from(orientation.quarter_turns) + | (u64::from(orientation.flip_h) << 2) + | (u64::from(orientation.flip_v) << 3) +} + /// Classes a recognised face may put a name on. /// /// Only these. A face inside a `tv` or a `laptop` is a photograph of someone on @@ -499,6 +615,116 @@ mod tests { assert_eq!(quantise(&[-1.0, 2.0]), vec![0, 255]); } + /// A non-square, wholly asymmetric grid: every pixel is its own index, so + /// any permutation that is not the intended one shows up as a mismatch + /// rather than being hidden by a symmetry. + fn ramp(w: usize, h: usize) -> Vec { + (0..w * h).flat_map(|i| [i as f32, 0.0, 0.0]).collect() + } + + fn red(rgb: &[f32]) -> Vec { + rgb.chunks_exact(3).map(|p| p[0]).collect() + } + + /// The property the whole fix rests on: what the model is shown and what + /// comes back are the same permutation, run in opposite directions. If + /// they ever disagree, every subject mask lands somewhere other than its + /// subject — and looks like a mask while doing it. + #[test] + fn standing_a_frame_up_and_laying_it_down_is_the_identity() { + const W: usize = 5; + const H: usize = 3; + let source = ramp(W, H); + + for tag in 1..=8u16 { + let o = Orientation::from_exif(tag); + let (up, uw, uh) = upright(&source, W, H, o); + let (ow, oh) = o.oriented_size(W as u32, H as u32); + assert_eq!( + (uw, uh), + (ow as usize, oh as usize), + "tag {tag}: the upright size is the oriented one" + ); + + let (back, _) = lay_down(&red(&up), (0.0, 0.0, 1.0, 1.0), uw, uh, o); + assert_eq!(back, red(&source), "tag {tag} did not come back"); + } + } + + /// The colour channels must travel together. Reading a pixel three times + /// with one index arithmetic mistake gives a plausible image with its + /// channels sheared, which the model would still detect *something* in. + #[test] + fn a_turn_carries_whole_pixels() { + let rgb: Vec = (0..2 * 3) + .flat_map(|i| [i as f32, i as f32 + 100.0, i as f32 + 200.0]) + .collect(); + let o = Orientation::from_exif(6); + let (up, uw, uh) = upright(&rgb, 2, 3, o); + + assert_eq!((uw, uh), (3, 2)); + for p in up.chunks_exact(3) { + assert_eq!(p[1], p[0] + 100.0, "green left its pixel"); + assert_eq!(p[2], p[0] + 200.0, "blue left its pixel"); + } + } + + /// A portrait frame is the case this exists for: the sensor is landscape, + /// the photograph is not, and the model has to be given the photograph. + #[test] + fn a_sideways_frame_reaches_the_model_upright() { + let o = Orientation::from_exif(6); + assert!(!o.is_normal()); + + let (_, uw, uh) = upright(&ramp(1600, 1066), 1600, 1066, o); + assert_eq!((uw, uh), (1066, 1600), "the model still got a landscape"); + } + + /// Where the model's box ends up, worked out by hand for the one turn a + /// portrait phone or a sideways body actually writes. + #[test] + fn a_box_comes_back_in_sensor_pixels() { + let o = Orientation::from_exif(6); + // Shown 4x6; the sensor it came from is 6x4. + let bbox = lay_down_bbox((0.0, 0.0, 2.0, 3.0), 4, 6, 6, 4, o); + assert_eq!(bbox, (0.0, 2.0, 3.0, 4.0)); + } + + /// Whatever a turn does to a box, it must still read low-to-high — a + /// permutation exchanges which corner is which, and the rest of the mask + /// pipeline measures `(x1 - x0)` without checking the sign. + #[test] + fn a_restored_box_keeps_its_corners_in_order() { + for tag in 1..=8u16 { + let o = Orientation::from_exif(tag); + let (sw, sh) = o.oriented_size(9, 6); + let (x0, y0, x1, y1) = lay_down_bbox((1.0, 2.0, 7.0, 5.0), 9, 6, sw, sh, o); + assert!(x0 <= x1, "tag {tag}: x runs backwards"); + assert!(y0 <= y1, "tag {tag}: y runs backwards"); + // A permutation moves a box; it does not resize one. + let (sw, sh) = o.oriented_size(9, 6); + assert!(x1 <= sw as f32 && y1 <= sh as f32, "tag {tag}: box escaped"); + assert!((((x1 - x0) * (y1 - y0)) - 18.0).abs() < 1e-3, "tag {tag}"); + } + } + + /// Turning the photograph changes what the model recognises, so the two + /// runs are different instance lists. If they could share a signature, a + /// layer built against one would be silently re-indexed into the other — + /// the same failure the tiling flag is in the signature to prevent. + #[test] + fn turning_the_photograph_changes_the_signature() { + let mut seen = std::collections::HashSet::new(); + for tag in 1..=8u16 { + let o = Orientation::from_exif(tag); + assert!( + seen.insert(orientation_key(o)), + "tag {tag} shares a key with an earlier one" + ); + } + assert_eq!(seen.len(), 8, "eight tags, but some collapsed"); + } + #[test] fn confidence_changes_the_signature() { // A different threshold is a different instance list, so the indices a diff --git a/ui/dr-ui/src/spots_ui.rs b/ui/dr-ui/src/spots_ui.rs new file mode 100644 index 0000000..61bdbd9 --- /dev/null +++ b/ui/dr-ui/src/spots_ui.rs @@ -0,0 +1,292 @@ +//! TRACES: FR-DEV-8 +//! Wiring the repair tool to the develop session. +//! +//! Translation only, exactly as [`crate::masks_ui`] is: circles out in one +//! direction, gestures in the other, and every decision in +//! [`crate::develop::DevelopSession`]. What this module does decide is when the +//! canvas is redrawn and when the panel is rebuilt, and those are the two +//! things that go wrong quietly here — see [`sync_handles`] for the one that +//! took a gesture out from under the finger. + +use std::cell::RefCell; +use std::rc::Rc; + +use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel}; + +use crate::develop::DevelopSession; +use crate::{AppWindow, SpotHandle}; + +/// TRACES: FR-DEV-8 | FR-UI-3 +/// Move the circles to where the repairs now are. +/// +/// # Why this is not `set_spot_handles(VecModel::from(…))` +/// +/// **A fresh model kills the gesture that is moving them.** The circles are a +/// repeater over this model, and handing Slint a new `ModelRc` makes it throw +/// the repeated items away and build new ones — the `TouchArea` holding the +/// pointer included. The drag then dies under the finger with the button still +/// down. `masks_ui::sync_handles` carries the same warning, and `develop.rs` +/// carries it about the parameter rows, where it broke slider drags: this is +/// the third place the same mistake is available, which is why it is written +/// down in all three. +/// +/// So the model is kept and its rows are rewritten in place. +pub(crate) fn sync_handles(window: &AppWindow, session: &DevelopSession) { + let next = session.spot_handles(); + let model = handle_model(); + + while model.row_count() > next.len() { + model.remove(model.row_count() - 1); + } + for (i, handle) in next.into_iter().enumerate() { + if i < model.row_count() { + // Only where it actually moved: an unchanged row written back is + // still a change notification. + if model.row_data(i).as_ref() != Some(&handle) { + model.set_row_data(i, handle); + } + } else { + model.push(handle); + } + } + + window.set_spot_handles(ModelRc::from(model)); + window.set_selected_spot(session.selected_spot_id().unwrap_or_default().into()); +} + +/// The circles' model, held for the life of the process — one shared identity, +/// for the reason [`sync_handles`] gives. +fn handle_model() -> Rc> { + thread_local! { + static HANDLES: Rc> = Rc::new(VecModel::default()); + } + HANDLES.with(Clone::clone) +} + +/// Take the repairs off the canvas between photographs. +/// +/// Emptied rather than replaced, keeping the model's identity for the reason +/// [`sync_handles`] gives. +pub(crate) fn reset(window: &AppWindow) { + let model = handle_model(); + while model.row_count() > 0 { + model.remove(model.row_count() - 1); + } + window.set_spot_handles(ModelRc::from(model)); + window.set_selected_spot(Default::default()); + window.set_spot_count(0); +} + +/// Install the repair tool's callbacks. +pub(crate) fn wire( + window: &AppWindow, + session: &Rc>>, + redraw: Rc, +) { + // --- placing ---------------------------------------------------------- + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_spot_placed(move |x, y| { + let Some(w) = weak.upgrade() else { return }; + let placed = session + .borrow_mut() + .as_mut() + .and_then(|s| s.place_spot(x, y)); + if placed.is_none() { + // The letterbox margin, or a full set. Neither is an error and + // neither should clear the selection: a near-miss that threw + // away what the column was describing would read as hostile. + return; + } + if let Some(s) = session.borrow().as_ref() { + sync_handles(&w, s); + sync_panel(&w, s); + } + sync_undo(&w, &session); + redraw(&w); + }); + } + + // --- dragging --------------------------------------------------------- + // + // The repair as it stood when the press landed, held for the gesture. A + // drag is applied to *that* rather than accumulated frame by frame: the + // clamps would otherwise compound, so a source dragged past its limit and + // back would not return to where it started, and the result would depend on + // how many pointer events the platform happened to deliver. + let dragging: Rc>> = Rc::new(RefCell::new(None)); + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + let dragging = dragging.clone(); + window.on_spot_handle_dragged(move |id, role, from_x, from_y, to_x, to_y| { + let Some(w) = weak.upgrade() else { return }; + let origin = dragging.borrow().clone(); + let started = session.borrow_mut().as_mut().and_then(|s| { + s.drag_spot( + id.as_str(), + role, + origin.as_ref(), + (from_x, from_y), + (to_x, to_y), + ) + }); + if started.is_none() { + return; + } + *dragging.borrow_mut() = started; + // Only the circles. Rebuilding the panel on every frame of a drag + // is work for no difference, and a full rebuild would take the + // gesture out from under the finger — see `sync_handles`. + if let Some(s) = session.borrow().as_ref() { + sync_handles(&w, s); + } + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + let dragging = dragging.clone(); + window.on_spot_handle_released(move || { + // Forgotten on release, so the next gesture measures from wherever + // this one left the repair. + if dragging.borrow_mut().take().is_none() { + // A press with no movement — a selection, not a drag. Recording + // a step would put an identical snapshot on the undo stack. + return; + } + if let Some(s) = session.borrow_mut().as_mut() { + s.commit_spot_drag(); + } + if let Some(w) = weak.upgrade() { + sync_undo(&w, &session); + } + }); + } + + // --- the column's controls -------------------------------------------- + // + // One closure shape, four callbacks: each hands the session a change to + // make to whichever repair is selected, and the session decides whether + // there is one. Reading the selection here instead would put the same + // `Option` check in four places and let them disagree. + macro_rules! control { + ($install:ident, $apply:expr) => {{ + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.$install(move |value| { + let Some(w) = weak.upgrade() else { return }; + let changed = session + .borrow_mut() + .as_mut() + .is_some_and(|s| s.set_selected_spot(|spot| $apply(spot, value))); + if !changed { + return; + } + if let Some(s) = session.borrow().as_ref() { + sync_handles(&w, s); + sync_panel(&w, s); + } + sync_undo(&w, &session); + redraw(&w); + }); + }}; + } + + control!(on_spot_radius_changed, |spot: &mut dr_pipeline::Spot, v| { + spot.set_radius(v) + }); + control!( + on_spot_feather_changed, + |spot: &mut dr_pipeline::Spot, v| { spot.set_feather(v) } + ); + control!( + on_spot_opacity_changed, + |spot: &mut dr_pipeline::Spot, v| { spot.set_opacity(v) } + ); + control!(on_spot_mode_picked, |spot: &mut dr_pipeline::Spot, i| { + spot.mode = if i == 1 { + dr_pipeline::SpotMode::Clone + } else { + dr_pipeline::SpotMode::Heal + } + }); + + // --- selecting and removing ------------------------------------------- + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_spot_selected(move |id| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.select_spot(Some(id.as_str())); + } + if let Some(s) = session.borrow().as_ref() { + sync_handles(&w, s); + sync_panel(&w, s); + } + // The canvas changes — the selected repair gains its source circle + // — but no pixel does, so this is a repaint of the overlay rather + // than of the photograph. `redraw` is the only hook there is, and + // it is cheap enough: the render caches on the invalidation key, + // which a selection does not move. + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let session = session.clone(); + let redraw = redraw.clone(); + window.on_spot_removed(move |id| { + let Some(w) = weak.upgrade() else { return }; + let removed = session + .borrow_mut() + .as_mut() + .is_some_and(|s| s.remove_spot(id.as_str())); + if !removed { + return; + } + if let Some(s) = session.borrow().as_ref() { + sync_handles(&w, s); + sync_panel(&w, s); + } + sync_undo(&w, &session); + redraw(&w); + }); + } +} + +/// TRACES: FR-DEV-8 +/// Push the selected repair's settings into the column. +/// +/// Separate from [`sync_handles`] because they answer different questions and +/// change at different times: the circles move on every frame of a drag and on +/// every pan, and these move when a repair is selected or a control is used. +pub(crate) fn sync_panel(window: &AppWindow, session: &DevelopSession) { + window.set_spot_count(session.spot_count() as i32); + if let Some(spot) = session.selected_spot() { + window.set_spot_radius(spot.radius); + window.set_spot_feather(spot.feather); + window.set_spot_opacity(spot.opacity); + window.set_spot_mode(match spot.mode { + dr_pipeline::SpotMode::Heal => 0, + dr_pipeline::SpotMode::Clone => 1, + }); + } +} + +/// Push whether undo and redo have anywhere to go. +/// +/// Every repair is a history step, so every one of them moves these — and a +/// disabled undo button after an edit that *is* undoable is the kind of small +/// lie that stops people trusting the button at all. +fn sync_undo(window: &AppWindow, session: &Rc>>) { + window.set_can_undo(session.borrow().as_ref().is_some_and(|s| s.can_undo())); + window.set_can_redo(session.borrow().as_ref().is_some_and(|s| s.can_redo())); +} diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index a831dc7..95966e5 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -673,6 +673,14 @@ export enum ViewMode { /// The region map is drawn, a click on the photograph selects, and the /// column is the mask stack and the selected layer's adjustments. local, + /// TRACES: FR-DEV-8 + /// The repairs are drawn on the photograph, a click places one, and the + /// column describes whichever is selected. + /// + /// A mode rather than a panel button for the reason the enum exists at + /// all: arming a click on the canvas is something only one tool may be + /// doing at a time, and two flags could both be true. + spots, } /// The strip: what the photographer is working on. @@ -741,15 +749,17 @@ export component ModeStrip inherits Rectangle { // than as an underline, so at a glance the strip reads as two runs // and it is never ambiguous which half a lit entry belongs to. // - // **Where a brush goes.** Painting a mask is a third mode of exactly - // this shape — it arms a canvas gesture and scopes the column — so it - // joins this list and the `ViewMode` enum, and needs nothing else here. - // `MaskSource::Brush` and the stroke calls on `MaskLayer` land in the - // core separately; what is missing on this side is only the canvas - // interaction, which is the gradient handles' neighbour. + // **Where a canvas tool goes.** A mode that arms a gesture on the + // photograph and scopes the column joins this list and the `ViewMode` + // enum, and needs nothing else here. *Repair* is the first to arrive + // that way (FR-DEV-8); painting a mask is the same shape and still to + // come — `MaskSource::Brush` and the stroke calls on `MaskLayer` + // already exist in the core, and what is missing on this side is only + // the canvas interaction the repairs now have a pattern for. for entry in [ { label: "Crop", value: ViewMode.crop }, { label: "Local", value: ViewMode.local }, + { label: "Repair", value: ViewMode.spots }, ]: mode-chip := TouchArea { width: mode-name.preferred-width + 2 * Theme.gap-sm; height: Theme.touch-target; diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index edcf5ef..776f8fe 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -1,6 +1,8 @@ import { Theme } from "theme.slint"; import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint"; import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint"; +import { SpotHandle, SpotPanel, SpotRole } from "spots.slint"; +import { HistoryPanel, HistoryRow } from "history.slint"; import { LaunchScreen } from "launch.slint"; import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint"; import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint"; @@ -11,7 +13,7 @@ import { SettingsPage } from "settings.slint"; import { ImportPage } from "import.slint"; export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView } -export { ViewMode, GradientHandle, HandleRole } +export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole } // Status strip — surfaces the GPU backend and adapter, which matters during // v0.1 because assumption A1 is exactly "does this compositing path work on @@ -327,6 +329,8 @@ export component AppWindow inherits Window { /// comparison is written once rather than at each of a dozen call sites. property cropping: root.view-mode == ViewMode.crop; property local-mode: root.view-mode == ViewMode.local; + /// TRACES: FR-DEV-8 + property repairing: root.view-mode == ViewMode.spots; /// The crop rect in fractions of the frame, mirrored from Rust so the /// overlay draws exactly what the pipeline holds. @@ -372,6 +376,15 @@ export component AppWindow inherits Window { in property can-redo: false; callback undo(); callback redo(); + + /// Every step, newest first. Rust owns the order and stamps each row with + /// its own position in the stack, so nothing here does arithmetic to turn + /// a row back into a step. + in property <[HistoryRow]> history-rows; + /// What undo would take back, resolved. Empty when there is nowhere to go. + in property undo-label: ""; + /// A row's own `index`. + callback history-picked(int); /// Scroll-to-zoom: factor, and the anchor in fractions of the visible area. callback zoom-at(float, float, float); callback pan-by(float, float); @@ -1019,6 +1032,51 @@ export component AppWindow inherits Window { /// rather than as one per frame of the gesture. callback gradient-handle-released(); + // --- repairs (FR-DEV-8) ------------------------------------------------- + + /// Every repair on the photograph, as circles in fractions of the shown + /// image. Rust maps them through the framing, so they follow a crop, a + /// zoom, a pan and a rotation without anything here knowing about any of + /// those. + in property <[SpotHandle]> spot-handles; + /// A click on the photograph in repair mode, in fractions of the shown + /// image: cover what is here. + callback spot-placed(float, float); + /// A repair's circle dragged: which repair, which half of it, where the + /// press landed and where the pointer is now. + /// + /// The press is carried rather than a running delta for the reason the + /// gradient handles carry it — the geometry is re-derived from where it + /// stood when the gesture began, so a drag cannot accumulate rounding + /// error along its length. + callback spot-handle-dragged(string, SpotRole, float, float, float, float); + /// A drag finished, so it can be one history step rather than one a frame. + callback spot-handle-released(); + /// A repair chosen, so the column describes it. + callback spot-selected(string); + /// Take the selected repair off the photograph. + callback spot-removed(string); + /// Which repair the column is describing, or "" for none. + in-out property selected-spot: ""; + /// The selected repair's settings, and how many repairs there are. + /// + /// Pushed as scalars rather than read off `spot-handles`, because the + /// circles carry what is *drawn* — a radius already mapped through the + /// framing into fractions of the shown image — and the panel edits what is + /// *stored*. Deriving one from the other would mean a slider that moved + /// differently at different zoom levels. + in property spot-count: 0; + in property spot-radius: 0.012; + in property spot-feather: 0.35; + in property spot-opacity: 1.0; + /// 0 heal, 1 clone. + in property spot-mode: 0; + + callback spot-radius-changed(float); + callback spot-feather-changed(float); + callback spot-opacity-changed(float); + callback spot-mode-picked(int); + callback segment-image(bool); /// A click on the photograph, in fractions of the shown image, plus /// whether it should extend the selection rather than replace it. @@ -1765,6 +1823,30 @@ in property panel-visible: true; } } + // TRACES: FR-DEV-8 + // Placing a repair, beside the region picker and for the + // same reasons: a click and a pan want different handlers, + // and this one has to reach the click first. + // + // Below the circles declared further down, so a press that + // lands on an existing repair takes hold of it instead of + // making another one on top. + if root.repairing && root.total > 0 && root.load-error == "": TouchArea { + x: parent.shown-x; + y: parent.shown-y; + width: parent.shown-w; + height: parent.shown-h; + mouse-cursor: MouseCursor.crosshair; + enabled: !root.cropping; + + clicked => { + root.spot-placed( + self.mouse-x / max(self.width, 1px), + self.mouse-y / max(self.height, 1px), + ); + } + } + if root.total > 0 && root.load-error == "": TouchArea { x: 0; y: 0; width: 100%; @@ -2104,6 +2186,123 @@ in property panel-visible: true; } } + // --- repairs (FR-DEV-8) ------------------------------------- + // + // **Drawn at the size they are.** A gradient's handles are + // dots because a gradient has no edge to show; a repair is + // a disc, and whether that disc covers a speck of dust is + // the entire judgement a photographer is making. A + // fixed-size dot standing in for it would say nothing about + // the edit. + // + // The source circle appears for the **selected** repair + // only. Every repair showing both would double the circles + // on a dusty sky and leave no way to tell which source + // belongs to which disc; Rust decides, and simply does not + // send the others. + // + // Positioned against `shown-*` like everything else that + // has to land on the picture, and mapped through the + // framing on the Rust side, so a repair follows a crop, a + // zoom and a rotation rather than sitting where it used to + // be. + for spot in root.spot-handles: Rectangle { + property disc: max(2 * spot.radius * parent.shown-h, 8px); + property is-source: spot.role == SpotRole.source; + + x: parent.shown-x + spot.x * parent.shown-w - self.width / 2; + y: parent.shown-y + spot.y * parent.shown-h - self.height / 2; + // Never smaller than a finger, whatever the repair's + // own size: a spot on a dust speck is a few pixels + // across at fit-to-window, and a target that small + // cannot be picked up again on a phone (FR-UI-3). The + // *drawn* circle keeps its true size; only the reach + // is padded. + width: max(self.disc, Theme.touch-target); + height: self.width; + + Rectangle { + width: parent.disc; + height: self.width; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + border-radius: self.width / 2; + border-width: spot.selected ? 2px : 1px; + // White on a dark ring, so a repair is visible on a + // blown sky and on a black frame alike — the same + // treatment the gradient handles use, and no theme + // token, because this is drawn over the photograph + // rather than over the interface. + border-color: spot.enabled ? #ffffffcc : #ffffff55; + background: parent.is-source + ? #ffffff14 + : (spot.selected ? #ffffff22 : transparent); + } + + // A second, darker ring just outside the first. One + // white circle vanishes against a white sky however it + // is drawn; two rings of opposite tone cannot both + // vanish against anything. + Rectangle { + width: parent.disc + 2px; + height: self.width; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + border-radius: self.width / 2; + border-width: 1px; + border-color: #00000066; + } + + grab := TouchArea { + width: 100%; + height: 100%; + mouse-cursor: MouseCursor.move; + + // Where the press landed, in the fractions the + // callback reports in — captured on the way down so + // the gesture is measured from one origin. + property from-x; + property from-y; + + function fraction-x(local-x: length) -> float { + return (parent.x + local-x - canvas-area.shown-x) + / max(canvas-area.shown-w, 1px); + } + function fraction-y(local-y: length) -> float { + return (parent.y + local-y - canvas-area.shown-y) + / max(canvas-area.shown-h, 1px); + } + + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + self.from-x = self.fraction-x(self.pressed-x); + self.from-y = self.fraction-y(self.pressed-y); + // Touching a repair is choosing it. On the + // way *down*, so the column has re-scoped + // by the time the drag begins and the + // controls describe what is moving. + root.spot-selected(spot.id); + } + if (ev.kind == PointerEventKind.up) { + root.spot-handle-released(); + } + } + + moved => { + if (self.pressed) { + root.spot-handle-dragged( + spot.id, + spot.role, + self.from-x, + self.from-y, + self.fraction-x(self.mouse-x), + self.fraction-y(self.mouse-y), + ); + } + } + } + } + // Arrow keys and space step through the folder. // // Focused on show rather than waiting for a click, exactly @@ -2141,6 +2340,18 @@ in property panel-visible: true; } return accept; } + // TRACES: FR-DEV-8 + // Delete removes the selected repair, and only in + // repair mode: the same key in the grid judges + // photographs, and a key that means two things + // depending on a mode the user has forgotten they + // are in is how work disappears. + if (root.repairing && root.selected-spot != "" + && (event.text == Key.Delete + || event.text == Key.Backspace)) { + root.spot-removed(root.selected-spot); + return accept; + } if (event.text == Key.RightArrow || event.text == " ") { root.next-image(); return accept; @@ -2183,7 +2394,9 @@ in property panel-visible: true; // Names the mode being left rather than saying // "Done", which was unambiguous while there was // one mode and would not be with two. - text: root.cropping ? "Done Cropping" : "Done Masking"; + text: root.cropping + ? "Done Cropping" + : (root.repairing ? "Done Repairing" : "Done Masking"); active: true; clicked => { root.mode-picked(ViewMode.photo); } } @@ -2308,13 +2521,13 @@ in property panel-visible: true; // screen. `masks.slint` carries the same note for // the same reason. column := VerticalLayout { - if !root.local-mode: InfoPanel { + if !root.local-mode && !root.repairing: InfoPanel { camera: root.camera; exposure: root.exposure; dimensions: root.dimensions; } - if !root.local-mode: Rectangle { + if !root.local-mode && !root.repairing: Rectangle { height: 1px; background: Theme.rule; } @@ -2344,7 +2557,7 @@ in property panel-visible: true; // made rather than how it is applied: the frame is decided // by eye first and the pipeline runs it last (see // `dr_pipeline::framing` on the coordinate order). - if !root.local-mode: GeometryPanel { + if !root.local-mode && !root.repairing: GeometryPanel { enabled: root.adjust-enabled; angle: root.straighten; max-straighten: root.max-straighten; @@ -2360,7 +2573,7 @@ in property panel-visible: true; reset => { root.framing-reset(); } } - if !root.local-mode: Rectangle { + if !root.local-mode && !root.repairing: Rectangle { height: 1px; background: Theme.rule; } @@ -2370,7 +2583,7 @@ in property panel-visible: true; // burying it under thirty sliders would put the one // control that operates on all of them below all of // them. - if !root.local-mode: TransferPanel { + if !root.local-mode && !root.repairing: TransferPanel { enabled: root.adjust-enabled; armed: root.settings-armed; summary: root.settings-summary; @@ -2393,6 +2606,32 @@ in property panel-visible: true; // peers that silently re-points another panel, it is // what that mode's column *is*. That also takes one // panel out of a scrolling column that had six. + // TRACES: FR-DEV-8 + // What the column *is* in repair mode, on the same + // terms as the mask panel: not a panel among peers + // that quietly re-points the sliders below it, but + // the whole of what this mode has to say. + if root.repairing: SpotPanel { + enabled: root.adjust-enabled; + has-selection: root.selected-spot != ""; + count: root.spot-count; + radius: root.spot-radius; + feather: root.spot-feather; + spot-opacity: root.spot-opacity; + mode: root.spot-mode; + + radius-changed(v) => { root.spot-radius-changed(v); } + feather-changed(v) => { root.spot-feather-changed(v); } + opacity-changed(v) => { root.spot-opacity-changed(v); } + mode-picked(i) => { root.spot-mode-picked(i); } + removed => { root.spot-removed(root.selected-spot); } + } + + if root.repairing: Rectangle { + height: 1px; + background: Theme.rule; + } + if root.local-mode: MaskPanel { enabled: root.adjust-enabled; masks: root.mask-rows; @@ -2469,6 +2708,35 @@ in property panel-visible: true; film-picked(i) => { root.film-picked(i); } film-print-toggled(on) => { root.film-print-toggled(on); } } + + Rectangle { + height: 1px; + background: Theme.rule; + } + + // Last in the column, and the length is why. The + // stack runs sixty-four deep, so anywhere else it + // would push the controls it is a record of below + // the fold. Its own rows run newest-first, which + // puts the steps worth reaching immediately under + // the heading rather than at the far end of them. + // + // Present in local mode as well. The mask work + // *is* history — adding a layer, moving a + // gradient and feathering an edge are all steps — + // and a list that emptied when the photographer + // entered the mode that produces the most steps + // would be a list they stopped trusting. + HistoryPanel { + rows: root.history-rows; + enabled: root.adjust-enabled; + can-undo: root.can-undo; + can-redo: root.can-redo; + undo-label: root.undo-label; + undo => { root.undo(); } + redo => { root.redo(); } + picked(i) => { root.history-picked(i); } + } } } } diff --git a/ui/dr-ui/ui/history.slint b/ui/dr-ui/ui/history.slint new file mode 100644 index 0000000..af404cf --- /dev/null +++ b/ui/dr-ui/ui/history.slint @@ -0,0 +1,192 @@ +// TRACES: FR-DEV-5 | FR-DEV-7 +// The steps this photograph has been through, and the way back to any of them. +// +// **Nothing here names an operation, and nothing here names a step.** The rows +// arrive already resolved — Rust turns each step's localisation key into a +// display string against the UI's catalogue — so an operation added to the +// pipeline as a YAML declaration appears in this list under a sensible name +// with no change to this file, on the same terms it appears in the panel +// (FR-DEV-3c). +// +// **Why a list and not just the two buttons.** Undo answers "take back the +// last thing", which is the question a photographer asks about the mistake +// they have just noticed. It is the wrong instrument for the one they notice +// six adjustments later: eight presses, each changing the picture, with no way +// to see how far back the mistake was without passing through it. A step is a +// whole state in `dr-pipeline`, so arriving at one from six away costs what +// arriving from one does — which is what makes a row worth making clickable +// rather than decorative. + +import { Theme } from "theme.slint"; +import { PanelHeading, Caption, Label, Value, Button } from "widgets.slint"; + +// One step, flattened for Slint's model system. +export struct HistoryRow { + // Routing back to the core: this step's position in the stack. **Not** its + // position in this list, which runs the other way — see `history-rows` in + // `develop.rs` for why the two are deliberately different numbers. + index: int, + + // Resolved in Rust against the UI's catalogue; the core deals in keys. + label: string, + + // The state the photograph is in right now. Exactly one row carries it. + current: bool, + + // A step the photographer has stepped back *out* of, still reachable by + // redo. Listed rather than hidden — redo would otherwise arrive somewhere + // this panel never mentioned — but drawn as the branch it is. + undone: bool, +} + +component StepRow inherits Rectangle { + in property data; + in property enabled: true; + callback picked(); + + height: Theme.row-height; + background: root.data.current + ? Theme.selected + : (touch.has-hover ? Theme.hover : transparent); + + touch := TouchArea { + width: 100%; + height: max(parent.height, Theme.touch-target); + y: (parent.height - self.height) / 2; + enabled: root.enabled; + mouse-cursor: root.enabled ? MouseCursor.pointer : MouseCursor.default; + clicked => { root.picked(); } + } + + HorizontalLayout { + // Small, because the panel around this already pads. The row's + // background is the highlight for the current step, so it wants to be + // a band the width of the column rather than a chip inset from it. + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + // The mark for where the photograph stands. A filled bar against the + // leading edge rather than a tick beside the name: the eye finds one + // edge down a column of forty rows, and a glyph in the text column + // would have to be read. + Rectangle { + width: 2px; + height: parent.height; + background: root.data.current ? Theme.active : transparent; + } + + Label { + text: root.data.label; + emphasised: root.data.current || touch.has-hover; + horizontal-stretch: 1; + overflow: elide; + // Dimmed rather than removed: this step is a future the + // photographer stepped out of, and it is still where redo goes. + opacity: root.data.undone ? 0.45 : 1.0; + } + } +} + +export component HistoryPanel inherits Rectangle { + in property <[HistoryRow]> rows; + in property enabled: true; + in property can-undo: false; + in property can-redo: false; + /// What undo would take back, already resolved. Empty when there is + /// nowhere to go. + in property undo-label: ""; + + callback undo(); + callback redo(); + /// A row's own `index`, not its position in `rows`. + callback picked(int); + + background: Theme.surface; + + // **Flat, deliberately** — `MaskPanel` carries the long version of this + // and it applies here unchanged: a nested layout under-reports its height, + // so what follows it gets drawn on top of what came before, which is + // invisible in the source and obvious the moment anyone opens the panel. + // Every element is a direct child of the one layout that measures them, + // and the ones that come and go carry their own condition rather than + // being grouped inside a wrapper. + // + // No explicit height either, for the same reason `AdjustPanel` and + // `MaskPanel` declare none: the row count changes as the photographer + // works, and a height pinned to a layout's preferred size is one more + // thing that has to keep up with a repeater. + VerticalLayout { + padding: Theme.gap; + spacing: Theme.gap-sm; + alignment: start; + + // A heading, not a collapsible. `GeometryPanel` carries the argument + // and it holds here: the list is last in the column, so what a lid + // would save is scrolling past nothing. + HorizontalLayout { + PanelHeading { text: "HISTORY"; } + Rectangle { horizontal-stretch: 1; } + if root.enabled && root.rows.length > 1: Value { + text: root.rows.length - 1 + (root.rows.length == 2 ? " step" : " steps"); + } + } + + if !root.enabled: Caption { text: "No image"; } + + // The pair, here as well as in the status strip. The strip's copy is + // the one that survives the column being put away; this one sits + // against the list that says what it will do, which is the pairing + // that makes either of them legible. + if root.enabled: HorizontalLayout { + spacing: Theme.gap-sm; + + Button { + text: "Undo"; + enabled: root.can-undo; + horizontal-stretch: 1; + clicked => { root.undo(); } + } + + Button { + text: "Redo"; + enabled: root.can-redo; + horizontal-stretch: 1; + clicked => { root.redo(); } + } + } + + // What the left-hand button would take back, spelled out. + // + // On a caption rather than on the button, because `Button` sizes to + // its label and does not elide: "Undo Highlights & Shadows" is most of + // a 280px column on its own, and two of those would lever the column + // open — `MaskPanel` has the same note about an unwrapped sentence. + // + // Undo only. Redo's destination is the row directly above the mark in + // the list below, where it can be seen; the step undo takes back is + // the one the photographer is standing on and stopped tracking after a + // run of small adjustments, which is the moment they are least willing + // to press a button to find out. + if root.enabled && root.can-undo: Caption { + text: "Undo: " + root.undo-label; + overflow: elide; + } + + if root.enabled && root.rows.length > 0: Rectangle { + height: 1px; + background: Theme.rule; + } + + // Newest first. The list is consulted to take back something just + // done rather than browsed from the beginning — the order + // `dr_catalog::trash` settled on for the same question — and it keeps + // the end being worked at against the heading rather than sixty-four + // rows below it. + for row in root.rows: StepRow { + data: row; + enabled: root.enabled; + picked => { root.picked(row.index); } + } + } +} diff --git a/ui/dr-ui/ui/spots.slint b/ui/dr-ui/ui/spots.slint new file mode 100644 index 0000000..1e58738 --- /dev/null +++ b/ui/dr-ui/ui/spots.slint @@ -0,0 +1,170 @@ +import { Theme } from "theme.slint"; +import { Button, PanelHeading, Caption, Value } from "widgets.slint"; +import { Segmented, SliderRow } from "controls.slint"; + +// TRACES: FR-DEV-8 +// Spot removal on the canvas: what is drawn over the photograph, and what a +// finger can take hold of. +// +// A repair is two circles and the line between them — the disc being covered, +// and the patch it is copied from. Both are drawn at the size they actually +// are, not as abstract handles, because the size *is* the edit: a photographer +// judging whether a disc covers a mark is judging a circle against a speck, and +// a fixed-size dot standing in for it would tell them nothing. +// +// Positions arrive already mapped through the framing, as fractions of the +// shown image, exactly as `GradientHandle` does — see `spots_ui.rs`. Nothing +// here knows what a crop is. + +/// Which half of a repair a handle is. +export enum SpotRole { + /// The disc over the mark. Dragging it moves the whole repair, source and + /// all, which is what a photographer means by nudging a spot. + destination, + /// Where the patch is read from. Dragging it moves the source alone. + source, +} + +/// One circle of one repair, in fractions of the shown image. +export struct SpotHandle { + id: string, + role: SpotRole, + /// Centre, in fractions of the shown image's width and height. + x: float, + y: float, + /// The disc's radius as a fraction of the shown image's **height**. + /// + /// One axis rather than two, because a repair is a circle: normalising x + /// by the width and y by the height would draw it as an ellipse on every + /// frame that is not square. The caller resolves the aspect on the way in. + radius: float, + /// Whether this repair is the one the panel is describing. + selected: bool, + /// Whether the repair draws at all — a spot switched off is still shown, + /// faintly, because it is still an edit somebody made. + enabled: bool, +} + +/// TRACES: FR-DEV-8 +/// The column while the repair tool is up. +/// +/// One repair at a time, because that is how repairs are made: a photographer +/// covers a mark, looks at it, and moves on. There is no stack here for the +/// same reason there is one for masks — a mask is a thing you come back to and +/// re-shape, and a repair is either right or deleted. +/// +/// **The controls describe the selected repair, and when none is selected they +/// describe nothing.** An earlier shape had them set the defaults for the +/// *next* repair, which reads identically on screen and does something +/// completely different: a photographer dragging Size with nothing selected +/// would see no change on the photograph and conclude the slider was broken. +export component SpotPanel inherits Rectangle { + in property enabled: true; + /// Whether a repair is selected — everything below is about it. + in property has-selection: false; + /// How many repairs are on this photograph. + in property count: 0; + + /// In frame units, which is what the model stores. The slider shows them + /// as a percentage of the frame's height, since "0.012" means nothing to + /// anybody and "1.2%" at least says how much of the picture is covered. + in property radius: 0.012; + in property feather: 0.35; + // `spot-opacity` and not `opacity`, which every element already has as a + // built-in: overriding it is refused by the compiler, and had it been + // allowed it would have faded the panel instead of describing the repair. + in property spot-opacity: 1.0; + /// 0 heal, 1 clone — the order the chips are listed in below. + in property mode: 0; + + callback radius-changed(float); + callback feather-changed(float); + callback opacity-changed(float); + callback mode-picked(int); + callback removed(); + + background: Theme.surface; + + // Flat rather than nested, for the reason `MaskPanel` gives: a nested + // conditional layout under-reported its height and drew rows on top of one + // another. + VerticalLayout { + padding: Theme.gap; + spacing: Theme.gap-sm; + alignment: start; + + HorizontalLayout { + PanelHeading { text: "REPAIR"; } + Rectangle { horizontal-stretch: 1; } + if root.count > 0: Value { + text: root.count + (root.count == 1 ? " spot" : " spots"); + } + } + + if !root.enabled: Caption { text: "No image"; } + + // The instruction, which is the whole interface until the first click. + // A tool whose canvas gesture is its only way in has to say so, or it + // is a mode that appears to do nothing. + if root.enabled && !root.has-selection: Caption { + text: root.count == 0 + ? "Click a mark on the photograph to cover it." + : "Click a mark to cover it, or a circle to adjust it."; + wrap: word-wrap; + } + + if root.enabled && root.has-selection: SliderRow { + label: "Size"; + hint: "% of frame"; + value: root.radius * 100; + default-value: 1.2; + minimum: 0.1; + maximum: 25; + precision: 1; + changed(v) => { root.radius-changed(v / 100); } + reset => { root.radius-changed(0.012); } + } + + if root.enabled && root.has-selection: SliderRow { + label: "Feather"; + hint: "% of size"; + value: root.feather * 100; + default-value: 35; + minimum: 0; + maximum: 100; + precision: 0; + changed(v) => { root.feather-changed(v / 100); } + reset => { root.feather-changed(0.35); } + } + + if root.enabled && root.has-selection: SliderRow { + label: "Opacity"; + value: root.spot-opacity * 100; + default-value: 100; + minimum: 0; + maximum: 100; + precision: 0; + changed(v) => { root.opacity-changed(v / 100); } + reset => { root.opacity-changed(1.0); } + } + + // Heal first, because it is the default and the right answer for dust. + // Clone is the escape for a repair that straddles an edge, where + // interpolating the boundary smears the edge across the disc. + if root.enabled && root.has-selection: Segmented { + label: "Blend"; + options: ["Heal", "Clone"]; + selected: root.mode; + picked(i) => { root.mode-picked(i); } + } + + if root.enabled && root.has-selection: Rectangle { + height: Theme.gap-sm; + } + + if root.enabled && root.has-selection: Button { + text: "Delete Repair"; + clicked => { root.removed(); } + } + } +}