diff --git a/core/dr-gpu/Cargo.toml b/core/dr-gpu/Cargo.toml index 7d7a6dd..7cd6618 100644 --- a/core/dr-gpu/Cargo.toml +++ b/core/dr-gpu/Cargo.toml @@ -32,3 +32,7 @@ required-features = ["readback"] default = [] # Exposes read_pixels outside tests. Production must not enable this. readback = [] + +[[example]] +name = "segment" +required-features = ["readback"] diff --git a/core/dr-gpu/examples/segment.rs b/core/dr-gpu/examples/segment.rs new file mode 100644 index 0000000..4f7d3d9 --- /dev/null +++ b/core/dr-gpu/examples/segment.rs @@ -0,0 +1,196 @@ +//! Segment an image and write the granularity ladder as false-coloured PPMs. +//! +//! The whole point of S15 step 2 (docs/segmentation.md §11): look at the +//! ladder and decide whether clicking through it would land on the things a +//! person means. No amount of design settles that — the pictures do. +//! +//! ```sh +//! cargo run -p dr-gpu --example segment --features readback -- IMG.CR2 +//! cargo run -p dr-gpu --example segment --features readback -- synthetic +//! ``` +//! +//! PPM for the same reason `develop` uses it: no encoder dependency, and +//! every viewer reads it. This is a diagnostic, not an export path. + +use dr_gpu::hierarchy::{MergeTree, RegionField}; +use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, SegmentOptions, SegmentPass}; + +/// The ladder the example dumps. Chosen to span "far too fine to be useful" +/// through "one or two objects", because both ends are informative: if no rung +/// looks right, the gradient is wrong rather than the ladder being too coarse. +const LEVELS: [usize; 6] = [2000, 800, 300, 120, 50, 16]; + +fn main() { + env_logger::init(); + + let mut args = std::env::args().skip(1); + let Some(input) = args.next() else { + eprintln!("usage: segment [out-prefix] [blur-radius]"); + std::process::exit(2); + }; + let prefix = args.next().unwrap_or_else(|| "segment".into()); + let blur_radius = args + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(SegmentOptions::default().blur_radius); + + let ctx = pollster::block_on(GpuContext::new_headless()).expect("gpu context"); + println!("gpu {}", ctx.adapter_name()); + + let source = if input == "synthetic" { + let (w, h) = (1200, 800); + println!("source synthetic {w} × {h}"); + DemosaicedImage::from_rgba8(&ctx, &synthetic(w, h), w, h).expect("synthetic source") + } else { + let bytes = std::fs::read(&input).expect("read file"); + let raw = dr_decode::decode(&bytes).expect("decode"); + println!("source {} × {}", raw.crop.width, raw.crop.height); + Demosaicer::new(&ctx) + .expect("demosaicer") + .run(&raw) + .expect("demosaic") + }; + + let opts = SegmentOptions { + blur_radius, + ..Default::default() + }; + let pass = SegmentPass::new(&ctx).expect("segment pass"); + + let t0 = std::time::Instant::now(); + let seg = pass.run(&source, opts).expect("segment"); + let (w, h) = seg.size(); + let field = seg.read_field().expect("read field"); + let gpu_ms = t0.elapsed().as_secs_f32() * 1000.0; + + let t1 = std::time::Instant::now(); + let tree = MergeTree::build(&field); + let tree_ms = t1.elapsed().as_secs_f32() * 1000.0; + + // M6, roughly: the readback is in `gpu_ms` and would not be there in a + // shipping build, so this over-reports the GPU half rather than under. + println!("proxy {w} × {h}, blur radius {blur_radius}"); + println!("basins {}", field.region_count); + println!("boundaries {}", field.adjacency.len()); + println!("merges {}", tree.merges.len()); + println!("segment {gpu_ms:.0} ms (includes readback)"); + println!("hierarchy {tree_ms:.1} ms"); + + if let (Some(first), Some(last)) = (tree.merges.first(), tree.merges.last()) { + println!("saddles {:.4} … {:.4}", first.saddle, last.saddle); + } + + for level in LEVELS { + if level > field.region_count { + println!("skip {level} (only {} basins)", field.region_count); + continue; + } + let grouping = tree.cut_to(level); + let pixels = field.apply(&grouping); + let groups = grouping.iter().max().map(|m| m + 1).unwrap_or(0); + let path = format!("{prefix}-{level:04}.ppm"); + write_ppm(&path, &false_colour(&pixels, &field), w, h); + println!("wrote {path} ({groups} regions)"); + } + + // The boundaries alone, which is what a snapped contour would cling to. + let path = format!("{prefix}-edges.ppm"); + write_ppm(&path, &boundaries(&field), w, h); + println!("wrote {path}"); +} + +/// A distinct colour per region. +/// +/// Hashed from the id rather than sampled from the image: two adjacent +/// regions that happen to look alike are exactly the case worth seeing, and +/// mean colours would hide it. +fn false_colour(pixels: &[u32], field: &RegionField) -> Vec { + let _ = field; + let mut out = Vec::with_capacity(pixels.len() * 3); + for &g in pixels { + // Cheap integer hash — golden-ratio multiply, then spread the bits + // across three channels. + let mut x = g.wrapping_mul(2_654_435_761); + x ^= x >> 15; + out.push((x & 0xff) as u8); + out.push(((x >> 8) & 0xff) as u8); + out.push(((x >> 16) & 0xff) as u8); + } + out +} + +/// White where two regions meet, black elsewhere. +fn boundaries(field: &RegionField) -> Vec { + let (w, h) = (field.width, field.height); + let mut out = vec![0u8; w * h * 3]; + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let edge = (x + 1 < w && field.labels[i] != field.labels[i + 1]) + || (y + 1 < h && field.labels[i] != field.labels[i + w]); + if edge { + out[i * 3] = 255; + out[i * 3 + 1] = 255; + out[i * 3 + 2] = 255; + } + } + } + out +} + +fn write_ppm(path: &str, rgb: &[u8], width: u32, height: u32) { + use std::io::Write as _; + let mut f = std::io::BufWriter::new(std::fs::File::create(path).expect("create ppm")); + write!(f, "P6\n{width} {height}\n255\n").expect("ppm header"); + f.write_all(rgb).expect("ppm body"); +} + +/// A test image with the failure modes the corpus is meant to provoke, so the +/// example is runnable before anyone has traced a single ground-truth mask. +/// +/// Deliberately includes a soft gradient boundary and a noisy patch: those are +/// where a watershed either earns its place or shatters, and a synthetic image +/// of clean shapes would flatter it. +fn synthetic(w: u32, h: u32) -> Vec { + let mut px = Vec::with_capacity((w * h * 4) as usize); + for y in 0..h { + for x in 0..w { + let fx = x as f32 / w as f32; + let fy = y as f32 / h as f32; + + // A smooth vertical gradient — the low-contrast boundary case. + let mut r = 40.0 + 120.0 * fy; + let mut g = 60.0 + 100.0 * fy; + let mut b = 110.0 + 90.0 * fy; + + // A hard-edged disc: the control case. + let d = ((fx - 0.3).powi(2) + (fy - 0.45).powi(2)).sqrt(); + if d < 0.16 { + r = 210.0; + g = 90.0; + b = 60.0; + } + + // A soft-edged disc: where the ladder should merge late. + let d2 = ((fx - 0.68).powi(2) + (fy - 0.6).powi(2)).sqrt(); + let t = (1.0 - (d2 / 0.18)).clamp(0.0, 1.0); + r = r * (1.0 - t) + 90.0 * t; + g = g * (1.0 - t) + 170.0 * t; + b = b * (1.0 - t) + 110.0 * t; + + // A noisy corner: the case pre-smoothing exists for. + if fx > 0.82 && fy < 0.22 { + let n = ((x * 7919 + y * 104_729) % 97) as f32 / 97.0; + r += (n - 0.5) * 90.0; + g += (n - 0.5) * 90.0; + b += (n - 0.5) * 90.0; + } + + px.push(r.clamp(0.0, 255.0) as u8); + px.push(g.clamp(0.0, 255.0) as u8); + px.push(b.clamp(0.0, 255.0) as u8); + px.push(255); + } + } + px +} diff --git a/core/dr-gpu/src/hierarchy.rs b/core/dr-gpu/src/hierarchy.rs new file mode 100644 index 0000000..25e5718 --- /dev/null +++ b/core/dr-gpu/src/hierarchy.rs @@ -0,0 +1,429 @@ +//! Region adjacency and the merge hierarchy — arm A's CPU half (S15). +//! +//! **Deliberately device-free.** Nothing here touches wgpu, and that is the +//! point: the hierarchy is where arm A's behaviour actually lives, so it has +//! to be assertable on hand-built inputs rather than only on whatever a GPU +//! happened to produce (ARCH §6.5a). Every test below runs on CI machines +//! with no adapter. +//! +//! It is also why this is a legitimate CPU stage rather than a violation of +//! ARCH §6.1. The watershed's basins are found on the GPU, over pixels; what +//! follows runs on the **region adjacency graph**, which for a 2 MP proxy is +//! a few thousand nodes. Pixels stay on the GPU, the graph is CPU-side — +//! the same split ARCH §3.4 already draws for the edit graph. +//! +//! # Why a hierarchy rather than one partition +//! +//! A single segmentation has exactly one granularity, and no threshold is +//! right for both "her eye" and "her face". So the watershed deliberately +//! *over*-segments, and the merge order recorded here is what lets one +//! interaction — a scroll, a drag — walk from a fragment up to the object +//! that contains it. + +use std::collections::HashMap; + +/// One boundary between two adjacent regions. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Edge { + pub a: u32, + pub b: u32, + /// The **saddle**: the lowest point on the ridge separating the two + /// basins, which is the height at which water joining them would first + /// spill over. + /// + /// The minimum over the shared boundary, not the mean or the maximum. A + /// long boundary that is strong everywhere except one weak gap describes + /// two regions that should merge early — the gap is exactly where a + /// person would say the edge fails. + pub saddle: f32, +} + +/// A partition of the image into labelled regions, plus how they adjoin. +/// +/// The shared interface from docs/segmentation.md §2: arm A produces this +/// from a watershed, arm B would produce it from a class map, and the +/// consumers above cannot tell which. +#[derive(Debug, Clone, PartialEq)] +pub struct RegionField { + pub width: usize, + pub height: usize, + /// Region id per pixel, compacted to `0..region_count`. + pub labels: Vec, + pub region_count: usize, + /// Deduplicated, `a < b`, sorted for reproducibility. + pub adjacency: Vec, +} + +impl RegionField { + /// Build from the watershed's raw output. + /// + /// `roots` holds, per pixel, the linear index of its basin root — what + /// the pointer-jumping pass converges to. Those indices are sparse and + /// arbitrary, so they are compacted here to `0..region_count` in order of + /// first appearance, which makes them stable to serialise and cheap to + /// index. + pub fn from_roots(roots: &[u32], gradient: &[f32], width: usize, height: usize) -> Self { + assert_eq!(roots.len(), width * height, "roots must cover every pixel"); + assert_eq!( + gradient.len(), + width * height, + "gradient must cover every pixel" + ); + + // Compact in raster order rather than by hashing the root value, so + // the same image always yields the same numbering (M5). + let mut compact: HashMap = HashMap::new(); + let mut labels = vec![0u32; roots.len()]; + for (i, &root) in roots.iter().enumerate() { + let next = compact.len() as u32; + labels[i] = *compact.entry(root).or_insert(next); + } + let region_count = compact.len(); + + // Saddles, over 4-neighbour adjacency. Crossing a boundary means + // climbing to the higher of the two pixels, so a pair's pass height + // is the max; the region pair's saddle is the min over all its pairs. + let mut saddles: HashMap<(u32, u32), f32> = HashMap::new(); + let note = |p: usize, q: usize, saddles: &mut HashMap<(u32, u32), f32>| { + let (la, lb) = (labels[p], labels[q]); + if la == lb { + return; + } + let key = (la.min(lb), la.max(lb)); + let pass = gradient[p].max(gradient[q]); + saddles + .entry(key) + .and_modify(|s| { + if pass < *s { + *s = pass; + } + }) + .or_insert(pass); + }; + + for y in 0..height { + for x in 0..width { + let i = y * width + x; + if x + 1 < width { + note(i, i + 1, &mut saddles); + } + if y + 1 < height { + note(i, i + width, &mut saddles); + } + } + } + + let mut adjacency: Vec = saddles + .into_iter() + .map(|((a, b), saddle)| Edge { a, b, saddle }) + .collect(); + // Sorted by (saddle, a, b) with `total_cmp` rather than `partial_cmp`: + // a total order over floats, so the sequence cannot depend on how the + // hash map happened to iterate. The merge order below is derived from + // this, and an unstable merge order is an unstable label field. + adjacency.sort_by(|x, y| { + x.saddle + .total_cmp(&y.saddle) + .then(x.a.cmp(&y.a)) + .then(x.b.cmp(&y.b)) + }); + + Self { + width, + height, + labels, + region_count, + adjacency, + } + } + + /// Apply a cut's grouping to every pixel, for display or masking. + pub fn apply(&self, grouping: &[u32]) -> Vec { + self.labels.iter().map(|&l| grouping[l as usize]).collect() + } +} + +/// One merge in the hierarchy. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Merge { + pub a: u32, + pub b: u32, + pub saddle: f32, +} + +/// The merge order over a [`RegionField`] — the whole hierarchy. +/// +/// Kruskal over the adjacency edges: sort by saddle, union-find, and record +/// each union that actually joined two distinct sets. The recording *is* the +/// dendrogram, which is why the multiscale part costs one `Vec` rather than a +/// second algorithm. +#[derive(Debug, Clone, PartialEq)] +pub struct MergeTree { + /// In merge order, so saddles are non-decreasing. + pub merges: Vec, + pub region_count: usize, +} + +impl MergeTree { + pub fn build(field: &RegionField) -> Self { + let mut uf = UnionFind::new(field.region_count); + let mut merges = Vec::new(); + + // `field.adjacency` is already sorted by (saddle, a, b). + for e in &field.adjacency { + if uf.union(e.a as usize, e.b as usize) { + merges.push(Merge { + a: e.a, + b: e.b, + saddle: e.saddle, + }); + } + } + + Self { + merges, + region_count: field.region_count, + } + } + + /// The grouping after applying every merge weaker than `threshold`. + /// + /// Returns one group id per original region, compacted to `0..groups`. + pub fn cut_at(&self, threshold: f32) -> Vec { + self.cut(self.merges.iter().take_while(|m| m.saddle <= threshold)) + } + + /// The grouping with at most `target` groups. + /// + /// The interface a scroll wheel wants: thresholds are in gradient units + /// and mean nothing to anyone, where "about two hundred regions" is a + /// granularity a person can ask for. + pub fn cut_to(&self, target: usize) -> Vec { + let target = target.max(1); + let take = self.region_count.saturating_sub(target); + self.cut(self.merges.iter().take(take)) + } + + fn cut<'a>(&self, merges: impl Iterator) -> Vec { + let mut uf = UnionFind::new(self.region_count); + for m in merges { + uf.union(m.a as usize, m.b as usize); + } + + // Compact roots in region order, so a cut's numbering is stable and + // independent of the merge sequence that produced it. + let mut seen: HashMap = HashMap::new(); + (0..self.region_count) + .map(|r| { + let root = uf.find(r); + let next = seen.len() as u32; + *seen.entry(root).or_insert(next) + }) + .collect() + } + + /// How many groups `cut_to` / `cut_at` would leave, without building one. + pub fn groups_at(&self, threshold: f32) -> usize { + let merged = self.merges.iter().filter(|m| m.saddle <= threshold).count(); + self.region_count - merged + } +} + +struct UnionFind { + parent: Vec, + rank: Vec, +} + +impl UnionFind { + fn new(n: usize) -> Self { + Self { + parent: (0..n).collect(), + rank: vec![0; n], + } + } + + fn find(&mut self, mut x: usize) -> usize { + while self.parent[x] != x { + // Path halving — the compression that keeps this near-linear + // without the second pass full compression needs. + self.parent[x] = self.parent[self.parent[x]]; + x = self.parent[x]; + } + x + } + + /// Returns whether this union joined two distinct sets. + fn union(&mut self, a: usize, b: usize) -> bool { + let (ra, rb) = (self.find(a), self.find(b)); + if ra == rb { + return false; + } + // Union by rank, but with a deterministic tie-break: equal ranks + // attach the higher index under the lower. Without it the tree shape + // depends on argument order, and `find` would return a different + // representative for the same input on a different day. + let (lo, hi) = if self.rank[ra] > self.rank[rb] { + (ra, rb) + } else if self.rank[rb] > self.rank[ra] { + (rb, ra) + } else { + let (lo, hi) = (ra.min(rb), ra.max(rb)); + self.rank[lo] += 1; + (lo, hi) + }; + self.parent[hi] = lo; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 4×2 field split down the middle, with a weak gap in the boundary. + /// + /// labels 0 0 1 1 gradient 0 5 5 0 + /// 0 0 1 1 0 2 2 0 + /// + /// The saddle is 2, not 5: the bottom row is where the ridge is lowest. + fn split_field() -> RegionField { + let roots = vec![0, 0, 2, 2, 0, 0, 2, 2]; + let gradient = vec![0.0, 5.0, 5.0, 0.0, 0.0, 2.0, 2.0, 0.0]; + RegionField::from_roots(&roots, &gradient, 4, 2) + } + + #[test] + fn roots_compact_to_dense_ids() { + // Basin roots are pixel indices and so are sparse and arbitrary. + // Anything that indexes by region needs them dense. + let f = split_field(); + assert_eq!(f.region_count, 2); + assert_eq!(f.labels, vec![0, 0, 1, 1, 0, 0, 1, 1]); + } + + #[test] + fn the_saddle_is_the_lowest_pass_not_the_typical_one() { + // The property that makes the hierarchy match human judgement: two + // regions joined by one weak gap belong together, however strong the + // rest of the boundary is. + let f = split_field(); + assert_eq!(f.adjacency.len(), 1); + assert_eq!(f.adjacency[0].saddle, 2.0); + } + + #[test] + fn a_uniform_image_is_one_region() { + // No minima to separate, so nothing to merge — and the tree must not + // invent a merge it cannot justify. + let f = RegionField::from_roots(&[0; 16], &[0.0; 16], 4, 4); + assert_eq!(f.region_count, 1); + assert!(f.adjacency.is_empty()); + assert!(MergeTree::build(&f).merges.is_empty()); + } + + /// Three regions in a row: 0 | 1 | 2, with the 1–2 boundary weaker. + fn chain_field() -> RegionField { + let roots = vec![0, 0, 2, 2, 4, 4]; + let gradient = vec![0.0, 9.0, 0.0, 3.0, 0.0, 0.0]; + RegionField::from_roots(&roots, &gradient, 6, 1) + } + + #[test] + fn the_weakest_boundary_merges_first() { + // The whole basis of the granularity ladder: walking up the tree must + // dissolve the least convincing edge before a strong one. + let tree = MergeTree::build(&chain_field()); + assert_eq!(tree.merges.len(), 2); + assert_eq!(tree.merges[0].saddle, 3.0); + assert_eq!(tree.merges[1].saddle, 9.0); + assert_eq!((tree.merges[0].a, tree.merges[0].b), (1, 2)); + } + + #[test] + fn merges_are_ordered_by_saddle() { + let tree = MergeTree::build(&chain_field()); + assert!( + tree.merges.windows(2).all(|w| w[0].saddle <= w[1].saddle), + "a cut at a threshold is only meaningful if merges are ordered" + ); + } + + #[test] + fn a_cut_walks_from_every_region_to_one() { + let tree = MergeTree::build(&chain_field()); + + // The bottom of the ladder: nothing merged. + let fine = tree.cut_to(3); + assert_eq!(fine, vec![0, 1, 2]); + + // The middle: the weak boundary is gone, the strong one survives. + let mid = tree.cut_to(2); + assert_eq!(mid[1], mid[2], "the weak boundary should have dissolved"); + assert_ne!(mid[0], mid[1], "the strong boundary should survive"); + + // The top: one region. + let coarse = tree.cut_to(1); + assert_eq!(coarse, vec![0, 0, 0]); + } + + #[test] + fn cut_to_is_clamped_rather_than_panicking() { + // A scroll wheel runs past both ends of the ladder, and neither end + // is an error. + let tree = MergeTree::build(&chain_field()); + assert_eq!(tree.cut_to(0), tree.cut_to(1)); + assert_eq!(tree.cut_to(99), vec![0, 1, 2]); + } + + #[test] + fn cut_at_and_cut_to_agree() { + let tree = MergeTree::build(&chain_field()); + // Above the weak saddle, below the strong one. + assert_eq!(tree.cut_at(5.0), tree.cut_to(2)); + assert_eq!(tree.groups_at(5.0), 2); + } + + #[test] + fn a_grouping_maps_back_onto_pixels() { + let f = chain_field(); + let tree = MergeTree::build(&f); + let px = f.apply(&tree.cut_to(2)); + assert_eq!(px, vec![0, 0, 1, 1, 1, 1]); + } + + #[test] + fn the_same_input_gives_a_bit_identical_result() { + // M5 in miniature. The CPU half must contribute no nondeterminism of + // its own, or there is no point asking whether the GPU half does: + // hash map iteration order is the obvious way to fail this, which is + // why both the adjacency list and the cut numbering are sorted rather + // than taken as the map yields them. + let (a, b) = (chain_field(), chain_field()); + assert_eq!(a, b); + + let (ta, tb) = (MergeTree::build(&a), MergeTree::build(&b)); + assert_eq!(ta, tb); + assert_eq!(ta.cut_to(2), tb.cut_to(2)); + } + + #[test] + fn a_ladder_over_many_regions_is_monotone() { + // Region counts must fall as you climb, with no level skipped — + // otherwise a scroll step could jump past the granularity someone + // wanted. + let n = 32; + let roots: Vec = (0..n).map(|i| i as u32).collect(); + let gradient: Vec = (0..n).map(|i| (i % 7) as f32).collect(); + let f = RegionField::from_roots(&roots, &gradient, n, 1); + let tree = MergeTree::build(&f); + + let mut last = usize::MAX; + for target in (1..=n).rev() { + let groups = tree.cut_to(target).iter().max().map(|m| *m as usize + 1); + let groups = groups.unwrap_or(0); + assert_eq!(groups, target, "cut_to({target}) should leave {target}"); + assert!(groups < last); + last = groups; + } + } +} diff --git a/core/dr-gpu/src/lib.rs b/core/dr-gpu/src/lib.rs index 1b7fb92..fcc204a 100644 --- a/core/dr-gpu/src/lib.rs +++ b/core/dr-gpu/src/lib.rs @@ -14,9 +14,12 @@ use wgpu::util::DeviceExt; mod adjust; mod demosaic; mod error; +pub mod hierarchy; +mod segment; pub use adjust::AdjustPass; pub use demosaic::{DemosaicedImage, Demosaicer}; pub use error::GpuError; +pub use segment::{SegmentOptions, SegmentPass, Segmentation}; /// Owns the wgpu device and queue. /// diff --git a/core/dr-gpu/src/segment.rs b/core/dr-gpu/src/segment.rs new file mode 100644 index 0000000..8488c16 --- /dev/null +++ b/core/dr-gpu/src/segment.rs @@ -0,0 +1,575 @@ +//! Watershed segmentation — arm A's GPU half (S15, docs/segmentation.md). +//! +//! Runs the five passes in `shaders/watershed.wgsl` over a demosaiced image +//! and leaves a basin label per pixel on the GPU. The hierarchy built from +//! those labels lives in [`crate::hierarchy`], which needs no device. +//! +//! # Cost +//! +//! Every pass is a trivial kernel and the whole chain is a handful of +//! milliseconds at proxy resolution. It runs **once per image**, off the +//! interactive path — the point of precomputing a region map is that +//! selection afterwards is a label comparison rather than a flood fill. +//! +//! # The open question this leaves +//! +//! [`Segmentation::read_field`] copies the label and gradient buffers back to +//! the CPU to build the region adjacency graph, and is gated behind the +//! `readback` feature for the same reason `read_pixels` is. That gate is not +//! ceremony: a shipping build cannot take this path (ARCH §6.1, AC-8), so the +//! RAG would have to be accumulated GPU-side with atomics instead. +//! +//! For a spike that trade is the right way round — the readback is once per +//! image and off the frame path, and building the GPU-side RAG before knowing +//! whether the granularity ladder is any good would be work spent on a +//! question not yet asked. But it is a real gap between this and something +//! shippable, and it should be read as one. + +use wgpu::util::DeviceExt; + +use crate::{DemosaicedImage, GpuContext, GpuError}; + +/// How the watershed is tuned for one image. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SegmentOptions { + /// Longest proxy edge. The segmentation runs here, not at sensor + /// resolution: a 24 MP watershed costs 12× the memory to place boundaries + /// a person cannot see, and the boundary refinement that matters at 1:1 + /// is a separate stage (docs/segmentation.md §4). + pub max_edge: u32, + /// Pre-smoothing radius in proxy pixels. The caller's to raise with ISO — + /// this is the single knob that decides whether a noisy file segments + /// into regions or into grain. + pub blur_radius: i32, + pub w_luma: f32, + pub w_chroma: f32, +} + +impl Default for SegmentOptions { + fn default() -> Self { + Self { + // ~1.3 MP at 3:2. Large enough that a boundary is within a pixel + // or two of where it belongs, small enough that the whole chain + // fits comfortably in memory on a phone. + max_edge: 1600, + blur_radius: 2, + w_luma: 1.0, + // Chroma carries most of the sensor noise and few of the + // boundaries anyone would draw, so it counts for less — but not + // zero, or a red flower on green leaves has no edge at all. + w_chroma: 0.5, + } + } +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct SegParams { + width: u32, + height: u32, + src_width: u32, + src_height: u32, + blur_radius: i32, + non_linear: u32, + w_luma: f32, + w_chroma: f32, +} + +/// One compute stage: its layout and its compiled pipeline. +struct Stage { + layout: wgpu::BindGroupLayout, + pipeline: wgpu::ComputePipeline, +} + +/// Runs the watershed chain. +pub struct SegmentPass { + ctx: GpuContext, + features: Stage, + blur: Stage, + gradient: Stage, + flow: Stage, + jump: Stage, +} + +impl SegmentPass { + pub fn new(ctx: &GpuContext) -> Result { + // A validation failure here is a bug in the shader, not a user error. + // Surfaced as a Result rather than wgpu's default panic, matching how + // `AdjustPass` handles its generated source. + let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); + + let module = ctx + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("watershed"), + source: wgpu::ShaderSource::Wgsl(include_str!("shaders/watershed.wgsl").into()), + }); + + let features = { + let layout = ctx + .device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("watershed-features-bgl"), + entries: &[ + uniform_entry(0), + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + storage_entry(2, false), + ], + }); + let pipeline = compute(ctx, &module, &layout, "features"); + Stage { layout, pipeline } + }; + + let buffer_stage = |in_binding: u32, out_binding: u32, entry: &str, label: &str| { + let layout = ctx + .device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(label), + entries: &[ + uniform_entry(0), + storage_entry(in_binding, true), + storage_entry(out_binding, false), + ], + }); + let pipeline = compute(ctx, &module, &layout, entry); + Stage { layout, pipeline } + }; + + let blur = buffer_stage(3, 4, "blur", "watershed-blur-bgl"); + let gradient = buffer_stage(5, 6, "gradient", "watershed-gradient-bgl"); + let flow = buffer_stage(7, 8, "flow", "watershed-flow-bgl"); + let jump = buffer_stage(9, 10, "jump", "watershed-jump-bgl"); + + if let Some(err) = pollster::block_on(scope.pop()) { + return Err(GpuError::ShaderCompilation(err.to_string())); + } + + Ok(Self { + ctx: ctx.clone(), + features, + blur, + gradient, + flow, + jump, + }) + } + + /// Segment an image into basins. + pub fn run( + &self, + source: &DemosaicedImage, + opts: SegmentOptions, + ) -> Result { + let (src_w, src_h) = source.size(); + let (width, height) = proxy_size(src_w, src_h, opts.max_edge); + let n = (width * height) as u64; + + let params = self + .ctx + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("watershed-params"), + contents: bytemuck::bytes_of(&SegParams { + width, + height, + src_width: src_w, + src_height: src_h, + blur_radius: opts.blur_radius, + non_linear: u32::from(source.is_non_linear()), + w_luma: opts.w_luma, + w_chroma: opts.w_chroma, + }), + usage: wgpu::BufferUsages::UNIFORM, + }); + + // `vec4` rather than `vec3` for the feature buffers: a WGSL storage + // array of vec3 still strides by 16 bytes, so packing to three floats + // would save nothing and cost an index calculation. + let feat_a = self.buffer("watershed-feat-a", n * 16, false); + let feat_b = self.buffer("watershed-feat-b", n * 16, false); + let gradient = self.buffer("watershed-gradient", n * 4, true); + let parent_a = self.buffer("watershed-parent-a", n * 4, true); + let parent_b = self.buffer("watershed-parent-b", n * 4, true); + + let mut enc = self + .ctx + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("watershed-encoder"), + }); + + let groups = (width.div_ceil(8), height.div_ceil(8)); + + let features_bg = self + .ctx + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("watershed-features-bg"), + layout: &self.features.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(source.view()), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: feat_a.as_entire_binding(), + }, + ], + }); + + let blur_bg = self.bind(&self.blur.layout, ¶ms, 3, &feat_a, 4, &feat_b); + let gradient_bg = self.bind(&self.gradient.layout, ¶ms, 5, &feat_b, 6, &gradient); + let flow_bg = self.bind(&self.flow.layout, ¶ms, 7, &gradient, 8, &parent_a); + let jump_ab = self.bind(&self.jump.layout, ¶ms, 9, &parent_a, 10, &parent_b); + let jump_ba = self.bind(&self.jump.layout, ¶ms, 9, &parent_b, 10, &parent_a); + + // Pointer jumping halves every path per pass, so log2 of the pixel + // count bounds it — that is the longest possible descent chain. A + // convergence test would cost a readback per iteration to save a + // handful of dispatches of a two-line kernel. + let jumps = (n as f64).log2().ceil() as u32 + 1; + + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("watershed-pass"), + timestamp_writes: None, + }); + + for (pipeline, bg) in [ + (&self.features.pipeline, &features_bg), + (&self.blur.pipeline, &blur_bg), + (&self.gradient.pipeline, &gradient_bg), + (&self.flow.pipeline, &flow_bg), + ] { + pass.set_pipeline(pipeline); + pass.set_bind_group(0, bg, &[]); + pass.dispatch_workgroups(groups.0, groups.1, 1); + } + + pass.set_pipeline(&self.jump.pipeline); + for i in 0..jumps { + let bg = if i % 2 == 0 { &jump_ab } else { &jump_ba }; + pass.set_bind_group(0, bg, &[]); + pass.dispatch_workgroups(groups.0, groups.1, 1); + } + } + + self.ctx.queue.submit(Some(enc.finish())); + + // An odd number of jumps leaves the result in B. + let labels = if jumps % 2 == 1 { parent_b } else { parent_a }; + + Ok(Segmentation { + ctx: self.ctx.clone(), + width, + height, + labels, + gradient, + }) + } + + fn buffer(&self, label: &str, size: u64, copyable: bool) -> wgpu::Buffer { + let mut usage = wgpu::BufferUsages::STORAGE; + if copyable { + usage |= wgpu::BufferUsages::COPY_SRC; + } + self.ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size, + usage, + mapped_at_creation: false, + }) + } + + fn bind( + &self, + layout: &wgpu::BindGroupLayout, + params: &wgpu::Buffer, + in_binding: u32, + input: &wgpu::Buffer, + out_binding: u32, + output: &wgpu::Buffer, + ) -> wgpu::BindGroup { + self.ctx + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("watershed-bg"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: in_binding, + resource: input.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: out_binding, + resource: output.as_entire_binding(), + }, + ], + }) + } +} + +/// The result of one segmentation: a basin label per pixel, on the GPU. +pub struct Segmentation { + ctx: GpuContext, + width: u32, + height: u32, + /// Per pixel, the linear index of its basin root. Sparse — compacted by + /// [`crate::hierarchy::RegionField::from_roots`]. + labels: wgpu::Buffer, + gradient: wgpu::Buffer, +} + +impl Segmentation { + pub fn size(&self) -> (u32, u32) { + (self.width, self.height) + } + + /// The label buffer, for a shader that masks by region id. + pub fn labels(&self) -> &wgpu::Buffer { + &self.labels + } + + /// Build the region adjacency graph, reading the labels back to the CPU. + /// + /// **Not a shipping path** — see this module's header. Gated so it cannot + /// be reached from a production build by accident. + #[cfg(any(test, feature = "readback"))] + pub fn read_field(&self) -> Result { + let n = (self.width * self.height) as usize; + let roots: Vec = read_buffer(&self.ctx, &self.labels, n)?; + let gradient: Vec = read_buffer(&self.ctx, &self.gradient, n)?; + Ok(crate::hierarchy::RegionField::from_roots( + &roots, + &gradient, + self.width as usize, + self.height as usize, + )) + } +} + +fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + } +} + +fn storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + } +} + +fn compute( + ctx: &GpuContext, + module: &wgpu::ShaderModule, + layout: &wgpu::BindGroupLayout, + entry: &str, +) -> wgpu::ComputePipeline { + let pipeline_layout = ctx + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("watershed-layout"), + bind_group_layouts: &[Some(layout)], + immediate_size: 0, + }); + ctx.device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: Some(&pipeline_layout), + module, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }) +} + +/// The proxy size for a source, preserving aspect and never upscaling. +fn proxy_size(src_w: u32, src_h: u32, max_edge: u32) -> (u32, u32) { + let longest = src_w.max(src_h); + if longest <= max_edge || longest == 0 { + return (src_w.max(1), src_h.max(1)); + } + let scale = f64::from(max_edge) / f64::from(longest); + ( + ((f64::from(src_w) * scale).round() as u32).max(1), + ((f64::from(src_h) * scale).round() as u32).max(1), + ) +} + +#[cfg(any(test, feature = "readback"))] +fn read_buffer( + ctx: &GpuContext, + buffer: &wgpu::Buffer, + len: usize, +) -> Result, GpuError> { + let size = (len * std::mem::size_of::()) as u64; + let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("watershed-readback"), + size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut enc = ctx.device.create_command_encoder(&Default::default()); + enc.copy_buffer_to_buffer(buffer, 0, &staging, 0, size); + ctx.queue.submit(Some(enc.finish())); + + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .map_err(|e| GpuError::Readback(e.to_string()))?; + rx.recv() + .map_err(|e| GpuError::Readback(e.to_string()))? + .map_err(|e| GpuError::Readback(e.to_string()))?; + + let data = slice.get_mapped_range(); + let out = bytemuck::cast_slice::(&data).to_vec(); + drop(data); + staging.unmap(); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hierarchy::MergeTree; + + fn ctx() -> Option { + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } + } + + #[test] + fn a_proxy_preserves_aspect_and_never_upscales() { + assert_eq!(proxy_size(6000, 4000, 1600), (1600, 1067)); + assert_eq!(proxy_size(4000, 6000, 1600), (1067, 1600)); + // A thumbnail must not be blown up to the proxy size — there is no + // detail there to find basins in. + assert_eq!(proxy_size(800, 600, 1600), (800, 600)); + assert_eq!(proxy_size(0, 0, 1600), (1, 1)); + } + + /// Two flat halves split by a hard vertical edge. + fn two_tone(w: u32, h: u32) -> Vec { + let mut px = Vec::with_capacity((w * h * 4) as usize); + for _ in 0..h { + for x in 0..w { + let v = if x < w / 2 { 30u8 } else { 220u8 }; + px.extend_from_slice(&[v, v, v, 255]); + } + } + px + } + + #[test] + fn a_hard_edge_produces_two_regions_at_the_top_of_the_ladder() { + // The end-to-end property, on an image whose answer is not in doubt: + // whatever the watershed does with texture, it must not lose an edge + // this obvious, and the coarsest non-trivial cut must be exactly the + // two halves. + let Some(ctx) = ctx() else { return }; + let (w, h) = (64u32, 64u32); + let src = DemosaicedImage::from_rgba8(&ctx, &two_tone(w, h), w, h).expect("source"); + + let pass = SegmentPass::new(&ctx).expect("segment pass"); + let seg = pass.run(&src, SegmentOptions::default()).expect("run"); + assert_eq!(seg.size(), (w, h)); + + let field = seg.read_field().expect("read field"); + let tree = MergeTree::build(&field); + let px = field.apply(&tree.cut_to(2)); + + for y in 0..h as usize { + let left = px[y * w as usize]; + let right = px[y * w as usize + w as usize - 1]; + assert_ne!(left, right, "the two halves must not share a region"); + } + } + + #[test] + fn a_flat_image_does_not_fragment() { + // The noise case in miniature. A gradient of zero everywhere is one + // enormous plateau, which is exactly where a watershed without a + // strict tie-break either hangs or shatters into per-pixel basins. + let Some(ctx) = ctx() else { return }; + let (w, h) = (32u32, 32u32); + let flat = vec![128u8; (w * h * 4) as usize]; + let src = DemosaicedImage::from_rgba8(&ctx, &flat, w, h).expect("source"); + + let pass = SegmentPass::new(&ctx).expect("segment pass"); + let seg = pass.run(&src, SegmentOptions::default()).expect("run"); + let field = seg.read_field().expect("read field"); + + assert_eq!( + field.region_count, 1, + "a plateau should resolve to one basin, not {}", + field.region_count + ); + } + + #[test] + fn the_same_image_segments_identically_twice() { + // M5 on one device — the weaker half of the determinism question, but + // the half that catches a race in the pointer jumping. Cross-vendor + // is the part that needs hardware this test cannot assume. + let Some(ctx) = ctx() else { return }; + let (w, h) = (48u32, 48u32); + let src = DemosaicedImage::from_rgba8(&ctx, &two_tone(w, h), w, h).expect("source"); + let pass = SegmentPass::new(&ctx).expect("segment pass"); + + let a = pass + .run(&src, SegmentOptions::default()) + .expect("run") + .read_field() + .expect("field"); + let b = pass + .run(&src, SegmentOptions::default()) + .expect("run") + .read_field() + .expect("field"); + + assert_eq!(a, b, "segmentation must be reproducible run to run"); + } +} diff --git a/core/dr-gpu/src/shaders/watershed.wgsl b/core/dr-gpu/src/shaders/watershed.wgsl new file mode 100644 index 0000000..c9e8e22 --- /dev/null +++ b/core/dr-gpu/src/shaders/watershed.wgsl @@ -0,0 +1,256 @@ +// Watershed segmentation — the passes behind arm A of S15 (docs/segmentation.md). +// +// Five entry points forming one chain: +// +// features source texture -> perceptual triple, box-downscaled to proxy size +// blur pre-smoothing, without which every noise grain becomes a basin +// gradient Sobel magnitude — the surface the watershed floods +// flow each pixel points downhill to its steepest neighbour +// jump pointer-jumping, until every pixel points at its basin root +// +// Everything after `features` works in storage buffers rather than textures. +// That is deliberate: the flow and jump passes need read-write access to the +// same array across dispatches, which storage textures do not give portably, +// and a buffer reads back without the 256-byte row padding a texture copy +// imposes. + +struct Params { + // Proxy dimensions — what every pass but `features` iterates over. + width: u32, + height: u32, + // Source dimensions, for the box downscale in `features`. + src_width: u32, + src_height: u32, + // Half-width of the pre-smoothing kernel, in proxy pixels. 0 disables it. + blur_radius: i32, + // 1 when the source is already display-encoded (the JPEG path), 0 for + // linear scene-referred data out of the demosaicer. + non_linear: u32, + // How much luma and chroma each contribute to the gradient. Chroma is + // weighted lower because it carries most of the sensor noise and few of + // the boundaries a person would draw. + w_luma: f32, + w_chroma: f32, +} + +// Binding slots are unique across the whole module, not reused per entry +// point: WGSL resource variables share one namespace, so two globals at the +// same (group, binding) is a module-level validation error even when no +// single entry point uses both. Each pass therefore gets its own pair, and +// each pipeline a layout declaring only the slots it touches. +@group(0) @binding(0) var u: Params; + +// ---------------------------------------------------------------- features + +@group(0) @binding(1) var src: texture_2d; +@group(0) @binding(2) var feat_out: array>; + +// Linear or display-encoded RGB to a roughly perceptual opponent triple. +// +// Perceptual rather than linear because the gradient has to agree with what +// a person calls an edge. In linear light a highlight rolloff swamps the +// boundary between two midtones, and the watershed would put its strongest +// walls where nobody sees one. +// +// The two chroma axes are opponent differences rather than a real Lab +// transform: they cost three subtractions instead of a matrix and a cube +// root, and the watershed only needs the *magnitude* of colour change, not a +// colorimetrically defensible value for it. +fn perceptual(c_in: vec3) -> vec3 { + var c = max(c_in, vec3(0.0)); + if (u.non_linear == 0u) { + c = pow(c, vec3(1.0 / 2.4)); + } + let l = dot(c, vec3(0.2126, 0.7152, 0.0722)); + let a = c.r - c.g; + let b = c.b - 0.5 * (c.r + c.g); + return vec3(l, a, b); +} + +// Source -> proxy, averaging every source pixel that falls in the proxy +// pixel's footprint. +// +// A box average rather than point sampling because the proxy is where the +// segmentation happens: point sampling a 24 MP sensor down to 2 MP aliases +// fine texture into false gradient, and the watershed would faithfully find +// basins in the aliasing. +@compute @workgroup_size(8, 8, 1) +fn features(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= u.width || gid.y >= u.height) { + return; + } + + let sx0 = (gid.x * u.src_width) / u.width; + let sy0 = (gid.y * u.src_height) / u.height; + let sx1 = max(sx0 + 1u, ((gid.x + 1u) * u.src_width) / u.width); + let sy1 = max(sy0 + 1u, ((gid.y + 1u) * u.src_height) / u.height); + + var acc = vec3(0.0); + var n = 0.0; + for (var sy = sy0; sy < sy1; sy = sy + 1u) { + for (var sx = sx0; sx < sx1; sx = sx + 1u) { + let c = textureLoad(src, vec2(i32(sx), i32(sy)), 0).rgb; + acc = acc + perceptual(c); + n = n + 1.0; + } + } + + feat_out[gid.y * u.width + gid.x] = vec4(acc / max(n, 1.0), 0.0); +} + +// -------------------------------------------------------------------- blur + +@group(0) @binding(3) var blur_in: array>; +@group(0) @binding(4) var blur_out: array>; + +fn clamp_coord(v: i32, hi: u32) -> u32 { + return u32(clamp(v, 0, i32(hi) - 1)); +} + +// Pre-smoothing. Not a refinement — without it the watershed is unusable. +// +// A raw gradient over sensor data has a local minimum at every noise grain, +// and one basin per local minimum means a 2 MP frame segments into hundreds +// of thousands of regions that correspond to nothing. The radius is the +// caller's to set from ISO. +@compute @workgroup_size(8, 8, 1) +fn blur(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= u.width || gid.y >= u.height) { + return; + } + let idx = gid.y * u.width + gid.x; + + if (u.blur_radius <= 0) { + blur_out[idx] = blur_in[idx]; + return; + } + + var acc = vec3(0.0); + var wsum = 0.0; + let r = u.blur_radius; + for (var dy = -r; dy <= r; dy = dy + 1) { + for (var dx = -r; dx <= r; dx = dx + 1) { + let sx = clamp_coord(i32(gid.x) + dx, u.width); + let sy = clamp_coord(i32(gid.y) + dy, u.height); + let d2 = f32(dx * dx + dy * dy); + let w = exp(-d2 / (2.0 * f32(r) * f32(r))); + acc = acc + blur_in[sy * u.width + sx].rgb * w; + wsum = wsum + w; + } + } + blur_out[idx] = vec4(acc / wsum, 0.0); +} + +// ---------------------------------------------------------------- gradient + +@group(0) @binding(5) var grad_in: array>; +@group(0) @binding(6) var grad_out: array; + +fn feat_at(x: i32, y: i32) -> vec3 { + let sx = clamp_coord(x, u.width); + let sy = clamp_coord(y, u.height); + return grad_in[sy * u.width + sx].rgb; +} + +// Sobel magnitude over the weighted opponent triple. +// +// This is the surface the watershed floods, so its units matter for nothing +// except ordering — only the *relative* height of one boundary against +// another decides which regions merge first. +@compute @workgroup_size(8, 8, 1) +fn gradient(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= u.width || gid.y >= u.height) { + return; + } + let x = i32(gid.x); + let y = i32(gid.y); + + let tl = feat_at(x - 1, y - 1); + let tc = feat_at(x, y - 1); + let tr = feat_at(x + 1, y - 1); + let ml = feat_at(x - 1, y); + let mr = feat_at(x + 1, y); + let bl = feat_at(x - 1, y + 1); + let bc = feat_at(x, y + 1); + let br = feat_at(x + 1, y + 1); + + let gx = (tr + 2.0 * mr + br) - (tl + 2.0 * ml + bl); + let gy = (bl + 2.0 * bc + br) - (tl + 2.0 * tc + tr); + + let w = vec3(u.w_luma, u.w_chroma, u.w_chroma); + let wx = gx * w; + let wy = gy * w; + + grad_out[gid.y * u.width + gid.x] = sqrt(dot(wx, wx) + dot(wy, wy)); +} + +// -------------------------------------------------------------------- flow + +@group(0) @binding(7) var flow_grad: array; +@group(0) @binding(8) var flow_out: array; + +// Each pixel points at the steepest-descent neighbour among its 8, or at +// itself if it is a local minimum — a basin seed. +// +// **The tie-break is load-bearing, twice over.** Comparing on (value, index) +// rather than value alone gives a strict total order, so the pointer graph +// descends monotonically and cannot contain a cycle — plateaux, which are +// everywhere in a smoothed image, would otherwise make two equal pixels point +// at each other and hang the pointer-jumping below. +// +// It is also what makes the result reproducible. S15's M5 asks whether a +// label field is stable enough across GPU vendors to be a cache key +// (ARCH §6.13); an arbitrary tie-break would answer no before the question +// was asked. +@compute @workgroup_size(8, 8, 1) +fn flow(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= u.width || gid.y >= u.height) { + return; + } + let idx = gid.y * u.width + gid.x; + + var best_val = flow_grad[idx]; + var best_idx = idx; + + for (var dy = -1; dy <= 1; dy = dy + 1) { + for (var dx = -1; dx <= 1; dx = dx + 1) { + if (dx == 0 && dy == 0) { + continue; + } + let nx = i32(gid.x) + dx; + let ny = i32(gid.y) + dy; + if (nx < 0 || ny < 0 || nx >= i32(u.width) || ny >= i32(u.height)) { + continue; + } + let ni = u32(ny) * u.width + u32(nx); + let nv = flow_grad[ni]; + if (nv < best_val || (nv == best_val && ni < best_idx)) { + best_val = nv; + best_idx = ni; + } + } + } + + flow_out[idx] = best_idx; +} + +// -------------------------------------------------------------------- jump + +@group(0) @binding(9) var jump_in: array; +@group(0) @binding(10) var jump_out: array; + +// Pointer jumping: parent = parent[parent]. +// +// Halves every path length per dispatch, so ceil(log2(longest path)) passes +// resolve every pixel to its basin root. The host runs a fixed count bounded +// by log2(pixel count) rather than testing for convergence, because a +// convergence test costs a readback per iteration and the bound is ~21 +// dispatches of a trivial kernel. +@compute @workgroup_size(8, 8, 1) +fn jump(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= u.width || gid.y >= u.height) { + return; + } + let idx = gid.y * u.width + gid.x; + jump_out[idx] = jump_in[jump_in[idx]]; +} diff --git a/docs/segmentation.md b/docs/segmentation.md new file mode 100644 index 0000000..eeca7c1 --- /dev/null +++ b/docs/segmentation.md @@ -0,0 +1,334 @@ +# Region segmentation for local masking + +Spec for **S15**, the spike that decides how DarkRoom finds the boundaries a local mask snaps to. + +Local adjustments (FR-DEV-3, "linear gradient, radial gradient, and brush masks") need more than +placement handles to be competitive. The interactions that matter — click to select a region, drag a +contour that clings to an edge, paint without crossing a boundary — all need the same thing +underneath: **a map of where the image's regions are.** + +There are two credible ways to produce that map and they are not obviously ordered. This document +specifies both, specifies the third option of combining them, and fixes the measurements that decide +between them *before* any of them is built. + +--- + +## 1. Why this is a spike and not a build + +Three properties make the choice expensive to get wrong. + +**It sets the mask representation.** If regions exist, a mask is a *set of region ids* — integers, +diffable, mergeable at node level under FR-NC-9, cheap in a sidecar. If they don't, a mask is a +raster, and rasters are none of those things. This is the decision that is expensive to retrofit; +everything else in local masking sits on top of it. + +**One arm collides with a settled policy.** D13 records that every dependency choice in this project +has gone the same way — rustls over aws-lc-rs, bundled SQLite, a Rust Lensfun port, zune-jpeg — to +avoid a C dependency under the Android NDK, and names `ort` as the largest exception that policy +would tolerate. Arm B needs exactly that exception. Arm A needs no new dependency at all. That +asymmetry is not a tiebreak, it is most of the cost difference, and it should be priced honestly +rather than discovered at packaging time. + +**Model licensing is a distribution blocker.** D13 already establishes this for the face pipeline, +and the same reading applies here — see §7. It is a licence-reading exercise, not a research +question, and it comes first. + +--- + +## 2. The common interface + +Both arms produce the same thing. This is what makes them comparable, and what lets the choice be +deferred behind a seam rather than baked into every consumer. + +```rust +/// A partition of the image into labelled regions. +pub struct RegionField { + /// Per-pixel region id at proxy resolution. R32Uint on the GPU. + labels: Texture, + /// Per-region summary: pixel count, bounding box, mean colour, + /// and (arm B only) a semantic class id. + regions: Vec, + /// Boundary strength per adjacent region pair — the edge weight + /// the merge tree is built from and the cost field reads. + adjacency: Vec<(RegionId, RegionId, f32)>, +} +``` + +Two consumers sit on it, and neither knows which arm produced it: + +**A cost field, for contour snapping.** Live-wire — Dijkstra from the last anchor to the cursor over +a per-pixel cost that is *low* on boundaries. The cost is a **sum of terms**, which is the property +that matters: image gradient is always available, region boundary strength is added when a +`RegionField` exists, and a semantic boundary term is added when a model is present. Each source +improves the snap without changing the interface, so the arms are not exclusive here even in +principle. + +**A region set, for click selection.** Click reads the label under the cursor; the mask is +`label(px) ∈ selected`. Add and subtract are set operations on ids. No flood fill, no readback, no +iteration — the whole reason the precomputed map is worth having. + +Both consumers are built once, in the spike, and shared by both arms. A comparison in which each arm +gets its own consumer measures the consumers. + +--- + +## 3. Arm A — multiscale watershed + +No model, no new dependency, deterministic, works on any image. + +**Gradient.** Sobel magnitude over a perceptual luma plus chroma distance, not camera-space RGB — +channel-weighted RGB gradient reads a saturated red edge as weaker than it looks. Computed after +demosaic and denoise, before the edit graph, so an exposure change does not invalidate it. + +**Pre-smoothing is not optional.** Raw watershed on a noisy file makes every grain its own basin. +A guided or bilateral pre-filter, with strength tied to the file's ISO, is part of the arm rather +than a refinement of it. + +**Basins.** Each pixel points downhill to its steepest neighbour; pointer-jumping resolves every +pixel to its basin root in log passes. Two compute shaders and a dispatch loop. + +**The hierarchy is the cheap part.** Build the region adjacency graph, sort edges by boundary +strength, union-find over them, and *record the merge order*. That recording is the merge tree — a +click selects a leaf, and a scroll walks up through progressively coarser merges. Textbook Kruskal on +a graph of a few thousand nodes. + +That node count is why the tree build is a legitimate CPU operation: it runs on the adjacency graph, +not on pixels. Pixels stay on the GPU, the graph is CPU-side — the same split ARCH §3.4 and §6.1 +already draw for the edit graph, so no exception to the no-readback rule is needed. + +**Known weaknesses, to be measured rather than argued about:** over-segmentation on noise and +texture, weak boundaries where contrast is low but semantics are obvious (a pale sky meeting a pale +wall), and a granularity ladder that is geometric rather than meaningful — level 7 is *a* coarser +partition, not necessarily *the* object. + +--- + +## 4. Arm B — semantic segmentation + +YOLO26-seg pretrained on ADE20K, run through `ort`. + +ADE20K's 150 classes include stuff — sky, vegetation, water, wall, road — which is a far better +vocabulary for photography than COCO's 80 thing-classes. "That patch of sky" is a class here. The +nano variant is ~1.6M parameters, which is genuinely mobile-viable in a way SAM never was. + +**It produces a flat partition with class ids**, so it populates `RegionField` directly: connected +components of the class map become regions, class boundaries become adjacency edges. + +**What it does not produce is a hierarchy.** One partition at one semantic granularity. Click "sky" +and you get all the sky; there is no level at which you get *this part* of the sky. Adjacent +same-class regions merge whether or not you wanted them to — two different walls are one wall. + +**Boundaries are semantically right and geometrically soft.** Internal stride is 4–8, upsampled to +H×W, so the class map is confident about *which* side of the boundary a pixel is on and vague about +*where* the boundary is to the pixel. Acceptable for biasing a contour. Not acceptable as a mask +edge at 100% zoom. + +**Costs it brings that arm A does not:** a C dependency on the Android NDK against D13's policy, a +model to distribute and cache, an AGPL question (§7), an inference runtime per platform, and output +whose determinism across drivers is unproven (§6). + +--- + +## 5. Arm C — semantic as a merge prior + +The arms are not alternatives, and a comparison that omits their combination is a false dichotomy. + +Weight each region-adjacency edge in arm A's union-find by boundary strength **and** by whether the +two regions share a semantic class. Regions that agree semantically merge earlier. + +The result is a hierarchy whose coarse levels align with semantic objects and whose fine levels stay +pixel-accurate — the model doing what models are good at, which is knowing what things *are*, and +watershed doing what it is good at, which is knowing where boundaries are, exactly, at every scale. +It also repairs arm B's two weaknesses at once: the soft boundary is replaced by the watershed +boundary underneath it, and the missing granularity ladder is arm A's. + +Arm C is the expected winner on quality. The question the spike actually has to answer is therefore +not "which is better" but **how much better than arm A alone, and is that increment worth D13's +cost.** §8 fixes that threshold in advance. + +--- + +## 6. What gets measured + +Per arm, over the corpus in §9, using the shared consumers from §2. + +| # | Measure | Method | Why it decides anything | +|---|---|---|---| +| **M1** | **Interactions to target mask** | Clicks plus scroll steps to reach ≥95% IoU against a hand-traced mask | The real UX metric. "How many actions to get the mask I meant" is what a user experiences | +| **M2** | **Boundary accuracy** | Precision/recall of snapped-contour pixels within a 2px slack of the hand trace | Whether the edge survives 100% zoom, where masks are actually judged | +| **M3** | **Granularity coverage** | Per case, yes/no: does *any* hierarchy level produce the target region? | A hard failure mode. Arm B is expected to fail this wherever the target is not a class | +| **M4** | **Out-of-vocabulary behaviour** | M1 and M3 restricted to the OOV subset | Whether the arm degrades gracefully or produces nothing usable off-distribution | +| **M5** | **Determinism** | Same input twice on one machine; then across Mesa/AMD, NVIDIA, and Adreno | Gates whether a label field can be a cache key at all — see below | +| **M6** | **Precompute cost** | ms at proxy resolution and peak memory, on the reference desktop and one Android device | Whether it fits a background prefetch alongside the proxy | +| **M7** | **Distribution cost** | Added binary size, model size, new native dependencies, licence | D13's axis. Priced, not assumed | + +**M5 deserves its own note, and it is a risk for arm A too.** ARCH §6.13 holds that cache keys are +computed over CPU-side *integer* state because GPU float results diverge across vendors. A label +field is integer state — but it is *derived from* float gradient arithmetic, so a boundary sitting +exactly between two basins could resolve differently on Adreno than on Mesa. If either arm proves +non-deterministic across vendors, its output cannot be a cache key and cannot round-trip through a +sidecar as region ids, which would push masks back toward rasters and undo most of §1's argument. +This is the measurement most likely to invalidate the whole approach, and it should be run early +rather than last. + +--- + +## 7. Licence reading — before any code + +D13's position applies unchanged: discovering at packaging time that a feature cannot ship is the +expensive failure, and it is entirely avoidable. + +**Ultralytics ships YOLO under AGPL-3.0** — confirmed 2026-08-17 by reading `LICENSE` at the head of +`github.com/ultralytics/ultralytics`, which is the GNU Affero General Public License v3 verbatim. +That is deliberate on their part; the commercial licence is their business model. + +GPLv3 §13 explicitly permits the combination, so this is *not* the blocker the InsightFace +non-commercial weights were: it is redistributable. But the combined work becomes effectively AGPL, +which is a change to DarkRoom's licensing posture rather than a dependency detail, and it needs to be +a decision made on purpose. + +Still to verify before writing any of arm B: + +- The licence on YOLO26 specifically, and on the ADE20K-pretrained weights *separately* from the + framework code — they are not necessarily the same grant. +- Whether ADE20K's own terms permit redistribution of weights derived from it. +- Whether AGPL is acceptable for DarkRoom, given Flatpak, F-Droid and Play distribution + (NFR-COMPAT-2). + +**Arm A raises none of these questions**, which is worth stating plainly as part of its cost. + +--- + +## 8. Decision criteria, fixed in advance + +Stated now so the result cannot be rationalised afterwards. + +- **Arm A ships alone** if it reaches within **one interaction** (M1) of arm C on the scene subset + *and* dominates arm C on the OOV subset (M4). The semantic increment does not then justify a C + dependency, an AGPL conversion, and a per-platform inference runtime. +- **Arm C ships** if it beats arm A by **two or more interactions** on the scene subset without + regressing OOV. That is a large enough difference to be felt in ordinary use, and it is what would + justify reopening D13. +- **Arm B never ships alone.** M3 is expected to fail on anything that is not an ADE20K class, and an + arm with a hard failure mode and no fallback is not a selection tool. If it surprises us and passes + M3 broadly, that is a genuine finding and this criterion is revisited on the evidence. +- **If M5 fails for an arm across vendors**, that arm cannot carry region ids into the sidecar + regardless of how it scored elsewhere. + +--- + +## 9. Corpus + +Roughly 24 images from a real library — three per category — hand-traced once and reused across all +arms. Categories chosen for the failure modes they provoke, not for coverage: + +| Category | Provokes | +|---|---| +| Gradient sky | Low-contrast boundary; watershed banding | +| Foliage against sky | High-frequency boundary — arm A over-segments, arm B blurs | +| Hair against a busy background | The classic hard mask edge | +| Out-of-focus background | No edges at all; tests graceful failure in both | +| High-ISO noise | Arm A's known weakness; tests whether pre-smoothing is sufficient | +| Backlit silhouette | Strong unambiguous edge — the control case | +| Macro, abstract, still life | **OOV for ADE20K.** Arm B expected to fail M3 here | +| Architectural detail | Repeated structure; arm B merges distinct walls into one class | + +Hand-tracing 24 masks is a couple of hours and it is what makes M1 and M2 mean anything. Without +ground truth this comparison is two demos and a preference. + +--- + +## 10. Deliverables + +Nothing in the UI, nothing in the graph, nothing in the sidecar. + +- `core/dr-gpu/src/shaders/watershed.wgsl` — gradient and basin propagation. +- `core/dr-gpu/src/segment.rs` — the passes, producing a `RegionField`. +- RAG construction and the union-find merge tree as a pure-CPU module with unit tests and no device, + so the hierarchy is testable headless the way `dr-pipeline` is (ARCH §6.5a). +- The two shared consumers from §2 — live-wire over a summable cost field, and region-set selection. +- `core/dr-gpu/examples/segment.rs` — false-coloured PNGs at four or five hierarchy levels, plus the + M1/M2 numbers against the traced corpus. + +It graduates to `core/dr-segment` if it ships; that is not a spike decision. + +--- + +## 11. Order + +1. **Licence reading (§7).** Hours, and it can eliminate arm B before anything is built. +2. **Arm A, and the shared consumers.** About a day. Look at the false-coloured PNGs — if the + granularity ladder does not feel right, nothing downstream matters and that is worth knowing + immediately. +3. **M5 across vendors, early.** It is the measurement that can invalidate the region-id + representation entirely, and it wants knowing before the corpus work is invested. +4. **The traced corpus, then M1–M4 on arm A.** Establishes the baseline every other arm is judged + against. +5. **Arms B and C**, only if §7 cleared and arm A's baseline leaves room worth closing. + +Arm A is a day and needs no model, no runtime, no licence and no new dependency. It is also the +substrate every model-based arm writes into — so it is first regardless of how the comparison +eventually lands. + +--- + +## 12. Arm A results + +Built 2026-08-17. `core/dr-gpu/src/{segment.rs,hierarchy.rs}`, +`shaders/watershed.wgsl`, `examples/segment.rs`. 15 tests, 11 of them device-free. + +**It works, and the hierarchy is not the expensive part.** On a 1200×800 synthetic at blur radius 2, +release build, RTX 3050 laptop: 6,730 basins and 19,223 boundaries found in **67 ms including the +readback**, and the merge tree built from them in **0.2 ms**. The tree is ~0.3% of the cost. The +estimate that priced it as a week's work was wrong by about two orders of magnitude, and the reason +is worth recording: it is Kruskal over a few thousand nodes, not a segmentation algorithm. + +**The granularity ladder behaves.** At the fine end the background fragments badly — a smooth tonal +ramp bands into horizontal strips, and flat areas break into diagonal chains (see below). By +`cut_to(300)` all of that is gone: the hard-edged disc is exactly one region, the whole gradient +background is one region, and only genuine noise still fragments. The over-segmentation is absorbed +by the merge order rather than needing to be prevented, which is the property the whole design rests +on. + +**Pre-smoothing is the knob it was claimed to be.** Radius 2 leaves the noisy corner fragmented at +300 regions; radius 5 largely clears it. Tying it to ISO is the right control. + +Three findings that change what comes next: + +**F1 — plateaux fragment into diagonal chains.** In an exactly flat region every pixel's steepest +descent is a tie, and the (value, index) tie-break sends them all up-left, so a plateau resolves into +diagonal streaks rather than one basin. Harmless here because those saddles are ~0 and the tree +merges them first — but a real sky or wall is a large plateau, and relying on the hierarchy to clean +up an artefact of the flow pass is fragile. The principled fix is a **lower-complete transform**: one +extra pass giving plateau pixels a gradient toward their nearest descending exit. Standard, cheap, +and worth doing before the corpus work. + +**F2 — `cut_to(N)` is a visualisation, not the interaction.** A global cut by region count spends its +budget wherever the saddles happen to be densest: at blur 5 the soft-edged disc's interior held a +cluster of near-equal saddles and ate the budget, fragmenting at a level where everything else was +clean. The real interaction walks up locally from the clicked region and has no such coupling. The +ladder in the example should not be read as what a user would experience. + +**F3 — the RAG build still needs a readback.** `Segmentation::read_field` copies labels and gradient +to the CPU, gated behind the `readback` feature exactly as `read_pixels` is. Fine for a spike and +off the frame path, but a shipping build cannot take it (ARCH §6.1, AC-8), so the adjacency +accumulation has to move GPU-side with atomics. That is the largest known gap between this and +something shippable. + +**M5 partially answered.** Run-to-run on one device is bit-identical, and the CPU half contributes no +nondeterminism of its own — both asserted by tests. Cross-vendor is untouched and remains the +measurement that can invalidate the region-id representation. + +--- + +## 13. Register entries + +To be added when this is accepted: + +**S15** — *Region segmentation for local masking:* implement arm A and the shared consumers, trace a +24-image corpus, measure M1–M7 across arms. **Resolve the licence question before writing arm B.** +Answers: which segmentation source local masking snaps to, and whether a mask can be stored as region +ids at all. Relates to: D13, D14, FR-DEV-3, ARCH §5.4, §6.13. + +**D14** — *Segmentation source for local masking* · **OPEN**. Decided by S15 against the criteria in +§8. Reopens D13's dependency-policy question if arm C wins. diff --git a/docs/traceability.md b/docs/traceability.md index b889a59..8dc2ac7 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 113 | -| TRACES tags found | 186 | +| Source files scanned | 118 | +| TRACES tags found | 199 | | Requirements defined | 151 | | Requirements covered | 77 | | **Coverage** | **51.0%** (77/151) | @@ -34,53 +34,53 @@ _None._ | ID | Tagged in | |---|---| | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:185`](../core/dr-types/src/lib.rs#L185), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | -| FR-CAT-11 | [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150) | +| FR-CAT-11 | [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:366`](../core/dr-sync-nextcloud/src/lib.rs#L366), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1299`](../ui/dr-ui/src/collections_ui.rs#L1299), [`ui/dr-ui/src/collections_ui.rs:817`](../ui/dr-ui/src/collections_ui.rs#L817), [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150), [`ui/dr-ui/src/library.rs:167`](../ui/dr-ui/src/library.rs#L167), [`ui/dr-ui/src/library.rs:2135`](../ui/dr-ui/src/library.rs#L2135), [`ui/dr-ui/src/library.rs:2167`](../ui/dr-ui/src/library.rs#L2167), [`ui/dr-ui/src/library_ui.rs:112`](../ui/dr-ui/src/library_ui.rs#L112), [`ui/dr-ui/src/library_ui.rs:476`](../ui/dr-ui/src/library_ui.rs#L476), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:458`](../ui/dr-ui/ui/collections.slint#L458) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:366`](../core/dr-sync-nextcloud/src/lib.rs#L366), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1299`](../ui/dr-ui/src/collections_ui.rs#L1299), [`ui/dr-ui/src/collections_ui.rs:817`](../ui/dr-ui/src/collections_ui.rs#L817), [`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:2430`](../ui/dr-ui/src/library.rs#L2430), [`ui/dr-ui/src/library.rs:2462`](../ui/dr-ui/src/library.rs#L2462), [`ui/dr-ui/src/library_ui.rs:112`](../ui/dr-ui/src/library_ui.rs#L112), [`ui/dr-ui/src/library_ui.rs:508`](../ui/dr-ui/src/library_ui.rs#L508), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:458`](../ui/dr-ui/ui/collections.slint#L458) | | FR-CAT-1a | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) | | 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-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) | | FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | | FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:240`](../core/dr-decode/src/lib.rs#L240), [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179) | | FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | -| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/develop.rs:820`](../ui/dr-ui/src/develop.rs#L820), [`ui/dr-ui/src/lib.rs:1114`](../ui/dr-ui/src/lib.rs#L1114), [`ui/dr-ui/src/lib.rs:1214`](../ui/dr-ui/src/lib.rs#L1214), [`ui/dr-ui/src/lib.rs:405`](../ui/dr-ui/src/lib.rs#L405), [`ui/dr-ui/src/lib.rs:607`](../ui/dr-ui/src/lib.rs#L607), [`ui/dr-ui/src/lib.rs:815`](../ui/dr-ui/src/lib.rs#L815), [`ui/dr-ui/src/library.rs:1075`](../ui/dr-ui/src/library.rs#L1075), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289), [`ui/dr-ui/src/library_ui.rs:2995`](../ui/dr-ui/src/library_ui.rs#L2995) | -| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-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:104`](../core/dr-types/src/lib.rs#L104), [`ui/dr-ui/src/library.rs:1035`](../ui/dr-ui/src/library.rs#L1035), [`ui/dr-ui/src/library.rs:136`](../ui/dr-ui/src/library.rs#L136), [`ui/dr-ui/src/library.rs:194`](../ui/dr-ui/src/library.rs#L194), [`ui/dr-ui/src/library.rs:2255`](../ui/dr-ui/src/library.rs#L2255), [`ui/dr-ui/src/library_ui.rs:1014`](../ui/dr-ui/src/library_ui.rs#L1014), [`ui/dr-ui/src/library_ui.rs:1056`](../ui/dr-ui/src/library_ui.rs#L1056), [`ui/dr-ui/src/library_ui.rs:1318`](../ui/dr-ui/src/library_ui.rs#L1318), [`ui/dr-ui/src/library_ui.rs:1583`](../ui/dr-ui/src/library_ui.rs#L1583), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:1902`](../ui/dr-ui/src/library_ui.rs#L1902), [`ui/dr-ui/src/library_ui.rs:1974`](../ui/dr-ui/src/library_ui.rs#L1974), [`ui/dr-ui/src/library_ui.rs:2139`](../ui/dr-ui/src/library_ui.rs#L2139), [`ui/dr-ui/src/library_ui.rs:291`](../ui/dr-ui/src/library_ui.rs#L291), [`ui/dr-ui/src/library_ui.rs:3147`](../ui/dr-ui/src/library_ui.rs#L3147), [`ui/dr-ui/src/library_ui.rs:3175`](../ui/dr-ui/src/library_ui.rs#L3175) | +| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/develop.rs:852`](../ui/dr-ui/src/develop.rs#L852), [`ui/dr-ui/src/lib.rs:1236`](../ui/dr-ui/src/lib.rs#L1236), [`ui/dr-ui/src/lib.rs:1341`](../ui/dr-ui/src/lib.rs#L1341), [`ui/dr-ui/src/lib.rs:406`](../ui/dr-ui/src/lib.rs#L406), [`ui/dr-ui/src/lib.rs:608`](../ui/dr-ui/src/lib.rs#L608), [`ui/dr-ui/src/lib.rs:937`](../ui/dr-ui/src/lib.rs#L937), [`ui/dr-ui/src/library.rs:1329`](../ui/dr-ui/src/library.rs#L1329), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291), [`ui/dr-ui/src/library.rs:349`](../ui/dr-ui/src/library.rs#L349), [`ui/dr-ui/src/library.rs:567`](../ui/dr-ui/src/library.rs#L567), [`ui/dr-ui/src/library_ui.rs:3136`](../ui/dr-ui/src/library_ui.rs#L3136), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-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:104`](../core/dr-types/src/lib.rs#L104), [`ui/dr-ui/src/library.rs:1289`](../ui/dr-ui/src/library.rs#L1289), [`ui/dr-ui/src/library.rs:1366`](../ui/dr-ui/src/library.rs#L1366), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2550`](../ui/dr-ui/src/library.rs#L2550), [`ui/dr-ui/src/library.rs:349`](../ui/dr-ui/src/library.rs#L349), [`ui/dr-ui/src/library.rs:547`](../ui/dr-ui/src/library.rs#L547), [`ui/dr-ui/src/library.rs:567`](../ui/dr-ui/src/library.rs#L567), [`ui/dr-ui/src/library.rs:621`](../ui/dr-ui/src/library.rs#L621), [`ui/dr-ui/src/library_ui.rs:1046`](../ui/dr-ui/src/library_ui.rs#L1046), [`ui/dr-ui/src/library_ui.rs:1072`](../ui/dr-ui/src/library_ui.rs#L1072), [`ui/dr-ui/src/library_ui.rs:1088`](../ui/dr-ui/src/library_ui.rs#L1088), [`ui/dr-ui/src/library_ui.rs:1182`](../ui/dr-ui/src/library_ui.rs#L1182), [`ui/dr-ui/src/library_ui.rs:1444`](../ui/dr-ui/src/library_ui.rs#L1444), [`ui/dr-ui/src/library_ui.rs:146`](../ui/dr-ui/src/library_ui.rs#L146), [`ui/dr-ui/src/library_ui.rs:1709`](../ui/dr-ui/src/library_ui.rs#L1709), [`ui/dr-ui/src/library_ui.rs:179`](../ui/dr-ui/src/library_ui.rs#L179), [`ui/dr-ui/src/library_ui.rs:2043`](../ui/dr-ui/src/library_ui.rs#L2043), [`ui/dr-ui/src/library_ui.rs:2115`](../ui/dr-ui/src/library_ui.rs#L2115), [`ui/dr-ui/src/library_ui.rs:2280`](../ui/dr-ui/src/library_ui.rs#L2280), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:3288`](../ui/dr-ui/src/library_ui.rs#L3288), [`ui/dr-ui/src/library_ui.rs:3316`](../ui/dr-ui/src/library_ui.rs#L3316), [`ui/dr-ui/src/library_ui.rs:365`](../ui/dr-ui/src/library_ui.rs#L365), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161) | -| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291) | | FR-DEV-3 | [`core/dr-pipeline/src/framing.rs:185`](../core/dr-pipeline/src/framing.rs#L185) | | FR-DEV-3a | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133), [`core/dr-pipeline/src/graph.rs:17`](../core/dr-pipeline/src/graph.rs#L17), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133), [`ui/dr-ui/src/develop.rs:1258`](../ui/dr-ui/src/develop.rs#L1258) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1734`](../core/dr-pipeline/build.rs#L1734), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:133`](../core/dr-pipeline/src/graph.rs#L133), [`ui/dr-ui/src/develop.rs:1290`](../ui/dr-ui/src/develop.rs#L1290) | | FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:185`](../core/dr-pipeline/src/framing.rs#L185) | | FR-DEV-3e | [`core/dr-decode/src/lib.rs:492`](../core/dr-decode/src/lib.rs#L492), [`core/dr-decode/src/lib.rs:612`](../core/dr-decode/src/lib.rs#L612) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:199`](../core/dr-pipeline/src/framing.rs#L199), [`core/dr-types/src/lib.rs:272`](../core/dr-types/src/lib.rs#L272) | -| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:132`](../core/dr-gpu/src/lib.rs#L132) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:801`](../ui/dr-ui/src/develop.rs#L801), [`ui/dr-ui/src/develop.rs:811`](../ui/dr-ui/src/develop.rs#L811), [`ui/dr-ui/src/lib.rs:815`](../ui/dr-ui/src/lib.rs#L815), [`ui/dr-ui/src/library.rs:1075`](../ui/dr-ui/src/library.rs#L1075), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289), [`ui/dr-ui/src/library.rs:317`](../ui/dr-ui/src/library.rs#L317), [`ui/dr-ui/src/library_ui.rs:1414`](../ui/dr-ui/src/library_ui.rs#L1414), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:434`](../ui/dr-ui/src/settings_ui.rs#L434), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:455`](../ui/dr-ui/ui/library.slint#L455), [`ui/dr-ui/ui/library.slint:474`](../ui/dr-ui/ui/library.slint#L474), [`ui/dr-ui/ui/library.slint:748`](../ui/dr-ui/ui/library.slint#L748), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | -| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:47`](../ui/dr-ui/src/lib.rs#L47) | +| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:135`](../core/dr-gpu/src/lib.rs#L135) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:833`](../ui/dr-ui/src/develop.rs#L833), [`ui/dr-ui/src/develop.rs:843`](../ui/dr-ui/src/develop.rs#L843), [`ui/dr-ui/src/lib.rs:937`](../ui/dr-ui/src/lib.rs#L937), [`ui/dr-ui/src/library.rs:1329`](../ui/dr-ui/src/library.rs#L1329), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291), [`ui/dr-ui/src/library.rs:319`](../ui/dr-ui/src/library.rs#L319), [`ui/dr-ui/src/library_ui.rs:1540`](../ui/dr-ui/src/library_ui.rs#L1540), [`ui/dr-ui/src/library_ui.rs:333`](../ui/dr-ui/src/library_ui.rs#L333), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:502`](../ui/dr-ui/src/settings_ui.rs#L502), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:455`](../ui/dr-ui/ui/library.slint#L455), [`ui/dr-ui/ui/library.slint:474`](../ui/dr-ui/ui/library.slint#L474), [`ui/dr-ui/ui/library.slint:748`](../ui/dr-ui/ui/library.slint#L748), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | +| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:48`](../ui/dr-ui/src/lib.rs#L48) | | 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-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-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:291`](../ui/dr-ui/src/lib.rs#L291), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-7 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/library_ui.rs:1991`](../ui/dr-ui/src/library_ui.rs#L1991) | +| 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:292`](../ui/dr-ui/src/lib.rs#L292), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) | +| FR-EXP-7 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/library_ui.rs:2132`](../ui/dr-ui/src/library_ui.rs#L2132) | | FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:91`](../core/dr-export/src/lib.rs#L91), [`core/dr-gpu/src/adjust.rs:332`](../core/dr-gpu/src/adjust.rs#L332), [`ui/dr-ui/src/develop.rs:542`](../ui/dr-ui/src/develop.rs#L542), [`ui/dr-ui/src/lib.rs:291`](../ui/dr-ui/src/lib.rs#L291) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:91`](../core/dr-export/src/lib.rs#L91), [`core/dr-gpu/src/adjust.rs:332`](../core/dr-gpu/src/adjust.rs#L332), [`ui/dr-ui/src/develop.rs:574`](../ui/dr-ui/src/develop.rs#L574), [`ui/dr-ui/src/lib.rs:292`](../ui/dr-ui/src/lib.rs#L292) | | 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/library_ui.rs:1991`](../ui/dr-ui/src/library_ui.rs#L1991) | +| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/library.rs:1366`](../ui/dr-ui/src/library.rs#L1366), [`ui/dr-ui/src/library.rs:349`](../ui/dr-ui/src/library.rs#L349), [`ui/dr-ui/src/library.rs:621`](../ui/dr-ui/src/library.rs#L621), [`ui/dr-ui/src/library_ui.rs:1088`](../ui/dr-ui/src/library_ui.rs#L1088), [`ui/dr-ui/src/library_ui.rs:2132`](../ui/dr-ui/src/library_ui.rs#L2132), [`ui/dr-ui/src/library_ui.rs:365`](../ui/dr-ui/src/library_ui.rs#L365), [`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:811`](../core/dr-sync-nextcloud/src/lib.rs#L811), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) | | FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) | | FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | | FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:1140`](../ui/dr-ui/src/lib.rs#L1140), [`ui/dr-ui/src/lib.rs:1668`](../ui/dr-ui/src/lib.rs#L1668), [`ui/dr-ui/src/library.rs:1035`](../ui/dr-ui/src/library.rs#L1035), [`ui/dr-ui/src/library.rs:854`](../ui/dr-ui/src/library.rs#L854), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1119`](../ui/dr-ui/src/library_ui.rs#L1119), [`ui/dr-ui/src/library_ui.rs:173`](../ui/dr-ui/src/library_ui.rs#L173), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:193`](../ui/dr-ui/src/library_ui.rs#L193), [`ui/dr-ui/src/library_ui.rs:250`](../ui/dr-ui/src/library_ui.rs#L250), [`ui/dr-ui/src/library_ui.rs:260`](../ui/dr-ui/src/library_ui.rs#L260), [`ui/dr-ui/src/library_ui.rs:302`](../ui/dr-ui/src/library_ui.rs#L302), [`ui/dr-ui/src/library_ui.rs:3164`](../ui/dr-ui/src/library_ui.rs#L3164), [`ui/dr-ui/src/library_ui.rs:349`](../ui/dr-ui/src/library_ui.rs#L349), [`ui/dr-ui/src/library_ui.rs:380`](../ui/dr-ui/src/library_ui.rs#L380), [`ui/dr-ui/src/library_ui.rs:761`](../ui/dr-ui/src/library_ui.rs#L761), [`ui/dr-ui/src/library_ui.rs:862`](../ui/dr-ui/src/library_ui.rs#L862), [`ui/dr-ui/src/library_ui.rs:981`](../ui/dr-ui/src/library_ui.rs#L981), [`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/library.slint:504`](../ui/dr-ui/ui/library.slint#L504) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:1267`](../ui/dr-ui/src/lib.rs#L1267), [`ui/dr-ui/src/lib.rs:1795`](../ui/dr-ui/src/lib.rs#L1795), [`ui/dr-ui/src/library.rs:1108`](../ui/dr-ui/src/library.rs#L1108), [`ui/dr-ui/src/library.rs:1131`](../ui/dr-ui/src/library.rs#L1131), [`ui/dr-ui/src/library.rs:1289`](../ui/dr-ui/src/library.rs#L1289), [`ui/dr-ui/src/library_ui.rs:1013`](../ui/dr-ui/src/library_ui.rs#L1013), [`ui/dr-ui/src/library_ui.rs:1245`](../ui/dr-ui/src/library_ui.rs#L1245), [`ui/dr-ui/src/library_ui.rs:187`](../ui/dr-ui/src/library_ui.rs#L187), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:207`](../ui/dr-ui/src/library_ui.rs#L207), [`ui/dr-ui/src/library_ui.rs:266`](../ui/dr-ui/src/library_ui.rs#L266), [`ui/dr-ui/src/library_ui.rs:276`](../ui/dr-ui/src/library_ui.rs#L276), [`ui/dr-ui/src/library_ui.rs:318`](../ui/dr-ui/src/library_ui.rs#L318), [`ui/dr-ui/src/library_ui.rs:3305`](../ui/dr-ui/src/library_ui.rs#L3305), [`ui/dr-ui/src/library_ui.rs:381`](../ui/dr-ui/src/library_ui.rs#L381), [`ui/dr-ui/src/library_ui.rs:412`](../ui/dr-ui/src/library_ui.rs#L412), [`ui/dr-ui/src/library_ui.rs:793`](../ui/dr-ui/src/library_ui.rs#L793), [`ui/dr-ui/src/library_ui.rs:894`](../ui/dr-ui/src/library_ui.rs#L894), [`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/library.slint:504`](../ui/dr-ui/ui/library.slint#L504) | | 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:104`](../core/dr-types/src/lib.rs#L104), [`core/dr-types/src/lib.rs:186`](../core/dr-types/src/lib.rs#L186), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | | FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/lib.rs:1114`](../ui/dr-ui/src/lib.rs#L1114), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:212`](../core/dr-pipeline/src/sidecar.rs#L212) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/lib.rs:1236`](../ui/dr-ui/src/lib.rs#L1236), [`ui/dr-ui/src/library.rs:291`](../ui/dr-ui/src/library.rs#L291), [`ui/dr-ui/src/library_ui.rs:333`](../ui/dr-ui/src/library_ui.rs#L333) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:212`](../core/dr-pipeline/src/sidecar.rs#L212), [`ui/dr-ui/src/library.rs:621`](../ui/dr-ui/src/library.rs#L621), [`ui/dr-ui/src/library.rs:748`](../ui/dr-ui/src/library.rs#L748) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | @@ -88,11 +88,11 @@ _None._ | FR-RAW-3 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-decode/src/lib.rs:94`](../core/dr-decode/src/lib.rs#L94) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:122`](../core/dr-decode/src/lib.rs#L122) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1750`](../ui/dr-ui/src/lib.rs#L1750), [`ui/dr-ui/src/lib.rs:55`](../ui/dr-ui/src/lib.rs#L55), [`ui/dr-ui/ui/library.slint:546`](../ui/dr-ui/ui/library.slint#L546) | -| FR-UI-2 | [`ui/dr-ui/src/lib.rs:55`](../ui/dr-ui/src/lib.rs#L55) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1877`](../ui/dr-ui/src/lib.rs#L1877), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56), [`ui/dr-ui/ui/library.slint:546`](../ui/dr-ui/ui/library.slint#L546) | +| FR-UI-2 | [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56) | | FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | -| FR-UI-4 | [`ui/dr-ui/ui/app.slint:1064`](../ui/dr-ui/ui/app.slint#L1064) | -| 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:1784`](../ui/dr-ui/src/lib.rs#L1784), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-4 | [`ui/dr-ui/ui/app.slint:1084`](../ui/dr-ui/ui/app.slint#L1084) | +| 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:1911`](../ui/dr-ui/src/lib.rs#L1911), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:256`](../core/dr-pipeline/src/framing.rs#L256) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | 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) | @@ -105,11 +105,11 @@ _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/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:47`](../ui/dr-ui/src/lib.rs#L47) | +| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:48`](../ui/dr-ui/src/lib.rs#L48) | | NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-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) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) | -| R4 | [`core/dr-gpu/src/lib.rs:132`](../core/dr-gpu/src/lib.rs#L132) | +| R4 | [`core/dr-gpu/src/lib.rs:135`](../core/dr-gpu/src/lib.rs#L135) | ## Not yet tagged diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index da43951..15e62ec 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -89,6 +89,32 @@ impl DevelopSession { } } +/// The empty choices model, shared by every row that is not an enum. +/// +/// **One identity, deliberately reused.** `ModelRc` compares by *identity*, not +/// by contents, and `sync_rows` decides which controls to invalidate by +/// comparing each fresh row against the one on screen. Handing out a brand-new +/// empty model per row per call therefore makes every row differ from itself on +/// every parameter event, so the panel rewrites all of them. +/// +/// That is not merely wasteful, it breaks dragging. An operation with several +/// parameters renders them through a repeater whose model is read off the +/// group's head row; rewriting that row re-evaluates the repeater, which +/// rebuilds its items and destroys the `TouchArea` holding the gesture. The +/// slider takes the press, jumps once, and then goes dead under the finger — +/// and only for multi-parameter operations, since a lone parameter has no inner +/// repeater to rebuild. +/// +/// `sync_rows` already carries the identical warning about a curve's `points`. +/// This is the same hazard arriving through a second field. +fn no_choices() -> slint::ModelRc { + thread_local! { + static EMPTY: slint::ModelRc = + slint::ModelRc::new(slint::VecModel::from(Vec::::new())); + } + EMPTY.with(Clone::clone) +} + /// Whether this frontend has an implementation of `widget` **anywhere**. /// /// "Anywhere" is doing real work: a widget may be drawn in the panel, as the @@ -269,7 +295,13 @@ pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec { unit: unit.into(), // Only curve rows carry points. points: slint::ModelRc::new(slint::VecModel::from(Vec::::new())), - choices: slint::ModelRc::new(slint::VecModel::from(choices)), + // The shared empty model unless this row really has choices — + // see `no_choices` for why the identity matters. + choices: if choices.is_empty() { + no_choices() + } else { + slint::ModelRc::new(slint::VecModel::from(choices)) + }, }); } } diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index be31362..d7acf76 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -28,6 +28,7 @@ mod net_runtime; mod presets; mod settings_store; mod settings_ui; +mod sidecar_cache; mod trash; use std::cell::RefCell; @@ -768,6 +769,127 @@ pub fn run(paths: Vec) -> Result<()> { library.set_keep_opened_originals(stored.cache.keep_opened_originals); } + // --- the export folder picker ---------------------------------------- + // + // Wired here rather than inside `settings_ui::wire` because listing a + // remote folder needs credentials, and the settings page deliberately + // holds no session — it is reachable before a library is opened and must + // not depend on one existing. + { + let weak = window.as_weak(); + let ctl = settings.clone(); + let library = library.clone(); + + // Every entry point needs the same three things, so they are fetched + // once here rather than at four call sites. + let start = move |ctl: &Rc, + weak: &slint::Weak, + library: &Rc, + path: String| { + match library.session() { + Some((creds, session)) => { + settings_ui::spawn_folder_list( + weak.clone(), + ctl.clone(), + creds, + session.user_id.clone(), + path, + ); + } + None => { + // No account, so nothing to browse. Said plainly rather + // than left as an empty list, which would read as a server + // with no folders on it. + ctl.set_error("Sign in to a library before choosing a folder on it."); + ctl.browser.replace(None); + } + } + }; + + { + let (weak, ctl, library, start) = + (weak.clone(), ctl.clone(), library.clone(), start); + window.on_settings_browse_open_picker(move || { + let Some(w) = weak.upgrade() else { return }; + // Opens on the library root rather than on whatever the + // destination field happens to contain: a half-typed path + // would list nothing and look like a broken picker. + ctl.browser.replace(Some(launch::FolderBrowser { + path: String::new(), + entries: Vec::new(), + loading: true, + })); + start(&ctl, &weak, &library, String::new()); + settings_ui::render(&w, &ctl); + }); + } + + { + let (weak, ctl, library, start) = + (weak.clone(), ctl.clone(), library.clone(), start); + window.on_settings_browse_into(move |name| { + let Some(w) = weak.upgrade() else { return }; + let path = { + let mut browser = ctl.browser.borrow_mut(); + let Some(b) = browser.as_mut() else { return }; + let path = b.child_path(&name); + b.path = path.clone(); + b.entries.clear(); + b.loading = true; + path + }; + start(&ctl, &weak, &library, path); + settings_ui::render(&w, &ctl); + }); + } + + { + let (weak, ctl, library, start) = + (weak.clone(), ctl.clone(), library.clone(), start); + window.on_settings_browse_up(move || { + let Some(w) = weak.upgrade() else { return }; + let path = { + let mut browser = ctl.browser.borrow_mut(); + let Some(b) = browser.as_mut() else { return }; + let Some(path) = b.parent_path() else { return }; + b.path = path.clone(); + b.entries.clear(); + b.loading = true; + path + }; + start(&ctl, &weak, &library, path); + settings_ui::render(&w, &ctl); + }); + } + + { + let (weak, ctl) = (weak.clone(), ctl.clone()); + window.on_settings_browse_confirm(move || { + let Some(w) = weak.upgrade() else { return }; + // The folder being *shown* is the one chosen, matching the + // library picker — so "use this one" means the same thing in + // both places rather than depending on a selection the list + // does not have. + let chosen = ctl.browser.borrow().as_ref().map(|b| b.path.clone()); + if let Some(path) = chosen { + ctl.set_destination(path); + } + ctl.browser.replace(None); + settings_ui::render(&w, &ctl); + refresh_export_label(&w, &ctl); + }); + } + + { + let (weak, ctl) = (weak.clone(), ctl.clone()); + window.on_settings_browse_cancel(move || { + let Some(w) = weak.upgrade() else { return }; + ctl.browser.replace(None); + settings_ui::render(&w, &ctl); + }); + } + } + let lib = library.clone(); let ctl = settings.clone(); let weak = window.as_weak(); @@ -1134,8 +1256,13 @@ pub fn run(paths: Vec) -> Result<()> { // it to — and starting it here means the edit is ready when the // photograph is, instead of the image appearing at its defaults // and visibly changing a moment later. - let sidecar_rx = - library::spawn_sidecar_fetch(creds.clone(), user_id.clone(), path.clone()); + let sidecar_rx = library::spawn_sidecar_fetch( + creds.clone(), + user_id.clone(), + path.clone(), + library.sidecar_cache_dir().unwrap_or_default(), + library.is_offline(), + ); // TRACES: FR-NC-6a // The cache is consulted first, so a second open of the same diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 2d59544..b03161d 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -28,6 +28,8 @@ use dr_catalog::{Catalog, JobKind, Priority}; use dr_sync::{RemoteBackend, RemoteId, RemotePath}; use dr_sync_nextcloud::{AppCredentials, NextcloudBackend}; use dr_thumbs::ThumbStore; + +use crate::sidecar_cache::SidecarCache; use dr_types::FormatFilter; /// Largest preview worth fetching whole. @@ -344,6 +346,7 @@ pub fn sidecar_path(image_path: &str) -> String { format!("{stem}.{}", dr_pipeline::sidecar::EXTENSION) } +/// TRACES: FR-CAT-8 | FR-CAT-9 | FR-NC-10 /// Persist amendments to sidecars beside their images. /// /// # Why this reads before it writes @@ -355,76 +358,135 @@ pub fn sidecar_path(image_path: &str) -> String { /// parsed, amended, and written back; a fetch that 404s simply means there is /// no sidecar yet and a new one is created. /// +/// # Why the local write is the commit point +/// +/// FR-CAT-9 requires that edits made offline *queue and apply when the source +/// returns*. So every amendment is written to the local cache first and the +/// upload is best-effort: an entry stays marked pending until the server has +/// actually taken it, and [`spawn_outbox_drain`] retries the marked ones later. +/// +/// This is what makes `offline` a parameter rather than a reason to skip. It +/// was one: a cull or a paste made with no connection used to be dropped +/// entirely, which for a pasted edit meant it survived nowhere at all — the +/// catalog holds no parameters. Now the two cases differ only in whether the +/// upload is attempted. +/// /// # Why failure here is logged rather than surfaced /// -/// The catalog write has already succeeded by the time this runs, so the star -/// is on screen and will survive a restart. A network failure costs -/// *durability across a catalog rebuild*, not the judgement itself, and -/// interrupting a cull with an error dialog per frame would be far worse than -/// the risk. The count of failures is reported once, at the end. +/// The write has already succeeded locally by the time the network is touched, +/// so nothing is lost by a failure and there is nothing for the user to do +/// about it. Interrupting a cull with an error dialog per frame would be far +/// worse than the risk. The counts are reported once, at the end. pub fn spawn_sidecar_writes( creds: AppCredentials, user_id: String, writes: Vec, + cache_dir: PathBuf, + offline: bool, ) -> Receiver { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = match crate::net_runtime::build() { - Ok(rt) => rt, - Err(e) => { - let _ = tx.send(SidecarMessage::Finished { - written: 0, - failed: writes.len(), - last_error: Some(e.to_string()), - }); - return; + let cache = SidecarCache::open(cache_dir); + + // The runtime and the backend are only needed to *upload*. Offline, + // neither is built — and a failure to build either is not a failure to + // record the edit, it just means every write is queued instead. + let rt = if offline { + None + } else { + match crate::net_runtime::build() { + Ok(rt) => Some(rt), + Err(e) => { + log::debug!("no runtime for sidecar upload ({e}); queueing"); + None + } } }; - rt.block_on(async { - let backend = match NextcloudBackend::new(&creds, &user_id) { - Ok(b) => b, - Err(e) => { - let _ = tx.send(SidecarMessage::Finished { - written: 0, - failed: writes.len(), - last_error: Some(e.to_string()), - }); - return; - } - }; - - let (mut written, mut failed) = (0usize, 0usize); - let mut last_error = None; - + let run = |backend: Option<&NextcloudBackend>| { + let mut report = SidecarReport::default(); for w in &writes { - match write_one_sidecar(&backend, w).await { - Ok(()) => written += 1, + match write_one_sidecar(backend, &cache, w) { + Ok(Outcome::Uploaded) => report.written += 1, + Ok(Outcome::Queued) => report.queued += 1, Err(e) => { log::debug!("sidecar for {}: {e}", w.image_path); - last_error = Some(e); - failed += 1; + report.last_error = Some(e); + report.failed += 1; } } } + report + }; - let _ = tx.send(SidecarMessage::Finished { - written, - failed, - last_error, - }); + let report = match rt { + None => run(None), + Some(rt) => rt.block_on(async { + match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => { + let mut report = SidecarReport::default(); + for w in &writes { + match write_one_sidecar_online(&b, &cache, w).await { + Ok(Outcome::Uploaded) => report.written += 1, + Ok(Outcome::Queued) => report.queued += 1, + Err(e) => { + log::debug!("sidecar for {}: {e}", w.image_path); + report.last_error = Some(e); + report.failed += 1; + } + } + } + report + } + // No backend: the edits are still recorded locally and + // will go up with the next drain. + Err(e) => { + log::debug!("no backend for sidecar upload ({e}); queueing"); + run(None) + } + } + }), + }; + + let _ = tx.send(SidecarMessage::Finished { + written: report.written, + queued: report.queued, + failed: report.failed, + last_error: report.last_error, }); }); rx } +/// What one write ended up doing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Outcome { + /// Recorded locally and accepted by the server. + Uploaded, + /// Recorded locally, still in the outbox. + Queued, +} + +/// Running totals for a batch, so the loop bodies stay readable. +#[derive(Debug, Default)] +struct SidecarReport { + written: usize, + queued: usize, + failed: usize, + last_error: Option, +} + /// The outcome of a batch of sidecar writes. #[derive(Debug)] pub enum SidecarMessage { Finished { written: usize, + /// Recorded locally but not yet on the server — offline, or an upload + /// that failed. These are retried by [`spawn_outbox_drain`], so this + /// is a count of *deferred* work rather than of losses. + queued: usize, failed: usize, /// Reported once rather than per file: a network that is down fails /// every write with the same message, and forty identical lines in the @@ -433,44 +495,14 @@ pub enum SidecarMessage { }, } -/// Read-modify-write one sidecar. -async fn write_one_sidecar(backend: &NextcloudBackend, w: &SidecarWrite) -> Result<(), String> { - let path = RemotePath::new(sidecar_path(&w.image_path)); - let id = RemoteId::Path(path.clone()); - - // An existing sidecar may hold an edit. Absent is the normal case on a - // library that has never been edited, and is not an error. - let existing = backend.get(&id, None).await.ok(); - let mut sidecar = existing - .as_deref() - .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) - .and_then(|text| match dr_pipeline::Sidecar::parse(&text) { - Ok(s) => Some(s), - // A corrupt sidecar is *not* overwritten silently: that would - // destroy an edit this build merely failed to understand. The - // judgement stays in the catalog and the file is left alone. - Err(e) => { - log::warn!( - "sidecar at {} is unreadable ({e}); not overwriting", - path.as_str() - ); - None - } - }) - .unwrap_or_default(); - - // A parse failure above means we must not touch the file at all. - if existing.is_some() && sidecar.versions.is_empty() && existing.as_deref() != Some(b"") { - // Distinguish "empty file" from "unparseable": only the latter is a - // refusal, and it already logged. - let text = existing - .as_deref() - .map(|b| String::from_utf8_lossy(b).into_owned()) - .unwrap_or_default(); - if dr_pipeline::Sidecar::parse(&text).is_err() { - return Err("existing sidecar is unreadable".into()); - } - } +/// Apply an amendment to a document, returning the new one. +/// +/// Split out from both write paths so that online and offline produce +/// *identical* documents: the only thing that differs between them is which +/// base was read and whether an upload follows. A second copy of this for the +/// offline case is how the two would come to disagree about what a paste means. +fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar { + let mut sidecar = base; // Amend the version this write belongs to, creating it if the file did // not have one. The uuid comes from the catalog, so the same photograph @@ -509,12 +541,234 @@ async fn write_one_sidecar(backend: &NextcloudBackend, w: &SidecarWrite) -> Resu version.modified = now_secs(); sidecar.put(version); + sidecar +} + +/// TRACES: FR-CAT-9 +/// Record an amendment with no server to send it to. +/// +/// The base is whatever the cache holds, which is either what the server last +/// had or what earlier offline writes have already built on top of it. Either +/// way the result is queued, and the drain reconciles it with the server's own +/// copy when the connection returns — that reconciliation is a *merge* +/// (FR-NC-9), not an overwrite, so building on a possibly-stale base here does +/// not cost another device's work. +fn write_one_sidecar( + _backend: Option<&NextcloudBackend>, + cache: &SidecarCache, + w: &SidecarWrite, +) -> Result { + let path = sidecar_path(&w.image_path); + let base = cache.load(&path).unwrap_or_default(); + cache.store(&path, &amend(base, w), true)?; + Ok(Outcome::Queued) +} + +/// TRACES: FR-CAT-8 | FR-CAT-9 +/// Read-modify-write one sidecar, with a server to read from and send to. +async fn write_one_sidecar_online( + backend: &NextcloudBackend, + cache: &SidecarCache, + w: &SidecarWrite, +) -> Result { + let path_str = sidecar_path(&w.image_path); + let path = RemotePath::new(path_str.clone()); + let id = RemoteId::Path(path.clone()); + + // An existing sidecar may hold an edit. Absent is the normal case on a + // library that has never been edited, and is not an error. + let existing = backend.get(&id, None).await.ok(); + + // A corrupt sidecar is *not* overwritten: that would destroy an edit this + // build merely failed to understand. Refused before anything is written, + // locally or remotely, so the cache cannot end up holding a document that + // silently discarded the file's real contents. + if let Some(bytes) = existing.as_deref() { + if !bytes.is_empty() { + let text = String::from_utf8_lossy(bytes); + if dr_pipeline::Sidecar::parse(&text).is_err() { + return Err(format!("sidecar at {path_str} is unreadable")); + } + } + } + + let base = existing + .as_deref() + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) + .and_then(|text| dr_pipeline::Sidecar::parse(&text).ok()) + // No sidecar on the server. The cache may still hold queued offline + // work for this image, and taking `default()` here would drop it. + .or_else(|| cache.load(&path_str)) + .unwrap_or_default(); + + let sidecar = amend(base, w); + + // Locally first: this is the commit point, and an upload that fails after + // it leaves the edit queued rather than lost. + cache.store(&path_str, &sidecar, true)?; backend .put(&path, sidecar.to_text().into_bytes(), None) .await - .map(|_| ()) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + // Accepted by the server, so it leaves the outbox. The document stays + // cached, which is what lets the next offline open still show the edit. + cache.store(&path_str, &sidecar, false)?; + Ok(Outcome::Uploaded) +} + +/// TRACES: FR-CAT-9 | FR-NC-9 | FR-NC-10 +/// Upload everything the outbox is still holding. +/// +/// # Why this merges rather than uploads +/// +/// A queued edit was built on whatever this device last saw. While it sat in +/// the outbox another device may have edited the same photograph, and simply +/// PUTting the local document would discard that work — the precise failure +/// FR-NC-9's node-level merge exists to prevent. So each entry is reconciled +/// against the server's current copy before it goes up, and disjoint edits +/// (a crop made here, an exposure change made there) both survive. +/// +/// # Why an entry stays queued on failure +/// +/// The marker is cleared only after the server has taken the bytes. A drain +/// interrupted halfway leaves the rest of the outbox exactly as it was, so +/// nothing depends on this running to completion. +pub fn spawn_outbox_drain( + creds: AppCredentials, + user_id: String, + cache_dir: PathBuf, +) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let cache = SidecarCache::open(cache_dir); + let queued = cache.pending(); + if queued.is_empty() { + let _ = tx.send(SidecarMessage::Finished { + written: 0, + queued: 0, + failed: 0, + last_error: None, + }); + return; + } + log::info!("draining {} queued sidecar(s)", queued.len()); + + let rt = match crate::net_runtime::build() { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send(SidecarMessage::Finished { + written: 0, + queued: queued.len(), + failed: 0, + last_error: Some(e.to_string()), + }); + return; + } + }; + + rt.block_on(async { + let backend = match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => b, + Err(e) => { + let _ = tx.send(SidecarMessage::Finished { + written: 0, + queued: queued.len(), + failed: 0, + last_error: Some(e.to_string()), + }); + return; + } + }; + + let mut report = SidecarReport::default(); + for path_str in &queued { + match drain_one(&backend, &cache, path_str).await { + Ok(()) => report.written += 1, + Err(e) => { + log::debug!("draining {path_str}: {e}"); + report.last_error = Some(e); + report.failed += 1; + // Still queued — the marker was never cleared. + report.queued += 1; + } + } + } + + let _ = tx.send(SidecarMessage::Finished { + written: report.written, + queued: report.queued, + failed: report.failed, + last_error: report.last_error, + }); + }); + }); + + rx +} + +/// Reconcile one queued sidecar with the server and upload it. +async fn drain_one( + backend: &NextcloudBackend, + cache: &SidecarCache, + path_str: &str, +) -> Result<(), String> { + let Some(mut local) = cache.load(path_str) else { + // The document went while the drain was running. Nothing to send. + return Ok(()); + }; + + let path = RemotePath::new(path_str.to_string()); + let id = RemoteId::Path(path.clone()); + let remote = backend.get(&id, None).await.ok(); + + if let Some(bytes) = remote.as_deref() { + if !bytes.is_empty() { + let text = String::from_utf8_lossy(bytes); + match dr_pipeline::Sidecar::parse(&text) { + Ok(remote) => merge_into(&mut local, &remote), + // Unreadable on the server. Uploading over it would destroy an + // edit this build failed to understand, so the entry stays + // queued rather than being resolved destructively. + Err(e) => return Err(format!("remote sidecar is unreadable ({e})")), + } + } + } + + backend + .put(&path, local.to_text().into_bytes(), None) + .await + .map_err(|e| e.to_string())?; + + cache.store(path_str, &local, false) +} + +/// TRACES: FR-NC-9 +/// Merge the server's copy into ours, version by version. +/// +/// No common ancestor is available — the outbox stores the result, not the +/// base it was built from — so the merge runs with `None`, which treats every +/// key either side holds as changed. Disjoint keys therefore still both +/// survive, and a key both sides set resolves by revision exactly as it would +/// with a base. What is lost without one is the ability to see a *deletion*: +/// a parameter reset to default on the other device reads as absent rather +/// than as removed, so our value stands. That is the same direction of caution +/// the judgement merge takes — an edit is preserved rather than erased. +fn merge_into(local: &mut dr_pipeline::Sidecar, remote: &dr_pipeline::Sidecar) { + for (uuid, their_version) in &remote.versions { + match local.versions.get(uuid).cloned() { + Some(mut ours) => { + ours.merge(their_version, None); + local.put(ours); + } + // A version only the server has — another device's virtual copy + // (FR-CAT-12). Keeping it is what stops one device's upload from + // deleting another's work. + None => local.put(their_version.clone()), + } + } } /// Where the catalog for an account lives. @@ -1100,44 +1354,85 @@ pub fn spawn_sidecar_fetch( creds: AppCredentials, user_id: String, image_path: String, + cache_dir: PathBuf, + offline: bool, ) -> Receiver> { let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let rt = match crate::net_runtime::build() { - Ok(e) => e, - Err(e) => { - log::debug!("sidecar fetch runtime: {e}"); - let _ = tx.send(None); - return; + let cache = SidecarCache::open(cache_dir); + let path_str = sidecar_path(&image_path); + + // TRACES: FR-CAT-9 | FR-NC-10 + // The cache wins outright when it is holding work the server has not + // seen. Fetching in that state would answer with a document *older* + // than the edit sitting in the outbox, and opening the photograph + // would silently show it without the change the user just made — + // which the next save would then write back over the top of. + if cache.is_pending(&path_str) { + log::debug!("{path_str} has queued local edits; opening from the cache"); + let _ = tx.send(cache.load(&path_str)); + return; + } + + // Offline there is nothing to ask, and the cache is the whole answer. + let rt = if offline { + None + } else { + match crate::net_runtime::build() { + Ok(e) => Some(e), + Err(e) => { + log::debug!("sidecar fetch runtime: {e}"); + None + } } }; + let Some(rt) = rt else { + let _ = tx.send(cache.load(&path_str)); + return; + }; rt.block_on(async { let backend = match NextcloudBackend::new(&creds, &user_id) { Ok(b) => b, Err(e) => { log::debug!("sidecar fetch backend: {e}"); - let _ = tx.send(None); + let _ = tx.send(cache.load(&path_str)); return; } }; - let path = RemotePath::new(sidecar_path(&image_path)); + let path = RemotePath::new(path_str.clone()); let id = RemoteId::Path(path.clone()); // A 404 is the normal case on a library that has never been // edited, so this is `ok()` rather than an error path. - let parsed = backend.get(&id, None).await.ok().and_then(|bytes| { - let text = String::from_utf8_lossy(&bytes).into_owned(); - match dr_pipeline::Sidecar::parse(&text) { - Ok(s) => Some(s), - Err(e) => { - log::warn!("sidecar at {} is unreadable ({e})", path.as_str()); - None - } + let Ok(bytes) = backend.get(&id, None).await else { + // Unreachable, or no such file. The cache cannot tell those + // apart and does not need to: either way it holds the best + // answer this device has. + let _ = tx.send(cache.load(&path_str)); + return; + }; + + let text = String::from_utf8_lossy(&bytes).into_owned(); + let parsed = match dr_pipeline::Sidecar::parse(&text) { + Ok(s) => Some(s), + Err(e) => { + log::warn!("sidecar at {} is unreadable ({e})", path.as_str()); + None } - }); + }; + + // Populate the cache from what the server said, so the *next* + // open of this photograph works with no connection. Clean rather + // than pending: this content came from the server, so there is + // nothing to send back. + if let Some(sidecar) = parsed.as_ref() { + if let Err(e) = cache.store(&path_str, sidecar, false) { + log::debug!("caching {path_str}: {e}"); + } + } let _ = tx.send(parsed); }); diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index a23b472..3fd6bbf 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -143,6 +143,20 @@ pub struct LibraryController { /// a drop all reload the window, and every one of them must honour it or /// the filter silently lapses. filter: RefCell, + /// TRACES: FR-CAT-9 + /// Drains the outbox uploader. Held so that a repaint while one is + /// already running does not start a second against the same channel — + /// this handle *is* the "a drain is in flight" state. + outbox_timer: RefCell>, + /// Whether the outbox might hold something, so the common case costs a + /// boolean rather than a directory walk. + /// + /// `refresh_offline` runs on scan progress as well as on a genuine + /// connectivity change, so the drain is *asked* far more often than there + /// is anything to send. Starts `true` so the first ask after launch does + /// walk — edits queued in a previous session are exactly the ones that + /// need sending, and nothing in memory knows about them. + outbox_maybe_dirty: std::cell::Cell, /// Drains the sidecar writer. Held so a second judgement replaces the /// timer rather than leaving two draining the same finished channel. sidecar_timer: RefCell>, @@ -236,6 +250,8 @@ impl LibraryController { sidecar_timer: RefCell::new(None), generation: std::cell::Cell::new(0), reachability: RefCell::new(dr_sync::Reachability::new()), + outbox_timer: RefCell::new(None), + outbox_maybe_dirty: std::cell::Cell::new(true), pin_timer: RefCell::new(None), local_only: std::cell::Cell::new(false), // The catalog's own floor until the settings page reports what the @@ -346,6 +362,22 @@ impl LibraryController { .ok() } + /// TRACES: FR-CAT-9 | FR-NC-10 + /// Where cached sidecars and the upload outbox live for the open library. + /// + /// Beside the catalog and the originals cache, for the same reason those + /// two sit together: all three are per-account and are discarded together. + /// A separate directory rather than a subfolder of `originals` because the + /// two have opposite lifetimes — originals are evicted under a budget + /// (FR-NC-6a), and a queued edit must never be. + pub fn sidecar_cache_dir(&self) -> Option { + let borrow = self.session.borrow(); + let (_, session, _) = borrow.as_ref()?; + library::catalog_path(&session.server, &session.user_id) + .parent() + .map(|p| p.join("sidecars")) + } + /// TRACES: FR-NC-6a /// Where cached originals live for the open library. /// @@ -1035,6 +1067,100 @@ fn refresh_offline(window: &AppWindow, ctl: &Rc) { if offline { window.set_library_error("".into()); } + drop(reach); + + // TRACES: FR-CAT-9 + // Back online: send whatever the outbox is still holding. + // + // Hung off the one function that paints connectivity rather than off each + // of the seven places that move it — a drain that had to be remembered at + // every call site is a drain that will be forgotten at one of them, and + // the symptom is an edit that stays queued until the app is restarted. + // + // `start_outbox_drain` is a no-op when the outbox is empty and while one + // is already running, so calling it on every repaint costs a directory + // walk that finds nothing. + if !offline { + start_outbox_drain(window, ctl); + } +} + +/// TRACES: FR-CAT-9 | FR-NC-10 +/// Upload the sidecars queued while this device had no connection. +/// +/// Guarded on the timer rather than on a flag: the timer *is* the "a drain is +/// running" state, and a second one started beside it would drain the same +/// channel twice. +fn start_outbox_drain(window: &AppWindow, ctl: &Rc) { + if ctl.outbox_timer.borrow().is_some() { + return; + } + if !ctl.outbox_maybe_dirty.get() { + return; + } + let Some(cache_dir) = ctl.sidecar_cache_dir() else { + return; + }; + if crate::sidecar_cache::SidecarCache::open(cache_dir.clone()) + .pending() + .is_empty() + { + // Nothing there. Recorded so the next hundred repaints skip the walk; + // a write that queues sets it again. + ctl.outbox_maybe_dirty.set(false); + return; + } + let Some((creds, session, _)) = ctl.session.borrow().clone() else { + return; + }; + + let rx = library::spawn_outbox_drain(creds, session.user_id.clone(), cache_dir); + let job = ctl + .activity + .begin(crate::activity::Kind::Upload, "Uploading queued edits"); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(250), + move || { + let Some(w) = weak.upgrade() else { return }; + match rx.try_recv() { + Ok(library::SidecarMessage::Finished { + written, + queued, + failed, + last_error, + }) => { + if failed > 0 { + log::warn!( + "{failed} queued sidecar(s) still undelivered: {}", + last_error.clone().unwrap_or_default() + ); + // Not `fail`: the edits are still safely queued, and + // reporting this as a loss would be wrong. + job.finish(format!("{written} uploaded · {queued} still queued")); + } else if written > 0 { + log::info!("{written} queued sidecar(s) uploaded"); + job.finish(format!("{written} queued edit(s) uploaded")); + w.set_library_status(format!("{written} queued edit(s) uploaded").into()); + } else { + job.finish_quietly(); + } + ctl_cb.outbox_maybe_dirty.set(queued > 0); + stop(&ctl_cb.outbox_timer); + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + job.finish_quietly(); + stop(&ctl_cb.outbox_timer); + } + } + }, + ); + *ctl.outbox_timer.borrow_mut() = Some(timer); } /// A coarse "how long ago", for the offline banner. @@ -1581,29 +1707,27 @@ pub(crate) fn start_sidecar_writes( } // TRACES: FR-CAT-9 - // Offline, this would stall on a timeout per sidecar, on the very - // keystroke path a cull is built for speed on (FR-CULL-1). The judgement - // itself is safe either way — `apply_judgement` has already committed it - // to the catalog, which is what the grid reads and what survives a - // restart. + // Offline is passed down rather than used to skip. // - // What is deferred is the sidecar, and with it the guarantee that the - // judgement survives a *catalog rebuild* (ARCH §6.12). There is no queue - // behind this yet, so a rating made offline is written to its sidecar only - // when that image is judged again while connected. That is a real gap - // rather than a hidden one: it trades a durability property that already - // depends on the network for a cull that stays responsive without it. - if ctl.is_offline() { - log::debug!("offline: skipping {} sidecar write(s)", writes.len()); - return; - } + // It used to skip, and the reasoning was that a cull stays responsive + // because the rating is safe in the catalog. That held for judgements and + // not for edits: the catalog stores no parameters, so a pasted edit made + // offline survived nowhere at all. Every write now commits to the local + // sidecar cache first and the upload is best-effort, which keeps the + // keystroke path off the network — the original concern — without the + // write being conditional on it. + let offline = ctl.is_offline(); let Some((creds, session, _)) = ctl.session.borrow().clone() else { return; }; + let Some(cache_dir) = ctl.sidecar_cache_dir() else { + return; + }; let count = writes.len(); - let rx = library::spawn_sidecar_writes(creds, session.user_id.clone(), writes); + let rx = + library::spawn_sidecar_writes(creds, session.user_id.clone(), writes, cache_dir, offline); let timer = slint::Timer::default(); let weak = window.as_weak(); @@ -1628,10 +1752,27 @@ pub(crate) fn start_sidecar_writes( match rx.try_recv() { Ok(library::SidecarMessage::Finished { written, + queued, failed, last_error, }) => { + if failed == 0 && queued > 0 { + // Recorded locally, waiting for the server. Said out + // loud because the user has just made an edit with no + // connection and deserves to know it is safe — the + // old behaviour here was to drop it silently. + log::debug!("{queued} sidecar(s) queued for upload"); + ctl_cb.outbox_maybe_dirty.set(true); + job.finish_quietly(); + w.set_library_status( + format!("{queued} edit(s) saved · will upload when back online").into(), + ); + stop(&ctl_cb.sidecar_timer); + return; + } if failed > 0 { + // A failed upload left the edit in the outbox. + ctl_cb.outbox_maybe_dirty.set(true); log::warn!( "{failed} sidecar write(s) failed: {}", last_error.clone().unwrap_or_default() diff --git a/ui/dr-ui/src/settings_ui.rs b/ui/dr-ui/src/settings_ui.rs index b730593..a197186 100644 --- a/ui/dr-ui/src/settings_ui.rs +++ b/ui/dr-ui/src/settings_ui.rs @@ -45,6 +45,23 @@ pub struct SettingsController { /// How much disk the cache is currently using, as a label. Supplied by /// whoever owns the catalog — this module has no connection to query. usage_label: RefCell, + /// TRACES: FR-EXP-6 + /// The remote folder picker, while it is open. + /// + /// The same [`FolderBrowser`](crate::launch::FolderBrowser) the launch + /// screen uses to choose a library root, reused rather than reimplemented: + /// it browses a remote tree and nothing about it is specific to what the + /// chosen folder is *for*. `None` means the picker is closed, which is + /// also the only state a device destination ever has — a path on this + /// machine is typed or chosen by the platform, not walked over WebDAV. + pub browser: RefCell>, + /// Polls the folder listing while one is in flight. + /// + /// Held here rather than in the function that starts it: a `slint::Timer` + /// stops the moment it is dropped, so a timer local to `spawn_folder_list` + /// would be collected before the listing it is waiting on ever arrived. + /// The same place `LaunchController` keeps its own poll timer. + poll_timer: RefCell>, } impl SettingsController { @@ -56,6 +73,8 @@ impl SettingsController { store, error: RefCell::new(None), usage_label: RefCell::new(String::new()), + browser: RefCell::new(None), + poll_timer: RefCell::new(None), }) } @@ -64,6 +83,21 @@ impl SettingsController { self.settings.borrow().clone() } + /// Adopt a folder chosen in the picker as the export destination. + /// + /// Goes through `edit` like every other change, so it is saved the moment + /// it is chosen — the page has no Save button and a destination that + /// survived only until the window closed would be the one setting that + /// behaved differently from all the others. + pub fn set_destination(&self, path: String) { + self.edit(|s| s.export.destination = path); + } + + /// Report a failure onto the page's error line. + pub fn set_error(&self, message: impl Into) { + *self.error.borrow_mut() = Some(message.into()); + } + /// Show what the cache is holding. Empty hides the line. pub fn set_usage_label(&self, label: String) { *self.usage_label.borrow_mut() = label; @@ -173,6 +207,40 @@ pub fn render(window: &AppWindow, controller: &SettingsController) { .into(), ); + // --- the remote folder picker -------------------------------------- + { + let browser = controller.browser.borrow(); + window.set_settings_browse_open(browser.is_some()); + match browser.as_ref() { + Some(b) => { + // The root is shown as a word rather than as an empty string, + // which would read as a control that had lost its value. + window.set_settings_browse_path( + if b.path.is_empty() { + "Library root".to_string() + } else { + b.path.clone() + } + .into(), + ); + window.set_settings_browse_loading(b.loading); + window.set_settings_browse_at_root(b.parent_path().is_none()); + window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from( + b.entries + .iter() + .map(|e| slint::SharedString::from(e.as_str())) + .collect::>(), + ))); + } + None => { + window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from( + Vec::::new(), + ))); + window.set_settings_browse_loading(false); + } + } + } + window.set_settings_error(controller.error.borrow().clone().unwrap_or_default().into()); } @@ -490,6 +558,112 @@ where } } +/// TRACES: FR-EXP-6 +/// List the folders under `path`, for the export destination picker. +/// +/// A near-twin of `launch_ui::spawn_folder_list` and deliberately not shared +/// with it. That one reaches into the `LaunchController` for its session and +/// reports failures onto the launch screen's error line; this one is handed +/// credentials and writes to the settings page. Factoring them together would +/// mean a function taking both controllers, or a trait implemented twice to +/// abstract two call sites — more machinery than the twenty lines it saves. +/// +/// The *model* is shared, which is the part that matters: both drive a +/// [`FolderBrowser`](crate::launch::FolderBrowser), so navigation behaves +/// identically in both places. +pub fn spawn_folder_list( + weak: slint::Weak, + ctl: Rc, + creds: dr_sync_nextcloud::AppCredentials, + user_id: String, + path: String, +) { + use dr_sync::{RemoteBackend, RemotePath}; + use dr_sync_nextcloud::NextcloudBackend; + + let (tx, rx) = std::sync::mpsc::channel::, String>>(); + + std::thread::spawn(move || { + // Multi-thread, for the reason the login worker records: a + // current-thread runtime left reqwest's connection future unpolled on + // Android, and the await never resolved. + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_io() + .enable_time() + .build(); + let Ok(rt) = rt else { + let _ = tx.send(Err("runtime".into())); + return; + }; + rt.block_on(async { + match NextcloudBackend::new(&creds, &user_id) { + Ok(b) => match b.list(&RemotePath::new(&path), None).await { + Ok(entries) => { + let mut dirs: Vec = entries + .iter() + .filter(|e| e.kind == dr_sync::EntryKind::Directory) + .map(|e| e.path.name().to_string()) + .collect(); + dirs.sort_by_key(|d| d.to_ascii_lowercase()); + let _ = tx.send(Ok(dirs)); + } + Err(e) => { + let _ = tx.send(Err(e.to_string())); + } + }, + Err(e) => { + let _ = tx.send(Err(e.to_string())); + } + } + }); + }); + + let timer = slint::Timer::default(); + let ctl_cb = ctl.clone(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(150), + move || { + let Some(w) = weak.upgrade() else { return }; + match rx.try_recv() { + Ok(Ok(dirs)) => { + if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() { + b.entries = dirs; + b.loading = false; + } + render(&w, &ctl_cb); + } + Ok(Err(e)) => { + // The picker stays open showing the folder it was on. A + // listing that failed is not a reason to discard where the + // user had navigated to. + if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() { + b.loading = false; + } + *ctl_cb.error.borrow_mut() = Some(format!("Could not list folders: {e}")); + render(&w, &ctl_cb); + } + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + if let Some(b) = ctl_cb.browser.borrow_mut().as_mut() { + b.loading = false; + } + render(&w, &ctl_cb); + } + } + // The channel has delivered, so there is nothing left to poll + // for. Stopping it here rather than leaving it running is what + // keeps a page opened and closed twenty times from accumulating + // twenty timers. + if let Some(t) = ctl_cb.poll_timer.borrow().as_ref() { + t.stop(); + } + }, + ); + *ctl.poll_timer.borrow_mut() = Some(timer); +} + #[cfg(test)] mod tests { use super::*; @@ -514,6 +688,8 @@ mod tests { store, error: RefCell::new(None), usage_label: RefCell::new(String::new()), + browser: RefCell::new(None), + poll_timer: RefCell::new(None), }) } @@ -584,6 +760,8 @@ mod tests { store: SettingsStore::open_at(blocker.join("settings.json")), error: RefCell::new(None), usage_label: RefCell::new(String::new()), + browser: RefCell::new(None), + poll_timer: RefCell::new(None), }; broken.edit(|s| s.export.quality = 50); diff --git a/ui/dr-ui/src/sidecar_cache.rs b/ui/dr-ui/src/sidecar_cache.rs new file mode 100644 index 0000000..5058141 --- /dev/null +++ b/ui/dr-ui/src/sidecar_cache.rs @@ -0,0 +1,420 @@ +//! TRACES: FR-CAT-9 | FR-NC-10 | FR-CAT-8 +//! A local copy of every sidecar this device has seen, and the outbox of the +//! ones the server has not. +//! +//! # Why a cache at all +//! +//! FR-CAT-9: *"edits queue and apply when the source returns."* Before this, +//! a rating or a pasted edit made with no connection was dropped — the +//! judgement survived in the catalog, which is disposable (ARCH §6.12), and a +//! pasted edit survived nowhere at all. An edit that vanishes because the +//! train went into a tunnel is the worst failure an authoritative store can +//! have, and it is silent. +//! +//! So the local file is the **commit point** and the upload is best-effort. +//! Writing is always a local write first; the network attempt follows and, if +//! it fails or was never possible, the entry stays marked and is retried when +//! the server comes back. Online and offline are therefore the same code path +//! differing only in whether the upload is attempted, rather than two paths +//! where one quietly does less. +//! +//! # Why the filesystem is the queue +//! +//! The catalog has a `jobs` table with backoff and coalescing, and +//! `JobKind::WriteSidecar` was reserved for this. It is deliberately not used: +//! the catalog is rebuildable and may be deleted at any time, and a queue of +//! *unuploaded edits* is the one thing in this system that cannot be +//! reconstructed from anywhere else. A pending marker sitting beside the +//! document it refers to survives a catalog deletion, an app reinstall that +//! keeps app data, and a crash — because there is no separate index that could +//! disagree with it. +//! +//! It is also debuggable in the way the sidecar format itself is meant to be: +//! the cache mirrors the server's layout, so the file holding an edit that +//! failed to upload is at the path you would guess, and a `.pending` marker +//! beside it says why it is still there. +//! +//! # Why there is no budget +//! +//! Unlike cached originals (FR-NC-6a), sidecars are hundreds of bytes. A +//! hundred-thousand-image library is tens of megabytes, and evicting one would +//! cost a round-trip to re-read an edit the user is about to open. The +//! originals cache exists because RAW files are 30 MB each; this does not have +//! the problem that motivates one. + +use std::path::{Path, PathBuf}; + +use dr_pipeline::Sidecar; + +/// Suffix marking a cached sidecar the server has not yet accepted. +/// +/// A separate zero-byte file rather than a flag inside the document: the +/// document's bytes are what gets uploaded, and a marker inside it would have +/// to be stripped on the way out — one more chance to upload something subtly +/// different from what was stored. +const PENDING_SUFFIX: &str = ".pending"; + +/// The on-disk sidecar cache for one library. +pub struct SidecarCache { + dir: PathBuf, +} + +impl SidecarCache { + /// Open the cache rooted at `dir`. Nothing is created until a write. + pub fn open(dir: PathBuf) -> Self { + Self { dir } + } + + /// Where a remote sidecar is cached. + /// + /// Mirrors the server's layout, so `photos/2024/a.drsc` is cached at + /// `/photos/2024/a.drsc`. Keyed on the **sidecar's** remote path + /// rather than the image's, because that is what an upload addresses — + /// deriving one from the other in two places is how they come to disagree. + /// + /// Returns `None` for a path that would escape the cache directory. The + /// path comes from a server response, so it is not this process's to + /// trust: a `..` component would let a hostile or merely broken server + /// name a file anywhere this app can write. + pub fn path_for(&self, remote_sidecar_path: &str) -> Option { + // No directory means no library is open. Every path would otherwise + // be relative and land in the process's working directory, which for a + // desktop launch is wherever the user happened to be standing. + if self.dir.as_os_str().is_empty() { + return None; + } + let mut out = self.dir.clone(); + let mut depth = 0usize; + for part in remote_sidecar_path.split('/') { + match part { + // Empty from a leading or doubled slash, and `.`, both mean + // "here" and are simply skipped. + "" | "." => continue, + ".." => return None, + // A Windows-style drive or a backslash cannot appear in a + // WebDAV path segment and would not mean what it looks like. + p if p.contains('\\') => return None, + p => { + out.push(p); + depth += 1; + } + } + } + (depth > 0).then_some(out) + } + + /// The cached document, if there is one. + /// + /// An unreadable cache entry answers `None` rather than an error: the + /// caller's fallback is to treat the photograph as unedited, and a cache + /// is by definition reconstructible from the server. + pub fn load(&self, remote_sidecar_path: &str) -> Option { + let path = self.path_for(remote_sidecar_path)?; + let text = std::fs::read_to_string(&path).ok()?; + match Sidecar::parse(&text) { + Ok(s) => Some(s), + Err(e) => { + log::warn!("cached sidecar at {} is unreadable ({e})", path.display()); + None + } + } + } + + /// Write a document into the cache. + /// + /// `pending` records whether the server has this content. Passing `false` + /// after a successful upload is what takes an entry out of the outbox, so + /// the marker is *removed* here rather than only in a separate call — one + /// function owns the pair, and they cannot drift apart. + pub fn store( + &self, + remote_sidecar_path: &str, + sidecar: &Sidecar, + pending: bool, + ) -> Result<(), String> { + let path = self + .path_for(remote_sidecar_path) + .ok_or_else(|| format!("unsafe sidecar path {remote_sidecar_path}"))?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + + // Write and rename, so an interrupted save cannot truncate an edit + // that was already safely on disk — the same discipline the settings + // store and the local sidecar writer use. + let tmp = path.with_extension("drsc.tmp"); + std::fs::write(&tmp, sidecar.to_text()).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?; + + // The marker is written *after* the document. The other order would + // leave a window where a crash produced a marker pointing at content + // that was never stored, and the drain would upload a stale document + // believing it to be the queued one. + let marker = marker_for(&path); + if pending { + std::fs::write(&marker, b"").map_err(|e| e.to_string())?; + } else if marker.exists() { + std::fs::remove_file(&marker).map_err(|e| e.to_string())?; + } + Ok(()) + } + + /// Whether this entry is waiting to be uploaded. + pub fn is_pending(&self, remote_sidecar_path: &str) -> bool { + self.path_for(remote_sidecar_path) + .is_some_and(|p| marker_for(&p).exists()) + } + + /// Every sidecar waiting to be uploaded, as remote paths. + /// + /// Walks the tree rather than consulting an index, which is the property + /// that makes the queue survive a catalog deletion: the markers *are* the + /// queue, so there is nothing to fall out of step with them. + /// + /// Sorted, so a drain that is interrupted resumes in the same order rather + /// than retrying whichever entry the directory happened to yield first. + pub fn pending(&self) -> Vec { + let mut out = Vec::new(); + collect_pending(&self.dir, &self.dir, &mut out); + out.sort(); + out + } +} + +/// The marker path for a cached document. +fn marker_for(path: &Path) -> PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(PENDING_SUFFIX); + PathBuf::from(s) +} + +/// Recursively gather remote paths whose marker exists. +/// +/// A missing or unreadable directory contributes nothing rather than failing +/// the walk: a cache that has never been written has no directory at all, and +/// that is the normal state on a fresh install. +fn collect_pending(root: &Path, dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_pending(root, &path, out); + continue; + } + // The marker names the document; the document names the remote path. + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Some(stem) = name.strip_suffix(PENDING_SUFFIX) else { + continue; + }; + let document = path.with_file_name(stem); + // A marker whose document has gone is not a queued upload; it is + // debris. Skipped rather than reported, since there is nothing to send + // and nothing the user could do about it. + if !document.exists() { + continue; + } + if let Ok(rel) = document.strip_prefix(root) { + // Back to a remote path: the cache mirrors the server's layout, so + // the relative path *is* the remote path. + let remote: Vec<&str> = rel.iter().filter_map(|c| c.to_str()).collect(); + if !remote.is_empty() { + out.push(remote.join("/")); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dr_pipeline::sidecar::Version; + + fn tempdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "dr-sidecar-cache-{name}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// A cache and the directory it is rooted at. + fn cache(name: &str) -> (SidecarCache, PathBuf) { + let root = tempdir(name).join("sidecars"); + (SidecarCache::open(root.clone()), root) + } + + fn rated(stars: u8) -> Sidecar { + let mut s = Sidecar::new(); + s.put(Version { + uuid: "u1".into(), + name: "Default".into(), + is_default: true, + revision: 1, + rating: stars, + ..Default::default() + }); + s + } + + #[test] + fn the_cache_mirrors_the_servers_layout() { + // What makes an entry findable by hand when an upload has gone wrong, + // and what lets `pending` recover a remote path with no index. + let (c, _d) = cache("layout"); + let p = c.path_for("photos/2024/a.drsc").unwrap(); + assert!( + p.ends_with("sidecars/photos/2024/a.drsc"), + "{}", + p.display() + ); + } + + #[test] + fn a_document_survives_a_store_and_a_load() { + let (c, _d) = cache("round-trip"); + c.store("photos/a.drsc", &rated(4), true).unwrap(); + + let back = c.load("photos/a.drsc").expect("cached"); + assert_eq!(back.default_version().unwrap().rating, 4); + } + + #[test] + fn an_absent_entry_loads_as_nothing() { + let (c, _d) = cache("absent"); + assert!(c.load("photos/never-seen.drsc").is_none()); + } + + #[test] + fn a_pending_write_appears_in_the_outbox() { + // The whole point: an edit made offline is queued rather than dropped. + let (c, _d) = cache("outbox"); + c.store("photos/a.drsc", &rated(3), true).unwrap(); + + assert!(c.is_pending("photos/a.drsc")); + assert_eq!(c.pending(), vec!["photos/a.drsc".to_string()]); + } + + #[test] + fn a_stored_upload_leaves_the_outbox() { + let (c, _d) = cache("drained"); + c.store("photos/a.drsc", &rated(3), true).unwrap(); + c.store("photos/a.drsc", &rated(3), false).unwrap(); + + assert!(!c.is_pending("photos/a.drsc")); + assert!(c.pending().is_empty()); + // And the content is still cached, so an offline open still shows the + // edit. Clearing the marker must not clear the document. + assert_eq!( + c.load("photos/a.drsc") + .unwrap() + .default_version() + .unwrap() + .rating, + 3 + ); + } + + #[test] + fn the_outbox_finds_entries_nested_at_any_depth() { + // The walk is what stands in for an index, so it has to reach an entry + // wherever the server's tree put it. + let (c, _d) = cache("nested"); + c.store("a.drsc", &rated(1), true).unwrap(); + c.store("photos/b.drsc", &rated(1), true).unwrap(); + c.store("photos/2024/spain/c.drsc", &rated(1), true) + .unwrap(); + c.store("photos/d.drsc", &rated(1), false).unwrap(); + + assert_eq!( + c.pending(), + vec![ + "a.drsc".to_string(), + "photos/2024/spain/c.drsc".to_string(), + "photos/b.drsc".to_string(), + ] + ); + } + + #[test] + fn an_empty_cache_has_an_empty_outbox() { + // A fresh install has no directory at all; the walk must not fail. + let (c, _d) = cache("fresh"); + assert!(c.pending().is_empty()); + } + + #[test] + fn a_marker_without_its_document_is_not_a_queued_upload() { + // Debris from an interrupted write. There is nothing to send, and + // reporting it as queued would leave the outbox permanently non-empty. + let (c, _d) = cache("debris"); + c.store("photos/a.drsc", &rated(1), true).unwrap(); + std::fs::remove_file(c.path_for("photos/a.drsc").unwrap()).unwrap(); + + assert!(c.pending().is_empty()); + } + + #[test] + fn a_path_escaping_the_cache_is_refused() { + // The remote path comes from a server response and is not this + // process's to trust. + let (c, _d) = cache("escape"); + assert!(c.path_for("../../etc/passwd.drsc").is_none()); + assert!(c.path_for("photos/../../../a.drsc").is_none()); + assert!(c.path_for("").is_none()); + assert!(c.store("../evil.drsc", &rated(1), true).is_err()); + } + + #[test] + fn a_cache_with_no_directory_is_inert() { + // What a caller holds before a library is open. Writing relative to + // the working directory would scatter sidecars wherever the app was + // launched from. + let c = SidecarCache::open(PathBuf::new()); + assert!(c.path_for("photos/a.drsc").is_none()); + assert!(c.load("photos/a.drsc").is_none()); + assert!(c.store("photos/a.drsc", &rated(1), true).is_err()); + assert!(c.pending().is_empty()); + } + + #[test] + fn a_leading_slash_is_not_an_absolute_path() { + // WebDAV paths often arrive with one. Treating it as absolute would + // push the write to the filesystem root. + let (c, root) = cache("leading-slash"); + let p = c.path_for("/photos/a.drsc").unwrap(); + assert!(p.starts_with(&root), "{}", p.display()); + } + + #[test] + fn a_corrupt_cache_entry_reads_as_absent_rather_than_failing() { + // A cache is reconstructible from the server by definition, so the + // fallback is to fetch — never to refuse to open the photograph. + let (c, _d) = cache("corrupt"); + c.store("photos/a.drsc", &rated(1), false).unwrap(); + std::fs::write(c.path_for("photos/a.drsc").unwrap(), "drsc 99\n").unwrap(); + + assert!(c.load("photos/a.drsc").is_none()); + } + + #[test] + fn storing_leaves_no_temporary_file_behind() { + let (c, root) = cache("no-temp"); + c.store("photos/a.drsc", &rated(1), true).unwrap(); + + let leftovers: Vec<_> = std::fs::read_dir(root.join("photos")) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n.ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "left {leftovers:?} behind"); + } +} diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 5292def..1be7c1e 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -571,6 +571,11 @@ export component AppWindow inherits Window { in property settings-strip-location: true; in property settings-destination: ""; in property settings-destination-hint; + in property settings-browse-open: false; + in property settings-browse-path; + in property <[string]> settings-browse-entries; + in property settings-browse-loading: false; + in property settings-browse-at-root: true; in property <[string]> settings-target-labels; in property settings-target-selected: 0; in property settings-error: ""; @@ -587,6 +592,11 @@ export component AppWindow inherits Window { callback settings-strip-location-toggled(bool); callback settings-destination-changed(string); callback settings-target-changed(int); + callback settings-browse-open-picker(); + callback settings-browse-into(string); + callback settings-browse-up(); + callback settings-browse-confirm(); + callback settings-browse-cancel(); callback settings-reset(); /// Show the settings page. Reads the file first, so a second instance's @@ -742,6 +752,11 @@ in property panel-visible: true; strip-location: root.settings-strip-location; destination: root.settings-destination; destination-hint: root.settings-destination-hint; + browse-open: root.settings-browse-open; + browse-path: root.settings-browse-path; + browse-entries: root.settings-browse-entries; + browse-loading: root.settings-browse-loading; + browse-at-root: root.settings-browse-at-root; target-labels: root.settings-target-labels; target-selected: root.settings-target-selected; error: root.settings-error; @@ -758,6 +773,11 @@ in property panel-visible: true; strip-location-toggled(on) => { root.settings-strip-location-toggled(on); } destination-changed(t) => { root.settings-destination-changed(t); } target-picked(i) => { root.settings-target-changed(i); } + browse-open-picker() => { root.settings-browse-open-picker(); } + browse-into(n) => { root.settings-browse-into(n); } + browse-up() => { root.settings-browse-up(); } + browse-confirm() => { root.settings-browse-confirm(); } + browse-cancel() => { root.settings-browse-cancel(); } activity-rows: root.activity-rows; activity-running: root.activity-running; diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint index 40fc5dd..47b6ebd 100644 --- a/ui/dr-ui/ui/settings.slint +++ b/ui/dr-ui/ui/settings.slint @@ -115,6 +115,18 @@ export component SettingsPage inherits Rectangle { in property <[string]> target-labels; in property target-selected: 0; + // --- the remote folder picker --------------------------------------- + // + // The same navigation the launch screen uses to choose a library root, + // driven by the same `FolderBrowser` model in Rust. A folder on the + // server is not something anyone can be expected to type from memory. + in property browse-open: false; + in property browse-path; + in property <[string]> browse-entries; + in property browse-loading: false; + /// At the library root, so there is nowhere up to go. + in property browse-at-root: true; + callback format-picked(int); callback quality-changed(int); callback colour-picked(int); @@ -127,6 +139,11 @@ export component SettingsPage inherits Rectangle { callback strip-location-toggled(bool); callback destination-changed(string); callback target-picked(int); + callback browse-open-picker(); + callback browse-into(string); + callback browse-up(); + callback browse-confirm(); + callback browse-cancel(); /// A save failed. The page's whole contract is that what it shows is /// stored, so this cannot be swallowed. @@ -537,6 +554,131 @@ export component SettingsPage inherits Rectangle { accepted(t) => { root.destination-changed(t); } } + // Offered only for a server destination. A folder on + // this device is chosen by the platform's own dialogue + // or typed; a folder on the server can only be found + // by walking it, and expecting anyone to recall the + // exact spelling of a path three levels down is how a + // destination silently becomes a new folder at the + // root. + if root.target-selected == 1 && !root.browse-open: HorizontalLayout { + alignment: start; + Button { + text: "Choose folder…"; + clicked => { root.browse-open-picker(); } + } + } + + if root.browse-open: Rectangle { + background: Theme.ground; + border-radius: Theme.radius; + height: picker.preferred-height + 2 * Theme.gap; + + picker := VerticalLayout { + x: Theme.gap; + y: Theme.gap; + width: parent.width - 2 * Theme.gap; + spacing: Theme.gap-sm; + + HorizontalLayout { + spacing: Theme.gap-sm; + + Button { + text: "↑ Up"; + // Disabled rather than hidden at the + // root: a control that vanishes moves + // everything beside it, and the row + // would jump as the user navigates. + enabled: !root.browse-at-root; + clicked => { root.browse-up(); } + } + + Value { + text: root.browse-path; + overflow: elide; + horizontal-stretch: 1; + vertical-alignment: center; + } + + Caption { + text: root.browse-loading ? "Listing…" : ""; + vertical-alignment: center; + } + } + + // A fixed height rather than one that grows + // with the listing: a folder with sixty + // children would otherwise push the rest of + // the settings page off the bottom. + Rectangle { + height: 180px; + background: Theme.surface; + border-radius: Theme.radius; + + Flickable { + x: 4px; + y: 4px; + width: parent.width - 8px; + height: parent.height - 8px; + viewport-height: folders.preferred-height; + + folders := VerticalLayout { + width: 100%; + spacing: 2px; + alignment: start; + + if root.browse-entries.length == 0 + && !root.browse-loading: Caption { + text: "No folders here. " + + "Use this one, or go up."; + } + + for name in root.browse-entries: Rectangle { + height: 32px; + background: touch.has-hover + ? Theme.surface-raised + : transparent; + border-radius: Theme.radius; + + Label { + x: Theme.gap-sm; + text: name; + vertical-alignment: center; + overflow: elide; + width: parent.width - 2 * Theme.gap-sm; + } + + touch := TouchArea { + clicked => { root.browse-into(name); } + } + } + } + } + } + + HorizontalLayout { + spacing: Theme.gap-sm; + alignment: end; + + Button { + text: "Cancel"; + clicked => { root.browse-cancel(); } + } + + // Confirms the folder currently *shown*, + // not one selected in the list — the same + // rule the library picker follows, so + // "use this one" means the same thing in + // both places. + Button { + text: "Use this folder"; + active: true; + clicked => { root.browse-confirm(); } + } + } + } + } + Check { label: "Strip location and personal metadata"; hint: "On. An export is usually the copy that leaves "