Choose the export folder by walking the server, not by typing it
The destination for a Nextcloud export was a text field. Nobody recalls the exact spelling of a path three levels down, and getting it wrong does not fail — `create_dir` makes whatever was typed, so a misremembered folder becomes a new one at the root and the exports are somewhere nobody looks. So it is picked the way the library root is picked, using the same `FolderBrowser` model the launch screen drives: up, into, and "use this folder", confirming the folder currently *shown* rather than one selected in the list. Same rule in both places, so the phrase means one thing. The model is shared; the worker is not. `settings_ui::spawn_folder_list` is a near-twin of the launch screen's, because that one reaches into the `LaunchController` for its session and reports onto the launch screen's error line, while this one is handed credentials and writes to the settings page. Factoring them together needs a function taking both controllers or a trait implemented twice to abstract two call sites — more machinery than the twenty lines it saves. What matters is shared already: navigation behaves identically because both drive the same model. The callbacks are wired in `lib.rs` rather than in `settings_ui::wire`, because listing a remote folder needs credentials and the settings page holds no session on purpose — it is reachable before a library is opened and must not depend on one existing. With no account the picker says to sign in first, rather than showing an empty list that reads as a server with no folders. Details that are decisions rather than accidents: the picker opens at the library root rather than at whatever half-typed path is in the field, which would list nothing and look broken. The listing area is a fixed 180px, since a folder with sixty children would otherwise push the rest of the settings page off the bottom. "Up" is disabled at the root rather than hidden, so the row does not jump as the user navigates. A failed listing leaves the picker open on the folder it was showing — where the user had got to is not something to discard over a dropped request. And the chosen folder saves immediately like every other setting on a page that has no Save button. The poll timer lives on the controller for the reason `LaunchController` keeps its own there: a `slint::Timer` stops when dropped, so one local to the function that starts it would be collected before the listing arrived. Carries in-flight work from a parallel session — a segmentation pass in dr-gpu, a sidecar cache, and the develop panel's continuing changes. 1020 tests pass, fmt clean. One clippy warning remains and is not mine: `sidecar_cache::dir` is unused while that work is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,3 +32,7 @@ required-features = ["readback"]
|
|||||||
default = []
|
default = []
|
||||||
# Exposes read_pixels outside tests. Production must not enable this.
|
# Exposes read_pixels outside tests. Production must not enable this.
|
||||||
readback = []
|
readback = []
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "segment"
|
||||||
|
required-features = ["readback"]
|
||||||
|
|||||||
@@ -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 <file.raw|synthetic> [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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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<u8> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<u32>,
|
||||||
|
pub region_count: usize,
|
||||||
|
/// Deduplicated, `a < b`, sorted for reproducibility.
|
||||||
|
pub adjacency: Vec<Edge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u32, u32> = 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<Edge> = 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<u32> {
|
||||||
|
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<Merge>,
|
||||||
|
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<u32> {
|
||||||
|
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<u32> {
|
||||||
|
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<Item = &'a Merge>) -> Vec<u32> {
|
||||||
|
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<usize, u32> = 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<usize>,
|
||||||
|
rank: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u32> = (0..n).map(|i| i as u32).collect();
|
||||||
|
let gradient: Vec<f32> = (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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,12 @@ use wgpu::util::DeviceExt;
|
|||||||
mod adjust;
|
mod adjust;
|
||||||
mod demosaic;
|
mod demosaic;
|
||||||
mod error;
|
mod error;
|
||||||
|
pub mod hierarchy;
|
||||||
|
mod segment;
|
||||||
pub use adjust::AdjustPass;
|
pub use adjust::AdjustPass;
|
||||||
pub use demosaic::{DemosaicedImage, Demosaicer};
|
pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||||
pub use error::GpuError;
|
pub use error::GpuError;
|
||||||
|
pub use segment::{SegmentOptions, SegmentPass, Segmentation};
|
||||||
|
|
||||||
/// Owns the wgpu device and queue.
|
/// Owns the wgpu device and queue.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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<Self, GpuError> {
|
||||||
|
// 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<Segmentation, GpuError> {
|
||||||
|
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<crate::hierarchy::RegionField, GpuError> {
|
||||||
|
let n = (self.width * self.height) as usize;
|
||||||
|
let roots: Vec<u32> = read_buffer(&self.ctx, &self.labels, n)?;
|
||||||
|
let gradient: Vec<f32> = 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<T: bytemuck::Pod>(
|
||||||
|
ctx: &GpuContext,
|
||||||
|
buffer: &wgpu::Buffer,
|
||||||
|
len: usize,
|
||||||
|
) -> Result<Vec<T>, GpuError> {
|
||||||
|
let size = (len * std::mem::size_of::<T>()) 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::<u8, T>(&data).to_vec();
|
||||||
|
drop(data);
|
||||||
|
staging.unmap();
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::hierarchy::MergeTree;
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
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<u8> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<uniform> u: Params;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- features
|
||||||
|
|
||||||
|
@group(0) @binding(1) var src: texture_2d<f32>;
|
||||||
|
@group(0) @binding(2) var<storage, read_write> feat_out: array<vec4<f32>>;
|
||||||
|
|
||||||
|
// 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<f32>) -> vec3<f32> {
|
||||||
|
var c = max(c_in, vec3<f32>(0.0));
|
||||||
|
if (u.non_linear == 0u) {
|
||||||
|
c = pow(c, vec3<f32>(1.0 / 2.4));
|
||||||
|
}
|
||||||
|
let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||||
|
let a = c.r - c.g;
|
||||||
|
let b = c.b - 0.5 * (c.r + c.g);
|
||||||
|
return vec3<f32>(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<u32>) {
|
||||||
|
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<f32>(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>(i32(sx), i32(sy)), 0).rgb;
|
||||||
|
acc = acc + perceptual(c);
|
||||||
|
n = n + 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feat_out[gid.y * u.width + gid.x] = vec4<f32>(acc / max(n, 1.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- blur
|
||||||
|
|
||||||
|
@group(0) @binding(3) var<storage, read> blur_in: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(4) var<storage, read_write> blur_out: array<vec4<f32>>;
|
||||||
|
|
||||||
|
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<u32>) {
|
||||||
|
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<f32>(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<f32>(acc / wsum, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- gradient
|
||||||
|
|
||||||
|
@group(0) @binding(5) var<storage, read> grad_in: array<vec4<f32>>;
|
||||||
|
@group(0) @binding(6) var<storage, read_write> grad_out: array<f32>;
|
||||||
|
|
||||||
|
fn feat_at(x: i32, y: i32) -> vec3<f32> {
|
||||||
|
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<u32>) {
|
||||||
|
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<f32>(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<storage, read> flow_grad: array<f32>;
|
||||||
|
@group(0) @binding(8) var<storage, read_write> flow_out: array<u32>;
|
||||||
|
|
||||||
|
// 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<u32>) {
|
||||||
|
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<storage, read> jump_in: array<u32>;
|
||||||
|
@group(0) @binding(10) var<storage, read_write> jump_out: array<u32>;
|
||||||
|
|
||||||
|
// 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<u32>) {
|
||||||
|
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]];
|
||||||
|
}
|
||||||
@@ -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<Region>,
|
||||||
|
/// 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.
|
||||||
+25
-25
@@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source files scanned | 113 |
|
| Source files scanned | 118 |
|
||||||
| TRACES tags found | 186 |
|
| TRACES tags found | 199 |
|
||||||
| Requirements defined | 151 |
|
| Requirements defined | 151 |
|
||||||
| Requirements covered | 77 |
|
| Requirements covered | 77 |
|
||||||
| **Coverage** | **51.0%** (77/151) |
|
| **Coverage** | **51.0%** (77/151) |
|
||||||
@@ -34,53 +34,53 @@ _None._
|
|||||||
| ID | Tagged in |
|
| ID | Tagged in |
|
||||||
|---|---|
|
|---|---|
|
||||||
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-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-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-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-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-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-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-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-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-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-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: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-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-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-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-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-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-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-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-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-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-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: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-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:47`](../ui/dr-ui/src/lib.rs#L47) |
|
| 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-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-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-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||||
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs: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-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:1991`](../ui/dr-ui/src/library_ui.rs#L1991) |
|
| 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-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-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-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-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-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-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-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-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-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-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-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) |
|
| 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-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-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) |
|
| 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-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-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-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-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:55`](../ui/dr-ui/src/lib.rs#L55) |
|
| 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-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-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:1784`](../ui/dr-ui/src/lib.rs#L1784), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs: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) |
|
| 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-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) |
|
| 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-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-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||||
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||||
| NFR-RES-1 | [`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-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) |
|
| 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) |
|
| 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
|
## Not yet tagged
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -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<slint::SharedString> {
|
||||||
|
thread_local! {
|
||||||
|
static EMPTY: slint::ModelRc<slint::SharedString> =
|
||||||
|
slint::ModelRc::new(slint::VecModel::from(Vec::<slint::SharedString>::new()));
|
||||||
|
}
|
||||||
|
EMPTY.with(Clone::clone)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether this frontend has an implementation of `widget` **anywhere**.
|
/// Whether this frontend has an implementation of `widget` **anywhere**.
|
||||||
///
|
///
|
||||||
/// "Anywhere" is doing real work: a widget may be drawn in the panel, as the
|
/// "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<ParamRow> {
|
|||||||
unit: unit.into(),
|
unit: unit.into(),
|
||||||
// Only curve rows carry points.
|
// Only curve rows carry points.
|
||||||
points: slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new())),
|
points: slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::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))
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+129
-2
@@ -28,6 +28,7 @@ mod net_runtime;
|
|||||||
mod presets;
|
mod presets;
|
||||||
mod settings_store;
|
mod settings_store;
|
||||||
mod settings_ui;
|
mod settings_ui;
|
||||||
|
mod sidecar_cache;
|
||||||
mod trash;
|
mod trash;
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -768,6 +769,127 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
library.set_keep_opened_originals(stored.cache.keep_opened_originals);
|
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<settings_ui::SettingsController>,
|
||||||
|
weak: &slint::Weak<AppWindow>,
|
||||||
|
library: &Rc<library_ui::LibraryController>,
|
||||||
|
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 lib = library.clone();
|
||||||
let ctl = settings.clone();
|
let ctl = settings.clone();
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
@@ -1134,8 +1256,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// it to — and starting it here means the edit is ready when the
|
// it to — and starting it here means the edit is ready when the
|
||||||
// photograph is, instead of the image appearing at its defaults
|
// photograph is, instead of the image appearing at its defaults
|
||||||
// and visibly changing a moment later.
|
// and visibly changing a moment later.
|
||||||
let sidecar_rx =
|
let sidecar_rx = library::spawn_sidecar_fetch(
|
||||||
library::spawn_sidecar_fetch(creds.clone(), user_id.clone(), path.clone());
|
creds.clone(),
|
||||||
|
user_id.clone(),
|
||||||
|
path.clone(),
|
||||||
|
library.sidecar_cache_dir().unwrap_or_default(),
|
||||||
|
library.is_offline(),
|
||||||
|
);
|
||||||
|
|
||||||
// TRACES: FR-NC-6a
|
// TRACES: FR-NC-6a
|
||||||
// The cache is consulted first, so a second open of the same
|
// The cache is consulted first, so a second open of the same
|
||||||
|
|||||||
+391
-96
@@ -28,6 +28,8 @@ use dr_catalog::{Catalog, JobKind, Priority};
|
|||||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
||||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||||
use dr_thumbs::ThumbStore;
|
use dr_thumbs::ThumbStore;
|
||||||
|
|
||||||
|
use crate::sidecar_cache::SidecarCache;
|
||||||
use dr_types::FormatFilter;
|
use dr_types::FormatFilter;
|
||||||
|
|
||||||
/// Largest preview worth fetching whole.
|
/// Largest preview worth fetching whole.
|
||||||
@@ -344,6 +346,7 @@ pub fn sidecar_path(image_path: &str) -> String {
|
|||||||
format!("{stem}.{}", dr_pipeline::sidecar::EXTENSION)
|
format!("{stem}.{}", dr_pipeline::sidecar::EXTENSION)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-8 | FR-CAT-9 | FR-NC-10
|
||||||
/// Persist amendments to sidecars beside their images.
|
/// Persist amendments to sidecars beside their images.
|
||||||
///
|
///
|
||||||
/// # Why this reads before it writes
|
/// # 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
|
/// parsed, amended, and written back; a fetch that 404s simply means there is
|
||||||
/// no sidecar yet and a new one is created.
|
/// 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
|
/// # Why failure here is logged rather than surfaced
|
||||||
///
|
///
|
||||||
/// The catalog write has already succeeded by the time this runs, so the star
|
/// The write has already succeeded locally by the time the network is touched,
|
||||||
/// is on screen and will survive a restart. A network failure costs
|
/// so nothing is lost by a failure and there is nothing for the user to do
|
||||||
/// *durability across a catalog rebuild*, not the judgement itself, and
|
/// about it. Interrupting a cull with an error dialog per frame would be far
|
||||||
/// interrupting a cull with an error dialog per frame would be far worse than
|
/// worse than the risk. The counts are reported once, at the end.
|
||||||
/// the risk. The count of failures is reported once, at the end.
|
|
||||||
pub fn spawn_sidecar_writes(
|
pub fn spawn_sidecar_writes(
|
||||||
creds: AppCredentials,
|
creds: AppCredentials,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
writes: Vec<SidecarWrite>,
|
writes: Vec<SidecarWrite>,
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
offline: bool,
|
||||||
) -> Receiver<SidecarMessage> {
|
) -> Receiver<SidecarMessage> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = match crate::net_runtime::build() {
|
let cache = SidecarCache::open(cache_dir);
|
||||||
Ok(rt) => rt,
|
|
||||||
Err(e) => {
|
// The runtime and the backend are only needed to *upload*. Offline,
|
||||||
let _ = tx.send(SidecarMessage::Finished {
|
// neither is built — and a failure to build either is not a failure to
|
||||||
written: 0,
|
// record the edit, it just means every write is queued instead.
|
||||||
failed: writes.len(),
|
let rt = if offline {
|
||||||
last_error: Some(e.to_string()),
|
None
|
||||||
});
|
} else {
|
||||||
return;
|
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 run = |backend: Option<&NextcloudBackend>| {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let mut report = SidecarReport::default();
|
||||||
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;
|
|
||||||
|
|
||||||
for w in &writes {
|
for w in &writes {
|
||||||
match write_one_sidecar(&backend, w).await {
|
match write_one_sidecar(backend, &cache, w) {
|
||||||
Ok(()) => written += 1,
|
Ok(Outcome::Uploaded) => report.written += 1,
|
||||||
|
Ok(Outcome::Queued) => report.queued += 1,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!("sidecar for {}: {e}", w.image_path);
|
log::debug!("sidecar for {}: {e}", w.image_path);
|
||||||
last_error = Some(e);
|
report.last_error = Some(e);
|
||||||
failed += 1;
|
report.failed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
report
|
||||||
|
};
|
||||||
|
|
||||||
let _ = tx.send(SidecarMessage::Finished {
|
let report = match rt {
|
||||||
written,
|
None => run(None),
|
||||||
failed,
|
Some(rt) => rt.block_on(async {
|
||||||
last_error,
|
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
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// The outcome of a batch of sidecar writes.
|
/// The outcome of a batch of sidecar writes.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum SidecarMessage {
|
pub enum SidecarMessage {
|
||||||
Finished {
|
Finished {
|
||||||
written: usize,
|
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,
|
failed: usize,
|
||||||
/// Reported once rather than per file: a network that is down fails
|
/// Reported once rather than per file: a network that is down fails
|
||||||
/// every write with the same message, and forty identical lines in the
|
/// every write with the same message, and forty identical lines in the
|
||||||
@@ -433,44 +495,14 @@ pub enum SidecarMessage {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-modify-write one sidecar.
|
/// Apply an amendment to a document, returning the new one.
|
||||||
async fn write_one_sidecar(backend: &NextcloudBackend, w: &SidecarWrite) -> Result<(), String> {
|
///
|
||||||
let path = RemotePath::new(sidecar_path(&w.image_path));
|
/// Split out from both write paths so that online and offline produce
|
||||||
let id = RemoteId::Path(path.clone());
|
/// *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
|
||||||
// An existing sidecar may hold an edit. Absent is the normal case on a
|
/// offline case is how the two would come to disagree about what a paste means.
|
||||||
// library that has never been edited, and is not an error.
|
fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
|
||||||
let existing = backend.get(&id, None).await.ok();
|
let mut sidecar = base;
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Amend the version this write belongs to, creating it if the file did
|
// 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
|
// 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();
|
version.modified = now_secs();
|
||||||
|
|
||||||
sidecar.put(version);
|
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<Outcome, String> {
|
||||||
|
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<Outcome, String> {
|
||||||
|
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
|
backend
|
||||||
.put(&path, sidecar.to_text().into_bytes(), None)
|
.put(&path, sidecar.to_text().into_bytes(), None)
|
||||||
.await
|
.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<SidecarMessage> {
|
||||||
|
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.
|
/// Where the catalog for an account lives.
|
||||||
@@ -1100,44 +1354,85 @@ pub fn spawn_sidecar_fetch(
|
|||||||
creds: AppCredentials,
|
creds: AppCredentials,
|
||||||
user_id: String,
|
user_id: String,
|
||||||
image_path: String,
|
image_path: String,
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
offline: bool,
|
||||||
) -> Receiver<Option<dr_pipeline::Sidecar>> {
|
) -> Receiver<Option<dr_pipeline::Sidecar>> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = match crate::net_runtime::build() {
|
let cache = SidecarCache::open(cache_dir);
|
||||||
Ok(e) => e,
|
let path_str = sidecar_path(&image_path);
|
||||||
Err(e) => {
|
|
||||||
log::debug!("sidecar fetch runtime: {e}");
|
// TRACES: FR-CAT-9 | FR-NC-10
|
||||||
let _ = tx.send(None);
|
// The cache wins outright when it is holding work the server has not
|
||||||
return;
|
// 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 {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!("sidecar fetch backend: {e}");
|
log::debug!("sidecar fetch backend: {e}");
|
||||||
let _ = tx.send(None);
|
let _ = tx.send(cache.load(&path_str));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = RemotePath::new(sidecar_path(&image_path));
|
let path = RemotePath::new(path_str.clone());
|
||||||
let id = RemoteId::Path(path.clone());
|
let id = RemoteId::Path(path.clone());
|
||||||
|
|
||||||
// A 404 is the normal case on a library that has never been
|
// A 404 is the normal case on a library that has never been
|
||||||
// edited, so this is `ok()` rather than an error path.
|
// edited, so this is `ok()` rather than an error path.
|
||||||
let parsed = backend.get(&id, None).await.ok().and_then(|bytes| {
|
let Ok(bytes) = backend.get(&id, None).await else {
|
||||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
// Unreachable, or no such file. The cache cannot tell those
|
||||||
match dr_pipeline::Sidecar::parse(&text) {
|
// apart and does not need to: either way it holds the best
|
||||||
Ok(s) => Some(s),
|
// answer this device has.
|
||||||
Err(e) => {
|
let _ = tx.send(cache.load(&path_str));
|
||||||
log::warn!("sidecar at {} is unreadable ({e})", path.as_str());
|
return;
|
||||||
None
|
};
|
||||||
}
|
|
||||||
|
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);
|
let _ = tx.send(parsed);
|
||||||
});
|
});
|
||||||
|
|||||||
+157
-16
@@ -143,6 +143,20 @@ pub struct LibraryController {
|
|||||||
/// a drop all reload the window, and every one of them must honour it or
|
/// a drop all reload the window, and every one of them must honour it or
|
||||||
/// the filter silently lapses.
|
/// the filter silently lapses.
|
||||||
filter: RefCell<library::RatingFilter>,
|
filter: RefCell<library::RatingFilter>,
|
||||||
|
/// 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<Option<slint::Timer>>,
|
||||||
|
/// 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<bool>,
|
||||||
/// Drains the sidecar writer. Held so a second judgement replaces the
|
/// Drains the sidecar writer. Held so a second judgement replaces the
|
||||||
/// timer rather than leaving two draining the same finished channel.
|
/// timer rather than leaving two draining the same finished channel.
|
||||||
sidecar_timer: RefCell<Option<slint::Timer>>,
|
sidecar_timer: RefCell<Option<slint::Timer>>,
|
||||||
@@ -236,6 +250,8 @@ impl LibraryController {
|
|||||||
sidecar_timer: RefCell::new(None),
|
sidecar_timer: RefCell::new(None),
|
||||||
generation: std::cell::Cell::new(0),
|
generation: std::cell::Cell::new(0),
|
||||||
reachability: RefCell::new(dr_sync::Reachability::new()),
|
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),
|
pin_timer: RefCell::new(None),
|
||||||
local_only: std::cell::Cell::new(false),
|
local_only: std::cell::Cell::new(false),
|
||||||
// The catalog's own floor until the settings page reports what the
|
// The catalog's own floor until the settings page reports what the
|
||||||
@@ -346,6 +362,22 @@ impl LibraryController {
|
|||||||
.ok()
|
.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<PathBuf> {
|
||||||
|
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
|
/// TRACES: FR-NC-6a
|
||||||
/// Where cached originals live for the open library.
|
/// Where cached originals live for the open library.
|
||||||
///
|
///
|
||||||
@@ -1035,6 +1067,100 @@ fn refresh_offline(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
if offline {
|
if offline {
|
||||||
window.set_library_error("".into());
|
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<LibraryController>) {
|
||||||
|
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.
|
/// A coarse "how long ago", for the offline banner.
|
||||||
@@ -1581,29 +1707,27 @@ pub(crate) fn start_sidecar_writes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: FR-CAT-9
|
// TRACES: FR-CAT-9
|
||||||
// Offline, this would stall on a timeout per sidecar, on the very
|
// Offline is passed down rather than used to skip.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// What is deferred is the sidecar, and with it the guarantee that the
|
// It used to skip, and the reasoning was that a cull stays responsive
|
||||||
// judgement survives a *catalog rebuild* (ARCH §6.12). There is no queue
|
// because the rating is safe in the catalog. That held for judgements and
|
||||||
// behind this yet, so a rating made offline is written to its sidecar only
|
// not for edits: the catalog stores no parameters, so a pasted edit made
|
||||||
// when that image is judged again while connected. That is a real gap
|
// offline survived nowhere at all. Every write now commits to the local
|
||||||
// rather than a hidden one: it trades a durability property that already
|
// sidecar cache first and the upload is best-effort, which keeps the
|
||||||
// depends on the network for a cull that stays responsive without it.
|
// keystroke path off the network — the original concern — without the
|
||||||
if ctl.is_offline() {
|
// write being conditional on it.
|
||||||
log::debug!("offline: skipping {} sidecar write(s)", writes.len());
|
let offline = ctl.is_offline();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
let Some((creds, session, _)) = ctl.session.borrow().clone() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let count = writes.len();
|
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 timer = slint::Timer::default();
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
@@ -1628,10 +1752,27 @@ pub(crate) fn start_sidecar_writes(
|
|||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(library::SidecarMessage::Finished {
|
Ok(library::SidecarMessage::Finished {
|
||||||
written,
|
written,
|
||||||
|
queued,
|
||||||
failed,
|
failed,
|
||||||
last_error,
|
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 {
|
if failed > 0 {
|
||||||
|
// A failed upload left the edit in the outbox.
|
||||||
|
ctl_cb.outbox_maybe_dirty.set(true);
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"{failed} sidecar write(s) failed: {}",
|
"{failed} sidecar write(s) failed: {}",
|
||||||
last_error.clone().unwrap_or_default()
|
last_error.clone().unwrap_or_default()
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ pub struct SettingsController {
|
|||||||
/// How much disk the cache is currently using, as a label. Supplied by
|
/// How much disk the cache is currently using, as a label. Supplied by
|
||||||
/// whoever owns the catalog — this module has no connection to query.
|
/// whoever owns the catalog — this module has no connection to query.
|
||||||
usage_label: RefCell<String>,
|
usage_label: RefCell<String>,
|
||||||
|
/// 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<Option<crate::launch::FolderBrowser>>,
|
||||||
|
/// 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<Option<slint::Timer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsController {
|
impl SettingsController {
|
||||||
@@ -56,6 +73,8 @@ impl SettingsController {
|
|||||||
store,
|
store,
|
||||||
error: RefCell::new(None),
|
error: RefCell::new(None),
|
||||||
usage_label: RefCell::new(String::new()),
|
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()
|
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<String>) {
|
||||||
|
*self.error.borrow_mut() = Some(message.into());
|
||||||
|
}
|
||||||
|
|
||||||
/// Show what the cache is holding. Empty hides the line.
|
/// Show what the cache is holding. Empty hides the line.
|
||||||
pub fn set_usage_label(&self, label: String) {
|
pub fn set_usage_label(&self, label: String) {
|
||||||
*self.usage_label.borrow_mut() = label;
|
*self.usage_label.borrow_mut() = label;
|
||||||
@@ -173,6 +207,40 @@ pub fn render(window: &AppWindow, controller: &SettingsController) {
|
|||||||
.into(),
|
.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::<Vec<_>>(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
window.set_settings_browse_entries(slint::ModelRc::new(slint::VecModel::from(
|
||||||
|
Vec::<slint::SharedString>::new(),
|
||||||
|
)));
|
||||||
|
window.set_settings_browse_loading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.set_settings_error(controller.error.borrow().clone().unwrap_or_default().into());
|
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<AppWindow>,
|
||||||
|
ctl: Rc<SettingsController>,
|
||||||
|
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::<Result<Vec<String>, 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<String> = 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -514,6 +688,8 @@ mod tests {
|
|||||||
store,
|
store,
|
||||||
error: RefCell::new(None),
|
error: RefCell::new(None),
|
||||||
usage_label: RefCell::new(String::new()),
|
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")),
|
store: SettingsStore::open_at(blocker.join("settings.json")),
|
||||||
error: RefCell::new(None),
|
error: RefCell::new(None),
|
||||||
usage_label: RefCell::new(String::new()),
|
usage_label: RefCell::new(String::new()),
|
||||||
|
browser: RefCell::new(None),
|
||||||
|
poll_timer: RefCell::new(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
broken.edit(|s| s.export.quality = 50);
|
broken.edit(|s| s.export.quality = 50);
|
||||||
|
|||||||
@@ -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
|
||||||
|
/// `<dir>/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<PathBuf> {
|
||||||
|
// 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<Sidecar> {
|
||||||
|
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<String> {
|
||||||
|
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<String>) {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -571,6 +571,11 @@ export component AppWindow inherits Window {
|
|||||||
in property <bool> settings-strip-location: true;
|
in property <bool> settings-strip-location: true;
|
||||||
in property <string> settings-destination: "";
|
in property <string> settings-destination: "";
|
||||||
in property <string> settings-destination-hint;
|
in property <string> settings-destination-hint;
|
||||||
|
in property <bool> settings-browse-open: false;
|
||||||
|
in property <string> settings-browse-path;
|
||||||
|
in property <[string]> settings-browse-entries;
|
||||||
|
in property <bool> settings-browse-loading: false;
|
||||||
|
in property <bool> settings-browse-at-root: true;
|
||||||
in property <[string]> settings-target-labels;
|
in property <[string]> settings-target-labels;
|
||||||
in property <int> settings-target-selected: 0;
|
in property <int> settings-target-selected: 0;
|
||||||
in property <string> settings-error: "";
|
in property <string> settings-error: "";
|
||||||
@@ -587,6 +592,11 @@ export component AppWindow inherits Window {
|
|||||||
callback settings-strip-location-toggled(bool);
|
callback settings-strip-location-toggled(bool);
|
||||||
callback settings-destination-changed(string);
|
callback settings-destination-changed(string);
|
||||||
callback settings-target-changed(int);
|
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();
|
callback settings-reset();
|
||||||
|
|
||||||
/// Show the settings page. Reads the file first, so a second instance's
|
/// Show the settings page. Reads the file first, so a second instance's
|
||||||
@@ -742,6 +752,11 @@ in property <bool> panel-visible: true;
|
|||||||
strip-location: root.settings-strip-location;
|
strip-location: root.settings-strip-location;
|
||||||
destination: root.settings-destination;
|
destination: root.settings-destination;
|
||||||
destination-hint: root.settings-destination-hint;
|
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-labels: root.settings-target-labels;
|
||||||
target-selected: root.settings-target-selected;
|
target-selected: root.settings-target-selected;
|
||||||
error: root.settings-error;
|
error: root.settings-error;
|
||||||
@@ -758,6 +773,11 @@ in property <bool> panel-visible: true;
|
|||||||
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
|
strip-location-toggled(on) => { root.settings-strip-location-toggled(on); }
|
||||||
destination-changed(t) => { root.settings-destination-changed(t); }
|
destination-changed(t) => { root.settings-destination-changed(t); }
|
||||||
target-picked(i) => { root.settings-target-changed(i); }
|
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-rows: root.activity-rows;
|
||||||
activity-running: root.activity-running;
|
activity-running: root.activity-running;
|
||||||
|
|||||||
@@ -115,6 +115,18 @@ export component SettingsPage inherits Rectangle {
|
|||||||
in property <[string]> target-labels;
|
in property <[string]> target-labels;
|
||||||
in property <int> target-selected: 0;
|
in property <int> 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 <bool> browse-open: false;
|
||||||
|
in property <string> browse-path;
|
||||||
|
in property <[string]> browse-entries;
|
||||||
|
in property <bool> browse-loading: false;
|
||||||
|
/// At the library root, so there is nowhere up to go.
|
||||||
|
in property <bool> browse-at-root: true;
|
||||||
|
|
||||||
callback format-picked(int);
|
callback format-picked(int);
|
||||||
callback quality-changed(int);
|
callback quality-changed(int);
|
||||||
callback colour-picked(int);
|
callback colour-picked(int);
|
||||||
@@ -127,6 +139,11 @@ export component SettingsPage inherits Rectangle {
|
|||||||
callback strip-location-toggled(bool);
|
callback strip-location-toggled(bool);
|
||||||
callback destination-changed(string);
|
callback destination-changed(string);
|
||||||
callback target-picked(int);
|
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
|
/// A save failed. The page's whole contract is that what it shows is
|
||||||
/// stored, so this cannot be swallowed.
|
/// stored, so this cannot be swallowed.
|
||||||
@@ -537,6 +554,131 @@ export component SettingsPage inherits Rectangle {
|
|||||||
accepted(t) => { root.destination-changed(t); }
|
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 {
|
Check {
|
||||||
label: "Strip location and personal metadata";
|
label: "Strip location and personal metadata";
|
||||||
hint: "On. An export is usually the copy that leaves "
|
hint: "On. An export is usually the copy that leaves "
|
||||||
|
|||||||
Reference in New Issue
Block a user