Merge branch 'master' into worktree-faces-scrfd-mbf
Build and test / Desktop (Linux) (push) Failing after 25s
Build and test / Layer separation (push) Successful in 22s
Traceability / Requirement traces (push) Successful in 58s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Failing after 33m26s

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/develop.rs
#	ui/dr-ui/src/segmentation.rs
This commit is contained in:
2026-08-27 11:57:38 +02:00
51 changed files with 7732 additions and 586 deletions
+7 -20
View File
@@ -39,27 +39,14 @@ impl Preview {
/// not: a quarter turn is a permutation, so it costs the same either way, /// 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. /// 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 /// The turn itself is [`dr_types::Orientation::into_shown`], which every
/// quarter turn on a non-square image is a cycle-following permutation /// other consumer of an orientation in this codebase also goes through.
/// that is both slower per pixel and far harder to get right, for a saving /// That is deliberate: a hand-written permutation per caller is how two of
/// that a thumbnail-sized buffer does not need. /// 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) { pub fn apply_orientation(&mut self, orientation: dr_types::Orientation) {
if orientation.is_normal() || self.width == 0 || self.height == 0 { let (rgba, dw, dh) = orientation.into_shown(&self.rgba, self.width, self.height, 4);
return; self.rgba = rgba;
}
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;
self.width = dw; self.width = dw;
self.height = dh; self.height = dh;
} }
-156
View File
@@ -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:?}");
}
}
}
+294
View File
@@ -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)
);
}
}
+93 -6
View File
@@ -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. /// The net density the granularity figure is quoted at.
const RMS_REFERENCE_NET_DENSITY: f32 = 1.0; 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 /// **Grain is a function of enlargement, and this is the half of it the
/// film format, so simulating one means choosing what the frame *would have /// photograph cannot supply.** A crystal is a fixed size in micrometres, so
/// been*; 35 mm is the choice that makes the numbers mean what a photographer /// how grainy a picture looks depends entirely on how much the frame was
/// expects, since published granularity and every intuition about how grainy a /// magnified to make it — and that is film size against output size.
/// stock looks come from 35 mm. ///
/// 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; pub const FRAME_WIDTH_UM: f32 = 36_000.0;
/// TRACES: FR-DEV-3f /// 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<f32> = 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] #[test]
fn a_pixel_never_holds_less_than_one_grain() { fn a_pixel_never_holds_less_than_one_grain() {
// Past this the model describes a pixel smaller than a crystal, where // Past this the model describes a pixel smaller than a crystal, where
+3 -1
View File
@@ -38,6 +38,7 @@
//! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019). //! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019).
pub mod bake; pub mod bake;
pub mod boolean_grain;
mod built_in; mod built_in;
pub mod grain; pub mod grain;
pub mod profile; pub mod profile;
@@ -45,7 +46,8 @@ pub mod spectrum;
pub mod tables; pub mod tables;
pub use bake::{bake, Baked, Recipe}; 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}; pub use profile::{Kind, Profile, Stage, Support};
use built_in::BUILT_IN; use built_in::BUILT_IN;
+50 -6
View File
@@ -30,6 +30,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, Subj
use dr_pipeline::descriptor::ParamId; use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology}; use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
use dr_pipeline::operation::compose_full; use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, EditGraph, Framing}; use dr_pipeline::{ops, EditGraph, Framing};
use dr_segment::{SemanticModel, SemanticOptions, Shaped}; use dr_segment::{SemanticModel, SemanticOptions, Shaped};
use dr_types::ColourSpace; use dr_types::ColourSpace;
@@ -55,7 +56,13 @@ fn main() {
// ---- the photograph --------------------------------------------------- // ---- the photograph ---------------------------------------------------
let bytes = std::fs::read(&path).expect("read file"); let bytes = std::fs::read(&path).expect("read file");
let raw = dr_decode::decode(&bytes).expect("decode"); 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!("source {} × {}", raw.crop.width, raw.crop.height);
println!("turns {}", orientation.quarter_turns);
let source = Demosaicer::new(&ctx) let source = Demosaicer::new(&ctx)
.expect("demosaicer") .expect("demosaicer")
.run(&raw) .run(&raw)
@@ -93,10 +100,16 @@ fn main() {
.collect(); .collect();
// ---- find the subject ------------------------------------------------- // ---- 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 t = std::time::Instant::now();
let mut model = SemanticModel::embedded().expect("model"); let mut model = SemanticModel::embedded().expect("model");
let instances = model let instances = model
.detect(&rgb, pw as usize, ph as usize, &SemanticOptions::default()) .detect(&upright, uw, uh, &SemanticOptions::default())
.expect("detect"); .expect("detect");
println!( println!(
"detect {} found in {:.0} ms", "detect {} found in {:.0} ms",
@@ -118,10 +131,11 @@ fn main() {
subject.class_name, subject.score subject.class_name, subject.score
); );
// Quantised exactly as the develop session does, so this example exercises // Laid back down, then quantised exactly as the develop session does, so
// the shipping path rather than a shortcut around it. // this example exercises the shipping path rather than a shortcut around
let alpha: Vec<u8> = subject // it. The mask has to end up in *source* space: the composed shader
.mask // samples the mask array after the framing map.
let alpha: Vec<u8> = lay_down(&subject.mask, uw, uh, orientation)
.iter() .iter()
.map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8) .map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
.collect(); .collect();
@@ -315,12 +329,42 @@ fn render_stack(
.render(stack, None, Some(&subjects), pw, ph) .render(stack, None, Some(&subjects), pw, ph)
.expect("rasterise masks"); .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 adjust
.render_masked(source, &shader, ow, oh, Some(array)) .render_masked(source, &shader, ow, oh, Some(array))
.expect("render"); .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<f32>, 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<f32> {
o.into_stored(mask, dw as u32, dh as u32, 1).0
}
fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) { fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) {
let s = (longest as f32 / w.max(h) as f32).min(1.0); let s = (longest as f32 / w.max(h) as f32).min(1.0);
( (
+2 -4
View File
@@ -1367,8 +1367,7 @@ mod tests {
// instead of the kernel under test. The chain still // instead of the kernel under test. The chain still
// carries the resolve pass that finishes the render, and // carries the resolve pass that finishes the render, and
// that generated source is worth compiling too. // that generated source is worth compiling too.
let scale = g.render_scale(img.size(), (16, 16)); let detail = g.compose_detail(img.size(), (16, 16));
let detail = g.compose_detail(scale);
let key = g.invalidation().through(dr_pipeline::Affects::Colour); let key = g.invalidation().through(dr_pipeline::Affects::Colour);
pass.render_detailed(&img, &shader, 16, 16, None, &detail, key) pass.render_detailed(&img, &shader, 16, 16, None, &detail, key)
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
@@ -1774,8 +1773,7 @@ mod tests {
// find those operations in neither stage and fail for a reason that is // find those operations in neither stage and fail for a reason that is
// not a defect. Shadows the smaller size deliberately. // not a defect. Shadows the smaller size deliberately.
let (w, h) = g.output_size(512, 512); let (w, h) = g.output_size(512, 512);
let scale = g.render_scale((512, 512), (w, h)); let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
let detail = g.compose_detail_for(scale, dr_types::ColourSpace::Srgb);
assert!( assert!(
!detail.is_empty(), !detail.is_empty(),
"the detail half composed nothing, so nothing of it was compiled" "the detail half composed nothing, so nothing of it was compiled"
+63
View File
@@ -149,6 +149,8 @@ pub(crate) struct DetailRunner {
/// Compiled pipelines by pass structure hash. /// Compiled pipelines by pass structure hash.
cache: HashMap<u64, wgpu::ComputePipeline>, cache: HashMap<u64, wgpu::ComputePipeline>,
pool: Intermediates, pool: Intermediates,
/// See [`placeholder_instances`].
no_instances: wgpu::Buffer,
} }
struct Layout { struct Layout {
@@ -156,6 +158,22 @@ struct Layout {
pipeline: wgpu::PipelineLayout, 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 { impl DetailRunner {
pub(crate) fn new(ctx: &GpuContext) -> Self { pub(crate) fn new(ctx: &GpuContext) -> Self {
Self { Self {
@@ -164,6 +182,7 @@ impl DetailRunner {
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"), to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
cache: HashMap::new(), cache: HashMap::new(),
pool: Intermediates::new(), pool: Intermediates::new(),
no_instances: placeholder_instances(ctx),
} }
} }
@@ -230,6 +249,24 @@ impl DetailRunner {
usage: wgpu::BufferUsages::UNIFORM, 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 let bind_group = self
.ctx .ctx
.device .device
@@ -249,6 +286,10 @@ impl DetailRunner {
binding: 2, binding: 2,
resource: wgpu::BindingResource::TextureView(destination), 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 { impl Layout {
fn new(ctx: &GpuContext, format: wgpu::TextureFormat, label: &str) -> Self { fn new(ctx: &GpuContext, format: wgpu::TextureFormat, label: &str) -> Self {
let bind_group = ctx let bind_group = ctx
@@ -376,6 +431,14 @@ impl Layout {
}, },
count: None, 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),
], ],
}); });
+2 -1
View File
@@ -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<u8> { fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); 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); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key) pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render"); .expect("render");
+169
View File
@@ -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<GpuContext> {
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<u8> = (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<f32>;
struct Params { detail_base: vec4<f32> }
@group(0) @binding(1) var<uniform> u: Params;
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
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<i32>(gid.xy), vec4<f32>(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<u8> {
// 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"
);
}
+4 -2
View File
@@ -101,7 +101,8 @@ fn render(
let _ = ctx; let _ = ctx;
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); 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); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key) pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render"); .expect("render");
@@ -402,7 +403,8 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE)); 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()); assert!(detail.is_empty());
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0) pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
+8 -1
View File
@@ -14,6 +14,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
use dr_pipeline::descriptor::ParamId; use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack}; use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full; use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, EditGraph, Framing}; use dr_pipeline::{ops, EditGraph, Framing};
use dr_types::ColourSpace; use dr_types::ColourSpace;
@@ -65,7 +66,13 @@ fn render_at(
h: u32, h: u32,
) -> Vec<u8> { ) -> Vec<u8> {
let source = grey_at(ctx, w, h); 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 mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, field, None, w, h).expect("rasterise"); let array = masks.render(stack, field, None, w, h).expect("rasterise");
+4 -2
View File
@@ -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<u8> { fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out)); 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); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key) pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render"); .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 // description of "a two-pixel surface structure is not present in a
// 128-pixel rendering". // 128-pixel rendering".
let scale = graph.render_scale(source.size(), (128, 128)); 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!( assert_eq!(
composed.len(), composed.len(),
1, 1,
+22 -3
View File
@@ -14,6 +14,7 @@ use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass, SubjectMasks};
use dr_pipeline::descriptor::ParamId; use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack}; use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full; use dr_pipeline::operation::compose_full;
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, Framing}; use dr_pipeline::{ops, Framing};
use dr_segment::Shaped; use dr_segment::Shaped;
use dr_types::ColourSpace; use dr_types::ColourSpace;
@@ -83,7 +84,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
.render(stack, None, Some(&subjects), PROXY, PROXY) .render(stack, None, Some(&subjects), PROXY, PROXY)
.expect("rasterise"); .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); let mut adjust = AdjustPass::new(ctx);
adjust adjust
.render_masked(&source, &shader, out, out, Some(array)) .render_masked(&source, &shader, out, out, Some(array))
@@ -95,7 +102,13 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
/// thumbnail were doing. /// thumbnail were doing.
fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> { fn render_unmasked(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
let source = grey(ctx, 64); 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); let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, out, out).expect("render"); adjust.render(&source, &shader, out, out).expect("render");
adjust.export_pixels().expect("readback").0 adjust.export_pixels().expect("readback").0
@@ -211,7 +224,13 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
let mut masks = MaskPass::new(ctx).expect("mask pass"); let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, None, None, w, h).expect("rasterise"); let array = masks.render(stack, None, None, w, h).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); let mut adjust = AdjustPass::new(ctx);
adjust adjust
.render_masked(&source, &shader, w, h, Some(array)) .render_masked(&source, &shader, w, h, Some(array))
+2 -1
View File
@@ -73,7 +73,8 @@ fn render_at(
scale: RenderScale, scale: RenderScale,
) -> Vec<u8> { ) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb); 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); let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key) pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
.expect("render"); .expect("render");
+389
View File
@@ -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<GpuContext> {
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<u8> = (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<u8> {
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<u8> = (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"
);
}
+72
View File
@@ -359,6 +359,29 @@ pub struct DetailPass {
/// Uniform values this pass's body reads. /// Uniform values this pass's body reads.
pub uniforms: Vec<Uniform>, pub uniforms: Vec<Uniform>,
/// 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<vec4<f32>>`, 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 /// TRACES: FR-DEV-3 | FR-DEV-8
@@ -396,6 +419,9 @@ pub struct ComposedDetailPass {
pub source: String, pub source: String,
/// Uniform values in the order the generated struct declares them. /// Uniform values in the order the generated struct declares them.
pub uniforms: Vec<f32>, pub uniforms: Vec<f32>,
/// TRACES: FR-DEV-8
/// The instance list, if this pass declared one. See [`DetailPass::storage`].
pub storage: Vec<[f32; 4]>,
/// See [`DetailPass::radius`]. /// See [`DetailPass::radius`].
pub radius: u32, pub radius: u32,
/// Whether this pass writes the display/export texture rather than another /// Whether this pass writes the display/export texture rather than another
@@ -463,10 +489,45 @@ pub fn compose_detail(
ops: &[Box<dyn Operation>], ops: &[Box<dyn Operation>],
scale: RenderScale, scale: RenderScale,
output: ColourSpace, 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<dyn Operation>],
spots: &[DetailPass],
scale: RenderScale,
output: ColourSpace,
) -> ComposedDetail { ) -> ComposedDetail {
// Every pass of every active detail operation, flattened, carrying the // Every pass of every active detail operation, flattened, carrying the
// operation it came from for the uniform prefix and the helper set. // operation it came from for the uniform prefix and the helper set.
let mut planned: Vec<(&'static str, &'static [Helper], DetailPass, usize)> = Vec::new(); 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 { for op in ops {
if !op.is_active() { if !op.is_active() {
continue; continue;
@@ -509,6 +570,7 @@ pub fn compose_detail(
radius: 0, radius: 0,
wgsl: String::new(), wgsl: String::new(),
uniforms: Vec::new(), uniforms: Vec::new(),
storage: Vec::new(),
}, },
0, 0,
scale, scale,
@@ -651,6 +713,10 @@ struct Params {{
@group(0) @binding(0) var source: texture_2d<f32>; @group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var<uniform> u: Params; @group(0) @binding(1) var<uniform> u: Params;
@group(0) @binding(2) var output: texture_storage_2d<{store_format}, write>; @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<storage, read> instances: array<vec4<f32>>;
// A neighbour, clamped to the edge of the image. // A neighbour, clamped to the edge of the image.
// //
@@ -681,6 +747,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
// What this render is, relative to the export it has to match. // What this render is, relative to the export it has to match.
let render_dims = u.detail_base.xy; let render_dims = u.detail_base.xy;
let render_scale = u.detail_base.z; 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<i32>(0)); var c = tap(coord, vec2<i32>(0));
// One scalar per pixel that survives the hand-off from one pass to the // 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<u32>) {{
label, label,
source, source,
uniforms: uniform_values, uniforms: uniform_values,
storage: pass.storage.clone(),
radius: pass.radius, radius: pass.radius,
writes_output, writes_output,
structure_hash, structure_hash,
@@ -793,6 +864,7 @@ mod tests {
&crate::Framing::new(), &crate::Framing::new(),
dr_types::ColourSpace::Srgb, dr_types::ColourSpace::Srgb,
&crate::mask::MaskStack::new(), &crate::mask::MaskStack::new(),
&crate::spot::SpotSet::new(),
) )
} }
+2
View File
@@ -141,6 +141,8 @@ impl DetailStage for BoxBlur {
.map(|(axis, _)| DetailPass { .map(|(axis, _)| DetailPass {
label: if axis == 0 { "horizontal" } else { "vertical" }, label: if axis == 0 { "horizontal" } else { "vertical" },
radius: r, radius: r,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![ uniforms: vec![
Uniform { Uniform {
name: "radius", name: "radius",
+80 -12
View File
@@ -359,6 +359,7 @@ impl Framing {
self.baseline = orientation; self.baseline = orientation;
} }
/// TRACES: FR-DEV-3 | FR-DEV-3h
/// The baseline and the user's turns and mirrors, collapsed into one. /// The baseline and the user's turns and mirrors, collapsed into one.
/// ///
/// Everything that renders or measures the frame goes through here; only /// 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 /// 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, /// "add the turns, or the flags" gets wrong, and gets wrong silently,
/// since the result is still a valid-looking orientation. /// 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; let b = self.baseline;
// The user's mirrors, seen from the far side of the baseline's turn. // The user's mirrors, seen from the far side of the baseline's turn.
let (ux, uy) = if b.swaps_axes() { let (ux, uy) = if b.swaps_axes() {
@@ -378,11 +388,16 @@ impl Framing {
} else { } else {
(self.flip_h, self.flip_v) (self.flip_h, self.flip_v)
}; };
( dr_types::Orientation {
(b.quarter_turns + self.quarter_turns) % 4, quarter_turns: (b.quarter_turns + self.quarter_turns) % 4,
b.flip_h != ux, flip_h: b.flip_h != ux,
b.flip_v != uy, 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. /// 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 /// turn combined with a user flip — a phone portrait that the user then
/// mirrors. Naive flag-ORing renders it mirrored about the wrong axis, /// mirrors. Naive flag-ORing renders it mirrored about the wrong axis,
/// which still looks like a photograph. /// 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] #[test]
fn a_baseline_and_a_user_rotation_compose_into_one_permutation() { fn a_baseline_and_a_user_rotation_compose_into_one_permutation() {
// Non-square and coprime, so no accidental symmetry hides an error. // 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_H, f32::from(u8::from(user_flip_h)));
f.set_param(FLIP_V, f32::from(u8::from(user_flip_v))); f.set_param(FLIP_V, f32::from(u8::from(user_flip_v)));
let (t, fh, fv) = f.effective(); // The public accessor, not the private tuple: this is
let combined = Orientation { // the permutation `dr-ui` turns the detector's input
quarter_turns: t, // by, so it is the one that has to be right.
flip_h: fh, let combined = f.effective_orientation();
flip_v: fv,
};
// The output size the composed transform produces must // The output size the composed transform produces must
// be the one the two stages produce in sequence. // be the one the two stages produce in sequence.
+143 -9
View File
@@ -7,6 +7,8 @@
//! Order is data, not code: operations run in the sequence this holds them, //! Order is data, not code: operations run in the sequence this holds them,
//! so reordering the pipeline needs no code change. //! so reordering the pipeline needs no code change.
use std::sync::Arc;
use crate::descriptor::{ use crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation, Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
}; };
@@ -14,6 +16,9 @@ use crate::framing::{CropRect, Framing};
use crate::mask::MaskStack; use crate::mask::MaskStack;
use crate::operation::{compose_full, ComposedShader, Operation}; use crate::operation::{compose_full, ComposedShader, Operation};
use crate::ops; use crate::ops;
use crate::preset::{Preset, Scope};
use crate::spot::SpotSet;
use crate::state::{EditState, FilmRebake, FilmRef};
/// TRACES: FR-DEV-3a /// TRACES: FR-DEV-3a
/// What one operation offers, as plain data. /// 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 /// 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 /// list would make the list recursive and every consumer that walks it
/// have to know that some entries are really sub-graphs. /// have to know that some entries are really sub-graphs.
masks: MaskStack, masks: Arc<MaskStack>,
/// TRACES: FR-DEV-3f /// TRACES: FR-DEV-3f
/// The film stock this edit renders through, if any. /// 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 /// 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. /// a sidecar comes to name one stock while the shader draws another.
film: Option<Film>, film: Option<Film>,
/// 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 /// TRACES: FR-DEV-3f
@@ -146,8 +160,9 @@ impl EditGraph {
Self { Self {
ops: ops::chain(), ops: ops::chain(),
framing: Framing::new(), framing: Framing::new(),
masks: MaskStack::new(), masks: Arc::new(MaskStack::new()),
film: None, film: None,
spots: SpotSet::new(),
} }
} }
@@ -173,13 +188,30 @@ impl EditGraph {
graph 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. /// The local adjustment stack.
pub fn masks(&self) -> &MaskStack { pub fn masks(&self) -> &MaskStack {
&self.masks &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 { pub fn masks_mut(&mut self) -> &mut MaskStack {
&mut self.masks Arc::make_mut(&mut self.masks)
} }
/// The framing — crop, straighten, rotation and flips. /// The framing — crop, straighten, rotation and flips.
@@ -310,6 +342,89 @@ impl EditGraph {
self.film.as_ref() 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) { pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
if op == crate::framing::ID { if op == crate::framing::ID {
let Some(desc) = self.framing.descriptor().param(param) else { 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 // 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 // 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. // 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 // 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 // *caller's* job rather than `Version::apply`'s: a sidecar names a
// stock, and turning a name into tables needs the profile database, // 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.ops.iter().any(|o| o.is_active())
&& !self.framing.edits_image() && !self.framing.edits_image()
&& self.masks.is_neutral() && self.masks.is_neutral()
&& self.spots.is_neutral()
} }
/// Generate the fused shader for the current state, encoded to sRGB. /// 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 /// graph renders to the screen and to a file in the same breath, and the
/// two want different answers. /// two want different answers.
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader { 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 /// TRACES: FR-DSP-1
@@ -462,9 +578,10 @@ impl EditGraph {
/// single encoded dispatch it always has. /// single encoded dispatch it always has.
pub fn compose_detail( pub fn compose_detail(
&self, &self,
scale: crate::detail::RenderScale, source: (u32, u32),
render: (u32, u32),
) -> crate::detail::ComposedDetail { ) -> 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 /// TRACES: FR-EXP-2
@@ -475,12 +592,21 @@ impl EditGraph {
/// transform — the fused pass stops at linear working values. Composing /// transform — the fused pass stops at linear working values. Composing
/// the two halves for different spaces would encode the edit twice, or /// the two halves for different spaces would encode the edit twice, or
/// not at all. /// 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( pub fn compose_detail_for(
&self, &self,
scale: crate::detail::RenderScale, source: (u32, u32),
render: (u32, u32),
output: dr_types::ColourSpace, output: dr_types::ColourSpace,
) -> crate::detail::ComposedDetail { ) -> 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 /// 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) crate::Invalidation::new(geometry, colour, detail)
} }
} }
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -42,6 +42,8 @@ pub mod operation;
pub mod ops; pub mod ops;
pub mod preset; pub mod preset;
pub mod sidecar; pub mod sidecar;
pub mod spot;
pub mod state;
pub use descriptor::{ pub use descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
@@ -52,7 +54,7 @@ pub use detail::{
}; };
pub use framing::{CropRect, Framing}; pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability}; 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 lens::{compose_warps, ComposedWarp, Warp};
pub use operation::{ pub use operation::{
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation, compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
@@ -60,6 +62,8 @@ pub use operation::{
}; };
pub use preset::{Preset, Scope}; pub use preset::{Preset, Scope};
pub use sidecar::{Sidecar, Version}; pub use sidecar::{Sidecar, Version};
pub use spot::{Spot, SpotMode, SpotSet};
pub use state::{EditState, FilmRebake, FilmRef};
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -150,8 +154,7 @@ mod tests {
// source pixels, so on a proxy it may honestly decline to draw at all // source pixels, so on a proxy it may honestly decline to draw at all
// (`RenderScale::resolves`) — which would put it in neither half and // (`RenderScale::resolves`) — which would put it in neither half and
// make the assertion fail for a reason that is not a defect. // 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((4000, 3000), (4000, 3000));
let detail = g.compose_detail(scale);
let mut fused_blocks = 0; let mut fused_blocks = 0;
for desc in g.descriptors() { for desc in g.descriptors() {
let id = desc.id.0; let id = desc.id.0;
+20 -6
View File
@@ -467,7 +467,13 @@ pub fn compose_with_framing(
framing: &Framing, framing: &Framing,
output: ColourSpace, output: ColourSpace,
) -> ComposedShader { ) -> ComposedShader {
compose_full(ops, framing, output, &MaskStack::new()) compose_full(
ops,
framing,
output,
&MaskStack::new(),
&crate::spot::SpotSet::new(),
)
} }
/// TRACES: FR-DEV-3 /// TRACES: FR-DEV-3
@@ -487,6 +493,7 @@ pub fn compose_full(
framing: &Framing, framing: &Framing,
output: ColourSpace, output: ColourSpace,
masks: &MaskStack, masks: &MaskStack,
spots: &crate::spot::SpotSet,
) -> ComposedShader { ) -> ComposedShader {
// Active *point* operations. A neighbourhood operation is filtered out // Active *point* operations. A neighbourhood operation is filtered out
// here rather than asked for a fragment it cannot write: it reads pixels // 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 // 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 // wrong would produce a shader whose storage format does not match the
// texture bound to it. // texture bound to it.
let output_mode = if ops.iter().any(|o| o.is_active() && o.detail().is_some()) { //
OutputMode::LinearWorking // TRACES: FR-DEV-8
} else { // The repairs count too, and they are the reason this takes a spot set at
OutputMode::Encoded // 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 // Whether an operation has taken over the rendering. Decided from the
// operations for the same reason `output_mode` is: a caller that got it // operations for the same reason `output_mode` is: a caller that got it
+8 -1
View File
@@ -358,6 +358,8 @@ impl DetailStage for CaptureSharpen {
.map(|label| DetailPass { .map(|label| DetailPass {
label, label,
radius: extent, radius: extent,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![ uniforms: vec![
Uniform { Uniform {
// −100…100 as a gain around zero. A hundred percent is // −100…100 as a gain around zero. A hundred percent is
@@ -410,6 +412,8 @@ fn nothing_to_sharpen() -> DetailPass {
label: "unresolved", label: "unresolved",
// Reads only the pixel it writes, so a tile needs no halo at all. // Reads only the pixel it writes, so a tile needs no halo at all.
radius: 0, radius: 0,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: Vec::new(), uniforms: Vec::new(),
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail 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 // 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 { 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] #[test]
+28
View File
@@ -37,6 +37,23 @@ pub const ID: OpId = OpId("film_sim");
pub const EXPOSURE: ParamId = ParamId("exposure"); pub const EXPOSURE: ParamId = ParamId("exposure");
pub const PRINT_EXPOSURE: ParamId = ParamId("print_exposure"); pub const PRINT_EXPOSURE: ParamId = ParamId("print_exposure");
pub const PUSH: ParamId = ParamId("push"); 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. /// 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, // 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. // and beyond a range like that a curve would have to be invented.
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0), 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, exposure: f32,
print_exposure: f32, print_exposure: f32,
push: f32, push: f32,
/// Index into `FORMATS`. Zero is 35 mm, which is the neutral choice.
format: f32,
tables: Option<FilmTables>, tables: Option<FilmTables>,
} }
@@ -173,6 +199,7 @@ impl Operation for FilmSim {
EXPOSURE => self.exposure = value, EXPOSURE => self.exposure = value,
PRINT_EXPOSURE => self.print_exposure = value, PRINT_EXPOSURE => self.print_exposure = value,
PUSH => self.push = value, PUSH => self.push = value,
FORMAT => self.format = value,
_ => log::warn!("film_sim: unknown parameter {id}"), _ => log::warn!("film_sim: unknown parameter {id}"),
} }
} }
@@ -182,6 +209,7 @@ impl Operation for FilmSim {
EXPOSURE => self.exposure, EXPOSURE => self.exposure,
PRINT_EXPOSURE => self.print_exposure, PRINT_EXPOSURE => self.print_exposure,
PUSH => self.push, PUSH => self.push,
FORMAT => self.format,
_ => 0.0, _ => 0.0,
} }
} }
@@ -475,12 +475,16 @@ impl<B: Band> DetailStage for LocalContrast<B> {
DetailPass { DetailPass {
label: "base", label: "base",
radius, radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: shape, uniforms: shape,
wgsl: BASE_X.to_string(), wgsl: BASE_X.to_string(),
}, },
DetailPass { DetailPass {
label: "combine", label: "combine",
radius, radius,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: combine, uniforms: combine,
wgsl: combine_body(B::RECIPE.midtone_taper), wgsl: combine_body(B::RECIPE.midtone_taper),
}, },
@@ -436,6 +436,8 @@ impl DetailStage for NoiseReduction {
passes.push(DetailPass { passes.push(DetailPass {
label: "luminance", label: "luminance",
radius: luma, radius: luma,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![ uniforms: vec![
Uniform { Uniform {
name: "radius", name: "radius",
@@ -474,6 +476,8 @@ impl DetailStage for NoiseReduction {
"chroma-vertical" "chroma-vertical"
}, },
radius: chroma, radius: chroma,
// A convolution, not a list: nothing to bind at binding 3.
storage: Vec::new(),
uniforms: vec![ uniforms: vec![
Uniform { Uniform {
name: "radius", name: "radius",
+367 -66
View File
@@ -68,7 +68,9 @@ use std::fmt::Write as _;
use crate::graph::EditGraph; use crate::graph::EditGraph;
use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER}; 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. /// Format version of the document itself.
/// ///
@@ -108,14 +110,10 @@ pub struct Sidecar {
/// TRACES: FR-DEV-3f /// TRACES: FR-DEV-3f
/// The stock and paper a version names, without the tables they bake to. /// The stock and paper a version names, without the tables they bake to.
#[derive(Debug, Clone, PartialEq, Eq, Default)] ///
pub struct FilmRef { /// Re-exported rather than defined here: a sidecar is one of the things an
pub stock: String, /// edit is written to, not where an edit is defined. See [`crate::state`].
/// The paper, if the negative is printed. Absent means the film is viewed pub use crate::state::FilmRef;
/// 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<String>,
}
/// TRACES: FR-CAT-12 | FR-NC-8 /// TRACES: FR-CAT-12 | FR-NC-8
/// One named edit variant. /// One named edit variant.
@@ -182,6 +180,15 @@ pub struct Version {
/// database, which this crate does not link, so [`Self::apply`] leaves the /// 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`. /// graph's film cleared and the caller re-bakes — see `EditGraph::set_film`.
pub film: Option<FilmRef>, pub film: Option<FilmRef>,
/// TRACES: FR-DEV-8 | FR-NC-9
/// The repairs (`docs/spot-removal.md`).
///
/// A line per spot, keyed `spot.<id>`, 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. /// Keys this build did not recognise, kept verbatim.
/// ///
/// An operation this build lacks would otherwise be deleted the moment an /// An operation this build lacks would otherwise be deleted the moment an
@@ -191,8 +198,22 @@ pub struct Version {
} }
impl 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<String>, name: impl Into<String>, graph: &EditGraph) -> Self { pub fn from_graph(uuid: impl Into<String>, name: impl Into<String>, graph: &EditGraph) -> Self {
let EditState {
params,
masks,
film,
spots,
} = graph.state();
let params = params.into_params();
let masks = (*masks).clone();
Self { Self {
uuid: uuid.into(), uuid: uuid.into(),
name: name.into(), name: name.into(),
@@ -206,40 +227,40 @@ impl Version {
// it in the "not yet looked at" state a cull resumes from. // it in the "not yet looked at" state a cull resumes from.
rating: 0, rating: 0,
flag: 0, flag: 0,
params: capture(graph), params,
masks: graph.masks().clone(), masks,
film: graph.film().map(|f| FilmRef { film,
stock: f.stock.clone(), spots,
print: f.print.clone(),
}),
unknown: BTreeMap::new(), 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 /// Loading is a *replacement* rather than an overlay: a parameter absent
/// overlay: a parameter absent from the file means default, and would /// from the file means default, and would otherwise silently inherit
/// otherwise silently inherit whatever the graph happened to hold. /// whatever the graph happened to hold.
/// ///
/// Unknown operations and parameters are skipped with a warning by /// Unknown operations and parameters are skipped with a warning by
/// [`EditGraph::set_param`], and values are clamped there, so a corrupt /// [`EditGraph::set_param`], and values are clamped there, so a corrupt
/// or newer file cannot reach a shader. /// 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(); graph.reset();
for ((op, param), value) in &self.params { graph.set_state(&EditState {
// `OpId` and `ParamId` hold `&'static str` because descriptors params: Preset::from_params(self.params.clone()),
// are statics, and a sidecar's strings are not. `resolve` matches masks: std::sync::Arc::new(self.masks.clone()),
// the file's names against the descriptors and hands back the film: self.film.clone(),
// static ids, so no string read from disk is ever leaked to get spots: self.spots.clone(),
// 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();
} }
/// Record `graph` into this version, bumping the revision. /// 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 /// 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. /// 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) { pub fn update(&mut self, graph: &EditGraph, device: &str, now: i64) {
self.params = capture(graph); // Exhaustive, and this is the call site that proves why it has to be:
self.masks = graph.masks().clone(); // 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.revision = self.revision.saturating_add(1);
self.device = device.to_string(); self.device = device.to_string();
self.modified = now; self.modified = now;
@@ -314,6 +349,89 @@ impl Version {
conflicts 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<String> = self
.spots
.spots()
.iter()
.chain(remote.spots.spots())
.map(|s| s.id.clone())
.collect::<std::collections::BTreeSet<_>>()
.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. /// Replace a layer of the same id, or append it.
/// ///
/// Position is not merged. Two devices that reordered the same stack have /// 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 — // 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. // 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_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 // Unknown keys follow the same rule, so an operation neither side
// understands is not dropped by the merge either. // 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 { impl Sidecar {
pub fn new() -> Self { pub fn new() -> Self {
Self::default() Self::default()
@@ -564,6 +669,15 @@ impl Sidecar {
for ((op, param), value) in &v.params { for ((op, param), value) in &v.params {
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value)); 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 { for (k, raw) in &v.unknown {
let _ = writeln!(out, "{k} = {raw}"); let _ = writeln!(out, "{k} = {raw}");
} }
@@ -691,6 +805,21 @@ impl Sidecar {
Some(value.to_string()); Some(value.to_string());
} }
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG), "flag" => version.flag = value.parse::<u8>().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('.') { _ => match key.split_once('.') {
// An `op.param` line whose value does not parse is a // An `op.param` line whose value does not parse is a
// corrupt number, not an unknown key; dropping it lets // 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.<id> = …` 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<Spot> {
if id.is_empty() {
return None;
}
let mut tokens = value.split_whitespace();
let mut number = || {
tokens
.next()
.and_then(|t| t.parse::<f32>().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. /// Write one mask layer as its own block.
/// ///
/// The version uuid is repeated in the header rather than relying on the /// 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) 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] #[test]
fn only_non_default_values_are_written() { fn only_non_default_values_are_written() {
// The property the whole format rests on: a neutral operation is // The property the whole format rests on: a neutral operation is
@@ -1197,7 +1473,8 @@ mod tests {
parsed parsed
.default_version() .default_version()
.expect("a 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!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
assert_eq!( assert_eq!(
@@ -1231,7 +1508,8 @@ mod tests {
parsed parsed
.default_version() .default_version()
.expect("a version") .expect("a version")
.apply(&mut restored); .apply(&mut restored)
.expect_no_film();
for cap in g.capabilities() { for cap in g.capabilities() {
for p in &cap.params { for p in &cap.params {
@@ -1268,7 +1546,8 @@ mod tests {
parsed parsed
.default_version() .default_version()
.expect("a version") .expect("a version")
.apply(&mut restored); .apply(&mut restored)
.expect_no_film();
assert_eq!(restored.crop(), g.crop()); assert_eq!(restored.crop(), g.crop());
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5)); assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
@@ -1308,7 +1587,8 @@ mod tests {
.expect("valid") .expect("valid")
.default_version() .default_version()
.expect("a version") .expect("a version")
.apply(&mut sideways); .apply(&mut sideways)
.expect_no_film();
assert_eq!( assert_eq!(
sideways.framing().baseline(), sideways.framing().baseline(),
@@ -1326,7 +1606,11 @@ mod tests {
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
let mut g = edited(); 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"); 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"; tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n";
let parsed = Sidecar::parse(text).expect("valid"); let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain(); 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 // The S-curve the file describes, on the master curve and nowhere
// else. // else.
@@ -1420,7 +1708,8 @@ mod tests {
parsed parsed
.default_version() .default_version()
.expect("a 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, blue), Some(0.08));
assert_eq!(restored.param(curve::ID, red), Some(0.92)); assert_eq!(restored.param(curve::ID, red), Some(0.92));
@@ -1449,7 +1738,11 @@ mod tests {
time_machine.year = 1994\n"; time_machine.year = 1994\n";
let parsed = Sidecar::parse(text).expect("valid"); let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain(); 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()); assert!(g.is_neutral());
} }
@@ -1459,7 +1752,11 @@ mod tests {
exposure.exposure = NaN\nwhite_balance.temperature = 20\n"; exposure.exposure = NaN\nwhite_balance.temperature = 20\n";
let parsed = Sidecar::parse(text).expect("valid"); let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain(); 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!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
assert_eq!( assert_eq!(
@@ -1476,7 +1773,11 @@ mod tests {
exposure.exposure = 99\n"; exposure.exposure = 99\n";
let parsed = Sidecar::parse(text).expect("valid"); let parsed = Sidecar::parse(text).expect("valid");
let mut g = EditGraph::default_chain(); 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)); 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"); assert_eq!(parsed.default_version().expect("default").name, "Colour");
let mut g = EditGraph::default_chain(); let mut g = EditGraph::default_chain();
parsed.versions["u2"].apply(&mut g); parsed.versions["u2"].apply(&mut g).expect_no_film();
assert_eq!( assert_eq!(
g.param(saturation::ID, saturation::SATURATION), g.param(saturation::ID, saturation::SATURATION),
Some(-100.0) Some(-100.0)
@@ -1555,7 +1856,7 @@ mod tests {
assert!(conflicts.is_empty(), "disjoint edits must not conflict"); assert!(conflicts.is_empty(), "disjoint edits must not conflict");
let mut g = EditGraph::default_chain(); 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.param(exposure::ID, exposure::EXPOSURE), Some(1.0));
assert_eq!(g.crop().width, 0.5); assert_eq!(g.crop().width, 0.5);
} }
@@ -1579,7 +1880,7 @@ mod tests {
assert_eq!(conflicts.len(), 1, "the same parameter, two values"); assert_eq!(conflicts.len(), 1, "the same parameter, two values");
let mut g = EditGraph::default_chain(); 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)); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(2.0));
} }
@@ -1604,7 +1905,7 @@ mod tests {
local.merge(&remote, Some(&base)); local.merge(&remote, Some(&base));
let mut g = EditGraph::default_chain(); let mut g = EditGraph::default_chain();
local.apply(&mut g); local.apply(&mut g).expect_no_film();
assert_eq!( assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE), g.param(exposure::ID, exposure::EXPOSURE),
Some(1.0), Some(1.0),
@@ -1626,7 +1927,7 @@ mod tests {
local.merge(&remote, Some(&base)); local.merge(&remote, Some(&base));
let mut g = EditGraph::default_chain(); 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"); 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"); assert_eq!(local.rating, 4, "the tablet's cull arrived");
let mut g = EditGraph::default_chain(); let mut g = EditGraph::default_chain();
local.apply(&mut g); local.apply(&mut g).expect_no_film();
assert_eq!( assert_eq!(
g.param(exposure::ID, exposure::EXPOSURE), g.param(exposure::ID, exposure::EXPOSURE),
Some(1.5), Some(1.5),
+796
View File
@@ -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<Self> {
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<Spot>,
}
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<Item = &Spot> {
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<String> {
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<Spot> {
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<Vec<usize>> {
let active: Vec<&Spot> = self.active().collect();
let mut rounds: Vec<Vec<usize>> = Vec::new();
let mut current: Vec<usize> = 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<f32>) -> vec3<f32> {
let last = vec2<i32>(textureDimensions(source)) - vec2<i32>(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<f32>(0.5);
let base = floor(q);
let f = q - base;
let i0 = clamp(vec2<i32>(base), vec2<i32>(0), last);
let i1 = clamp(i0 + vec2<i32>(1), vec2<i32>(0), last);
let s00 = textureLoad(source, vec2<i32>(i0.x, i0.y), 0).rgb;
let s10 = textureLoad(source, vec2<i32>(i1.x, i0.y), 0).rgb;
let s01 = textureLoad(source, vec2<i32>(i0.x, i1.y), 0).rgb;
let s11 = textureLoad(source, vec2<i32>(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>(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<f32>(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<f32>(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<crate::detail::DetailPass> {
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",
}
}
+313
View File
@@ -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<String>,
}
/// 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<MaskStack>,
/// TRACES: FR-DEV-3f
/// The stock this edit develops on, named.
pub film: Option<FilmRef>,
/// 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());
}
}
+3 -2
View File
@@ -39,7 +39,8 @@ fn round_trip(graph: &EditGraph) -> EditGraph {
.versions .versions
.get("default") .get("default")
.expect("version survived") .expect("version survived")
.apply(&mut restored); .apply(&mut restored)
.expect_no_film();
restored restored
} }
@@ -239,7 +240,7 @@ fn applying_a_maskless_version_clears_existing_masks() {
assert_eq!(graph.masks().len(), 1); assert_eq!(graph.masks().len(), 1);
let plain = Version::from_graph("clean", "Clean", &EditGraph::default_chain()); 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()); assert!(graph.masks().is_empty());
} }
+298
View File
@@ -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());
}
+303
View File
@@ -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::<Vec<_>>()]);
}
/// 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");
}
+2 -1
View File
@@ -26,7 +26,8 @@ fn apply(text: &str) -> EditGraph {
sidecar sidecar
.default_version() .default_version()
.expect("a default version") .expect("a default version")
.apply(&mut graph); .apply(&mut graph)
.expect_no_film();
graph graph
} }
+341
View File
@@ -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<T: Copy + Default>(
self,
stored: &[T],
width: u32,
height: u32,
channels: usize,
) -> (Vec<T>, 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<T: Copy + Default>(
self,
shown: &[T],
dw: u32,
dh: u32,
channels: usize,
) -> (Vec<T>, 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 /// TRACES: FR-EXP-8
/// Where a photograph was taken. /// Where a photograph was taken.
/// ///
@@ -778,4 +986,137 @@ mod tests {
assert!(Availability::Original > Availability::Preview); assert!(Availability::Original > Availability::Preview);
assert!(Availability::Preview > Availability::MetadataOnly); 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<u32> {
(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");
}
}
} }
+97
View File
@@ -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}
+11 -2
View File
@@ -10,8 +10,10 @@ and records the decisions and constraints behind the design.
## 1. Overview ## 1. Overview
DarkRoom is a Rust application with a Slint interface, rendering through wgpu to Vulkan on both DarkRoom is a Rust application with a Slint interface. On Linux it renders through wgpu to Vulkan.
Linux and Android. The design is organised around four ideas, each of which the rest of this 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: document elaborates:
1. **Pixels stay on the GPU.** From decode to display, image data never round-trips through the 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 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. 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 ### 6.2 Tiling from day one
Mobile GPUs have far less memory. Retrofitting tiling into a whole-image pipeline is a rewrite. Mobile GPUs have far less memory. Retrofitting tiling into a whole-image pipeline is a rewrite.
+569
View File
@@ -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<f32>` 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<Spot>, // 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<Vec<[f32; 4]>>,
```
and the generated preamble gains one binding:
```wgsl
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
```
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.
+150
View File
@@ -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.
+55 -55
View File
@@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value | | Metric | Value |
|---|---| |---|---|
| Source files scanned | 244 | | Source files scanned | 254 |
| TRACES tags found | 634 | | TRACES tags found | 732 |
| Requirements defined | 177 | | Requirements defined | 177 |
| Requirements covered | 97 | | Requirements covered | 98 |
| **Coverage** | **54.8%** (97/177) | | **Coverage** | **55.4%** (98/177) |
### By type ### By type
| Type | Covered | Defined | | Type | Covered | Defined |
|---|---|---| |---|---|---|
| FR | 77 | 122 | | FR | 78 | 122 |
| NFR | 18 | 49 | | NFR | 18 | 49 |
| R | 2 | 6 | | R | 2 | 6 |
@@ -34,88 +34,89 @@ _None._
| ID | Tagged in | | 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-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-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: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-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:120`](../core/dr-pipeline/src/sidecar.rs#L120) | | 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-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-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-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-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-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-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: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-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: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-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/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-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: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-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:134`](../core/dr-decode/src/preview.rs#L134) | | 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: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-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-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-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-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: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-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) |
| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:343`](../core/dr-catalog/src/schema.rs#L343), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/ui/settings.slint:385`](../ui/dr-ui/ui/settings.slint#L385), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:343`](../core/dr-catalog/src/schema.rs#L343), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/ui/settings.slint: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-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-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-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: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-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:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) | | 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: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-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: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-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: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-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/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-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-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) | | 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-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-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: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-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-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-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-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-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-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-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-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-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-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-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: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-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: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-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:2367`](../ui/dr-ui/src/develop.rs#L2367), [`ui/dr-ui/src/lib.rs:331`](../ui/dr-ui/src/lib.rs#L331) | | 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-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-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-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-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-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-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-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-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-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-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:1051`](../ui/dr-ui/src/lib.rs#L1051) | | 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: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-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:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:1723`](../core/dr-pipeline/src/sidecar.rs#L1723), [`core/dr-pipeline/src/sidecar.rs:332`](../core/dr-pipeline/src/sidecar.rs#L332), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) | | FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs: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-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-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-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-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-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-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: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-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: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-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: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-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: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-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) | | 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-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-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-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-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-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-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302) |
@@ -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-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-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-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-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-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) | | 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 ## 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.
<details><summary>Show untagged requirements</summary> <details><summary>Show untagged requirements</summary>
@@ -144,7 +145,6 @@ _None._
- FR-CULL-7 - FR-CULL-7
- FR-DEV-1 - FR-DEV-1
- FR-DEV-3g - FR-DEV-3g
- FR-DEV-7
- FR-DSP-2 - FR-DSP-2
- FR-DSP-3 - FR-DSP-3
- FR-DSP-4 - FR-DSP-4
+13 -6
View File
@@ -65,11 +65,14 @@ rusqlite.workspace = true
# Consequence worth stating plainly: the desktop app now needs a working wgpu # 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 # adapter to open a window at all. Slint refuses a CPU adapter for this
# renderer unless `SLINT_WGPU_CPU` is set in the environment. # renderer unless `SLINT_WGPU_CPU` is set in the environment.
slint = { workspace = true, features = [ # TEST BUILD: the wgpu renderer features have moved to the desktop-only
"compat-1-2", # dependency below. On Android they made `AndroidWindowAdapter` choose
"renderer-femtovg-wgpu", # `SkiaRenderer::default_wgpu_29`, and so put the app on wgpu's Vulkan
"unstable-wgpu-29", # 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 wgpu.workspace = true
anyhow.workspace = true anyhow.workspace = true
# `SettingsError` distinguishes an io failure from a malformed file, which the # `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; # under `cfg(target_os = "android")`, so this only has to name the feature;
# cargo resolves it away entirely on desktop. # cargo resolves it away entirely on desktop.
[target.'cfg(not(target_os = "android"))'.dependencies] [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] [target.'cfg(target_os = "android")'.dependencies]
slint = { workspace = true, features = ["backend-android-activity-06"] } slint = { workspace = true, features = ["backend-android-activity-06"] }
+648 -73
View File
File diff suppressed because it is too large Load Diff
+243 -56
View File
@@ -9,47 +9,153 @@
//! a derived label, before anyone writes a translation for it. That is the //! 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. //! 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. /// Resolve a key, deriving a fallback where none is catalogued.
pub fn resolve(key: &str) -> String { 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 // Operations
// The attribute names. Short on purpose: these are read as a strip // The attribute names. Short on purpose: these are read as a strip
// of tabs, where a long word crowds out the next one. // of tabs, where a long word crowds out the next one.
"attr.tone" => "Light".into(), "attr.tone" => "Light",
"attr.colour" => "Colour".into(), "attr.colour" => "Colour",
"attr.detail" => "Detail".into(), "attr.detail" => "Detail",
"attr.optics" => "Optics".into(), "attr.optics" => "Optics",
"attr.geometry" => "Geometry".into(), "attr.geometry" => "Geometry",
"attr.effect" => "Effects".into(), "attr.effect" => "Effects",
"op.white_balance" => "White Balance".into(), "op.white_balance" => "White Balance",
"op.exposure" => "Exposure".into(), "op.exposure" => "Exposure",
"op.highlights_shadows" => "Highlights & Shadows".into(), "op.highlights_shadows" => "Highlights & Shadows",
"op.blacks_whites" => "Blacks & Whites".into(), "op.blacks_whites" => "Blacks & Whites",
"op.brilliance" => "Brilliance".into(), "op.brilliance" => "Brilliance",
"op.vibrance" => "Vibrance".into(), "op.vibrance" => "Vibrance",
"op.saturation" => "Saturation".into(), "op.saturation" => "Saturation",
"op.colour_mixer" => "Colour Mixer".into(), "op.colour_mixer" => "Colour Mixer",
// "Sharpening" rather than what `derive` would make of the id. The id // "Sharpening" rather than what `derive` would make of the id. The id
// says *capture* sharpening to separate it from the output sharpening // says *capture* sharpening to separate it from the output sharpening
// an export applies (FR-EXP-4), which is a distinction about where in // 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 // the pipeline it sits; in the develop panel there is only one, and
// "Capture Sharpen" would name a distinction the photographer cannot // "Capture Sharpen" would name a distinction the photographer cannot
// see from there. // see from there.
"op.capture_sharpen" => "Sharpening".into(), "op.capture_sharpen" => "Sharpening",
"op.framing" => "Crop & Rotate".into(), "op.framing" => "Crop & Rotate",
// Parameters // Parameters
"param.temperature" => "Temperature".into(), "param.temperature" => "Temperature",
"param.tint" => "Tint".into(), "param.tint" => "Tint",
"param.exposure" => "Exposure".into(), "param.exposure" => "Exposure",
"param.highlights" => "Highlights".into(), "param.highlights" => "Highlights",
"param.shadows" => "Shadows".into(), "param.shadows" => "Shadows",
"param.blacks" => "Blacks".into(), "param.blacks" => "Blacks",
"param.whites" => "Whites".into(), "param.whites" => "Whites",
"param.brilliance" => "Brilliance".into(), "param.brilliance" => "Brilliance",
"param.vibrance" => "Vibrance".into(), "param.vibrance" => "Vibrance",
"param.saturation" => "Saturation".into(), "param.saturation" => "Saturation",
// What a faceted parameter adjusts — the mixer's three channels. // 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 // "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 // abbreviation at the head of a section is a word the reader has to
// expand every time they scan past it. // expand every time they scan past it.
"param.channel.hue" => "Hue".into(), "param.channel.hue" => "Hue",
"param.channel.sat" => "Saturation".into(), "param.channel.sat" => "Saturation",
"param.channel.lum" => "Luminance".into(), "param.channel.lum" => "Luminance",
// The tone curve's four curves, which its points are *subject* to. // 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, // colours would derive correctly and are written out beside it anyway,
// since a list where one entry is translated and three are guessed is // since a list where one entry is translated and three are guessed is
// the shape a half-finished translation takes. // the shape a half-finished translation takes.
"channel.rgb" => "RGB".into(), "channel.rgb" => "RGB",
"channel.red" => "Red".into(), "channel.red" => "Red",
"channel.green" => "Green".into(), "channel.green" => "Green",
"channel.blue" => "Blue".into(), "channel.blue" => "Blue",
// The hue bands, which a faceted row is *subject* to. // 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 // 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 // down in words, which is what a photographer who cannot separate the
// squares by eye has to go on. // squares by eye has to go on.
"band.red" => "Red".into(), "band.red" => "Red",
"band.orange" => "Orange".into(), "band.orange" => "Orange",
"band.yellow" => "Yellow".into(), "band.yellow" => "Yellow",
"band.chartreuse" => "Yellow-Green".into(), "band.chartreuse" => "Yellow-Green",
"band.green" => "Green".into(), "band.green" => "Green",
"band.spring" => "Blue-Green".into(), "band.spring" => "Blue-Green",
"band.cyan" => "Cyan".into(), "band.cyan" => "Cyan",
"band.azure" => "Azure".into(), "band.azure" => "Azure",
"band.blue" => "Blue".into(), "band.blue" => "Blue",
"band.violet" => "Violet".into(), "band.violet" => "Violet",
"band.magenta" => "Magenta".into(), "band.magenta" => "Magenta",
"band.rose" => "Rose".into(), "band.rose" => "Rose",
// Framing. "Straighten" rather than "Angle" because that is the task // Framing. "Straighten" rather than "Angle" because that is the task
// the control performs; the number it reports is still degrees. // the control performs; the number it reports is still degrees.
"param.angle" => "Straighten".into(), "param.angle" => "Straighten",
"param.rotation" => "Rotate".into(), "param.rotation" => "Rotate",
"param.flip_h" => "Flip Horizontal".into(), "param.flip_h" => "Flip Horizontal",
"param.flip_v" => "Flip Vertical".into(), "param.flip_v" => "Flip Vertical",
"param.crop_x" => "Crop Left".into(), "param.crop_x" => "Crop Left",
"param.crop_y" => "Crop Top".into(), "param.crop_y" => "Crop Top",
"param.crop_w" => "Crop Width".into(), "param.crop_w" => "Crop Width",
"param.crop_h" => "Crop Height".into(), "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`. /// Turn `op.some_new_thing` into `Some New Thing`.
@@ -137,6 +287,43 @@ fn derive(key: &str) -> String {
mod tests { mod tests {
use super::*; 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<String> = 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] #[test]
fn catalogued_keys_resolve_to_their_label() { fn catalogued_keys_resolve_to_their_label() {
assert_eq!(resolve("op.white_balance"), "White Balance"); assert_eq!(resolve("op.white_balance"), "White Balance");
+122 -3
View File
@@ -43,9 +43,10 @@ mod segmentation;
mod settings_store; mod settings_store;
mod settings_ui; mod settings_ui;
mod sidecar_cache; mod sidecar_cache;
mod spots_ui;
mod trash; mod trash;
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; 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 // overlay or the crosshair to the next one would offer a selection of
// regions that are not in the picture on screen. // regions that are not in the picture on screen.
masks_ui::reset(window); 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. /// 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 /// `None` means develop is unavailable and the viewer falls back to embedded
/// previews — the same degradation as a machine with no adapter at all. /// previews — the same degradation as a machine with no adapter at all.
#[cfg(not(target_os = "android"))]
fn shared_gpu() -> Option<dr_gpu::GpuContext> { fn shared_gpu() -> Option<dr_gpu::GpuContext> {
let shared = match pollster::block_on(dr_gpu::GpuContext::new_shared()) { let shared = match pollster::block_on(dr_gpu::GpuContext::new_shared()) {
Ok(shared) => shared, Ok(shared) => shared,
@@ -778,6 +785,45 @@ fn shared_gpu() -> Option<dr_gpu::GpuContext> {
Some(ctx) 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<dr_gpu::GpuContext> {
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 /// TRACES: M-13 | M-14
/// Build and run the viewer. /// Build and run the viewer.
pub fn run(paths: Vec<PathBuf>) -> Result<()> { pub fn run(paths: Vec<PathBuf>) -> Result<()> {
@@ -1325,9 +1371,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// //
// Called on every slider change, so it must do no more than run the // Called on every slider change, so it must do no more than run the
// adjust pass — the demosaic is not repeated. // 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<Cell<Option<u64>>> = Rc::new(Cell::new(None));
let render_now: Render = { let render_now: Render = {
let session = session.clone(); let session = session.clone();
let viewport = viewport.clone(); let viewport = viewport.clone();
let drawn_history = drawn_history.clone();
Rc::new(move |window: &AppWindow, draft: bool| { Rc::new(move |window: &AppWindow, draft: bool| {
let mut slot = session.borrow_mut(); let mut slot = session.borrow_mut();
let Some(s) = slot.as_mut() else { return }; let Some(s) = slot.as_mut() else { return };
@@ -1340,6 +1393,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
window.set_can_undo(s.can_undo()); window.set_can_undo(s.can_undo());
window.set_can_redo(s.can_redo()); 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 // TRACES: FR-DEV-3
// Which part of the region overlay the view is showing. Here // 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 // rather than in the panel's own sync because a pan or a zoom
@@ -1347,6 +1414,18 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// those ends in a redraw. // those ends in a redraw.
masks_ui::sync_overlay_view(window, s); 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(); let (mut w, mut h) = *viewport.borrow();
// **Half resolution while the gesture is still moving.** // **Half resolution while the gesture is still moving.**
@@ -2050,6 +2129,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
} }
masks_ui::wire(&window, &session, &rows, &redraw); 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 // 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 // without a mouse. Off unless the variable is set, so it costs a getenv
@@ -2139,7 +2219,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
.and_then(dr_film::find) .and_then(dr_film::find)
.and_then(dr_film::default_print) .and_then(dr_film::default_print)
.is_some(); .is_some();
s.choose_film(*stock, print); s.pick_film(*stock, print);
} }
sync_rows(&w, &rows, &session); sync_rows(&w, &rows, &session);
redraw(&w); redraw(&w);
@@ -2153,7 +2233,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() { if let Some(s) = session.borrow_mut().as_mut() {
if let Some((stock, _)) = s.film().map(|(a, b)| (a.to_string(), b)) { 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); sync_film(&w, &session);
@@ -2199,6 +2279,27 @@ pub fn run(paths: Vec<PathBuf>) -> 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, pan and crop ---------------------------------------------
// //
// Zoom and pan are viewing state and touch no parameter, so unlike the // Zoom and pan are viewing state and touch no parameter, so unlike the
@@ -2275,14 +2376,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
w.set_crop_h(c.height); w.set_crop_h(c.height);
} }
ViewMode::Local => s.set_overlay(true), 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 => { ViewMode::Photo => {
s.set_overlay(false); s.set_overlay(false);
s.set_active_mask(None); 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); w.set_view_mode(mode);
masks_ui::sync(&w, &session); 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 // The scope may have just changed, so the panel below is now
// describing a different chain. // describing a different chain.
sync_rows(&w, &rows, &session); sync_rows(&w, &rows, &session);
+3 -2
View File
@@ -606,7 +606,7 @@ mod tests {
let version = sidecar.default_version().expect("a default version"); let version = sidecar.default_version().expect("a default version");
let mut reopened = EditGraph::default_chain(); let mut reopened = EditGraph::default_chain();
version.apply(&mut reopened); version.apply(&mut reopened).expect_no_film();
assert_eq!( assert_eq!(
reopened.masks().len(), reopened.masks().len(),
@@ -646,7 +646,8 @@ mod tests {
sidecar sidecar
.default_version() .default_version()
.expect("a version") .expect("a version")
.apply(&mut restored); .apply(&mut restored)
.expect_no_film();
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(1.5)); assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
} }
+237 -11
View File
@@ -34,6 +34,7 @@ use std::sync::Arc;
use dr_gpu::GpuContext; use dr_gpu::GpuContext;
use dr_pipeline::mask::segmentation_signature; use dr_pipeline::mask::segmentation_signature;
use dr_types::Orientation;
/// One recognised object. /// One recognised object.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -243,27 +244,41 @@ impl Default for Options {
/// Find what can be selected in this photograph. /// Find what can be selected in this photograph.
/// ///
/// `rgb` is the proxy the model reads — tightly packed RGB floats at /// `rgb` is the rendered proxy — tightly packed RGB floats at
/// `(width, height)`. Passed in rather than derived here because the caller /// `(width, height)`, in the **sensor's** own orientation. Passed in rather
/// already has the rendered proxy, and re-deriving it would mean a second /// than derived here because the caller already has it, and re-deriving it
/// readback of something the CPU is holding. /// 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( pub fn compute(
_ctx: &GpuContext, _ctx: &GpuContext,
rgb: &[f32], rgb: &[f32],
width: usize, width: usize,
height: usize, height: usize,
orientation: Orientation,
options: &Options, options: &Options,
) -> Result<Segmentation, String> { ) -> Result<Segmentation, String> {
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<InstanceSummary> = found let instances: Vec<InstanceSummary> = found
.iter() .iter()
.filter(|i| i.score >= options.confidence) .filter(|i| i.score >= options.confidence)
.map(|i| InstanceSummary { .map(|i| {
class_name: i.class_name.clone(), let (mask, bbox) = lay_down(&i.mask, i.bbox, uw, uh, orientation);
score: i.score, InstanceSummary {
mask: quantise(&i.mask), class_name: i.class_name.clone(),
bbox: i.bbox, 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(); .collect();
@@ -278,21 +293,122 @@ pub fn compute(
// a signature, a layer built against the coarse pass would be silently // a signature, a layer built against the coarse pass would be silently
// reinterpreted against the fine one. That is a *wrong* mask, which is // reinterpreted against the fine one. That is a *wrong* mask, which is
// far worse than a stale one, because nothing announces it. // 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 options.confidence.to_bits() as u64
^ if options.fine { ^ if options.fine {
0x9E37_79B9_7F4A_7C15 0x9E37_79B9_7F4A_7C15
} else { } else {
0 0
}, }
^ orientation_key(orientation),
); );
Ok(Segmentation { Ok(Segmentation {
instances, instances,
signature, 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), 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<f32>, 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, 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. /// 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 /// 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]); 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<f32> {
(0..w * h).flat_map(|i| [i as f32, 0.0, 0.0]).collect()
}
fn red(rgb: &[f32]) -> Vec<f32> {
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<f32> = (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] #[test]
fn confidence_changes_the_signature() { fn confidence_changes_the_signature() {
// A different threshold is a different instance list, so the indices a // A different threshold is a different instance list, so the indices a
+292
View File
@@ -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<VecModel<SpotHandle>> {
thread_local! {
static HANDLES: Rc<VecModel<SpotHandle>> = 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<RefCell<Option<DevelopSession>>>,
redraw: Rc<dyn Fn(&AppWindow)>,
) {
// --- 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<RefCell<Option<dr_pipeline::Spot>>> = 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<RefCell<Option<DevelopSession>>>) {
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()));
}
+16 -6
View File
@@ -673,6 +673,14 @@ export enum ViewMode {
/// The region map is drawn, a click on the photograph selects, and the /// The region map is drawn, a click on the photograph selects, and the
/// column is the mask stack and the selected layer's adjustments. /// column is the mask stack and the selected layer's adjustments.
local, 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. /// 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 // 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. // 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 // **Where a canvas tool goes.** A mode that arms a gesture on the
// this shape — it arms a canvas gesture and scopes the column — so it // photograph and scopes the column joins this list and the `ViewMode`
// joins this list and the `ViewMode` enum, and needs nothing else here. // enum, and needs nothing else here. *Repair* is the first to arrive
// `MaskSource::Brush` and the stroke calls on `MaskLayer` land in the // that way (FR-DEV-8); painting a mask is the same shape and still to
// core separately; what is missing on this side is only the canvas // come — `MaskSource::Brush` and the stroke calls on `MaskLayer`
// interaction, which is the gradient handles' neighbour. // 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 [ for entry in [
{ label: "Crop", value: ViewMode.crop }, { label: "Crop", value: ViewMode.crop },
{ label: "Local", value: ViewMode.local }, { label: "Local", value: ViewMode.local },
{ label: "Repair", value: ViewMode.spots },
]: mode-chip := TouchArea { ]: mode-chip := TouchArea {
width: mode-name.preferred-width + 2 * Theme.gap-sm; width: mode-name.preferred-width + 2 * Theme.gap-sm;
height: Theme.touch-target; height: Theme.touch-target;
+275 -7
View File
@@ -1,6 +1,8 @@
import { Theme } from "theme.slint"; import { Theme } from "theme.slint";
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint"; import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.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 { LaunchScreen } from "launch.slint";
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint"; import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint"; import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint";
@@ -11,7 +13,7 @@ import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint"; import { ImportPage } from "import.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView } 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 // Status strip — surfaces the GPU backend and adapter, which matters during
// v0.1 because assumption A1 is exactly "does this compositing path work on // 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. /// comparison is written once rather than at each of a dozen call sites.
property <bool> cropping: root.view-mode == ViewMode.crop; property <bool> cropping: root.view-mode == ViewMode.crop;
property <bool> local-mode: root.view-mode == ViewMode.local; property <bool> local-mode: root.view-mode == ViewMode.local;
/// TRACES: FR-DEV-8
property <bool> repairing: root.view-mode == ViewMode.spots;
/// The crop rect in fractions of the frame, mirrored from Rust so the /// The crop rect in fractions of the frame, mirrored from Rust so the
/// overlay draws exactly what the pipeline holds. /// overlay draws exactly what the pipeline holds.
@@ -372,6 +376,15 @@ export component AppWindow inherits Window {
in property <bool> can-redo: false; in property <bool> can-redo: false;
callback undo(); callback undo();
callback redo(); 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 <string> undo-label: "";
/// A row's own `index`.
callback history-picked(int);
/// Scroll-to-zoom: factor, and the anchor in fractions of the visible area. /// Scroll-to-zoom: factor, and the anchor in fractions of the visible area.
callback zoom-at(float, float, float); callback zoom-at(float, float, float);
callback pan-by(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. /// rather than as one per frame of the gesture.
callback gradient-handle-released(); 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 <string> 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 <int> spot-count: 0;
in property <float> spot-radius: 0.012;
in property <float> spot-feather: 0.35;
in property <float> spot-opacity: 1.0;
/// 0 heal, 1 clone.
in property <int> 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); callback segment-image(bool);
/// A click on the photograph, in fractions of the shown image, plus /// A click on the photograph, in fractions of the shown image, plus
/// whether it should extend the selection rather than replace it. /// whether it should extend the selection rather than replace it.
@@ -1765,6 +1823,30 @@ in property <bool> 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 { if root.total > 0 && root.load-error == "": TouchArea {
x: 0; y: 0; x: 0; y: 0;
width: 100%; width: 100%;
@@ -2104,6 +2186,123 @@ in property <bool> 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 <length> disc: max(2 * spot.radius * parent.shown-h, 8px);
property <bool> 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 <float> from-x;
property <float> 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. // Arrow keys and space step through the folder.
// //
// Focused on show rather than waiting for a click, exactly // Focused on show rather than waiting for a click, exactly
@@ -2141,6 +2340,18 @@ in property <bool> panel-visible: true;
} }
return accept; 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 == " ") { if (event.text == Key.RightArrow || event.text == " ") {
root.next-image(); root.next-image();
return accept; return accept;
@@ -2183,7 +2394,9 @@ in property <bool> panel-visible: true;
// Names the mode being left rather than saying // Names the mode being left rather than saying
// "Done", which was unambiguous while there was // "Done", which was unambiguous while there was
// one mode and would not be with two. // 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; active: true;
clicked => { root.mode-picked(ViewMode.photo); } clicked => { root.mode-picked(ViewMode.photo); }
} }
@@ -2308,13 +2521,13 @@ in property <bool> panel-visible: true;
// screen. `masks.slint` carries the same note for // screen. `masks.slint` carries the same note for
// the same reason. // the same reason.
column := VerticalLayout { column := VerticalLayout {
if !root.local-mode: InfoPanel { if !root.local-mode && !root.repairing: InfoPanel {
camera: root.camera; camera: root.camera;
exposure: root.exposure; exposure: root.exposure;
dimensions: root.dimensions; dimensions: root.dimensions;
} }
if !root.local-mode: Rectangle { if !root.local-mode && !root.repairing: Rectangle {
height: 1px; height: 1px;
background: Theme.rule; background: Theme.rule;
} }
@@ -2344,7 +2557,7 @@ in property <bool> panel-visible: true;
// made rather than how it is applied: the frame is decided // made rather than how it is applied: the frame is decided
// by eye first and the pipeline runs it last (see // by eye first and the pipeline runs it last (see
// `dr_pipeline::framing` on the coordinate order). // `dr_pipeline::framing` on the coordinate order).
if !root.local-mode: GeometryPanel { if !root.local-mode && !root.repairing: GeometryPanel {
enabled: root.adjust-enabled; enabled: root.adjust-enabled;
angle: root.straighten; angle: root.straighten;
max-straighten: root.max-straighten; max-straighten: root.max-straighten;
@@ -2360,7 +2573,7 @@ in property <bool> panel-visible: true;
reset => { root.framing-reset(); } reset => { root.framing-reset(); }
} }
if !root.local-mode: Rectangle { if !root.local-mode && !root.repairing: Rectangle {
height: 1px; height: 1px;
background: Theme.rule; background: Theme.rule;
} }
@@ -2370,7 +2583,7 @@ in property <bool> panel-visible: true;
// burying it under thirty sliders would put the one // burying it under thirty sliders would put the one
// control that operates on all of them below all of // control that operates on all of them below all of
// them. // them.
if !root.local-mode: TransferPanel { if !root.local-mode && !root.repairing: TransferPanel {
enabled: root.adjust-enabled; enabled: root.adjust-enabled;
armed: root.settings-armed; armed: root.settings-armed;
summary: root.settings-summary; summary: root.settings-summary;
@@ -2393,6 +2606,32 @@ in property <bool> panel-visible: true;
// peers that silently re-points another panel, it is // peers that silently re-points another panel, it is
// what that mode's column *is*. That also takes one // what that mode's column *is*. That also takes one
// panel out of a scrolling column that had six. // 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 { if root.local-mode: MaskPanel {
enabled: root.adjust-enabled; enabled: root.adjust-enabled;
masks: root.mask-rows; masks: root.mask-rows;
@@ -2469,6 +2708,35 @@ in property <bool> panel-visible: true;
film-picked(i) => { root.film-picked(i); } film-picked(i) => { root.film-picked(i); }
film-print-toggled(on) => { root.film-print-toggled(on); } 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); }
}
} }
} }
} }
+192
View File
@@ -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 <HistoryRow> data;
in property <bool> 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 <bool> enabled: true;
in property <bool> can-undo: false;
in property <bool> can-redo: false;
/// What undo would take back, already resolved. Empty when there is
/// nowhere to go.
in property <string> 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); }
}
}
}
+170
View File
@@ -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 <bool> enabled: true;
/// Whether a repair is selected — everything below is about it.
in property <bool> has-selection: false;
/// How many repairs are on this photograph.
in property <int> 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 <float> radius: 0.012;
in property <float> 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 <float> spot-opacity: 1.0;
/// 0 heal, 1 clone — the order the chips are listed in below.
in property <int> 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(); }
}
}
}