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
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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+737
-62
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+97
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+243
-56
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
@@ -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
@@ -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); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user