diff --git a/.gitignore b/.gitignore index ba2478f..69321fb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,10 @@ Cargo.lock.bak # tools/film-profiles/convert.py --fetch. Not source: the converted # profiles in core/dr-film/profiles are. tools/film-profiles/upstream/ + +# flatpak-builder's cache and its output tree. `packaging/flatpak/` holds the +# manifest, which is source; everything a build derives from it is not — and +# `.flatpak-builder/` in particular caches an unpacked copy of the whole +# checkout, so it is larger than the repository it sits in. +/.flatpak-builder/ +/build/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6e1e06..7c554e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,6 +151,7 @@ One commit per change. If you fixed two things, that is two commits. | [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync | | [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs | | [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen | +| [`docs/distribution.md`](docs/distribution.md) | Packaging a build, or adding a permission to one | | [`docs/requirements.md`](docs/requirements.md) | Reference, not reading | `technical-debt.md` is the one to check before "fixing" anything surprising. diff --git a/README.md b/README.md index 0125092..8656899 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,25 @@ A cross-platform, non-destructive RAW photo editor for Linux and Android. -**Status:** early. v0.1 is a remote library viewer — see -[docs/milestone-v0.1.md](docs/milestone-v0.1.md). +**Status:** 0.9.0, and no longer a spike. A library opens, culls, develops and +exports on both platforms, across eight tagged releases. What is *not* +built is written down rather than merely absent — see +[docs/outstanding.md](docs/outstanding.md) for the requirements that have no +implementation and why, and [docs/technical-debt.md](docs/technical-debt.md) +for the compromises that were chosen. ## Documentation | Document | Contents | |---|---| -| [requirements.md](docs/requirements.md) | What the software must do — 122 numbered requirements | +| [CONTRIBUTING.md](CONTRIBUTING.md) | How to land a first change without reading the rest | +| [requirements.md](docs/requirements.md) | What the software must do — 179 numbered requirements | | [architecture.md](docs/architecture.md) | How it is built — crates, GPU pipeline, data model, sync | -| [milestone-v0.1.md](docs/milestone-v0.1.md) | The first buildable milestone | -| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measures | +| [technical-debt.md](docs/technical-debt.md) | Compromises taken deliberately, each with the condition that retires it | +| [outstanding.md](docs/outstanding.md) | What is not built, and whether that is a decision or a gap | +| [code-health.md](docs/code-health.md) | What a contribution costs, per seam, measured | +| [traceability.md](docs/traceability.md) | Generated: which requirement is claimed by which file | +| [faces.md](docs/faces.md) | Face detection and identity — the models, the licence problem, and what S14 measured | ## Building @@ -28,21 +36,48 @@ Android (containerised toolchain, see [docker/android](docker/android/README.md) ./docker/android/build.sh cargo ndk -t arm64-v8a build --release ``` +Git LFS is required for the model weights, and the toolchain pins itself. +[CONTRIBUTING.md](CONTRIBUTING.md) has the details and the four commands CI +will run against what you send. + ## Current state -Working: workspace, GPU context and compute pass, adaptive Slint shell, Android -cross-compilation of the core crates. +**Working.** A catalog over a local folder, a Nextcloud account, or a folder a +sync client keeps in virtual-files mode — where a placeholder is treated as the +photograph rather than as a one-byte file. A virtualised library grid with a +capture-time timeline, ratings, labels, keywords, collections and a trash that +survives a crash mid-operation. Card ingest. Face detection and identity, with +the index syncing between devices. A develop pipeline of fifteen declared +operations fused into a single compute dispatch, plus the neighbourhood +operations that cannot be — clarity, texture, capture sharpening, noise +reduction, lens correction, spectral film simulation. Crop, straighten, spot +removal, gradient and subject-segmentation masks, named presets, and a +generated panel that no operation in `ui/` is allowed to name. Export to JPEG, +PNG and 8- or 16-bit TIFF with resize and output sharpening. -**Not yet working:** the zero-copy display path. The build currently uploads -frames through the CPU, which is exactly what -[ARCH §6.1](docs/architecture.md) forbids — measured at 96% of frame time at -4K. Replacing it is spike S1, the project's highest priority. +**The zero-copy display path works on desktop.** The compute pass writes a +texture that Slint composites directly, which is what +[ARCH §6.1](docs/architecture.md) requires; the readback it forbids costs 96% +of frame time at 4K, and -``` +```bash cargo run -p dr-gpu --example bench --features readback ``` -reproduces that measurement. +still reproduces that measurement. **The one exception is the Android develop +view**, which reads the frame back through the CPU because zero-copy there +needs wgpu's Vulkan swapchain, and that tears a portrait window on a tablet +whose panel is mounted landscape. It is debt, not a revision of the rule: the +reasoning, the on-device measurements that forced it, and the three separate +things any one of which would remove it are in +[technical-debt.md TD-1](docs/technical-debt.md). + +**Not built.** Plugins, compare and survey culling, focus peaking, burst +grouping, AI denoise, tiled and progressive rendering, and most of the Android +platform integration beyond running. The performance targets in §4.1 are +unverified rather than unmet — the per-commit benchmark suite §8 requires does +not exist, so nothing fails a build on a regression. +[docs/outstanding.md](docs/outstanding.md) is the list, with the reasoning. ## Licence diff --git a/apps/darkroom-android/src/lib.rs b/apps/darkroom-android/src/lib.rs index 79b20b3..6eda761 100644 --- a/apps/darkroom-android/src/lib.rs +++ b/apps/darkroom-android/src/lib.rs @@ -51,7 +51,47 @@ fn android_main(app: slint::android::AndroidApp) { // After the data dir and before anything asks whether a model is present. install_bundled_face_models(&app); - if let Err(e) = slint::android::init(app) { + // TRACES: FR-PLAT-AND-5 + // The listener is the whole reason this is not the one-line + // `slint::android::init(app)`. Slint owns the event loop on Android, so + // the platform's lifecycle and memory events reach the application only if + // it asks for them here — and it must ask *before* the loop starts, which + // is why this sits between the data directory and `dr_ui::run`. + // + // The listener runs inside `poll_events`, on the same thread the event + // loop and every interface cache live on, which is what lets + // `dr_ui::memory` be a thread-local registry of plain `Fn()` rather than a + // cross-thread channel (see its module documentation). + // + // # Why two events and not eight + // + // FR-PLAT-AND-5 names `onTrimMemory`, whose `TRIM_MEMORY_*` levels grade + // how badly the system wants the memory back. Those levels do not exist + // here: `ComponentCallbacks2` is a Java interface implemented by an + // `Activity` or `Application`, and this app has neither — it is a bare + // `NativeActivity`, whose native callback table offers only the ungraded + // `onLowMemory`. android-activity surfaces exactly that as `LowMemory`. + // Reading the grades would mean shipping a Java subclass to forward them, + // which is a distribution-manifest change and not this one. + // + // `Stop` recovers the one grade that matters most anyway, and for free. + // It is the moment the activity stops being visible — `TRIM_MEMORY_UI_HIDDEN` + // in all but name — and it is the cheapest possible time to give memory + // back, because nothing that is freed has to be drawn again before anyone + // sees it. `Pause` deliberately does not qualify: a permission dialog or + // the share sheet pauses an activity that is still on screen behind it, + // and throwing away its render pipeline would make every such interruption + // cost a full re-render. + use slint::android::android_activity::{MainEvent, PollEvent}; + if let Err(e) = slint::android::init_with_event_listener(app, |event| match event { + PollEvent::Main(MainEvent::LowMemory) => { + dr_ui::memory::relieve(dr_ui::memory::Level::Critical); + } + PollEvent::Main(MainEvent::Stop) => { + dr_ui::memory::relieve(dr_ui::memory::Level::UiHidden); + } + _ => {} + }) { log::error!("Slint Android backend failed to initialise: {e}"); return; } diff --git a/core/dr-catalog/src/bursts.rs b/core/dr-catalog/src/bursts.rs new file mode 100644 index 0000000..7402579 --- /dev/null +++ b/core/dr-catalog/src/bursts.rs @@ -0,0 +1,1309 @@ +//! TRACES: FR-CULL-5 +//! Bursts: frames that are one moment, grouped so they can be judged as one. +//! +//! A burst is the commonest thing in a cull and the least interesting. Twelve +//! frames of the same gull, shot at 10 fps, occupy twelve cells of the grid, +//! are scrolled past twelve times, and end with the photographer keeping one. +//! Collapsing them to a single cell with a count on it is the whole feature: +//! the twelve are still there, still reachable, still individually ratable — +//! they simply stop costing twelve decisions where one was meant. +//! +//! # What this deliberately does not do +//! +//! **Nothing here ranks a frame.** FR-CULL-5 names the failure it is avoiding: +//! automated *selection* is distrusted because the documented way it fails is +//! rejecting the only frame of an important moment because somebody blinked. +//! So there is no sharpness score, no eye detector, no "best of burst". The +//! representative of a group is the **earliest frame**, which is a fact about +//! the clock and not a judgement about the photograph, and the user can name a +//! different one at any time ([`choose_representative`]). +//! +//! That is also why this sits beside [`crate::dedup`] rather than inside it. +//! Dedup answers "are these the same file?" with a whole-file digest, for +//! import collision; two bytes of difference make two different answers, which +//! is exactly right there and useless here. A burst is a set of frames that are +//! *deliberately* different — the wing is in another place in each one. +//! +//! # Two signals, and why neither alone will do +//! +//! **Time alone** groups a wedding ceremony into one burst. Frames arrive +//! seconds apart for an hour, and a photographer shooting steadily never +//! produces the gap that would end the run. +//! +//! **Similarity alone** groups the same subject photographed on two different +//! days — a studio setup, a copy stand, a hundred frames of the same document. +//! Those are not a burst, they are a project, and collapsing them hides work +//! the user did on purpose. +//! +//! Together they are specific: *adjacent in time* **and** *looks like the frame +//! before it*. Both bounds are in [`Rules`]. +//! +//! # Chained, not compared to the first frame +//! +//! Each frame is tested against the one immediately before it, and a burst is a +//! run of frames that each joined the last. Comparing every frame to the *first* +//! would split a pan: by frame twenty a camera following a bird has nothing in +//! common with frame one, and yet no two adjacent frames in that sequence differ +//! by much. Chaining follows the subject; a fixed anchor loses it half way. +//! +//! The accepted consequence is that the first and last frames of a long burst +//! may be quite unlike each other. That is what a burst *is*, and the time bound +//! is what stops the chain running away: a sequence that has drifted a long way +//! has almost always paused somewhere, and the pause ends the run. +//! +//! # The similarity signal is a 64-bit difference hash +//! +//! [`Signature`] is a dHash taken on a 9×8 box-averaged reduction of the image: +//! 64 comparisons of a cell against its right-hand neighbour, one bit each. It +//! is scale-independent, survives JPEG artefacts and mild exposure changes, +//! costs nothing to store, and compares in one `xor` and a `popcount` — which +//! matters, because the grouping pass is a whole-library operation. +//! +//! What it is bad at is worth stating plainly: **a featureless frame hashes to +//! zero**, and so do all the other featureless frames. A lens cap, a black +//! frame, a white wall and an overexposed sky are mutually indistinguishable to +//! it. The capture-time bound is what keeps that from grouping every mistake in +//! the library into one enormous burst, and it is sufficient in practice — but +//! it is the reason this is grouping and not deduplication. +//! +//! # Where the pixels come from, and why the hash is stored +//! +//! This module never decodes an image. It takes a luma or RGBA buffer somebody +//! else already had in hand ([`signature_of_luma`], [`signature_of_rgba`]) and +//! `images.perceptual_hash` keeps the answer, the same bargain +//! [`crate::dedup::set_content_hash`] makes: whoever is already holding the +//! pixels pays nothing, and everybody afterwards pays nothing at all. In +//! practice the caller is the thumbnail store — a 256px thumbnail is far more +//! resolution than a 9×8 reduction needs — so a library that has been browsed +//! has already paid for its signatures. +//! +//! # A pass, not a job +//! +//! Grouping has no natural `subject_id`: it is a property of a *run* of frames, +//! so a per-image job would rebuild the world once per photograph. It is +//! therefore a debounced library-level pass, for exactly the reasons +//! docs/catalog.md §10.2 gives for face clustering, and [`regroup`] is the whole +//! of it — one ordered walk, no per-pair comparison beyond adjacent frames. +//! +//! # Grouping is not hiding +//! +//! A burst this pass has never seen before is recorded **open**: the grid keeps +//! every row it had, and all that appears is a mark saying how many frames each +//! run holds. Only the user folds one up. That is a deliberate refusal of the +//! obvious default — collapsing on discovery would be tidier and would also +//! mean a background pass removing photographs from under somebody part way +//! through a cull, which is the same class of surprise FR-CULL-5 is written to +//! avoid. A burst that is already known keeps whatever state it is in, so a +//! pass after the next import does not spring open a morning's work. +//! +//! # What is stored, and what a merge does with it +//! +//! `burst_members` is derived: deleting it costs one pass and loses nothing. +//! `burst_pick` is not — it is the user saying which frame stands for the +//! moment, and it lives in its own table precisely so [`regroup`] can rewrite +//! the grouping without erasing the judgement. That is the same argument +//! `people.ignored` makes one subsystem over: nothing short of remembering a +//! decision survives re-clustering. +//! +//! Both tables travel in the uploaded catalog snapshot, and nothing on the far +//! side reads them — [`crate::sync`] merges collections and keywords only — so +//! a second device rebuilds its own grouping from its own signatures. Bursts +//! are not yet cross-device state, and pretending otherwise would mean syncing +//! `burst_pick` as user data, which is a merge question this does not answer. + +use std::collections::{HashMap, HashSet}; + +use dr_types::ImageId; +use rusqlite::Connection; + +use crate::CatalogError; + +/// A 64-bit perceptual signature of one image. +/// +/// Comparable only to other signatures from this build: the reduction grid and +/// the bit order are part of the definition, and changing either silently +/// changes what "similar" means. If that ever happens the column has to be +/// cleared, the way a face `model_id` change forces a re-index. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Signature(pub u64); + +impl Signature { + /// Differing bits, 0..=64. Small is similar. + pub fn distance(self, other: Signature) -> u32 { + (self.0 ^ other.0).count_ones() + } + + /// The stored form. SQLite integers are signed, so the bits are reinterpreted + /// rather than converted — `as` in both directions is exact and lossless. + pub fn to_stored(self) -> i64 { + self.0 as i64 + } + + /// The inverse of [`Self::to_stored`]. + pub fn from_stored(v: i64) -> Self { + Signature(v as u64) + } +} + +/// Columns in the reduction grid. One more than the number of comparisons per +/// row, because each bit compares a cell against its right-hand neighbour. +const GRID_W: usize = 9; +/// Rows in the reduction grid. `(GRID_W - 1) * GRID_H` is 64 — the width of the +/// signature, and the reason these two numbers are what they are. +const GRID_H: usize = 8; + +/// Reduce a greyscale buffer to a [`Signature`]. +/// +/// `luma` is row-major, one byte a pixel, `width * height` of them. Returns +/// `None` for an empty or short buffer rather than panicking: the caller is +/// usually handing over the result of a decode, and a truncated JPEG is a +/// normal thing to find in a library rather than a programming error. +/// +/// # Why box-averaged rather than sampled +/// +/// Point-sampling a 9×8 grid out of a 256px thumbnail reads 72 pixels of +/// several hundred thousand, and two frames of a burst that differ by one pixel +/// of camera shake can sample entirely different detail. Averaging the whole +/// cell is what makes the signature stable under the small movements a burst is +/// made of — which is the property the whole feature rests on. +pub fn signature_of_luma(luma: &[u8], width: u32, height: u32) -> Option { + let (w, h) = (width as usize, height as usize); + if w == 0 || h == 0 || luma.len() < w * h { + return None; + } + + let mut cells = [0f32; GRID_W * GRID_H]; + for gy in 0..GRID_H { + // Cell bounds by proportion, so every source pixel lands in exactly one + // cell whatever the aspect ratio. The `max` keeps a cell non-empty when + // the source is narrower or shorter than the grid — a 4px-wide preview + // is degenerate but must not divide by zero. + let y0 = gy * h / GRID_H; + let y1 = (((gy + 1) * h) / GRID_H).max(y0 + 1).min(h); + for gx in 0..GRID_W { + let x0 = gx * w / GRID_W; + let x1 = (((gx + 1) * w) / GRID_W).max(x0 + 1).min(w); + + let mut sum = 0u32; + let mut n = 0u32; + for y in y0..y1 { + let row = &luma[y * w..y * w + w]; + for px in &row[x0..x1] { + sum += *px as u32; + n += 1; + } + } + cells[gy * GRID_W + gx] = sum as f32 / n as f32; + } + } + + // Each bit: is this cell brighter than the one to its right? A *difference* + // rather than a level, which is what makes the signature indifferent to the + // exposure drifting a third of a stop through a burst. + let mut bits = 0u64; + for gy in 0..GRID_H { + for gx in 0..GRID_W - 1 { + bits <<= 1; + if cells[gy * GRID_W + gx] > cells[gy * GRID_W + gx + 1] { + bits |= 1; + } + } + } + Some(Signature(bits)) +} + +/// [`signature_of_luma`] for the packed RGBA a decoded thumbnail arrives as. +/// +/// Alpha is ignored: a thumbnail is opaque, and a signature that changed with +/// it would make two renderings of one frame look like two photographs. +pub fn signature_of_rgba(rgba: &[u8], width: u32, height: u32) -> Option { + let (w, h) = (width as usize, height as usize); + if w == 0 || h == 0 || rgba.len() < w * h * 4 { + return None; + } + // Rec. 601 luma in fixed point. The exact weights matter less than their + // being the same weights every time — see [`Signature`]. + let luma: Vec = rgba + .chunks_exact(4) + .take(w * h) + .map(|p| ((77 * p[0] as u32 + 150 * p[1] as u32 + 29 * p[2] as u32) >> 8) as u8) + .collect(); + signature_of_luma(&luma, width, height) +} + +/// The two bounds that decide what counts as one burst. +/// +/// Held together in a struct rather than passed as two numbers so a caller +/// cannot supply one and default the other, and so the pair can be logged as +/// what a given grouping was produced under. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rules { + /// Longest gap, in seconds, between two frames of one burst. + pub max_gap: i64, + /// Largest [`Signature::distance`] between two frames of one burst. + pub max_distance: u32, +} + +impl Default for Rules { + /// # Why two seconds, when a burst fires ten frames in one + /// + /// Because `images.captured_at` is **whole seconds**. EXIF `DateTimeOriginal` + /// has no sub-second field — `SubSecTimeOriginal` is a separate, optional tag + /// that plenty of bodies omit — so a 10 fps burst arrives in the catalog as + /// ten frames sharing one timestamp, and any threshold below a second is a + /// threshold on information that is not there. + /// + /// Two seconds is therefore the smallest bound that can distinguish + /// anything: it holds a burst together across the second boundary it will + /// certainly straddle, and it is short enough that a photographer working at + /// a considered pace — a frame every three or four seconds — is never + /// grouped. Where the pace really is faster than that, the similarity bound + /// is what separates the frames. + /// + /// # Why eight bits of sixty-four + /// + /// A difference hash of two frames of one burst typically differs by nought + /// to four bits; two unrelated photographs differ by twenty to thirty-two, + /// with thirty-two being the expected distance between two *random* + /// signatures. Eight sits in the empty ground between those, near enough to + /// the burst end of it that a subject change inside one second — the case + /// FR-CULL-5's failure mode is really about — does not merge. + /// + /// Erring low is the right direction: a burst left ungrouped costs the user + /// a scroll, and two moments wrongly merged hide a photograph behind a cell + /// that does not look like it. + fn default() -> Self { + Rules { + max_gap: 2, + max_distance: 8, + } + } +} + +/// One frame as the grouping pass sees it. +/// +/// Deliberately not a catalog row: the whole decision is made from these four +/// fields, which is what lets [`group`] be tested against constructed frames +/// with no database at all. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub id: ImageId, + /// UTC seconds. A frame without one cannot be placed in a run and is never + /// read by the pass. + pub captured_at: i64, + /// The joined make-and-model string, as the scan stores it. + pub camera: Option, + /// `None` until something has hashed this image's pixels. + pub signature: Option, +} + +/// Whether `b` continues the burst that `a` is part of. +/// +/// `b` is assumed not to precede `a`. +fn continues(a: &Frame, b: &Frame, rules: Rules) -> bool { + if b.captured_at - a.captured_at > rules.max_gap { + return false; + } + + // Two bodies firing at the same instant are two photographs of one moment, + // not one burst — a second shooter at a wedding, or a camera on a tripod + // triggered alongside the one in your hands. Where either camera is unknown + // the frames are not separated on that account: an unread EXIF header is + // absence of evidence, and the similarity bound still has to be satisfied. + if let (Some(x), Some(y)) = (&a.camera, &b.camera) { + if x != y { + return false; + } + } + + // A missing signature never groups. It would be easy to fall back to time + // alone here and it would be wrong: an unhashed library would collapse + // every steadily-shot sequence in it, and the user would have no way to see + // that the reason was a missing hash rather than a resemblance. + match (a.signature, b.signature) { + (Some(p), Some(q)) => p.distance(q) <= rules.max_distance, + _ => false, + } +} + +/// Group frames into bursts. The whole of the algorithm. +/// +/// Returns only groups of two or more, each ordered by capture time, in the +/// order the bursts themselves begin. A lone frame is not a burst and gets no +/// row anywhere: "is this image in a burst" is then answerable as "does it have +/// a `burst_members` row", and the grid pays nothing for the overwhelming +/// majority of a library that is not bursts. +/// +/// Sorts its own input. The caller usually reads frames in capture order +/// anyway, but the run structure is only meaningful in that order and a +/// mis-ordered caller would get silently wrong groups rather than an error. +pub fn group(frames: &[Frame], rules: Rules) -> Vec> { + let mut ordered: Vec<&Frame> = frames.iter().collect(); + // Ties on the second broken by id, which is the order the frames were + // catalogued in and therefore the order the camera wrote them. It is the + // same tiebreak the grid uses, so a burst is a contiguous run on screen. + ordered.sort_by_key(|f| (f.captured_at, f.id.0)); + + let mut out: Vec> = Vec::new(); + let mut run: Vec = Vec::new(); + + for pair in ordered.windows(2) { + let (a, b) = (pair[0], pair[1]); + if continues(a, b, rules) { + if run.is_empty() { + run.push(a.id); + } + run.push(b.id); + } else if !run.is_empty() { + out.push(std::mem::take(&mut run)); + } + } + if !run.is_empty() { + out.push(run); + } + out +} + +/// What one [`regroup`] did, for the log and for a progress line. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Report { + /// Groups of two or more frames. + pub bursts: usize, + /// Frames inside them. Never the size of the library. + pub frames: usize, + /// Frames in the largest single burst. + pub largest: usize, +} + +/// Rebuild the whole library's grouping. +/// +/// One ordered read of the dated, visible images, one pass over it, one +/// transaction. The cost is linear in library size and the comparison count is +/// one per *adjacent pair* — there is no all-pairs step here and there must +/// never be one, because that is what turns a grouping pass into a job nobody +/// can afford to run. +/// +/// Existing membership is replaced rather than amended. Amending would need to +/// know which frames had changed since the last pass, and the answer is +/// "possibly any of them, because a signature arrives long after the row does"; +/// a full rebuild is a few milliseconds and cannot drift. +pub fn regroup(conn: &Connection, rules: Rules) -> Result { + let frames = read_frames(conn)?; + let groups = group(&frames, rules); + let picked = user_picks(conn)?; + let known = known_bursts(conn)?; + + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM burst_members", [])?; + { + let mut insert = tx.prepare( + "INSERT INTO burst_members(image_id, burst_id, representative) + VALUES (?1, ?2, ?3)", + )?; + for members in &groups { + // The earliest frame's own id names the burst. It is stable across + // a regroup that leaves the burst alone, which is what lets the UI + // remember that this group is open; it is not a durable identity, + // and a burst that gains an earlier frame is a new group as far as + // anything keyed on this is concerned. + let burst_id = members[0].0 as i64; + let representative = members + .iter() + .find(|id| picked.contains(*id)) + .copied() + .unwrap_or(members[0]); + for id in members { + insert.execute(rusqlite::params![ + id.0 as i64, + burst_id, + (*id == representative) as i64 + ])?; + } + + // **A burst the user has never seen arrives open.** Nothing is + // hidden by this pass running: the grid holds exactly the + // photographs it held before, and the only new thing is a mark on + // each burst saying how many frames it is part of. Folding one up + // is then something the user did, which is the difference between + // a tool that groups and a tool that decides — and it is the only + // arrangement in which a background pass cannot make a row + // disappear from under somebody mid-cull. + // + // A burst that is already known keeps whatever state it is in, so + // the pass that runs after the next import does not spring open + // everything the user spent the morning folding away. + if !known.contains(&burst_id) { + tx.execute( + "INSERT OR IGNORE INTO burst_expanded(burst_id) VALUES (?1)", + [burst_id], + )?; + } + } + } + // A group that no longer exists must not leave an expansion behind: the id + // is an image id, and the next pass could hand it to a different burst. + tx.execute( + "DELETE FROM burst_expanded + WHERE burst_id NOT IN (SELECT burst_id FROM burst_members)", + [], + )?; + tx.commit()?; + + Ok(Report { + bursts: groups.len(), + frames: groups.iter().map(|g| g.len()).sum(), + largest: groups.iter().map(|g| g.len()).max().unwrap_or(0), + }) +} + +/// The frames a grouping pass considers, in capture order. +/// +/// The same two exclusions the grid makes, for the same two reasons: a shadowed +/// JPEG is its RAW's frame rather than a second photograph, and a trashed image +/// is not in the library. Undated frames are excluded because a burst is a +/// statement about time and there is nothing to say about a frame with none. +fn read_frames(conn: &Connection) -> Result, CatalogError> { + let mut stmt = conn.prepare( + "SELECT id, captured_at, camera, perceptual_hash + FROM images + WHERE captured_at IS NOT NULL + AND shadowed_by IS NULL + AND trashed_at IS NULL + ORDER BY captured_at ASC, id ASC", + )?; + let rows = stmt.query_map([], |r| { + Ok(Frame { + id: ImageId(r.get::<_, i64>(0)? as u64), + captured_at: r.get(1)?, + camera: r.get(2)?, + signature: r.get::<_, Option>(3)?.map(Signature::from_stored), + }) + })?; + Ok(rows.collect::, _>>()?) +} + +/// The bursts the previous pass left behind, so this one can tell a group the +/// user has already met from a group that is new. +fn known_bursts(conn: &Connection) -> Result, CatalogError> { + let mut stmt = conn.prepare("SELECT DISTINCT burst_id FROM burst_members")?; + let rows = stmt.query_map([], |r| r.get::<_, i64>(0))?; + Ok(rows.collect::, _>>()?) +} + +fn user_picks(conn: &Connection) -> Result, CatalogError> { + let mut stmt = conn.prepare("SELECT image_id FROM burst_pick")?; + let rows = stmt.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?; + Ok(rows.collect::, _>>()?) +} + +/// Record the signature of an image somebody has just decoded. +/// +/// Returns rows updated: zero means the image is no longer in the catalog, +/// which is a normal race with a rescan rather than an error — the same answer +/// [`crate::dedup::set_content_hash`] gives. +pub fn set_signature( + conn: &Connection, + image: ImageId, + signature: Signature, +) -> Result { + Ok(conn.execute( + "UPDATE images SET perceptual_hash = ?2 WHERE id = ?1", + rusqlite::params![image.0 as i64, signature.to_stored()], + )?) +} + +/// What [`set_signature`] stored, if anything. +pub fn signature(conn: &Connection, image: ImageId) -> Result, CatalogError> { + let stored: Option = conn.query_row( + "SELECT perceptual_hash FROM images WHERE id = ?1", + [image.0 as i64], + |r| r.get(0), + )?; + Ok(stored.map(Signature::from_stored)) +} + +/// Visible, dated images that nothing has hashed yet. +/// +/// What the pass that fills the column iterates. Dated, because an undated +/// frame can never join a burst however well it hashes, and hashing it would be +/// work with no possible consequence. +pub fn images_without_signature(conn: &Connection) -> Result, CatalogError> { + let mut stmt = conn.prepare( + "SELECT id FROM images + WHERE perceptual_hash IS NULL + AND captured_at IS NOT NULL + AND shadowed_by IS NULL + AND trashed_at IS NULL + ORDER BY captured_at ASC, id ASC", + )?; + let rows = stmt.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?; + Ok(rows.collect::, _>>()?) +} + +/// What the grid needs to know about one cell's burst. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Membership { + /// The group's id, which is also the image id of its earliest frame. + pub burst_id: ImageId, + /// Frames in the group, this one included. Always two or more. + pub size: u32, + /// Whether this is the frame the group collapses to. + pub representative: bool, + /// Whether the group is currently open in the grid. + pub expanded: bool, +} + +/// Burst membership for one window of the grid, in a single query. +/// +/// One statement for the whole window rather than one per cell, which is the +/// same discipline the collection badges and the rating counts follow: a grid +/// that asks the catalog a question per cell is a grid that stutters while a +/// finger is on it. +pub fn memberships( + conn: &Connection, + images: &[ImageId], +) -> Result, CatalogError> { + if images.is_empty() { + return Ok(HashMap::new()); + } + // Placeholders are generated from the *count* of ids, never from anything + // the user typed. + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT bm.image_id, + bm.burst_id, + bm.representative, + (SELECT count(*) FROM burst_members m WHERE m.burst_id = bm.burst_id), + EXISTS (SELECT 1 FROM burst_expanded be WHERE be.burst_id = bm.burst_id) + FROM burst_members bm + WHERE bm.image_id IN ({placeholders})" + ); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(( + ImageId(r.get::<_, i64>(0)? as u64), + Membership { + burst_id: ImageId(r.get::<_, i64>(1)? as u64), + size: r.get::<_, i64>(3)? as u32, + representative: r.get::<_, i64>(2)? != 0, + expanded: r.get::<_, i64>(4)? != 0, + }, + )) + })?; + Ok(rows.collect::, _>>()?) +} + +/// The frames of one burst, in the order the grid lists them. +pub fn members(conn: &Connection, burst: ImageId) -> Result, CatalogError> { + let mut stmt = conn.prepare( + "SELECT bm.image_id + FROM burst_members bm + JOIN images i ON i.id = bm.image_id + WHERE bm.burst_id = ?1 + ORDER BY i.captured_at ASC, i.id ASC", + )?; + let rows = stmt.query_map([burst.0 as i64], |r| { + Ok(ImageId(r.get::<_, i64>(0)? as u64)) + })?; + Ok(rows.collect::, _>>()?) +} + +/// Open or close one burst in the grid. +/// +/// Kept in the catalog rather than in the interface's own memory for one +/// reason: the grid is a *window* over an ordered query, and what is collapsed +/// has to be decided by that query or the window's row count stops matching the +/// scrollbar. Once the state has to be visible to SQL, the catalog is where it +/// lives — and a culling session that survives closing the app is the shape +/// FR-CULL-4 already asks for elsewhere. +pub fn set_expanded(conn: &Connection, burst: ImageId, expanded: bool) -> Result<(), CatalogError> { + if expanded { + conn.execute( + // `OR IGNORE` rather than an upsert: the toggle is wired to a + // callback that can fire twice, and re-opening an open burst must + // be a no-op rather than an error. + "INSERT OR IGNORE INTO burst_expanded(burst_id) VALUES (?1)", + [burst.0 as i64], + )?; + } else { + conn.execute( + "DELETE FROM burst_expanded WHERE burst_id = ?1", + [burst.0 as i64], + )?; + } + Ok(()) +} + +/// Whether this burst is currently open. +pub fn is_expanded(conn: &Connection, burst: ImageId) -> Result { + let n: i64 = conn.query_row( + "SELECT count(*) FROM burst_expanded WHERE burst_id = ?1", + [burst.0 as i64], + |r| r.get(0), + )?; + Ok(n > 0) +} + +/// Close every open burst. Returns how many were open. +pub fn collapse_all(conn: &Connection) -> Result { + Ok(conn.execute("DELETE FROM burst_expanded", [])?) +} + +/// The user names the frame their burst collapses to. +/// +/// The one *choice* in this module, and the reason the rest of it makes none. +/// Recorded against the image rather than the group so that it survives the +/// next [`regroup`]: a group is rebuilt from scratch every pass, and a +/// representative stored on it would be forgotten every time a frame arrived. +/// +/// Any previous pick within the same burst is dropped — a burst collapses to +/// one frame, and two picks would make the choice depend on row order. +pub fn choose_representative(conn: &Connection, image: ImageId) -> Result<(), CatalogError> { + let id = image.0 as i64; + let tx = conn.unchecked_transaction()?; + + // Only meaningful for an image that is actually in a burst; anything else + // would leave a pick that no group can ever honour. + let burst: Option = tx + .query_row( + "SELECT burst_id FROM burst_members WHERE image_id = ?1", + [id], + |r| r.get(0), + ) + .ok(); + let Some(burst) = burst else { + return Ok(()); + }; + + tx.execute( + "DELETE FROM burst_pick + WHERE image_id IN (SELECT image_id FROM burst_members WHERE burst_id = ?1)", + [burst], + )?; + tx.execute("INSERT INTO burst_pick(image_id) VALUES (?1)", [id])?; + // The grouping already exists, so the flag it carries is corrected now + // rather than at the next pass — the user expects the cell to change under + // the pointer, not after a background sweep. + tx.execute( + "UPDATE burst_members SET representative = (image_id = ?2) WHERE burst_id = ?1", + [burst, id], + )?; + tx.commit()?; + Ok(()) +} + +/// SQL for "this row is not a frame a collapsed burst is standing in for". +/// +/// Handed out as a predicate rather than as a list of ids because the grid is a +/// window: the rows a collapsed burst hides are mostly not loaded, so the +/// question can only be answered where the ordering and the `LIMIT` are — in +/// the query itself. `image` names the table or alias the image row is in, +/// because the grid aliases `images` as `i` and other callers do not. +/// +/// Both subqueries are probes on a primary key, so this costs one index lookup +/// per row the query walks and nothing at all for a library with no bursts in +/// it, where `burst_members` is empty. +/// +/// Never interpolate anything user-supplied as `image`. +pub fn not_collapsed_away(image: &str) -> String { + format!( + "NOT EXISTS (SELECT 1 FROM burst_members bm + WHERE bm.image_id = {image}.id + AND bm.representative = 0 + AND NOT EXISTS (SELECT 1 FROM burst_expanded be + WHERE be.burst_id = bm.burst_id))" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Catalog; + + /// A frame with everything the grouping looks at. + fn frame(id: u64, at: i64, sig: u64) -> Frame { + Frame { + id: ImageId(id), + captured_at: at, + camera: Some("Canon EOS R5".into()), + signature: Some(Signature(sig)), + } + } + + // ── the signature ───────────────────────────────────────────────────── + + /// A blocky pseudo-random scene, deterministic and free of any dependency. + /// + /// Blocks rather than per-pixel noise, because per-pixel noise averages to a + /// flat grey in the 9×8 reduction and every such image would hash alike — + /// which would make the tests below pass for the wrong reason. + fn scene(w: usize, h: usize, seed: u64, shift: usize, brighter: i32) -> Vec { + let block = 8; + let mut out = vec![0u8; w * h]; + for y in 0..h { + for x in 0..w { + let bx = (x + shift) / block; + let by = y / block; + // A small LCG, evaluated per block: reproducible, and nothing + // in the workspace has to provide it. + let mut s = seed + .wrapping_add(bx as u64) + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add((by as u64).wrapping_mul(1_442_695_040_888_963_407)); + s ^= s >> 33; + let v = (s % 256) as i32 + brighter; + out[y * w + x] = v.clamp(0, 255) as u8; + } + } + out + } + + #[test] + fn two_frames_of_one_burst_hash_almost_alike() { + // The property the whole feature rests on: the same scene, moved by a + // pixel and a third of a stop brighter, is the same signature or very + // nearly. + let a = signature_of_luma(&scene(64, 64, 7, 0, 0), 64, 64).unwrap(); + let b = signature_of_luma(&scene(64, 64, 7, 1, 6), 64, 64).unwrap(); + assert!( + a.distance(b) <= Rules::default().max_distance, + "a burst pair differed by {} bits, which the default rules would \ + not group", + a.distance(b) + ); + } + + #[test] + fn two_different_subjects_do_not_hash_alike() { + let a = signature_of_luma(&scene(64, 64, 7, 0, 0), 64, 64).unwrap(); + let b = signature_of_luma(&scene(64, 64, 99, 0, 0), 64, 64).unwrap(); + assert!( + a.distance(b) > Rules::default().max_distance, + "two unrelated scenes differed by only {} bits", + a.distance(b) + ); + } + + #[test] + fn the_signature_does_not_change_with_scale() { + // Detection runs against whichever preview tier is in hand, and the + // cache is entitled to regenerate one at another size. A signature that + // moved with the resolution would silently stop matching its own + // library. + let big = scene(128, 128, 21, 0, 0); + // Nearest-neighbour halving, which is the harshest thing a real + // downscale could do to it. + let mut small = vec![0u8; 64 * 64]; + for y in 0..64 { + for x in 0..64 { + small[y * 64 + x] = big[(y * 2) * 128 + x * 2]; + } + } + let a = signature_of_luma(&big, 128, 128).unwrap(); + let b = signature_of_luma(&small, 64, 64).unwrap(); + assert!( + a.distance(b) <= Rules::default().max_distance, + "halving the image moved {} bits, enough to stop it matching itself", + a.distance(b) + ); + } + + #[test] + fn a_short_or_empty_buffer_is_not_a_signature() { + assert!(signature_of_luma(&[], 0, 0).is_none()); + assert!(signature_of_luma(&[1, 2, 3], 64, 64).is_none()); + assert!(signature_of_rgba(&[1, 2, 3, 255], 64, 64).is_none()); + } + + #[test] + fn rgba_and_luma_agree() { + let luma = scene(64, 64, 3, 0, 0); + let rgba: Vec = luma.iter().flat_map(|v| [*v, *v, *v, 255]).collect(); + // Grey is grey: the Rec. 601 weights sum to 256/256, so a neutral pixel + // survives the conversion within a rounding step. + let a = signature_of_luma(&luma, 64, 64).unwrap(); + let b = signature_of_rgba(&rgba, 64, 64).unwrap(); + assert!(a.distance(b) <= 2, "{} bits apart", a.distance(b)); + } + + #[test] + fn a_signature_survives_the_trip_through_sqlite() { + // The top bit is the one at risk: SQLite integers are signed. + let s = Signature(u64::MAX); + assert_eq!(Signature::from_stored(s.to_stored()), s); + assert_eq!( + Signature::from_stored(Signature(0).to_stored()), + Signature(0) + ); + } + + // ── the grouping ────────────────────────────────────────────────────── + + #[test] + fn frames_a_second_apart_and_alike_are_one_burst() { + let g = group( + &[ + frame(1, 1000, 0xFF00), + frame(2, 1001, 0xFF00), + frame(3, 1001, 0xFF01), + ], + Rules::default(), + ); + assert_eq!(g, vec![vec![ImageId(1), ImageId(2), ImageId(3)]]); + } + + #[test] + fn a_pause_ends_the_burst() { + // The boundary itself: `max_gap` is inclusive, so two seconds continues + // a run and three begins a new one. + let rules = Rules::default(); + let g = group( + &[ + frame(1, 1000, 0xFF00), + frame(2, 1002, 0xFF00), + frame(3, 1005, 0xFF00), + frame(4, 1006, 0xFF00), + ], + rules, + ); + assert_eq!( + g, + vec![vec![ImageId(1), ImageId(2)], vec![ImageId(3), ImageId(4)],], + "the three-second pause did not end the first burst" + ); + } + + #[test] + fn two_subjects_one_second_apart_are_not_a_burst() { + // The failure FR-CULL-5 names, in its mildest form: turning round and + // photographing something else does not make the two frames one moment, + // however quickly it was done. + let bird = signature_of_luma(&scene(64, 64, 11, 0, 0), 64, 64).unwrap(); + let sign = signature_of_luma(&scene(64, 64, 404, 0, 0), 64, 64).unwrap(); + let g = group( + &[ + Frame { + id: ImageId(1), + captured_at: 1000, + camera: Some("X-T5".into()), + signature: Some(bird), + }, + Frame { + id: ImageId(2), + captured_at: 1001, + camera: Some("X-T5".into()), + signature: Some(sign), + }, + ], + Rules::default(), + ); + assert!(g.is_empty(), "two different subjects were grouped: {g:?}"); + } + + #[test] + fn a_lone_frame_is_not_a_burst() { + let g = group(&[frame(1, 1000, 0xAB)], Rules::default()); + assert!(g.is_empty()); + } + + #[test] + fn a_burst_chains_through_a_pan() { + // Frame one and frame four have nothing in common; each frame resembles + // the one before it. That is a camera following a bird, and it is one + // burst. + let rules = Rules::default(); + let g = group( + &[ + frame(1, 1000, 0x0000_0000_0000_0000), + frame(2, 1000, 0x0000_0000_0000_00FF), + frame(3, 1001, 0x0000_0000_0000_FFFF), + frame(4, 1001, 0x0000_0000_00FF_FFFF), + ], + rules, + ); + assert_eq!(g.len(), 1, "the pan was split: {g:?}"); + assert_eq!(g[0].len(), 4); + // And the ends really are far apart — otherwise this test would pass + // without exercising the chaining at all. + assert!(Signature(0).distance(Signature(0x00FF_FFFF)) > rules.max_distance); + } + + #[test] + fn frames_from_two_cameras_at_one_instant_stay_apart() { + // A second shooter, or a tethered body beside the one in your hands. + let g = group( + &[ + Frame { + id: ImageId(1), + captured_at: 1000, + camera: Some("Canon EOS R5".into()), + signature: Some(Signature(0xFF00)), + }, + Frame { + id: ImageId(2), + captured_at: 1000, + camera: Some("NIKON Z 9".into()), + signature: Some(Signature(0xFF00)), + }, + ], + Rules::default(), + ); + assert!(g.is_empty(), "two bodies were merged into one burst: {g:?}"); + } + + #[test] + fn an_unknown_camera_does_not_split_a_burst() { + // metadata_state 1 is a normal resting state for a freshly scanned + // library; refusing to group on it would mean bursts appear only after + // a full EXIF sweep. + let g = group( + &[ + Frame { + id: ImageId(1), + captured_at: 1000, + camera: None, + signature: Some(Signature(0xFF00)), + }, + Frame { + id: ImageId(2), + captured_at: 1000, + camera: Some("Canon EOS R5".into()), + signature: Some(Signature(0xFF00)), + }, + ], + Rules::default(), + ); + assert_eq!(g.len(), 1); + } + + #[test] + fn an_unhashed_frame_never_groups() { + // Time alone would put these three together, which is precisely the + // wedding-ceremony failure. + let g = group( + &[ + Frame { + id: ImageId(1), + captured_at: 1000, + camera: None, + signature: None, + }, + Frame { + id: ImageId(2), + captured_at: 1001, + camera: None, + signature: None, + }, + Frame { + id: ImageId(3), + captured_at: 1002, + camera: None, + signature: None, + }, + ], + Rules::default(), + ); + assert!(g.is_empty(), "unhashed frames were grouped on time alone"); + } + + #[test] + fn input_order_does_not_change_the_answer() { + let frames = vec![ + frame(3, 1002, 0xFF00), + frame(1, 1000, 0xFF00), + frame(2, 1001, 0xFF00), + ]; + let g = group(&frames, Rules::default()); + assert_eq!(g, vec![vec![ImageId(1), ImageId(2), ImageId(3)]]); + } + + // ── the pass, against a catalog ─────────────────────────────────────── + + /// A catalog holding `frames` as (id, captured_at, hash). + fn seeded(frames: &[(i64, i64, Option)]) -> Catalog { + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + for (id, at, hash) in frames { + c.execute( + "INSERT INTO images(id, root_id, source_ref, captured_at, camera, + perceptual_hash, added_at) + VALUES (?1, 1, ?2, ?3, 'Canon EOS R5', ?4, 0)", + rusqlite::params![ + id, + format!("IMG_{id}.CR3"), + at, + hash.map(|h| Signature(h).to_stored()) + ], + ) + .unwrap(); + } + cat + } + + #[test] + fn a_pass_records_the_bursts_it_found() { + let cat = seeded(&[ + (1, 1000, Some(0xFF00)), + (2, 1001, Some(0xFF00)), + (3, 2000, Some(0xFF00)), + ]); + let report = regroup(cat.connection(), Rules::default()).unwrap(); + assert_eq!(report.bursts, 1); + assert_eq!(report.frames, 2); + assert_eq!(report.largest, 2); + + let m = memberships(cat.connection(), &[ImageId(1), ImageId(2), ImageId(3)]).unwrap(); + assert_eq!(m.len(), 2, "the lone frame should have no row"); + assert_eq!(m[&ImageId(1)].burst_id, ImageId(1)); + assert!(m[&ImageId(1)].representative); + assert!(!m[&ImageId(2)].representative); + assert_eq!(m[&ImageId(2)].size, 2); + } + + #[test] + fn a_second_pass_replaces_the_first() { + let cat = seeded(&[(1, 1000, Some(0xFF00)), (2, 1001, Some(0xFF00))]); + regroup(cat.connection(), Rules::default()).unwrap(); + // The frames turn out to be seconds apart after all — an EXIF sweep + // corrected the timestamp. + cat.connection() + .execute("UPDATE images SET captured_at = 3000 WHERE id = 2", []) + .unwrap(); + let report = regroup(cat.connection(), Rules::default()).unwrap(); + assert_eq!(report.bursts, 0); + assert!(memberships(cat.connection(), &[ImageId(1), ImageId(2)]) + .unwrap() + .is_empty()); + } + + #[test] + fn the_representative_is_the_earliest_frame_and_not_a_judgement() { + let cat = seeded(&[ + (7, 1000, Some(0xFF00)), + (8, 1000, Some(0xFF00)), + (9, 1001, Some(0xFF00)), + ]); + regroup(cat.connection(), Rules::default()).unwrap(); + let m = memberships(cat.connection(), &[ImageId(7), ImageId(8), ImageId(9)]).unwrap(); + assert!(m[&ImageId(7)].representative); + assert!(!m[&ImageId(8)].representative); + assert!(!m[&ImageId(9)].representative); + } + + #[test] + fn the_users_choice_of_representative_survives_a_regroup() { + // The same guarantee `people.ignored` has, for the same reason: a pass + // that forgot the decision would ask for it again after every scan. + let cat = seeded(&[ + (1, 1000, Some(0xFF00)), + (2, 1001, Some(0xFF00)), + (3, 1002, Some(0xFF00)), + ]); + regroup(cat.connection(), Rules::default()).unwrap(); + choose_representative(cat.connection(), ImageId(2)).unwrap(); + + let m = memberships(cat.connection(), &[ImageId(2)]).unwrap(); + assert!( + m[&ImageId(2)].representative, + "the pick did not take effect" + ); + + regroup(cat.connection(), Rules::default()).unwrap(); + let m = memberships(cat.connection(), &[ImageId(1), ImageId(2)]).unwrap(); + assert!(m[&ImageId(2)].representative, "the regroup forgot the pick"); + assert!(!m[&ImageId(1)].representative); + } + + #[test] + fn choosing_a_representative_outside_a_burst_does_nothing() { + let cat = seeded(&[(1, 1000, Some(0xFF00))]); + regroup(cat.connection(), Rules::default()).unwrap(); + choose_representative(cat.connection(), ImageId(1)).unwrap(); + let n: i64 = cat + .connection() + .query_row("SELECT count(*) FROM burst_pick", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 0); + } + + #[test] + fn a_newly_found_burst_arrives_open() { + // The pass must not take rows off the screen. It marks them; the user + // folds them. + let cat = seeded(&[(1, 1000, Some(0xFF00)), (2, 1001, Some(0xFF00))]); + regroup(cat.connection(), Rules::default()).unwrap(); + assert!(is_expanded(cat.connection(), ImageId(1)).unwrap()); + } + + #[test] + fn a_burst_the_user_folded_up_stays_folded_through_the_next_pass() { + // Otherwise every import springs open a morning's culling. + let cat = seeded(&[ + (1, 1000, Some(0xFF00)), + (2, 1001, Some(0xFF00)), + (3, 9000, Some(0x00FF)), + ]); + regroup(cat.connection(), Rules::default()).unwrap(); + set_expanded(cat.connection(), ImageId(1), false).unwrap(); + + // A frame arrives elsewhere in the library, so the pass runs again. + cat.connection() + .execute( + "INSERT INTO images(id, root_id, source_ref, captured_at, camera, + perceptual_hash, added_at) + VALUES (4, 1, 'IMG_4.CR3', 9001, 'Canon EOS R5', ?1, 0)", + [Signature(0x00FF).to_stored()], + ) + .unwrap(); + regroup(cat.connection(), Rules::default()).unwrap(); + + assert!(!is_expanded(cat.connection(), ImageId(1)).unwrap()); + assert!( + is_expanded(cat.connection(), ImageId(3)).unwrap(), + "the burst nobody has seen yet should have arrived open" + ); + } + + #[test] + fn expansion_is_remembered_and_reversible() { + let cat = seeded(&[(1, 1000, Some(0xFF00)), (2, 1001, Some(0xFF00))]); + regroup(cat.connection(), Rules::default()).unwrap(); + + set_expanded(cat.connection(), ImageId(1), false).unwrap(); + assert!(!is_expanded(cat.connection(), ImageId(1)).unwrap()); + set_expanded(cat.connection(), ImageId(1), true).unwrap(); + assert!(is_expanded(cat.connection(), ImageId(1)).unwrap()); + // Idempotent: the UI toggles this from a callback that can fire twice. + set_expanded(cat.connection(), ImageId(1), true).unwrap(); + assert_eq!(collapse_all(cat.connection()).unwrap(), 1); + assert!(!is_expanded(cat.connection(), ImageId(1)).unwrap()); + } + + #[test] + fn a_burst_that_no_longer_exists_leaves_no_expansion_behind() { + // The id is an image id, so a stale row would eventually re-open some + // unrelated group. + let cat = seeded(&[(1, 1000, Some(0xFF00)), (2, 1001, Some(0xFF00))]); + regroup(cat.connection(), Rules::default()).unwrap(); + assert!(is_expanded(cat.connection(), ImageId(1)).unwrap()); + + cat.connection() + .execute("UPDATE images SET captured_at = 9000 WHERE id = 2", []) + .unwrap(); + regroup(cat.connection(), Rules::default()).unwrap(); + + assert!(!is_expanded(cat.connection(), ImageId(1)).unwrap()); + } + + #[test] + fn a_collapsed_burst_hides_everything_but_its_representative() { + let cat = seeded(&[ + (1, 1000, Some(0xFF00)), + (2, 1001, Some(0xFF00)), + (3, 1002, Some(0xFF00)), + (4, 9000, Some(0xFF00)), + ]); + regroup(cat.connection(), Rules::default()).unwrap(); + + let sql = format!( + "SELECT id FROM images i WHERE {} ORDER BY id", + not_collapsed_away("i") + ); + let visible = |c: &Connection| -> Vec { + let mut stmt = c.prepare(&sql).unwrap(); + let rows = stmt.query_map([], |r| r.get::<_, i64>(0)).unwrap(); + rows.collect::, _>>().unwrap() + }; + + // Open, as a new burst always is: nothing has been taken away. + assert_eq!(visible(cat.connection()), vec![1, 2, 3, 4]); + set_expanded(cat.connection(), ImageId(1), false).unwrap(); + assert_eq!(visible(cat.connection()), vec![1, 4]); + set_expanded(cat.connection(), ImageId(1), true).unwrap(); + assert_eq!(visible(cat.connection()), vec![1, 2, 3, 4]); + } + + #[test] + fn a_library_with_no_bursts_hides_nothing() { + // The predicate is in every grid query, so its cost and its effect on a + // library that has never been grouped both matter. + let cat = seeded(&[(1, 1000, Some(0xFF00)), (2, 9000, Some(0xFF00))]); + let sql = format!( + "SELECT count(*) FROM images i WHERE {}", + not_collapsed_away("i") + ); + let n: i64 = cat.connection().query_row(&sql, [], |r| r.get(0)).unwrap(); + assert_eq!(n, 2); + } + + #[test] + fn an_unhashed_image_is_offered_for_hashing_once() { + let cat = seeded(&[(1, 1000, None), (2, 1001, Some(0xFF00))]); + assert_eq!( + images_without_signature(cat.connection()).unwrap(), + vec![ImageId(1)] + ); + set_signature(cat.connection(), ImageId(1), Signature(0x1234)).unwrap(); + assert!(images_without_signature(cat.connection()) + .unwrap() + .is_empty()); + assert_eq!( + signature(cat.connection(), ImageId(1)).unwrap(), + Some(Signature(0x1234)) + ); + } + + #[test] + fn hashing_an_image_the_scan_has_dropped_is_not_an_error() { + let cat = seeded(&[(1, 1000, None)]); + assert_eq!( + set_signature(cat.connection(), ImageId(404), Signature(1)).unwrap(), + 0 + ); + } + + #[test] + fn a_trashed_or_shadowed_frame_is_not_part_of_a_burst() { + // The same two exclusions the grid makes. A shadowed JPEG beside its RAW + // would otherwise be a two-frame burst with the frame it *is*. + let cat = seeded(&[ + (1, 1000, Some(0xFF00)), + (2, 1000, Some(0xFF00)), + (3, 1001, Some(0xFF00)), + ]); + cat.connection() + .execute("UPDATE images SET shadowed_by = 1 WHERE id = 2", []) + .unwrap(); + cat.connection() + .execute("UPDATE images SET trashed_at = 99 WHERE id = 3", []) + .unwrap(); + let report = regroup(cat.connection(), Rules::default()).unwrap(); + assert_eq!(report.bursts, 0); + } + + #[test] + fn members_come_back_in_the_order_the_grid_lists_them() { + let cat = seeded(&[ + (5, 1002, Some(0xFF00)), + (6, 1000, Some(0xFF00)), + (7, 1001, Some(0xFF00)), + ]); + regroup(cat.connection(), Rules::default()).unwrap(); + assert_eq!( + members(cat.connection(), ImageId(6)).unwrap(), + vec![ImageId(6), ImageId(7), ImageId(5)] + ); + } +} diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index c99c94c..a638cc6 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -16,6 +16,7 @@ //! - [`collections`] — the collection tree and membership the UI edits //! - [`keywords`] — the keyword vocabulary and what it is assigned to //! - [`faces`] — detected faces, the people they belong to, and who said so +//! - [`bursts`] — frames that are one moment, grouped so they judge as one //! - [`jobs`] — the durable background work queue //! - [`trash`] — soft delete to a folder, then permanent delete //! - [`merge`] / [`sync`] — cross-device merging of collections and keywords @@ -33,6 +34,7 @@ use std::path::Path; use dr_types::{Availability, ImageId}; use rusqlite::Connection; +pub mod bursts; pub mod cache; pub mod collections; pub mod dedup; @@ -63,7 +65,7 @@ pub use query::{Query, Sort}; pub use rating::{Judgement, MAX_RATING}; pub use scan::{DirAction, DirState, EntryAction, ScanOutcome}; pub use trash::{TrashedImage, TRASH_DIR}; -pub use walk::{ensure_root, scan_root, RootKind, ScanProgress, ScanReport}; +pub use walk::{ensure_root, mark_root_offline, scan_root, RootKind, ScanProgress, ScanReport}; /// One row of the library grid. /// diff --git a/core/dr-catalog/src/schema.rs b/core/dr-catalog/src/schema.rs index 98094f9..c621c37 100644 --- a/core/dr-catalog/src/schema.rs +++ b/core/dr-catalog/src/schema.rs @@ -15,7 +15,7 @@ use rusqlite::Connection; use crate::error::CatalogError; /// Schema version this build writes and understands. -pub const SCHEMA_VERSION: i64 = 10; +pub const SCHEMA_VERSION: i64 = 11; /// Apply migrations up to [`SCHEMA_VERSION`]. /// @@ -98,6 +98,13 @@ pub fn migrate(conn: &Connection) -> Result { tx.commit()?; } + if from < 11 { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(V11)?; + tx.pragma_update(None, "user_version", 11)?; + tx.commit()?; + } + Ok(from) } @@ -205,6 +212,11 @@ pub fn v1_for_attached(schema_name: &str) -> String { /// creating it over there would fail on columns that are not there. Nothing is /// lost by its absence — it exists to make the *grid* page quickly, and the /// grid never reads across an attachment. +/// +/// V11 is excluded on the same grounds and for the plainer reason that a merge +/// has nothing to do with it: burst grouping is rebuilt locally from local +/// signatures, and no code reads a remote catalog's `burst_*` tables. Its +/// `ALTER TABLE` would fail here anyway, being unqualifiable by the rewrite. pub fn for_attached(schema_name: &str) -> String { // V10 is `ALTER TABLE`, which the textual rewrite cannot qualify, so its // columns are spelled out. A remote genuinely older than V10 is a real @@ -397,6 +409,73 @@ ALTER TABLE people ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0; ALTER TABLE faces ADD COLUMN crop BLOB; "#; +/// TRACES: FR-CULL-5 +/// Burst grouping: which frames are one moment, and which one stands for it. +/// +/// The reasoning behind the grouping itself is in [`crate::bursts`]; what +/// belongs here is why it is stored in three pieces rather than one. +/// +/// **`images.perceptual_hash` is a column, not a table**, for the same reason +/// `content_hash` is: it is one number per image, NULL until something has had +/// the pixels in hand, and every query that wants it is already reading the +/// image row. It is local derived state — a rebuilt catalog recomputes it from +/// thumbnails — which is also why it is absent from [`for_attached`], alongside +/// the shadowing and trashing columns V2 through V5 add. +/// +/// **`burst_members` is rewritten whole by every pass.** No id of its own: the +/// group is named by the image id of its earliest frame, so a burst that has not +/// changed keeps its name across a regroup and the interface can remember that +/// this one is open. There is no `bursts` table to go with it because a group +/// has no properties beyond its members — inventing a row for it would create an +/// identity that survives the grouping being rebuilt, which is precisely what +/// must not happen. +/// +/// **`burst_pick` is the one thing here that is not derived**, and it is a +/// separate table so that rewriting the grouping cannot erase it. A +/// representative stored on `burst_members` would be forgotten every time a +/// frame arrived; the user would be asked the same question after every scan. +/// The same argument `people.ignored` makes in V10, one subsystem over. +/// +/// **`burst_expanded` is view state in the catalog**, which is unusual enough to +/// justify. The grid is a window over an ordered query — `LIMIT n OFFSET k` — +/// so what a collapsed burst hides has to be decided by the query, or the row +/// count stops agreeing with the scrollbar and the ordinals a scrub resolves to. +/// Once SQL has to see it, this is where it lives. Nothing else reads it, and it +/// is emptied of stale groups by every pass. +const V11: &str = r#" +-- A 64-bit perceptual signature. Local derived state: NULL until something has +-- decoded the image, recomputed from thumbnails if the catalog is rebuilt, and +-- comparable only to signatures produced by the same build (`bursts`). +ALTER TABLE images ADD COLUMN perceptual_hash INTEGER; + +CREATE TABLE burst_members ( + -- One burst at most per image: a frame belongs to the moment it was taken + -- in, and nothing else. + image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE, + -- The image id of the burst's earliest frame. Not a foreign key by + -- accident: the leader is itself a member, so this genuinely references + -- images(id), and cascading its deletion is right. + burst_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE, + -- The frame the group collapses to. Exactly one per burst. + representative INTEGER NOT NULL DEFAULT 0 +); +-- Counting a burst's frames and listing them are what the grid asks for, once +-- per window; without this both are a scan of every grouped frame in the +-- library. +CREATE INDEX burst_members_burst ON burst_members(burst_id); + +-- The user's own choice of representative. User data, never rewritten by a +-- grouping pass -- see the module doc above. +CREATE TABLE burst_pick ( + image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE +); + +-- Bursts the grid is currently showing in full. +CREATE TABLE burst_expanded ( + burst_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE +); +"#; + const V9: &str = r#" -- TRACES: FR-CULL-8 -- A record that face detection has *run* on an image, distinct from what it diff --git a/core/dr-catalog/src/walk.rs b/core/dr-catalog/src/walk.rs index 11246d3..7d935a1 100644 --- a/core/dr-catalog/src/walk.rs +++ b/core/dr-catalog/src/walk.rs @@ -432,7 +432,7 @@ fn bump_generation(conn: &Connection, root: RootId, now: i64) -> Result Result Result<(), CatalogError> { +/// +/// # Why the ETag goes with the mtime +/// +/// The three columns are the same fact told by three kinds of storage: a local +/// directory proves it is unchanged with its mtime and entry count, and a +/// remote one proves it with a propagating ETag (ARCH §6.6). Clearing two of +/// them and leaving the third would disarm the re-listing on exactly the +/// libraries this is most likely to be called for — a remote scan prunes on +/// the ETag alone, so a root that came back would be walked, found unchanged +/// at every level, pruned whole, and left with every row still marked offline +/// and nothing that would ever clear the mark. +/// +/// # Public, because losing a root is not only the local walk's business +/// +/// This began as the private end of [`scan_root`]'s root-failure branches, +/// which is the only route a library reached through [`Storage`] can take. +/// The application does not currently take that route at all: it opens +/// libraries through `dr-sync`'s connectors, so the discovery happens in a +/// crate that cannot see this one's internals, and the correct response is +/// identical (FR-PLAT-AND-2). Exported rather than reimplemented beside the +/// caller that found out — a second copy would be a second thing to remember +/// when the ETag rule below changes. +/// +/// [`Storage`]: dr_plat::Storage +pub fn mark_root_offline(conn: &Connection, root: RootId) -> Result<(), CatalogError> { let root_id = root.0 as i64; conn.execute( "UPDATE images SET availability = ?1 WHERE root_id = ?2 AND availability != ?1", rusqlite::params![availability_code(Availability::Offline), root_id], )?; conn.execute( - "UPDATE folders SET mtime = NULL, entry_count = NULL WHERE root_id = ?1", + "UPDATE folders SET mtime = NULL, entry_count = NULL, etag = NULL WHERE root_id = ?1", [root_id], )?; Ok(()) @@ -995,6 +1019,40 @@ mod tests { ); } + /// TRACES: FR-PLAT-AND-2 | FR-CAT-9 + #[test] + fn marking_a_root_offline_forgets_the_remote_validator_too() { + // The half of the marking that only a remote library can notice, and + // the reason it has to be here rather than beside the connector: a + // remote scan prunes on the propagating ETag alone (ARCH §6.6). Clear + // the local mtime and leave the ETag standing and a library that came + // back would be walked, found unchanged at every level, pruned whole, + // and left with every row still marked offline — with nothing that + // would ever clear the mark, because clearing it is something only a + // listing can do. + // + // Written directly because this module never writes an ETag; it is + // `ui/dr-ui/src/library.rs`'s scan that does, against the same table. + let lib = Library::new("etag-forgotten"); + lib.file("2026/IMG.CR3", b"raw"); + lib.scan(); + lib.conn() + .execute( + "UPDATE folders SET etag = 'e1' WHERE root_id = ?1", + [lib.root.0 as i64], + ) + .expect("etag"); + assert!(lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL") > 0); + + mark_root_offline(lib.conn(), lib.root).expect("mark"); + + assert_eq!( + lib.count("SELECT COUNT(*) FROM folders WHERE etag IS NOT NULL"), + 0, + "an unreachable library must be re-listed, not pruned as unchanged" + ); + } + #[test] fn a_root_that_comes_back_is_available_again() { // The other half: a drive plugged back in must return the library to diff --git a/core/dr-export/src/lib.rs b/core/dr-export/src/lib.rs index cc9699d..a406101 100644 --- a/core/dr-export/src/lib.rs +++ b/core/dr-export/src/lib.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 +//! TRACES: FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-4 | FR-EXP-6 | FR-EXP-9 | R3 //! Turning a rendered frame into a file's worth of bytes. //! //! # What this crate is, and is not diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index 90109bc..0a2d2e7 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -1026,6 +1026,44 @@ impl AdjustPass { h } + /// TRACES: FR-PLAT-AND-5 | NFR-RES-1 + /// Give back every allocation this pass is holding only to be fast again. + /// + /// What goes, and why each is safe to lose: + /// + /// - **The compiled pipelines**, here and in the detail stage. A pure + /// lookup keyed by structure hash with a compile-on-miss behind it, and + /// unbounded until now — nothing ever removed an entry, so a session + /// that visited enough distinct edit structures accumulated shader + /// objects for the life of the process. + /// - **The detail intermediates**, which are viewport-sized `Rgba16Float` + /// and, as `detail.rs` says of them, grow but never shrink. + /// - **The two output textures.** Dropping these does not take the picture + /// off the screen: whatever was handed to the compositor holds its own + /// reference to the `wgpu::Texture`, so releasing ours only means the + /// *next* render allocates rather than reuses. `ensure_target` already + /// treats an empty slot as "allocate", because that is the state it + /// starts in. + /// + /// **`colour_key` must be cleared with them, and this is the part that + /// would bite.** The key is the promise that slot 0 of the detail pool + /// still holds the fused colour result, and it is what lets a sharpening + /// slider skip the colour chain (FR-DEV-3d). Freeing the pool while the + /// promise stood would make the next detail-only render sample a + /// just-allocated texture with nothing in it — a silently wrong frame, not + /// a failure, and one that would only appear on a device under memory + /// pressure. + /// + /// What deliberately stays: the demosaiced source is not this pass's to + /// drop, the film tables are set once by a caller that will not be asked + /// again, and the bind group layouts are bytes rather than megabytes. + pub fn release_caches(&mut self) { + self.cache.clear(); + self.detail.release_caches(); + self.targets = [None, None]; + self.colour_key = None; + } + /// How many distinct pipelines are compiled. Exposed for tests asserting /// that slider movement does not recompile. pub fn cached_pipelines(&self) -> usize { diff --git a/core/dr-gpu/src/detail.rs b/core/dr-gpu/src/detail.rs index 5c70c49..14553b3 100644 --- a/core/dr-gpu/src/detail.rs +++ b/core/dr-gpu/src/detail.rs @@ -157,6 +157,24 @@ impl Intermediates { self.allocations += 1; } } + + /// TRACES: FR-PLAT-AND-5 + /// Drop the pool, leaving it as [`Intermediates::new`] left it. + /// + /// The size is reset along with the slots, not merely because it is tidy: + /// [`Self::ensure`] only refills when the count is short *or* the size + /// differs, so a pool cleared while still claiming its old dimensions is + /// indistinguishable from one that never held anything — which is fine + /// here, and would stop being fine the moment `ensure` grew a fast path + /// that trusted the stored size. `allocations` deliberately keeps + /// counting: it exists so a test can see textures being made, and a + /// counter reset on eviction would hide a reallocation storm rather than + /// report one. + fn release(&mut self) { + self.slots.clear(); + self.width = 0; + self.height = 0; + } } /// Runs the detail stage. @@ -526,6 +544,19 @@ impl DetailRunner { self.cache.len() } + /// TRACES: FR-PLAT-AND-5 + /// Give back everything this stage is only holding to be fast. + /// + /// Both pools and the pipeline cache. Nothing here is state: a pool slot + /// is re-created by the next [`Intermediates::ensure`] and a pipeline by + /// the next compile-on-miss, so the only cost of this call is the work of + /// doing both again. + pub(crate) fn release_caches(&mut self) { + self.cache.clear(); + self.pool.release(); + self.reduced.release(); + } + /// How many intermediate textures have been allocated since this pass was /// created. For tests — see [`crate::MaskPass::allocations`] for the /// regression this shape of counter exists to catch. diff --git a/core/dr-gpu/src/focus.rs b/core/dr-gpu/src/focus.rs new file mode 100644 index 0000000..11818af --- /dev/null +++ b/core/dr-gpu/src/focus.rs @@ -0,0 +1,1007 @@ +//! TRACES: FR-CULL-3 | NFR-P14 +//! Focus peaking — saying what is sharp, so nobody has to zoom in to find out. +//! +//! # What this is for +//! +//! FR-CULL-3 lists three raw-truth overlays and gives the reason for this one +//! plainly: peaking "removes the largest single source of culling latency from +//! the critical path". Checking focus by zooming to 1:1 costs a render, a pan +//! to the subject's eye, and a zoom back, per frame, on a folder of three +//! thousand. D11 keeps 1:1 available for certainty; this is what makes reaching +//! for it the exception. +//! +//! It is *raw*-truth for the same reason the histogram beside it is: what this +//! measures is the frame the develop pipeline rendered from sensor data +//! through the demosaic, not the camera's embedded JPEG. A JPEG has already +//! been sharpened by the body, at a strength and radius nobody outside the +//! manufacturer knows, and peaking on one measures that sharpening at least as +//! much as it measures the lens. +//! +//! # Why a layer, and not a tint in the picture +//! +//! The first shape tried was the obvious one — read the display frame, write +//! the same frame with marked pixels replaced, hand *that* to the compositor. +//! It is wrong, and `app.slint` had already written down why, on the region +//! overlay it composites over the canvas: a diagnostic "must not reach the +//! histogram, an export, or the texture the develop pass hands the +//! compositor". +//! +//! All three would have happened. `HistogramPass` counts whatever texture the +//! session last rendered, so a red mark on every in-focus edge would have +//! arrived in the histogram as a red spike and in the clipping figure as blown +//! highlights. So this pass writes its **own** texture, transparent everywhere +//! except where something is in focus, and the interface lays it over the +//! canvas. The photograph is untouched by construction rather than by care. +//! +//! # Why it stays on the GPU +//! +//! Per-pixel work over the whole frame, every settled frame, is exactly what +//! ARCH §6.1 exists about: the measurement on this project puts a 4K readback +//! at 7.4 ms against a 0.28 ms compute pass, 96% of it transfer. The marks are +//! produced where the pixels already are and handed to the compositor as a +//! texture, and nothing in this file can read a pixel back on the display path +//! — see [`FocusPeakPass::read_overlay`] for the one transfer that exists and +//! who is allowed to call it. +//! +//! # Two textures, alternating +//! +//! For the reason [`crate::AdjustPass`] keeps two: Slint decides whether to +//! repaint by comparing the image property against its previous value, and two +//! images wrapping the same `wgpu::Texture` compare equal. A pass that always +//! wrote one texture would compute a new overlay every frame and never once be +//! asked to show it. +//! +//! # What it costs +//! +//! One dispatch, nine texture loads per pixel, no readback and no +//! reallocation at a steady viewport size. NFR-P14 allows 100 ms after the +//! preview on desktop and 150 ms on Android; this is two orders of magnitude +//! inside that, and the assertion in `overlay_is_ready_well_inside_the_budget` +//! is what keeps the claim honest rather than remembered. + +use wgpu::util::DeviceExt as _; + +use crate::readback::await_mapping; +use crate::{GpuContext, GpuError}; + +/// How much local contrast counts as focus. +/// +/// Three steps rather than a slider, because the number underneath is not one +/// a photographer can reason about and the choice being made is coarse: *this +/// frame is noisy, mark less* or *this subject is low-contrast, mark more*. +/// Every camera that offers peaking offers it this way. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum PeakSensitivity { + /// The high-ISO setting. Marks only edges nothing but focus explains. + Low, + #[default] + Medium, + /// For a low-contrast subject — fur, fabric, distant foliage — at the + /// price of marking noise as well. + High, +} + +impl PeakSensitivity { + /// The luma difference, 0..1, at which a pixel is called in focus. + /// + /// **Where these three numbers come from.** A hard one-pixel step of + /// height *D* produces a response of `0.375 D` (see the shader), so a + /// threshold *t* marks any sharp edge whose contrast exceeds `t / 0.375`. + /// At `Medium` that is 0.107 in luma — about 27 code values — which is a + /// perfectly ordinary edge and well above what a photograph's own texture + /// produces by accident. + /// + /// **And what bounds them from below is noise, not taste.** Sensor noise + /// surviving into an 8-bit render is a few code values; call it a standard + /// deviation of 0.008. The response subtracts a mean of eight independent + /// neighbours from one sample, so its standard deviation is + /// `0.008 * sqrt(1 + 1/8)` = 0.0085. `Medium` sits 4.7 of those out, which + /// a normal tail puts at roughly one pixel in a million; `High` sits 2.4 + /// out, which is about one pixel in a hundred — visible as a dusting on a + /// noisy frame, which is the trade the setting is named for. `Low` is 9.4 + /// out and will not mark noise at all. + /// + /// Noise reduction, if the edit has any, has already run by the time this + /// pass sees the frame, so those figures are the pessimistic end. + pub fn threshold(self) -> f32 { + match self { + PeakSensitivity::Low => 0.080, + PeakSensitivity::Medium => 0.040, + PeakSensitivity::High => 0.020, + } + } +} + +/// What colour the marks are drawn in. +/// +/// A choice rather than a constant, and the reason is NFR-A11Y-3's: status +/// must not rest on hue alone. An overlay's information *is* positional — it +/// says where, not what — so the requirement is not violated by there being a +/// colour; it would be violated by there being only one, because a red mark on +/// a red jersey conveys nothing, and a photographer with a red-green +/// deficiency looking at foliage is in the same position permanently. +/// +/// Four fully saturated choices, no mixtures. A desaturated mark has to +/// compete with the photograph for the same colours, which is the one thing a +/// mark must not do. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum PeakColour { + /// The convention, and what most cameras do. + #[default] + Red, + /// For a red or warm subject, and the most visible of the four on a dark + /// frame. + Yellow, + /// For a warm photograph as a whole, and the choice that survives a + /// red-green deficiency. + Cyan, + /// For a cool or green photograph — the hue photographs contain least. + Magenta, +} + +impl PeakColour { + /// The mark, as the encoded triple the overlay is written in. + pub fn rgb(self) -> [f32; 3] { + match self { + PeakColour::Red => [1.0, 0.0, 0.0], + PeakColour::Yellow => [1.0, 1.0, 0.0], + PeakColour::Cyan => [0.0, 1.0, 1.0], + PeakColour::Magenta => [1.0, 0.0, 1.0], + } + } +} + +/// TRACES: FR-CULL-3 +/// Everything the photographer chose about the overlay. +/// +/// One value rather than two arguments, because the two travel together +/// everywhere — into the session, into the sidecar-free view state, back out +/// to the panel — and a pair that is always passed together is a type. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct FocusPeaking { + pub sensitivity: PeakSensitivity, + pub colour: PeakColour, +} + +/// The dispatch's view of the frame. Padded to std140's 16 bytes before the +/// marker, exactly as the shader's `Params` declares it. +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct Params { + width: u32, + height: u32, + threshold: f32, + pad_0: u32, + marker: [f32; 4], +} + +/// One overlay texture. +struct Layer { + texture: wgpu::Texture, + view: wgpu::TextureView, + width: u32, + height: u32, +} + +/// TRACES: FR-CULL-3 | NFR-P14 +/// Produces the focus-peaking overlay for a rendered frame. +pub struct FocusPeakPass { + ctx: GpuContext, + pipeline: wgpu::ComputePipeline, + bind_group_layout: wgpu::BindGroupLayout, + params: wgpu::Buffer, + /// Alternating overlay textures — see the module documentation for why + /// there are two rather than one. + layers: [Option; 2], + current: usize, + /// Textures allocated since this pass was created. Exists to be asserted + /// on: an overlay reallocated per frame instead of per resize costs a + /// great deal of bandwidth and looks identical in the picture, which is + /// the shape of regression only a counter can see. + allocations: usize, + dispatches: usize, +} + +impl FocusPeakPass { + /// The overlay's format. + /// + /// The same `Rgba8Unorm` [`crate::AdjustPass`] writes, and for the same + /// non-negotiable reason: it is one of the two formats Slint's texture + /// import accepts. The alpha channel is what carries the overlay, so the + /// eight bits it has are seven more than this needs. + pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + + pub fn new(ctx: &GpuContext) -> Result { + // A validation error here is a bug in the shader beside this file + // rather than anything a user did, so it is caught in an error scope + // and returned — wgpu's default handler panics. + let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); + + let module = ctx + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("focus-peak"), + source: wgpu::ShaderSource::Wgsl(include_str!("shaders/focus_peak.wgsl").into()), + }); + + let bind_group_layout = + ctx.device + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("focus-peak-bgl"), + entries: &[ + // The rendered frame, read with `textureLoad` — the + // same texture the compositor is showing, so what is + // measured is what is on screen. + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::WriteOnly, + format: Self::FORMAT, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + ], + }); + + let layout = ctx + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("focus-peak-layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + + let pipeline = ctx + .device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("focus-peak-pipeline"), + layout: Some(&layout), + module: &module, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + + if let Some(err) = pollster::block_on(scope.pop()) { + return Err(GpuError::ShaderCompilation(err.to_string())); + } + + let params = ctx + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("focus-peak-params"), + contents: bytemuck::bytes_of(&Params { + width: 0, + height: 0, + threshold: PeakSensitivity::default().threshold(), + pad_0: 0, + marker: [0.0; 4], + }), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + Ok(Self { + ctx: ctx.clone(), + pipeline, + bind_group_layout, + params, + layers: [None, None], + current: 0, + allocations: 0, + dispatches: 0, + }) + } + + /// TRACES: FR-CULL-3 | NFR-P14 + /// Mark the in-focus regions of `frame`, returning the overlay to lay + /// over it. + /// + /// `frame` must carry `TEXTURE_BINDING`, which [`crate::AdjustPass`]'s + /// output does because the compositor samples it. + /// + /// **Give this a settled frame, not a draft one.** The measure is the + /// energy in the top octave of what it is handed, so it is a statement + /// about a particular sampling grid: at half resolution — which is what a + /// draft frame is rendered at — a defocused edge spanning four pixels + /// spans two, which is the signature of a sharp one. Peaking a draft frame + /// would mark the out-of-focus background of every photograph, briefly, + /// during every drag. The interface runs this where it runs the histogram, + /// on the settled frame, and for the same class of reason. + pub fn render( + &mut self, + frame: &wgpu::Texture, + settings: FocusPeaking, + ) -> Result<&wgpu::Texture, GpuError> { + // No zero-size guard: wgpu will not create a texture with a zero + // extent, so a frame that exists has at least one pixel in it. + let (width, height) = (frame.width(), frame.height()); + self.ensure_layer(width, height); + + let rgb = settings.colour.rgb(); + self.ctx.queue.write_buffer( + &self.params, + 0, + bytemuck::bytes_of(&Params { + width, + height, + threshold: settings.sensitivity.threshold(), + pad_0: 0, + marker: [rgb[0], rgb[1], rgb[2], 1.0], + }), + ); + + let layer = self.layers[self.current] + .as_ref() + .expect("ensure_layer just built it"); + let source = frame.create_view(&Default::default()); + let bind_group = self + .ctx + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("focus-peak-bg"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&source), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: self.params.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&layer.view), + }, + ], + }); + + let mut enc = self + .ctx + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("focus-peak-encoder"), + }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("focus-peak-pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); + } + // Submitted on its own queue entry after the render that produced + // `frame`. Submission order is the whole of the synchronisation, as it + // is between the fused pass and the detail chain: one queue, and this + // reads what that wrote. + self.ctx.queue.submit(Some(enc.finish())); + self.dispatches += 1; + + Ok(&self.layers[self.current] + .as_ref() + .expect("just written") + .texture) + } + + /// The overlay the last [`Self::render`] wrote, if there has been one. + pub fn overlay(&self) -> Option<&wgpu::Texture> { + self.layers[self.current].as_ref().map(|l| &l.texture) + } + + /// Forget the overlay, so nothing stale is composited. + /// + /// Called when the photograph changes and when peaking is switched off. + /// The alternative — leaving the last overlay resident and merely not + /// drawing it — is one interface bug away from laying one photograph's + /// focus marks over another's, which is the single worst thing an + /// instrument like this can do. + pub fn clear(&mut self) { + self.layers = [None, None]; + self.current = 0; + } + + /// Overlay textures allocated since this pass was created. + /// + /// For tests. A steady viewport must not move this number, and the only + /// evidence of that is a counter — a per-frame reallocation renders + /// identically to a cached one. + pub fn allocations(&self) -> usize { + self.allocations + } + + /// Dispatches encoded since this pass was created. + /// + /// The other half of [`Self::allocations`]: together they say that eight + /// frames cost eight dispatches and two textures, which is the shape a + /// steady viewport is supposed to have. + pub fn dispatches(&self) -> usize { + self.dispatches + } + + /// Make sure the current slot holds a texture of this size. + /// + /// Rotates first, so consecutive frames land in different textures — see + /// the module documentation. A size change drops both, because neither + /// fits any more and a stale one of the wrong size would be composited + /// stretched over the new frame. + fn ensure_layer(&mut self, width: u32, height: u32) { + self.current ^= 1; + let fits = self.layers[self.current] + .as_ref() + .is_some_and(|l| l.width == width && l.height == height); + if fits { + return; + } + let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("focus-peak-overlay"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: Self::FORMAT, + // STORAGE_BINDING to be written by the dispatch and + // TEXTURE_BINDING to be sampled by the compositor. + // RENDER_ATTACHMENT is not used by anything here and is required + // anyway: Slint rejects an imported texture without it. COPY_SRC + // is for `read_overlay` and its two callers. + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + self.layers[self.current] = Some(Layer { + texture, + view, + width, + height, + }); + self.allocations += 1; + } + + /// TRACES: AC-8 + /// Copy the overlay to the CPU, as RGBA8 rows with no padding. + /// + /// **Two callers, and neither is the desktop display path.** The tests + /// below are one: an overlay is a claim about which pixels are sharp, and + /// there is no way to check that claim without looking at the pixels. The + /// other is the Android develop view, which reads the *frame* back for the + /// reasons `technical-debt.md` TD-1 records — wgpu's Android swapchain + /// tears a portrait window, so Slint is not drawing with wgpu there and no + /// texture can be handed over. An overlay that stayed on the device on a + /// platform where the picture underneath it does not would simply never be + /// seen. + /// + /// On desktop nothing calls this, and ARCH §6.1 holds on the path that + /// matters: the overlay reaches the compositor as a texture. + pub fn read_overlay(&self) -> Result<(Vec, u32, u32), GpuError> { + let Some(layer) = self.layers[self.current].as_ref() else { + return Err(GpuError::Readback("no overlay has been rendered".into())); + }; + let (w, h) = (layer.width, layer.height); + + let unpadded = w * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded = unpadded.div_ceil(align) * align; + + let buf = self.ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("focus-peak-readback"), + size: (padded * h) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + let mut enc = self.ctx.device.create_command_encoder(&Default::default()); + enc.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &layer.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(h), + }, + }, + wgpu::Extent3d { + width: w, + height: h, + depth_or_array_layers: 1, + }, + ); + self.ctx.queue.submit(Some(enc.finish())); + + let slice = buf.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + await_mapping(&self.ctx, &rx)?; + + let data = slice.get_mapped_range(); + let mut out = Vec::with_capacity((unpadded * h) as usize); + for row in 0..h { + let start = (row * padded) as usize; + out.extend_from_slice(&data[start..start + unpadded as usize]); + } + drop(data); + buf.unmap(); + Ok((out, w, h)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> Option { + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } + } + + /// Upload a greyscale frame the pass can read, the way `AdjustPass` hands + /// its output over. + fn frame(ctx: &GpuContext, grey: &[f32], width: u32, height: u32) -> wgpu::Texture { + let rgba: Vec = grey + .iter() + .flat_map(|v| { + let c = (v.clamp(0.0, 1.0) * 255.0).round() as u8; + [c, c, c, 255] + }) + .collect(); + let tex = ctx.device.create_texture(&wgpu::TextureDescriptor { + label: Some("focus-peak-test-frame"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + ctx.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &tex, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + ctx.queue.submit(std::iter::empty()); + tex + } + + /// A vertical step from `dark` to `bright`, blurred with a Gaussian of + /// this `sigma` in pixels. `sigma == 0.0` is the hard edge. + /// + /// The profile is evaluated analytically rather than by convolving a + /// sampled image, so "the same edge, out of focus" is exactly that and not + /// a second thing the test would also have to trust. + fn step(width: u32, height: u32, dark: f32, bright: f32, sigma: f32) -> Vec { + let edge = width as f32 / 2.0 - 0.5; + (0..height) + .flat_map(|_| { + (0..width).map(move |x| { + let d = x as f32 - edge; + let t = if sigma <= 0.0 { + if d < 0.0 { + 0.0 + } else { + 1.0 + } + } else { + // The error function, which is what a Gaussian blur of + // a step is, via a tanh approximation good to ~1e-4 — + // ample, since the assertions below are about orders + // of magnitude rather than about the fourth digit. + 0.5 * (1.0 + (1.202_7 * (d / sigma)).tanh()) + }; + dark + t * (bright - dark) + }) + }) + .collect() + } + + /// How many pixels of an overlay carry a mark, and what colour they are. + fn marks(pixels: &[u8]) -> (usize, Vec<[u8; 3]>) { + let mut count = 0; + let mut colours: Vec<[u8; 3]> = Vec::new(); + for px in pixels.chunks_exact(4) { + if px[3] != 0 { + count += 1; + let c = [px[0], px[1], px[2]]; + if !colours.contains(&c) { + colours.push(c); + } + } + } + (count, colours) + } + + fn peak( + ctx: &GpuContext, + pass: &mut FocusPeakPass, + grey: &[f32], + w: u32, + h: u32, + settings: FocusPeaking, + ) -> Vec { + let tex = frame(ctx, grey, w, h); + pass.render(&tex, settings).expect("peak"); + pass.read_overlay().expect("readback").0 + } + + #[test] + fn the_three_sensitivities_are_ordered_and_none_of_them_is_zero() { + // Arithmetic, so no device. A threshold of zero marks every pixel of + // every photograph — including a flat sky, where the response is + // whatever the last bit of the encoder rounded to — and an overlay + // that covers the frame says nothing at all. + let (low, med, high) = ( + PeakSensitivity::Low.threshold(), + PeakSensitivity::Medium.threshold(), + PeakSensitivity::High.threshold(), + ); + assert!(high > 0.0, "a zero threshold marks everything"); + assert!( + low > med && med > high, + "more sensitive must mean a lower bar: {low} {med} {high}" + ); + assert_eq!(PeakSensitivity::default(), PeakSensitivity::Medium); + } + + #[test] + fn every_colour_is_fully_saturated_and_distinct() { + // The overlay competes with the photograph for attention, and a + // desaturated mark loses. Each choice must also be a different colour + // from the others — two entries that render the same would be a menu + // that lies. + let mut seen: Vec<[f32; 3]> = Vec::new(); + for c in [ + PeakColour::Red, + PeakColour::Yellow, + PeakColour::Cyan, + PeakColour::Magenta, + ] { + let rgb = c.rgb(); + assert!( + rgb.iter().all(|v| *v == 0.0 || *v == 1.0), + "{c:?} is not a saturated primary: {rgb:?}" + ); + assert!(rgb.iter().any(|v| *v > 0.0), "{c:?} is black"); + assert!(!seen.contains(&rgb), "{c:?} duplicates another choice"); + seen.push(rgb); + } + } + + #[test] + fn a_flat_frame_is_marked_nowhere() { + // The floor. An overlay that marks an empty sky is one a photographer + // stops believing within a frame, and there is nothing subtle about + // the failure — it would be the whole picture. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + + let flat = vec![0.45f32; 64 * 64]; + let px = peak(&ctx, &mut pass, &flat, 64, 64, FocusPeaking::default()); + assert_eq!(marks(&px).0, 0, "flat grey produced marks"); + } + + #[test] + fn a_sharp_edge_is_marked_and_the_same_edge_defocused_is_not() { + // **The claim the whole overlay rests on**, and the one a gradient + // detector would fail: these two frames have identical contrast and + // differ only in how many pixels the transition is spread over. If + // both are marked, the overlay is an edge detector wearing focus + // peaking's name and it will light up every out-of-focus background + // ever photographed. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (64u32, 16u32); + + let sharp = step(w, h, 0.25, 0.75, 0.0); + let soft = step(w, h, 0.25, 0.75, 2.0); + + let marked_sharp = marks(&peak( + &ctx, + &mut pass, + &sharp, + w, + h, + FocusPeaking::default(), + )) + .0; + let marked_soft = marks(&peak(&ctx, &mut pass, &soft, w, h, FocusPeaking::default())).0; + + // Two columns of the sharp edge respond — the pixel each side of the + // transition — so a full-height edge marks 2 per row. + assert_eq!( + marked_sharp, + (2 * h) as usize, + "a hard edge should mark the column each side of it, on every row" + ); + assert_eq!( + marked_soft, 0, + "the same edge at sigma 2 is out of focus and must not be marked" + ); + } + + #[test] + fn sensitivity_decides_how_faint_an_edge_still_counts() { + // The setting doing what its name says, from both sides. A hard edge + // of 0.08 contrast responds at `0.375 * 0.08` = 0.031 — above `High`'s + // bar of 0.020 and below `Medium`'s of 0.040 — so the same photograph + // must be marked at one setting and clean at the other. Without this, + // three menu entries that all behaved identically would pass every + // other test in this file. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (64u32, 8u32); + let faint = step(w, h, 0.46, 0.54, 0.0); + + let at = |pass: &mut FocusPeakPass, sensitivity| { + marks(&peak( + &ctx, + pass, + &faint, + w, + h, + FocusPeaking { + sensitivity, + ..Default::default() + }, + )) + .0 + }; + + assert!( + at(&mut pass, PeakSensitivity::High) > 0, + "a faint but sharp edge is exactly what High is for" + ); + assert_eq!( + at(&mut pass, PeakSensitivity::Medium), + 0, + "Medium should hold its bar above an 0.08 edge" + ); + assert_eq!(at(&mut pass, PeakSensitivity::Low), 0); + } + + #[test] + fn the_mark_is_the_colour_that_was_asked_for_and_the_rest_is_transparent() { + // Both halves matter. The colour, because a menu that quietly draws + // red whatever is chosen is worse than not offering the choice; and + // the transparency, because the overlay is composited over the + // photograph and an alpha of even 1/255 across the frame is a veil + // over every judgement made through it. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (32u32, 8u32); + let sharp = step(w, h, 0.2, 0.8, 0.0); + + for colour in [ + PeakColour::Red, + PeakColour::Yellow, + PeakColour::Cyan, + PeakColour::Magenta, + ] { + let px = peak( + &ctx, + &mut pass, + &sharp, + w, + h, + FocusPeaking { + colour, + ..Default::default() + }, + ); + let (count, colours) = marks(&px); + assert!(count > 0, "{colour:?} marked nothing"); + let expected: [u8; 3] = colour.rgb().map(|v| (v * 255.0).round() as u8); + assert_eq!(colours, vec![expected], "{colour:?} drew the wrong ink"); + + // Everything unmarked is fully transparent *and* black, which is + // what makes the layer correct whether the compositor treats it as + // premultiplied or not. + for texel in px.chunks_exact(4) { + if texel[3] == 0 { + assert!( + texel[..3].iter().all(|c| *c == 0), + "an unmarked texel carried colour under a zero alpha" + ); + } else { + assert_eq!(texel[3], 255, "a mark was drawn part-way transparent"); + } + } + } + } + + #[test] + fn the_edge_of_the_frame_is_not_marked_by_its_own_edge() { + // The clamp in `neighbour`. Wrapping instead would fold the right-hand + // column of the picture into the left-hand one's neighbourhood, and a + // photograph whose two sides differ — which is most of them — would be + // marked down both borders regardless of focus. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (33u32, 17u32); + + // Dark on the left, bright on the right, with the transition a long + // way from either border. + let split = step(w, h, 0.1, 0.9, 0.0); + let px = peak(&ctx, &mut pass, &split, w, h, FocusPeaking::default()); + + for y in 0..h { + for x in [0u32, w - 1] { + let a = px[(((y * w + x) * 4) + 3) as usize]; + assert_eq!(a, 0, "the frame border at ({x}, {y}) was marked"); + } + } + // A size that is not a multiple of the 8x8 workgroup, so the edge + // groups run off the image: every texel must still have been written. + assert_eq!(px.len(), (w * h * 4) as usize); + } + + #[test] + fn consecutive_overlays_are_different_textures() { + // Slint compares the image property by value to decide whether to + // repaint, so two frames wrapping one texture compare equal and the + // second is never drawn. `AdjustPass` keeps two targets for this + // reason; the overlay beside it has to do the same or it freezes on + // whatever it first showed. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let grey = vec![0.5f32; 16 * 16]; + + let tex = frame(&ctx, &grey, 16, 16); + let first = + std::ptr::from_ref(pass.render(&tex, FocusPeaking::default()).expect("first")) as usize; + let second = std::ptr::from_ref(pass.render(&tex, FocusPeaking::default()).expect("second")) + as usize; + assert_ne!( + first, second, + "two consecutive overlays landed in the same texture" + ); + } + + #[test] + fn a_steady_viewport_allocates_nothing_after_the_first_two_frames() { + // Reallocating a viewport-sized texture per frame costs a great deal + // of bandwidth and renders identically, which is why this is a counter + // and not an assertion about the picture. Two allocations, not one: + // the pair alternates, so the second frame builds the other slot. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let grey = vec![0.5f32; 64 * 64]; + let tex = frame(&ctx, &grey, 64, 64); + + for _ in 0..8 { + pass.render(&tex, FocusPeaking::default()).expect("render"); + } + assert_eq!(pass.dispatches(), 8); + assert_eq!( + pass.allocations(), + 2, + "the overlay was reallocated per frame" + ); + + // A resize is the one thing that must reallocate: a stale overlay of + // the wrong size would be composited stretched over the new frame. + let small = vec![0.5f32; 32 * 32]; + let smaller = frame(&ctx, &small, 32, 32); + pass.render(&smaller, FocusPeaking::default()) + .expect("render"); + assert_eq!(pass.allocations(), 3); + } + + #[test] + fn a_cleared_pass_has_no_overlay_to_composite() { + // Switching peaking off, or opening a different photograph, must leave + // nothing behind. One interface bug away from laying one frame's focus + // marks over another's, which is the worst thing an instrument can do: + // be confidently about the wrong picture. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let grey = vec![0.5f32; 16 * 16]; + let tex = frame(&ctx, &grey, 16, 16); + pass.render(&tex, FocusPeaking::default()).expect("render"); + assert!(pass.overlay().is_some()); + + pass.clear(); + assert!(pass.overlay().is_none()); + assert!( + pass.read_overlay().is_err(), + "a cleared pass must not hand out a stale overlay" + ); + } + + #[test] + fn the_overlay_is_ready_well_inside_its_budget() { + // NFR-P14: ready within 100 ms of the preview on desktop, 150 ms on + // Android. The bound asserted here is 50 ms at 4K, which is half the + // desktop budget and a third of Android's, and is still around two + // orders of magnitude above what the dispatch actually costs — chosen + // so that an unrelated machine under load does not fail the suite, + // while a change that made this a multi-pass or readback-bound + // operation could not possibly stay under it. + // + // The readback is deliberately outside the timed region. It is a + // property of this test rather than of the overlay, and at 4K it is + // the 7 ms transfer ARCH §6.1 exists to keep off the frame path. + let Some(ctx) = ctx() else { return }; + let mut pass = FocusPeakPass::new(&ctx).expect("pass"); + let (w, h) = (3840u32, 2160u32); + let tex = frame(&ctx, &step(w, h, 0.2, 0.8, 0.0), w, h); + + // One frame to allocate the layer and warm the pipeline; a resize is + // not what the budget is about. + pass.render(&tex, FocusPeaking::default()).expect("warm"); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("idle"); + + let start = std::time::Instant::now(); + pass.render(&tex, FocusPeaking::default()).expect("render"); + ctx.device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("idle"); + let elapsed = start.elapsed(); + + assert!( + elapsed.as_millis() < 50, + "the overlay took {elapsed:?} at {w}x{h}; NFR-P14 allows 100 ms" + ); + } +} diff --git a/core/dr-gpu/src/lib.rs b/core/dr-gpu/src/lib.rs index 705ceb1..fa46ff9 100644 --- a/core/dr-gpu/src/lib.rs +++ b/core/dr-gpu/src/lib.rs @@ -1,3 +1,4 @@ +//! TRACES: NFR-PORT-2 //! GPU device and compute for DarkRoom. //! //! In v0.1 this exists to prove one thing: a compute shader can write a @@ -21,6 +22,7 @@ mod adjust; mod demosaic; mod detail; mod error; +mod focus; mod histogram; mod mask; mod readback; @@ -33,6 +35,7 @@ pub use adjust::AdjustPass; pub use demosaic::{DemosaicedImage, Demosaicer}; pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT; pub use error::GpuError; +pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity}; // Renamed on the way out: `BINS` says enough inside `histogram`, and nothing // at all at a crate root shared with demosaic and segmentation. pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS}; diff --git a/core/dr-gpu/src/shaders/focus_peak.wgsl b/core/dr-gpu/src/shaders/focus_peak.wgsl new file mode 100644 index 0000000..098cba2 --- /dev/null +++ b/core/dr-gpu/src/shaders/focus_peak.wgsl @@ -0,0 +1,141 @@ +// TRACES: FR-CULL-3 | NFR-P14 +// Marking what is sharp, in a layer laid over the frame rather than into it. +// +// # Why the top octave, and not a gradient +// +// The obvious detector is a gradient magnitude — Sobel, or a central +// difference — and it is the wrong one, for a reason that decides whether the +// overlay is useful at all. A gradient answers "is there an edge here", and a +// defocused edge is still an edge: blur a 100-code step with a two-pixel +// Gaussian and the peak gradient is still around 20 codes per pixel, larger +// than a genuinely sharp edge across a low-contrast texture. Peaking built on +// gradients lights up the out-of-focus background of every portrait ever +// taken, which is the frame it exists to reject. +// +// What separates sharp from soft is *scale*, not amplitude. Defocus is a +// low-pass: it removes the top octave and leaves everything below it intact. +// So the detector is a high-pass — this pixel against the mean of its eight +// neighbours, a discrete Laplacian — which by construction responds only to +// the frequencies defocus destroys. +// +// The arithmetic, on a one-dimensional step of height D: +// +// | profile | abs(centre - mean of 8) | +// |--------------------------|-------------------------| +// | hard step, 1 px | 0.375 D | +// | Gaussian blur, sigma 1 | ~0.10 D | +// | Gaussian blur, sigma 2 | ~0.03 D | +// | linear ramp, any slope | 0 | +// +// The ramp row is the property being bought: the smooth luminance falloff +// across an out-of-focus highlight scores zero however bright it is. +// +// # Why luma, and why the histogram's luma +// +// One channel rather than three, because a colour edge carrying no luminance +// difference is both rare and, at the acuity an overlay is read at, invisible. +// The weights are `histogram.wgsl`'s 54/183/19 over 256 — the same Rec.709 +// weighting on the same encoded values — so the two instruments in this +// application agree about what "luma" means. Two definitions of brightness in +// one panel is the kind of disagreement nobody finds until it has already +// misled someone. +// +// # Why the frame is read where it is encoded, and not in linear light +// +// This runs on the output of the display transform, on encoded values, and +// that is deliberate: a fixed difference in sRGB code values is roughly +// equally visible wherever it sits in the range, which is what a transfer +// curve is for. Measured in linear light the same detector would need a +// threshold that varied with exposure, and a shadow texture the photographer +// can plainly see would score a hundredth of the identical texture in the +// highlights. The encoding has already done the normalisation, so the +// threshold is one number. +// +// # Why this writes a layer and not the picture +// +// The frame the compositor is handed is also what the histogram counts and +// what an export renders (`app.slint`, on the region overlay: a diagnostic +// "must not reach the histogram, an export, or the texture the develop pass +// hands the compositor"). So the marks go in their own texture — transparent +// everywhere except where something is in focus — and the compositor blends +// them. Nothing about the photograph changes, and the peaking overlay cannot +// leak into a measurement or a file. +// +// Alpha is written as exactly 0 or exactly 1, never between. The importing +// compositor's convention for whether colour arrives premultiplied is not +// something this shader can see, and at those two values the two conventions +// agree — which is a cheaper guarantee than being right about which one it is. + +struct Params { + width: u32, + height: u32, + // Luma difference at which a pixel is called in focus. See + // `PeakSensitivity::threshold` for where the three values come from. + threshold: f32, + // std140 rounds the scalar block up to 16 bytes before the vec4; named so + // the Rust struct's padding is visibly the same shape. + pad_0: u32, + // The mark's colour, fully saturated. Its alpha is ignored — see above. + marker: vec4, +} + +@group(0) @binding(0) var frame: texture_2d; +@group(0) @binding(1) var params: Params; +@group(0) @binding(2) var marks: texture_storage_2d; + +/// Rec.709 luma of an encoded triple, weighted exactly as `histogram.wgsl` +/// weights it. 54 + 183 + 19 is 256, so the weights sum to unity. +fn luma(c: vec3) -> f32 { + return dot(c, vec3(54.0, 183.0, 19.0) / 256.0); +} + +/// A neighbour, with the frame edge held rather than wrapped. +/// +/// Clamping duplicates the edge pixel into the missing half of the +/// neighbourhood, which pulls the mean towards the centre and so biases the +/// response *down* on the outermost row and column. That is the right +/// direction to be wrong in: the failure is a missing mark at the frame edge, +/// where nobody is judging focus, rather than a false mark produced by +/// folding the opposite side of the picture into the kernel. +fn neighbour(x: i32, y: i32) -> f32 { + let cx = clamp(x, 0i, i32(params.width) - 1i); + let cy = clamp(y, 0i, i32(params.height) - 1i); + return luma(textureLoad(frame, vec2(cx, cy), 0).rgb); +} + +// 8x8, matching the detail stage's dispatch. Each texel is loaded by nine +// invocations and no workgroup-memory tile is built to avoid that: at viewport +// resolution the reads are perfectly coherent and the texture cache serves +// eight of the nine. The budget is NFR-P14's 100 ms against a dispatch +// measured in tenths of a millisecond, so there is nothing here worth the +// complexity of a tiled load. +@compute @workgroup_size(8, 8, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + if (gid.x >= params.width || gid.y >= params.height) { + return; + } + let x = i32(gid.x); + let y = i32(gid.y); + + // The eight neighbours, centre excluded. Excluded rather than folded in + // because it makes the response readable: `abs(c - mean8)` is the height + // of this pixel above its surroundings in the same units as the step it + // sits on, so the threshold can be quoted as a luma difference rather than + // as eight-ninths of one. + var sum = 0.0; + for (var dy = -1; dy <= 1; dy = dy + 1) { + for (var dx = -1; dx <= 1; dx = dx + 1) { + if (dx != 0 || dy != 0) { + sum = sum + neighbour(x + dx, y + dy); + } + } + } + let centre = luma(textureLoad(frame, vec2(x, y), 0).rgb); + let response = abs(centre - sum / 8.0); + + if (response >= params.threshold) { + textureStore(marks, vec2(x, y), vec4(params.marker.rgb, 1.0)); + } else { + textureStore(marks, vec2(x, y), vec4(0.0, 0.0, 0.0, 0.0)); + } +} diff --git a/core/dr-gpu/tests/detail_stage.rs b/core/dr-gpu/tests/detail_stage.rs index ab18ef5..dd14be0 100644 --- a/core/dr-gpu/tests/detail_stage.rs +++ b/core/dr-gpu/tests/detail_stage.rs @@ -413,3 +413,56 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() { assert_eq!(pass.detail_dispatches(), 0); assert_eq!(pass.detail_allocations(), 0); } + +/// TRACES: FR-PLAT-AND-5 +#[test] +fn eviction_gives_the_pools_back_without_changing_a_pixel() { + // The half of memory-pressure eviction that cannot be checked by looking + // at a counter. `release_caches` frees the detail pool, and slot 0 of that + // pool is where the fused colour result lives between frames — so the + // render after an eviction has to notice that the promise recorded in + // `colour_key` no longer holds and run the colour chain again. + // + // Leave the key standing and this test does not error: it draws. It draws + // whatever a freshly-allocated texture happens to contain, which is the + // failure worth building a test around, because on a device it would + // appear only under memory pressure and only as a wrong-looking photograph. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 48; + let source = step_edge(&ctx, SIZE); + let mut pass = AdjustPass::new(&ctx); + let mut graph = EditGraph::with_detail_probe(); + graph.set_param(PROBE, RADIUS, 0.05); + + let before = render(&ctx, &mut pass, &graph, &source, SIZE); + assert!( + pass.cached_pipelines() > 0, + "the colour pass compiled something" + ); + assert!( + pass.cached_detail_pipelines() > 0, + "so did the detail stage" + ); + let allocations = pass.detail_allocations(); + assert!(allocations > 0, "and the pool holds textures"); + + pass.release_caches(); + assert_eq!(pass.cached_pipelines(), 0); + assert_eq!(pass.cached_detail_pipelines(), 0); + + // The same edit at the same size. Nothing about the picture changed, so + // nothing about the pixels may change either — only what it cost. + let after = render(&ctx, &mut pass, &graph, &source, SIZE); + assert_eq!(before.len(), after.len()); + for (i, (a, b)) in before.iter().zip(&after).enumerate() { + assert!( + a.abs_diff(*b) <= 1, + "byte {i}: {a} before eviction, {b} after — the colour chain did \ + not re-run, so this frame is reading an empty intermediate" + ); + } + assert!( + pass.detail_allocations() > allocations, + "the pool was rebuilt, which is the evidence it was really given back" + ); +} diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 209bbd3..20c2225 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -1,3 +1,4 @@ +//! TRACES: FR-DEV-1 //! The edit graph — an ordered set of operations (ARCH §3.4). //! //! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index dbb86b6..fb7faab 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -1,3 +1,4 @@ +//! TRACES: R3 //! The develop pipeline — operations, descriptors, and shader composition. //! //! # What this crate is @@ -62,7 +63,7 @@ pub use operation::{ compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation, OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS, }; -pub use preset::{Preset, Scope}; +pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Scope}; pub use sidecar::{Sidecar, Version}; pub use spot::{Spot, SpotMode, SpotSet}; pub use state::{EditState, FilmRebake, FilmRef}; diff --git a/core/dr-pipeline/src/preset.rs b/core/dr-pipeline/src/preset.rs index 0d2a9be..71bcb4d 100644 --- a/core/dr-pipeline/src/preset.rs +++ b/core/dr-pipeline/src/preset.rs @@ -39,6 +39,8 @@ //! is the whole claim the action makes. use std::collections::BTreeMap; +use std::fmt; +use std::fmt::Write as _; use crate::descriptor::{OpId, ParamId}; use crate::graph::EditGraph; @@ -240,6 +242,328 @@ pub(crate) fn resolve(graph: &EditGraph, op: &str, param: &str) -> Option<(OpId, Some((cap.id, p.id)) } +// --------------------------------------------------------------------------- +// Named presets +// --------------------------------------------------------------------------- + +/// TRACES: FR-DEV-6 +/// Format version of a preset library file. +/// +/// Present where `settings.json` has no version field, and the difference is +/// not an inconsistency. Settings are a flat bag of `#[serde(default)]` +/// fields, so an older file is *missing* keys rather than wrong about them and +/// additive change needs no version. This file has structure — blocks, and a +/// name carried in a block header — and a change to what a block *means* is +/// not something a reader can detect by noticing an absent key. +pub const LIBRARY_FORMAT_VERSION: u32 = 1; + +/// The file extension for a DarkRoom preset library. +pub const LIBRARY_EXTENSION: &str = "drpl"; + +/// Why a name was refused. +/// +/// A closed set rather than a string, so the interface can say something +/// specific about each and the message is not written here — this crate +/// depends on nothing and has no business holding user-facing prose. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NameError { + /// Empty, or nothing but whitespace. + Empty, + /// Contains a character the block header cannot carry: `[`, `]`, or a + /// line break. + /// + /// A round-trip constraint rather than a matter of taste. The header is + /// `[preset ]`, so a `]` inside the name would make the file parse + /// back as a *different* library, and a newline would make it parse back + /// as two. + Unrepresentable, +} + +/// TRACES: FR-DEV-6 +/// A set of named presets, as stored. +/// +/// # Why one file rather than one file per preset +/// +/// A preset per file makes the name a *path*, and every name then has to +/// survive a filesystem: a `/` becomes a directory, a name that differs only +/// in case collides on one platform and not another, and renaming becomes two +/// operations that can half-fail. Here the name is a key in a document, so +/// renaming is a map operation, deleting cannot leave an orphan, and the whole +/// library is written atomically by the same tmp-and-rename the settings store +/// uses. +/// +/// The cost is that the file is rewritten whole on every change. A preset is a +/// few dozen floats and a photographer has tens of them, not thousands, so the +/// file is kilobytes; the trade would look different at a scale this is not. +/// +/// # Why the sidecar's shape rather than JSON +/// +/// A preset *is* the non-default half of a version (see the module note), so +/// the lines here are the lines a sidecar carries, keyed the same way. That +/// makes the two files diffable against each other and lets someone debugging +/// an edit paste a block from one into the other. It also keeps this crate +/// dependency-free, which is the property that lets it be tested without a +/// device (ARCH §6.5a). +/// +/// # Ordering +/// +/// By name, so the same library always writes the same bytes and a caller may +/// compare content to decide whether a write is needed — the same +/// determinism [`Sidecar::to_text`](crate::Sidecar::to_text) offers, for the +/// same reason. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct PresetLibrary { + presets: BTreeMap, + /// Lines inside a `[preset]` block that were not `op.param = float`. + /// + /// Keyed by preset name and written back verbatim, so a build that + /// predates whatever wrote them round-trips the file without discarding + /// it. Unknown *parameters* need no such machinery: [`Preset`] holds + /// whatever keys it was given and resolves them against the descriptors + /// only at apply time, so a parameter this build has never heard of + /// survives simply by being stored. + unknown: BTreeMap>, +} + +impl PresetLibrary { + /// Whether a name is storable, and why not if it is not. + /// + /// Leading and trailing whitespace is trimmed rather than refused — it is + /// almost always a stray keystroke, and refusing it would mean a dialogue + /// about a space. + pub fn check_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(NameError::Empty); + } + if name.contains([']', '[', '\n', '\r']) { + return Err(NameError::Unrepresentable); + } + Ok(name.to_string()) + } + + /// Store `preset` under `name`, replacing any preset already there. + /// + /// Returns whether something was replaced. + /// + /// Replacing rather than refusing, with [`Self::contains`] beside it for a + /// caller that wants to ask first: whether overwriting needs a + /// confirmation is a question about the interface, and answering it here + /// would force every caller into the same answer. + /// + /// An *empty* preset is stored like any other. A neutral edit is a real + /// thing to save — applying it returns an image to default, which is the + /// fastest "undo everything on these forty frames" there is — and the + /// module note above is the same argument made about the clipboard. + pub fn insert(&mut self, name: &str, preset: Preset) -> Result { + let name = Self::check_name(name)?; + let replaced = self.presets.insert(name, preset).is_some(); + Ok(replaced) + } + + /// The preset stored under `name`. + pub fn get(&self, name: &str) -> Option<&Preset> { + self.presets.get(name) + } + + /// Whether a preset is stored under `name`. + pub fn contains(&self, name: &str) -> bool { + self.presets.contains_key(name) + } + + /// Remove the preset stored under `name`, reporting whether there was one. + pub fn remove(&mut self, name: &str) -> bool { + self.unknown.remove(name); + self.presets.remove(name).is_some() + } + + /// Rename `from` to `to`. + /// + /// `Ok(false)` means there was nothing called `from` — not an error, since + /// the caller may be acting on a list another window has already changed. + /// Renaming onto an existing name replaces it, for the same reason + /// [`Self::insert`] does. + pub fn rename(&mut self, from: &str, to: &str) -> Result { + let to = Self::check_name(to)?; + let Some(preset) = self.presets.remove(from) else { + return Ok(false); + }; + if let Some(unknown) = self.unknown.remove(from) { + self.unknown.insert(to.clone(), unknown); + } + self.presets.insert(to, preset); + Ok(true) + } + + /// Every stored name, in the order they are written. + pub fn names(&self) -> impl Iterator { + self.presets.keys().map(String::as_str) + } + + /// Every stored preset with its name, in the order they are written. + pub fn iter(&self) -> impl Iterator { + self.presets.iter().map(|(n, p)| (n.as_str(), p)) + } + + /// How many presets are stored. + pub fn len(&self) -> usize { + self.presets.len() + } + + /// Whether nothing is stored. + pub fn is_empty(&self) -> bool { + self.presets.is_empty() + } + + /// Serialise to the on-disk form. + /// + /// Deterministic, like the sidecar's: the same library always produces the + /// same bytes. + pub fn to_text(&self) -> String { + let mut out = format!("drpl {LIBRARY_FORMAT_VERSION}\n"); + for (name, preset) in &self.presets { + let _ = write!(out, "\n[preset {name}]\n"); + for ((op, param), value) in preset.params() { + let _ = writeln!(out, "{op}.{param} = {}", format_value(*value)); + } + for line in self.unknown.get(name).into_iter().flatten() { + let _ = writeln!(out, "{line}"); + } + } + out + } + + /// Parse the on-disk form. + /// + /// Tolerant on the same terms as the sidecar's parser, and for a weaker + /// version of the same reason: a preset library is not the authoritative + /// store an edit lives in, but it is still work the user did by hand, and + /// one bad line must cost that line rather than the collection. The only + /// hard failures are a file that is not a preset library at all and one + /// written by a newer build, where continuing would mean guessing. + pub fn parse(text: &str) -> Result { + let mut lines = text.lines(); + let header = lines.next().unwrap_or_default().trim(); + let Some(version) = header.strip_prefix("drpl ") else { + return Err(LibraryParseError::NotALibrary); + }; + match version.trim().parse::() { + Ok(v) if v <= LIBRARY_FORMAT_VERSION => {} + Ok(v) => return Err(LibraryParseError::UnsupportedVersion(v)), + Err(_) => return Err(LibraryParseError::NotALibrary), + } + + let mut library = Self::default(); + let mut current: Option = None; + let mut params: BTreeMap<(String, String), f32> = BTreeMap::new(); + + for line in lines { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some(head) = line + .strip_prefix("[preset ") + .and_then(|l| l.strip_suffix(']')) + { + if let Some(name) = current.take() { + library.presets.insert(name, Preset::from_params(params)); + params = BTreeMap::new(); + } + // A name the writer should never have produced is dropped + // rather than taken: accepting it would mean writing a file + // back out that no longer parses as this one. + match Self::check_name(head) { + Ok(name) => current = Some(name), + Err(_) => { + log::warn!("preset library: unusable preset name {head:?}; skipping"); + current = None; + } + } + continue; + } + + let Some(name) = current.clone() else { + log::warn!("preset library: line outside any preset: {line}"); + continue; + }; + + match line.split_once('=') { + Some((key, value)) => { + let key = key.trim(); + let value = value.trim(); + match (key.split_once('.'), value.parse::()) { + (Some((op, param)), Ok(v)) if !op.is_empty() && !param.is_empty() => { + params.insert((op.to_string(), param.to_string()), v); + } + _ => library + .unknown + .entry(name) + .or_default() + .push(line.to_string()), + } + } + None => library + .unknown + .entry(name) + .or_default() + .push(line.to_string()), + } + } + + if let Some(name) = current { + library.presets.insert(name, Preset::from_params(params)); + } + + // A block whose every line was unreadable still produced a preset, and + // an unknown block belonging to no preset would be written back into + // whichever one happened to sort first. Drop the orphans. + library + .unknown + .retain(|k, _| library.presets.contains_key(k)); + + Ok(library) + } +} + +/// Format a value the way the sidecar does — no trailing `.0`, no exponent — +/// so the two files stay comparable line for line. +fn format_value(v: f32) -> String { + let mut s = format!("{v:.6}"); + if s.contains('.') { + s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + } + if s == "-0" { + s = "0".to_string(); + } + s +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LibraryParseError { + /// The header line was missing or not a `drpl` header. + NotALibrary, + /// Written by a newer build, in a format this one cannot read. + UnsupportedVersion(u32), +} + +impl fmt::Display for LibraryParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotALibrary => f.write_str("not a DarkRoom preset library"), + Self::UnsupportedVersion(v) => { + write!( + f, + "preset library format version {v} is newer than this build" + ) + } + } + } +} + +impl std::error::Error for LibraryParseError {} + #[cfg(test)] mod tests { use super::*; @@ -563,4 +887,196 @@ mod tests { .collect(); assert_eq!(excluded, vec![framing::ID.0]); } + + // ----------------------------------------------------------------------- + // Named presets + // ----------------------------------------------------------------------- + + fn named() -> PresetLibrary { + let mut lib = PresetLibrary::default(); + lib.insert("Warm portrait", Preset::capture(&edited())) + .unwrap(); + lib.insert("Neutral", Preset::default()).unwrap(); + lib + } + + #[test] + fn a_library_round_trips_through_its_text_form() { + let lib = named(); + let back = PresetLibrary::parse(&lib.to_text()).unwrap(); + assert_eq!(back, lib); + } + + #[test] + fn the_same_library_always_writes_the_same_bytes() { + // What lets a caller skip a write by comparing content. Built in the + // opposite order to `named()` so insertion order cannot be what makes + // this pass. + let mut other = PresetLibrary::default(); + other.insert("Neutral", Preset::default()).unwrap(); + other + .insert("Warm portrait", Preset::capture(&edited())) + .unwrap(); + assert_eq!(other.to_text(), named().to_text()); + } + + #[test] + fn a_neutral_preset_is_storable_and_survives_the_round_trip() { + // The empty preset is the "clear these forty frames" action, so it has + // to be a real entry rather than an absence — and a block with no + // lines under it has to parse back as a preset rather than vanish. + let back = PresetLibrary::parse(&named().to_text()).unwrap(); + assert_eq!(back.get("Neutral"), Some(&Preset::default())); + } + + #[test] + fn an_unreadable_line_costs_that_line_and_not_the_library() { + let text = format!( + "drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\ + this line is not a setting\nsaturation.amount = not a number\n" + ); + let lib = PresetLibrary::parse(&text).unwrap(); + let preset = lib.get("Keep").expect("the preset survived"); + assert_eq!( + preset.params().get(&("exposure".into(), "exposure".into())), + Some(&0.5) + ); + } + + #[test] + fn lines_this_build_cannot_read_are_written_back_untouched() { + // The sidecar's version-skew promise, applied here: a build running + // behind must not silently strip what a newer one wrote. + let text = format!( + "drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nexposure.exposure = 0.5\n\ + something_new_entirely\n" + ); + let out = PresetLibrary::parse(&text).unwrap().to_text(); + assert!(out.contains("something_new_entirely"), "{out}"); + } + + #[test] + fn an_unknown_parameter_survives_without_any_machinery_for_it() { + // `Preset` stores whatever keys it is given and resolves them against + // the descriptors only at apply time, so a parameter from a newer + // build needs no preservation path of its own. + let text = format!( + "drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Keep]\nnot_an_op.not_a_param = 0.25\n" + ); + let out = PresetLibrary::parse(&text).unwrap().to_text(); + assert!(out.contains("not_an_op.not_a_param = 0.25"), "{out}"); + } + + #[test] + fn a_file_from_a_newer_build_is_refused_rather_than_guessed_at() { + let text = format!("drpl {}\n", LIBRARY_FORMAT_VERSION + 1); + assert_eq!( + PresetLibrary::parse(&text), + Err(LibraryParseError::UnsupportedVersion( + LIBRARY_FORMAT_VERSION + 1 + )) + ); + } + + #[test] + fn something_that_is_not_a_preset_library_is_refused() { + assert_eq!( + PresetLibrary::parse("drsc 1\n\n[version abc]\n"), + Err(LibraryParseError::NotALibrary) + ); + assert_eq!( + PresetLibrary::parse(""), + Err(LibraryParseError::NotALibrary) + ); + } + + #[test] + fn a_name_that_would_not_parse_back_is_refused() { + // Round-trip safety, not taste: a `]` would close the header early and + // the file would read back as a different library. + let mut lib = PresetLibrary::default(); + assert_eq!( + lib.insert("bracket] inside", Preset::default()), + Err(NameError::Unrepresentable) + ); + assert_eq!( + lib.insert("two\nlines", Preset::default()), + Err(NameError::Unrepresentable) + ); + assert_eq!(lib.insert(" ", Preset::default()), Err(NameError::Empty)); + } + + #[test] + fn surrounding_whitespace_is_trimmed_rather_than_refused() { + let mut lib = PresetLibrary::default(); + lib.insert(" Warm ", Preset::default()).unwrap(); + assert!(lib.contains("Warm")); + } + + #[test] + fn saving_over_a_name_replaces_it_and_says_so() { + let mut lib = named(); + assert_eq!(lib.insert("Warm portrait", Preset::default()), Ok(true)); + assert_eq!(lib.insert("Brand new", Preset::default()), Ok(false)); + assert_eq!(lib.get("Warm portrait"), Some(&Preset::default())); + } + + #[test] + fn renaming_moves_the_preset_and_leaves_nothing_behind() { + let mut lib = named(); + let before = lib.get("Warm portrait").cloned().unwrap(); + assert_eq!(lib.rename("Warm portrait", "Cool portrait"), Ok(true)); + assert!(!lib.contains("Warm portrait")); + assert_eq!(lib.get("Cool portrait"), Some(&before)); + } + + #[test] + fn renaming_something_that_is_gone_is_not_an_error() { + // Another window may have deleted it since this list was drawn. + let mut lib = named(); + assert_eq!(lib.rename("Never existed", "Whatever"), Ok(false)); + } + + #[test] + fn deleting_reports_whether_there_was_anything_to_delete() { + let mut lib = named(); + assert!(lib.remove("Neutral")); + assert!(!lib.remove("Neutral")); + assert_eq!(lib.len(), 1); + } + + #[test] + fn a_stored_preset_applies_exactly_as_a_pasted_one_does() { + // The whole point of sharing one representation: a named preset is not + // a second kind of thing with a second apply path. + let lib = named(); + let stored = PresetLibrary::parse(&lib.to_text()).unwrap(); + let preset = stored.get("Warm portrait").unwrap(); + + let mut target = EditGraph::default_chain(); + preset.apply(&mut target, Scope::Adjustments); + assert_eq!(target.param(exposure::ID, exposure::EXPOSURE), Some(0.75)); + // The target keeps its own framing on the default scope. + assert_eq!(target.param(framing::ID, framing::ANGLE), Some(0.0)); + } + + #[test] + fn a_stored_preset_amends_a_sidecar_without_a_graph() { + // The batch path: applying to forty images must not build forty + // graphs, so this is the call the library's batch apply makes. + let lib = named(); + let preset = lib.get("Warm portrait").unwrap(); + let mut params: BTreeMap<(String, String), f32> = BTreeMap::new(); + params.insert(("framing".into(), "angle".into()), 5.0); + params.insert(("exposure".into(), "exposure".into()), -1.0); + + preset.amend(&mut params, Scope::Adjustments); + + assert_eq!( + params.get(&("exposure".into(), "exposure".into())), + Some(&0.75) + ); + // Out of scope, so the target's own crop is untouched. + assert_eq!(params.get(&("framing".into(), "angle".into())), Some(&5.0)); + } } diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index 93e2372..f70d91c 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -1,3 +1,4 @@ +//! TRACES: FR-DEV-1 //! Sidecar serialisation — the edit graph as durable, mergeable data. //! //! # Generic, for the same reason the UI is generic diff --git a/core/dr-sync-folder/src/borrow.rs b/core/dr-sync-folder/src/borrow.rs index f48526a..490a938 100644 --- a/core/dr-sync-folder/src/borrow.rs +++ b/core/dr-sync-folder/src/borrow.rs @@ -1,4 +1,4 @@ -// TRACES: FR-NC-6c | FR-NC-6a +// TRACES: FR-NC-6c | FR-NC-6a | FR-NC-6d //! Hydrating a file for as long as it is needed, and no longer. //! //! A pass over a library — thumbnails, face indexing — needs each photograph's diff --git a/core/dr-sync-folder/src/lib.rs b/core/dr-sync-folder/src/lib.rs index 69f0d22..486fe47 100644 --- a/core/dr-sync-folder/src/lib.rs +++ b/core/dr-sync-folder/src/lib.rs @@ -1,4 +1,4 @@ -// TRACES: FR-NC-13 | FR-NC-12 +// TRACES: FR-NC-13 | FR-NC-12 | FR-NC-6d //! A library that is just a directory. //! //! The second [`RemoteBackend`], and the one that exists to prove the first @@ -216,10 +216,17 @@ impl std::fmt::Debug for FolderBackend { impl FolderBackend { /// Open the folder at `root`. /// - /// The directory must exist now. It may stop existing later — a drive - /// unplugged, a mount dropped — and that surfaces per-operation as - /// [`RemoteError::Network`], which is what puts the app into offline mode - /// and leaves the catalog readable, exactly as a dead server does. + /// The directory must exist now, and not existing is + /// [`RemoteError::RootUnavailable`] — the library folder could not be + /// opened, which is the whole of what this knows. A drive unplugged + /// between sessions and a path typed wrongly at setup are the same + /// observation from here, and both are answered the same way: keep the + /// catalog, say which folder, and offer it again (FR-PLAT-AND-2). + /// + /// A mount dropped *during* a session surfaces per-operation as + /// [`RemoteError::Network`] instead, which is what puts the app into + /// offline mode and leaves the catalog readable, exactly as a dead server + /// does. pub fn new(root: impl Into) -> Result { Self::with_vfs(root, Arc::new(NoVfs)) } @@ -232,7 +239,15 @@ impl FolderBackend { pub fn with_vfs(root: impl Into, vfs: Arc) -> Result { let root = root.into(); if !root.is_dir() { - return Err(RemoteError::Configuration(format!( + // TRACES: FR-PLAT-AND-2 + // Not `Configuration`, which is where this lived while there was + // nothing better. The distinction that matters is not "was the + // account written wrongly" — which nothing here can know — but + // "can this library be opened", and a caller that knows the + // library was working yesterday can act on the second answer: + // mark what it holds as offline rather than deleting it, and ask + // for the folder again (FR-CAT-9). + return Err(RemoteError::RootUnavailable(format!( "{} is not a folder", root.display() ))); diff --git a/core/dr-sync-folder/src/tests.rs b/core/dr-sync-folder/src/tests.rs index 606cdf1..3ecea0b 100644 --- a/core/dr-sync-folder/src/tests.rs +++ b/core/dr-sync-folder/src/tests.rs @@ -48,13 +48,22 @@ fn names(entries: &[RemoteEntry]) -> Vec { // --- opening -------------------------------------------------------------- +/// TRACES: FR-PLAT-AND-2 #[test] -fn a_missing_folder_is_a_configuration_error_not_a_network_one() { - // It must not put the app into offline mode: nothing was unreachable, the - // account names somewhere that is not a folder. +fn a_missing_folder_is_an_unavailable_root_not_a_network_failure() { + // Still not offline mode — nothing was unreachable over a wire, and + // reporting it as a network failure would tell the user to wait for a + // connection that is working. + // + // `RootUnavailable` rather than `Configuration`, because the caller that + // has to act on this is the one whose library worked yesterday: an + // ejected card is indistinguishable from a mistyped path here, and only + // the first of those has a catalog full of ratings to protect. let err = FolderBackend::new("/definitely/not/here").unwrap_err(); - assert!(matches!(err, RemoteError::Configuration(_)), "{err:?}"); + assert!(matches!(err, RemoteError::RootUnavailable(_)), "{err:?}"); + assert!(err.indicates_lost_root()); assert!(!err.indicates_offline()); + assert!(err.to_string().contains("/definitely/not/here"), "{err}"); } // --- listing -------------------------------------------------------------- diff --git a/core/dr-sync-folder/src/vfs.rs b/core/dr-sync-folder/src/vfs.rs index 3e05e4d..a49e0d9 100644 --- a/core/dr-sync-folder/src/vfs.rs +++ b/core/dr-sync-folder/src/vfs.rs @@ -1,4 +1,4 @@ -// TRACES: FR-NC-6c +// TRACES: FR-NC-6c | FR-NC-6d //! Virtual-filesystem conventions layered over a directory. //! //! A sync client in virtual-files mode leaves a *placeholder* where a file is diff --git a/core/dr-sync-nextcloud/src/lib.rs b/core/dr-sync-nextcloud/src/lib.rs index bfc30f7..ad93db0 100644 --- a/core/dr-sync-nextcloud/src/lib.rs +++ b/core/dr-sync-nextcloud/src/lib.rs @@ -1,3 +1,4 @@ +//! TRACES: R6 | NFR-SEC-3 //! Nextcloud connector. //! //! One of two [`RemoteBackend`] implementations, registered through diff --git a/core/dr-sync-nextcloud/src/provider.rs b/core/dr-sync-nextcloud/src/provider.rs index c182935..7b1a503 100644 --- a/core/dr-sync-nextcloud/src/provider.rs +++ b/core/dr-sync-nextcloud/src/provider.rs @@ -1,4 +1,4 @@ -// TRACES: FR-NC-12 | FR-NC-1 +// TRACES: FR-NC-12 | FR-NC-1 | NFR-SEC-3 //! Registering Nextcloud as a storage backend. //! //! The account model this connector used to own now lives in diff --git a/core/dr-sync/src/error.rs b/core/dr-sync/src/error.rs index 0f966bf..de57c94 100644 --- a/core/dr-sync/src/error.rs +++ b/core/dr-sync/src/error.rs @@ -61,14 +61,18 @@ pub enum RemoteError { /// connector for. /// /// **Not a network failure and not an auth failure**, which is why it is - /// its own variant. A folder library whose directory has been unmounted, - /// or an account naming a backend a cut-down build was not compiled with, - /// produces a request that never leaves the process — reporting either as - /// `Network` would put the app into offline mode and tell the user their - /// connection is down, and reporting them as `AuthFailed` would send them - /// to re-enter a credential that is fine. The message names what is wrong - /// with the configuration, because that is the only thing that will fix - /// it. + /// its own variant. An account naming a backend a cut-down build was not + /// compiled with, or a path that would leave the library folder, produces + /// a request that never leaves the process — reporting either as `Network` + /// would put the app into offline mode and tell the user their connection + /// is down, and reporting them as `AuthFailed` would send them to re-enter + /// a credential that is fine. The message names what is wrong with the + /// configuration, because that is the only thing that will fix it. + /// + /// A folder library whose directory is not there was once reported here + /// too, and is now [`RootUnavailable`](Self::RootUnavailable): it is not + /// something wrong with the configuration, it is the library being gone, + /// and only the second of those has a catalog to protect. #[error("account misconfigured: {0}")] Configuration(String), @@ -105,6 +109,43 @@ pub enum RemoteError { #[error("operation cancelled")] Cancelled, + + /// TRACES: FR-PLAT-AND-2 | FR-CAT-9 + /// The library root itself could not be opened. + /// + /// **The one failure that is about the library rather than about a file in + /// it**, and it is a separate variant because every other classification + /// of it is wrong in a way that costs the user something: + /// + /// - As [`NotFound`](Self::NotFound) it is indistinguishable from a folder + /// deleted between listing its parent and reaching it, which the walk + /// correctly steps over — so a whole library going away is reported as a + /// successful scan that found nothing. + /// - As [`PermissionDenied`](Self::PermissionDenied) it inherits a message + /// about Nextcloud share permissions and sidecar writes, which is + /// accurate for the case it was written for and nonsense for a tree + /// grant the user revoked in system settings. + /// - As [`Network`](Self::Network) it would claim the connection is down, + /// which is a promise that waiting will fix it. + /// + /// Today this is a Nextcloud root that answers 404 or 403 — deleted, or a + /// share withdrawn — or a folder library whose directory is not there. It + /// is also, exactly, the shape a revoked Android tree permission will have + /// when the Storage Access Framework connector FR-PLAT-AND-1 asks for + /// exists: the tree URI still stored, the permission behind it gone, every + /// read failing at the root and nowhere else. **That connector is not + /// built**, so no SAF grant can be lost yet; what this variant does is put + /// the recovery FR-PLAT-AND-2 requires in the one place all three causes + /// pass through, so the third needs no new handling above it. + /// + /// The response is the same for all of them and is the point of the + /// variant: mark what the catalog holds as offline, keep every row, and + /// say which library and why (FR-CAT-9). + /// + /// The string is the underlying failure, not a rewrite of it. What the + /// user is told is composed where the library's name is known. + #[error("the library folder could not be opened: {0}")] + RootUnavailable(String), } impl RemoteError { @@ -150,6 +191,19 @@ impl RemoteError { pub fn indicates_offline(&self) -> bool { matches!(self, RemoteError::Network(_)) } + + /// TRACES: FR-PLAT-AND-2 + /// Whether the *library* is gone, as opposed to the server or one file. + /// + /// Kept beside [`Self::indicates_offline`] because the two answer the same + /// shape of question and must not be confused. Both put the app into a + /// degraded mode that keeps working from the catalog, but they differ in + /// what the user is told and in what would end it: an offline library + /// comes back when the network does, and an unavailable root comes back + /// only when someone grants access again. + pub fn indicates_lost_root(&self) -> bool { + matches!(self, RemoteError::RootUnavailable(_)) + } } #[cfg(test)] diff --git a/core/dr-sync/src/scan.rs b/core/dr-sync/src/scan.rs index 6f723cd..6e4bad5 100644 --- a/core/dr-sync/src/scan.rs +++ b/core/dr-sync/src/scan.rs @@ -171,6 +171,37 @@ where let entries = match backend.list(&dir, None).await { Ok(e) => e, + + // TRACES: FR-PLAT-AND-2 | FR-CAT-9 + // The root is the one directory the walk may not step over, and + // `depth == 0` is the only place it can be — nothing is ever + // pushed at that depth but the root itself. + // + // Below, a directory that has gone is a directory that went away + // between its parent being listed and it being reached, and + // continuing is right. At the root the identical error means the + // *library* is gone, and continuing is catastrophic in a way that + // is completely silent: the walk ends, the scan succeeds having + // found nothing, and the app reports a healthy library with no new + // images while every path in the catalog now points nowhere. + // + // Refused rather than reclassified. Only these two causes are — + // a `Network` failure at the root is still a network failure, and + // must stay one or an unplugged network cable would present itself + // as a revoked permission and offline mode would never engage. + Err(RemoteError::NotFound(_)) if depth == 0 => { + return Err(RemoteError::RootUnavailable(format!( + "{root} is no longer there" + ))); + } + Err(RemoteError::PermissionDenied) if depth == 0 => { + // Deliberately not the variant's own message, which describes + // a Nextcloud share that refuses to *update* a sidecar. At the + // root nothing has been read at all. + return Err(RemoteError::RootUnavailable(format!( + "{root} can no longer be read" + ))); + } Err(RemoteError::NotFound(_)) => { // Deleted between listing its parent and reaching it. log::debug!("scan: {dir} vanished during the walk"); @@ -239,6 +270,20 @@ mod tests { caps: Capabilities, lists: RefCell, probes: RefCell, + /// Directories whose listing fails, and how. + /// + /// An absent directory is not enough to model this: the fake answers + /// an unknown path with an empty listing, which is exactly the shape + /// the walk must *not* confuse with a library that has gone away. + deny: HashMap, + } + + /// The two ways a real backend refuses a directory that is still named in + /// the catalog: it is not there, or it may not be read. + #[derive(Clone, Copy)] + enum Deny { + Missing, + Forbidden, } // The fake is single-threaded; tests never share it across threads. @@ -307,8 +352,15 @@ mod tests { }, lists: RefCell::new(0), probes: RefCell::new(0), + deny: HashMap::new(), } } + + /// Make one directory refuse to be listed. + fn denying(mut self, path: &str, how: Deny) -> Self { + self.deny.insert(path.to_string(), how); + self + } } #[async_trait] @@ -325,6 +377,11 @@ mod tests { _since: Option<&Validator>, ) -> Result, RemoteError> { *self.lists.borrow_mut() += 1; + match self.deny.get(dir.as_str()) { + Some(Deny::Missing) => return Err(RemoteError::NotFound(dir.to_string())), + Some(Deny::Forbidden) => return Err(RemoteError::PermissionDenied), + None => {} + } Ok(self.tree.get(dir.as_str()).cloned().unwrap_or_default()) } async fn dir_validator(&self, dir: &RemotePath) -> Result { @@ -404,6 +461,83 @@ mod tests { assert_eq!(r.progress.directories_listed, 3); } + /// TRACES: FR-PLAT-AND-2 | FR-CAT-9 + #[tokio::test] + async fn a_root_that_is_gone_is_a_failure_and_not_an_empty_library() { + // The silent one. A vanished directory below the root is stepped over, + // and before this the root was stepped over on the same terms — which + // ended the walk immediately, returned `Ok` with nothing in it, and + // let the app report a successful scan of a library that no longer + // exists. Nothing in that path is ever told the library went away, so + // nothing marks it offline and nothing tells the user. + let b = + FakeBackend::sample(ChangeDetection::PropagatingEtags).denying("Photos", Deny::Missing); + let e = scan( + &b, + &RemotePath::new("Photos"), + &FormatFilter::all(), + &HashMap::new(), + |_| {}, + ) + .await + .expect_err("a library that is not there is not a library with no photographs"); + + assert!(e.indicates_lost_root(), "got {e:?}"); + assert!(!e.indicates_offline(), "waiting will not bring this back"); + assert!(e.to_string().contains("Photos"), "names the library: {e}"); + } + + /// TRACES: FR-PLAT-AND-2 + #[tokio::test] + async fn a_root_that_may_not_be_read_reports_the_root_and_not_the_share_advice() { + // A Nextcloud share withdrawn, a directory the process may no longer + // read — and the shape a revoked Android tree grant will have when one + // can be held at all. Reported as plain `PermissionDenied` it would + // have carried that variant's message, which is several lines about a + // Nextcloud share refusing to *update* an existing sidecar: advice for + // a case where reads work, offered to a user whose reads have stopped + // entirely. + let b = FakeBackend::sample(ChangeDetection::PropagatingEtags) + .denying("Photos", Deny::Forbidden); + let e = scan( + &b, + &RemotePath::new("Photos"), + &FormatFilter::all(), + &HashMap::new(), + |_| {}, + ) + .await + .expect_err("a root that cannot be read is a failed scan"); + + assert!(e.indicates_lost_root(), "got {e:?}"); + assert!( + !e.to_string().contains("sidecar"), + "the share-permission advice does not belong here: {e}" + ); + } + + /// TRACES: FR-PLAT-AND-2 + #[tokio::test] + async fn a_folder_that_goes_away_below_the_root_is_still_stepped_over() { + // The other side of the split, and the reason the root is keyed on + // depth rather than on the error. A subfolder deleted between its + // parent being listed and it being reached is ordinary, and failing + // the scan over it would abandon every photograph beside it. + let b = FakeBackend::sample(ChangeDetection::PropagatingEtags) + .denying("Photos/2025", Deny::Missing); + let r = scan( + &b, + &RemotePath::new("Photos"), + &FormatFilter::all(), + &HashMap::new(), + |_| {}, + ) + .await + .expect("one folder going away is not the library going away"); + + assert_eq!(r.images.len(), 1, "2026 was still walked"); + } + /// A library with a trash folder holding a soft-deleted image. fn with_trash() -> FakeBackend { let mut b = FakeBackend::sample(ChangeDetection::PropagatingEtags); diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index 13ccafe..ee5eb80 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-NC-6a | FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-6 | FR-PLAT-LIN-1 +//! TRACES: FR-NC-6a | FR-EXP-1 | FR-EXP-2 | FR-EXP-3 | FR-EXP-6 | FR-PLAT-LIN-1 | NFR-OPS-3 //! Device preferences: how much disk to spend, and what an export defaults to. //! //! # Why these live beside the session and not in the catalog diff --git a/docs/catalog.md b/docs/catalog.md index 1268bd3..5d1dee8 100644 --- a/docs/catalog.md +++ b/docs/catalog.md @@ -810,6 +810,60 @@ confidence is unavailable. It does not fall back to an untuned default dressed u --- +## 10a. Bursts and near-duplicates + +Specified by FR-CULL-5, implemented in `dr_catalog::bursts` (a v11 migration) with the pass that +feeds it in `dr_ui::bursts`. + +A burst is a run of frames that are **adjacent in time and look like the frame before them**. Both +halves are load-bearing. Time alone groups a whole wedding ceremony, because a photographer working +steadily never leaves the gap that would end the run. Similarity alone groups a studio setup shot +across two days, which is a project rather than a moment. The bounds are two seconds and eight bits +of a 64-bit difference hash, and the reasoning for each figure is in the module. + +**Two seconds, for a burst that fires ten frames in one.** `images.captured_at` is whole seconds: +EXIF's `DateTimeOriginal` has no sub-second field, and `SubSecTimeOriginal` is optional and widely +omitted. Ten frames of a burst therefore arrive sharing a timestamp, and any threshold finer than a +second is a threshold on information the catalog does not have. Where the pace really is faster than +two seconds, the similarity bound is what separates the frames. + +**The signal is a perceptual hash of the thumbnail, not of the original.** `images.perceptual_hash` +is filled from the 256px thumbnails §7 already stores — vastly more resolution than a 9×8 reduction +uses — so a library that has been browsed has already paid for its signatures and no RAW is decoded +for this. The consequence is stated rather than hidden: an image with no thumbnail gets no +signature, and a frame with no signature never joins a burst. It is picked up by the next pass. + +**It is a pass, not a job kind**, for exactly the reason §10.2 gives for face clustering: a burst is +a property of a *run* of frames and has no natural `subject_id`, so a per-image job would rebuild +the world once per photograph. It runs when the thumbnail sweep finishes, which is the first moment +the signatures can all be computed. + +**A newly found burst arrives open.** The pass marks frames; it never takes them off the screen. +Collapsing on discovery would be tidier and would also mean a background pass removing photographs +from under someone part way through a cull. Folding a burst up is the user's act, it is remembered +(`burst_expanded`), and a burst that is already known keeps whatever state it is in — so the pass +that follows the next import does not spring open a morning's work. + +**Nothing here ranks a frame.** The representative of a collapsed burst is its *earliest* frame, +which is a fact about the clock rather than a judgement about the photograph. FR-CULL-5 names the +failure this avoids — rejecting the only frame of an important moment because someone blinked — and +the only judgement in the subsystem is the user's own choice of representative, which lives in its +own table (`burst_pick`) so that rebuilding the grouping cannot erase it. Same argument as +`people.ignored` in §10. + +**What the collapse costs the grid.** Which rows a collapsed burst hides has to be decided by the +query rather than by the cells, because the grid is a window (`LIMIT n OFFSET k`) and the frames it +hides are mostly not loaded. So the predicate joins `VISIBLE` in every query that lists or counts +cells, under the same discipline: present in four places of five, the header's count, the +scrollbar, the shift-click range and the scrub's ordinal stop describing the same list. + +**What this does not settle.** Bursts are local: the tables ride along in the uploaded catalog +snapshot and nothing on the far side reads them, so a second device rebuilds its own grouping from +its own signatures. Making `burst_pick` cross-device is a merge question of the same shape as §8.4's +and is not answered here. + +--- + ## 11. Requirements touched | ID | How this document addresses it | @@ -828,6 +882,7 @@ confidence is unavailable. It does not fall back to an untuned default dressed u | NFR-P1 | §3.1 one stat per directory, not per file | | NFR-P3 | §7.1 on-demand generation | | NFR-ARCH-2 | §6.3 priority classes shared with the GPU scheduler | +| FR-CULL-5 | §10a burst grouping: capture-time proximity and image similarity, collapse without selection | | NFR-ARCH-3 | §4.3 query cancellation, §6 job cancellation | | NFR-RES-4 | §7.3 LRU cap, eviction order | | FR-CULL-8 | §10.1 `faces` schema, §6.1 `DetectFaces` job kind on the proxy tier | diff --git a/docs/distribution.md b/docs/distribution.md new file mode 100644 index 0000000..90e3075 --- /dev/null +++ b/docs/distribution.md @@ -0,0 +1,251 @@ +# DarkRoom — Distribution + +**Satisfies:** NFR-COMPAT-2 (v1 channels) · FR-PLAT-LIN-3 (sandboxed distribution) +**Companion to:** [requirements.md](requirements.md) §3.8, §4.8 · [storage.md](storage.md) + +NFR-COMPAT-2 asks for the v1 channels to be *stated*, and says why in its own +second sentence: the channel decision and the storage design are coupled. A +channel is not a build target. It is a set of constraints that reach back into +the code — what the application is allowed to see, what it may ask for, and +what it must be able to do without asking. This document records which channels +v1 targets and what each one costs, and it is where to look before adding a +permission to a package rather than after. + +--- + +## 1. The channels + +| Platform | Channel | State | What it constrains | +|---|---|---|---| +| Linux | Arch source package — [`packaging/PKGBUILD`](../packaging/PKGBUILD) | Built, in tree | Nothing. Full filesystem access, system Vulkan, system secret daemon | +| Linux | Flatpak — [`packaging/flatpak/`](../packaging/flatpak/) | Manifest in tree, **library selection does not work** (§4) | Portals only. No `--filesystem=`, no host mount table, no typed paths | +| Linux | AppImage | v1 channel, **recipe not yet written** (§5) | Oldest supported glibc, and no sandbox at all | +| Android | F-Droid | v1 channel, not yet submitted | GPLv3-clean build, reproducible, no proprietary blobs | +| Android | Play Store | **Not v1** (§6) | Would make ARCH §6.9 binding as policy rather than as engineering | + +Three of these five exist as recipes and two do not. That is stated rather than +smoothed over, because the value of writing the channels down is knowing which +constraints are already being met and which are promises. + +### What every channel has to get right + +Independent of packaging format, and each of these has bitten a package +somewhere: + +- **One identifier, four places.** `paris.tourolle.darkroom` is the AppStream + component id, the `.desktop` basename, the Flatpak application id, and the + string `dr_ui::run` sets as the Wayland `app_id` and X11 `WM_CLASS`. A rename + that misses one of them costs the icon in the shell or the association in the + software centre, and neither failure announces itself. +- **The metainfo, not just the desktop entry.** + [`packaging/paris.tourolle.darkroom.metainfo.xml`](../packaging/paris.tourolle.darkroom.metainfo.xml) + is the single description of the application, installed by every channel that + has somewhere to put it. Its `metadata_license` is CC0-1.0 and its + `project_license` is GPL-3.0-or-later; those differ on purpose — see the + comment in the file. +- **Vulkan is a requirement, not a preference.** The develop pipeline is + compute shaders through wgpu, and NFR-R8 — how far a CPU fallback goes — is + still open, so today there is nothing behind it. A package that installs onto + a machine with no working ICD produces an application that starts and cannot + develop. +- **A Secret Service implementation, or an honest degraded mode.** FR-NC-2 is + explicit that the absence of a secrets daemon is a stated degraded mode and + never a silent fall back to plaintext. Packages express this as an optional + dependency (the PKGBUILD) or a talk hole (the Flatpak manifest), never as a + hard dependency — a headless or minimal-WM install is a supported way to run. +- **The face models are Git LFS objects.** A checkout without `git lfs pull` + has ~130-byte pointers where 11 MB models should be. Both the PKGBUILD and + the Flatpak manifest check the file size and refuse, because the alternative + is a package whose face indexing fails inside the graph loader on a user's + machine rather than on the packager's. + +--- + +## 2. Why Flatpak is the channel that matters most + +Not because it is expected to be the most used. Because it is the only one that +tests anything. + +The Arch package and an AppImage both hand the application the same +unrestricted process the developer runs it in, so neither can discover that a +design assumed unrestricted access. Flatpak takes that assumption away, and +FR-PLAT-LIN-3 exists to make the discovery happen deliberately rather than in a +bug report. §4 is what it discovered. + +The same argument runs the other way on Android, where SAF has been the only +option since before the first line was written (ARCH §6.9) and `SourceRef` +exists because of it. Linux got the abstraction — `LocalStorage::grant` is the +one place a `Path` enters — and never got the constraint that would have proved +it worked. + +--- + +## 3. What already works inside the sandbox, unchanged + +Worth listing, because it is the part FR-PLAT-LIN-1 quietly paid for in +advance: + +- **XDG directories.** Flatpak redirects `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and + `XDG_CACHE_HOME` into `~/.var/app/paris.tourolle.darkroom/`. Settings + (`settings_store.rs`), accounts (`dr_sync::account`), the catalog and the + thumbnail store all read those variables, so every one of them lands in the + application's own directory with no code change and no permission. +- **The face models.** `system_face_models_dirs()` reads `$XDG_DATA_DIRS` + rather than hard-coding `/usr/share`, which is exactly why `/app/share` + inside a Flatpak is found by the same lookup that finds the Arch package's + copy. +- **Opening a photograph from a file manager.** The `.desktop` entry declares + the RAW MIME types and `Exec=darkroom-desktop %F`; under Flatpak the file is + exported through the document portal and arrives in `argv` as a path under + `/run/user/$UID/doc/`, which is mounted in every sandbox. `main.rs` takes + paths from `argv` and `collect()` handles a file or a directory. This is + genuine portal-mediated access and it needs nothing new. +- **The Nextcloud sign-in browser.** `open_in_browser` spawns `xdg-open`; the + freedesktop runtime's `xdg-open` forwards to the OpenURI portal, and portal + calls need no `--talk-name` because Flatpak always permits them. FR-NC-1's + "system browser, never an embedded webview" therefore holds inside the + sandbox for the same reason it holds outside it. +- **Credentials.** The keyring crate speaks the Secret Service D-Bus interface, + reached through the session-bus proxy with one talk hole. The app password + stays visible to `secret-tool` and Seahorse, which is what keeps it + individually revocable by the user. + +--- + +## 4. What does not work: choosing a library + +**FR-PLAT-LIN-3 is not satisfied today, and the manifest does not pretend +otherwise.** + +A folder library is chosen by typing an absolute path. `dr-sync-folder`'s +provider declares `SignIn::EndpointOnly` with the placeholder +`/home/you/Pictures`, and `normalise_endpoint` expands `~`, requires the path +to be absolute, and checks it with `std::fs`. Nothing in the tree calls the +FileChooser portal — there is no `ashpd`, no `rfd`, and no toolkit file dialog +anywhere in `ui/`, `platform/` or `core/`. + +Inside a sandbox with no `--filesystem=`, `$HOME` still resolves to the real +home *path* but that directory holds only the application's own +`.var/app/…` tree. So a typed `~/Pictures` fails the `exists()` check and the +launch screen says `No folder at /home/you/Pictures.` — a truthful message +about a situation the user cannot fix from inside the application. + +Import is blocked one step earlier. `dr_plat::volumes()` finds a camera card by +reading `/proc/self/mountinfo` and the `removable` flag under `/sys`. A +sandboxed process is in its own mount namespace, so the table it reads +describes the sandbox; a card mounted at `/run/media/…` on the host is not in +it. `volumes()` correctly returns an empty list, which the interface presents +as "no card found" — right for the code, wrong for the user, who is looking at +a card. + +### The permission that would hide this, and why it is not in the manifest + +`--filesystem=host` makes both work immediately and is the thing FR-PLAT-LIN-3 +names as the alternative to portals. Granting it would mean the sandboxed build +never exercises the sandbox, which removes the entire reason for shipping one +(§2). `--filesystem=xdg-pictures` is narrower and would be tempting, but it is +still a static grant that lets a typed path resolve — it makes the same design +work by not testing it, only in a smaller directory. + +So the manifest grants no filesystem access at all. The consequence is stated +plainly: **a Flatpak built from this manifest can open photographs handed to it +and cannot yet be pointed at a library.** + +### What closes it + +Two changes, in this order: + +1. **A portal file chooser behind a platform seam.** `ashpd`'s + `OpenFileRequest` with `directory(true)` returns a URI the document portal + has exported, which the sandbox can read and which stays valid across + restarts. It resolves to a real path under `/run/user/$UID/doc/`, so + `normalise_endpoint` accepts it as it stands — `canonicalize()` on a fuse + path returns the path itself. The seam matters more than the crate: this + belongs beside `LocalStorage::grant` in `dr-plat`, which is already the one + place a `Path` enters the application, and must not become a second way for + `ui/` to learn about paths. +2. **Removable volumes through the same door.** There is no portal for "list + the mounted cards". The honest answer is that under a sandbox + `imports_supported()` should report the same `false` it reports on Android, + for the same reason it gives there — the operation cannot be performed + however hard the user tries — and the import flow should offer the folder + chooser instead of a volume list. + +**Done when:** a Flatpak built from +[`packaging/flatpak/paris.tourolle.darkroom.yml`](../packaging/flatpak/paris.tourolle.darkroom.yml), +with its `finish-args` unchanged and no `flatpak override` applied, can select a +library root, scan it, and write a sidecar back into it. + +### Running a Flatpak build before then + +For testing the rest of the application inside the sandbox, grant the access +per-installation rather than in the manifest, so the file that describes the +application keeps telling the truth: + +```bash +flatpak override --user --filesystem=~/Pictures paris.tourolle.darkroom +``` + +--- + +## 5. AppImage + +A v1 channel, and the recipe is outstanding work rather than a decision to be +made. What it will have to account for, none of which is a surprise: + +- **glibc.** An AppImage links against the oldest glibc it must run on, so it + is built in a container with an old base rather than on a rolling-release + developer machine. A release binary built on a current rolling-release host carries + `GLIBC_2.44` references and would run on almost nothing else. +- **What to bundle and what not to.** The binary links fontconfig, freetype, + expat, libpng, zlib, brotli and bzip2 — bundle those. It does *not* link + Vulkan, libxkbcommon or either display-server library: wgpu `dlopen`s + `libvulkan.so.1`, and `x11rb` and `wayland-client` speak the wire protocols + in Rust. The Vulkan loader and the ICD must come from the host, and bundling + a loader is the classic way to break an AppImage on a driver it did not + expect. +- **The models.** ~15 MB of ONNX weights inside the image, or a first-run + download. In-tree is consistent with how the Lensfun database ships and with + NFR-SEC-5's local-first posture; the licence question (D13) is the same one + it is everywhere else and is not made easier or harder by this channel. +- **No sandbox.** An AppImage tests nothing about FR-PLAT-LIN-3. It is a + convenience channel for distributions the PKGBUILD does not serve, and should + never be the channel a portal problem is discovered on. + +--- + +## 6. Android: F-Droid in v1, Play deferred + +NFR-COMPAT-2 says Play distribution is what makes ARCH §6.9's constraints +binding, and that is worth reading precisely, because the constraint is already +met and would be met whatever the channel. + +§6.9 is *verified*, not assumed: `MANAGE_EXTERNAL_STORAGE` is not grantable +under Play policy, and `READ_MEDIA_IMAGES` would not help because proprietary +RAW is not typed `image/*` by the platform scanner and does not appear in +`MediaStore.Images`. SAF is the only route that works, so FR-PLAT-AND-1 asks +for it unconditionally and `SourceRef` (ARCH §3.1) exists to make it possible. +A sideloaded or F-Droid build *could* ask for broader permissions; it would +gain nothing by doing so. + +So the coupling runs the opposite way from how it is usually described. Play is +deferred for a reason that has nothing to do with storage: GPLv3 distribution +through Play is generally workable but has not been confirmed for this project +(ARCH §14), and F-Droid has no such question. Confirming it is a licence-reading +exercise; nothing in the storage design waits on the answer. + +--- + +## 7. Where the recipes live + +``` +packaging/ + PKGBUILD Arch source package + paris.tourolle.darkroom.desktop the desktop entry, installed by every channel + paris.tourolle.darkroom.metainfo.xml AppStream, installed by every channel + flatpak/ + paris.tourolle.darkroom.yml the manifest, and where the permissions are argued +``` + +`packaging/` also accumulates built `.pkg.tar.zst` artefacts from local +`makepkg` runs. Those are not part of any channel and should not be committed. diff --git a/docs/outstanding.md b/docs/outstanding.md new file mode 100644 index 0000000..3e6011e --- /dev/null +++ b/docs/outstanding.md @@ -0,0 +1,358 @@ +# DarkRoom — Outstanding work + +**Status:** Living document · first written 2026-08-29 +**Companion to:** [requirements.md §7](requirements.md), [technical-debt.md](technical-debt.md), +[traceability.md](traceability.md) + +What is specified and not built, and for each cluster whether that is a decision, a dependency, or a +gap nobody has looked at. + +This document exists because [traceability.md](traceability.md) cannot tell those apart. It reports +one number — the share of requirements carrying a `TRACES` tag — and a missing tag means either +"nobody has built this" or "somebody built it and did not say so". Both read the same way in the +summary table, which makes that figure pessimistic *and* uninformative at once: it understates what +works while hiding which of the remainder matters. Eight requirements gained a tag on this branch +because the code already satisfied them and nobody had said so. Everything below is the other kind. + +It is also not a plan. [requirements.md §7](requirements.md) records what was deferred deliberately +and needs no argument; this records what is still nominally in scope, so that the distance between +the register and the binary is visible rather than something a reader has to reconstruct from a +percentage. Where the honest answer is "this requirement should be amended rather than met", it says +so — an unbuilt requirement that nobody intends to build is worse than a deferred one, because it +keeps costing attention. + +**Four of these are being built right now**, in parallel worktrees, and are marked **⟳ in +progress** where they appear: focus peaking (part of FR-CULL-3), burst grouping (FR-CULL-5), +Flatpak packaging (FR-PLAT-LIN-3), and Android platform integration (FR-PLAT-AND-2/4/5/6). Strike +those lines as they land rather than rewriting around them. + +--- + +## 1. Plugins — 21 requirements, and a contradiction to resolve before any of them + +**Untagged:** FR-PLG-1, -1a, -2a, -2b, -2c, -3, -3a, -4, -4a, -5, -5a, -5b, -5c, -6, -6a, -7, -8, +-9, -10, -11, -12. + +No plugin host exists. No crate loads anything at runtime: there is no manifest reader, no WASM or +Lua engine, no registry, no signature check, no install path, no capability grant, no per-plugin +failure ledger. `declared/mod.rs` says as much in its own documentation — the operation format is +"not a plugin directory read at startup". + +**Two of §3.10's requirements are met, and they are the interesting two.** FR-PLG-2 and FR-PLG-2d — +the declarative node format — are built and tagged: `core/dr-pipeline/ops/*.yaml` compiled by +`build.rs`, with the restricted expression grammar in `declared/expr.rs` and a parity test asserting +a declared operation and a hand-written one produce identical output. +[code-health.md §3](code-health.md) calls it "a working plugin system that happens to resolve at +build time", and that is exactly right. What is missing is not the format; it is everything that +would let somebody who is not in this repository use it. + +**The contradiction.** [requirements.md §7](requirements.md) lists `| Plugin API | — |` among the +things deferred for v1 — a bare row, where most deferrals carry a justifying note. §3.10 then spends +roughly 280 lines and 23 requirement IDs specifying that same Plugin API in detail. Both statements +are in the register of record, and the traceability denominator counts the second one: 21 IDs, 12% +of all 179 defined requirements, worth about twelve points of coverage on their own — and nearly a +third of everything the matrix reports as uncovered. A reader looking at the coverage figure has no +way to know that, or that the subsystem behind it is one the same document says is not in this +version. + +**And D16 is open.** [Decision D16](requirements.md) — plugin licensing — records that GPLv3 +answers the derivative-work question differently for each of §3.10's three plugin forms, and that +this "must be answered *before* an ecosystem exists, not after", because a term introduced later +cannot be applied to plugins already written. D16 explicitly does not block FR-PLG-2; it blocks +publishing a third-party format as stable. + +**What would resolve this:** an edit to `requirements.md`, not code. Either §7 drops the row, or +§3.10 is marked deferred with the two built requirements carved out. Until one of those happens the +coverage figure is measuring a decision that has already been taken, and taking it again every time +somebody reads the matrix. + +--- + +## 2. Culling — the stated differentiator, half built + +[D11](requirements.md) names culling "the core differentiator". FR-CULL-1, -2, -4 and -8 through -12 +are built. Four are not. + +**FR-CULL-3 — Raw-truth overlays. All three bullets, unbuilt.** Focus peaking does not exist +anywhere; the string appears zero times in the tree. + +The other two are easy to mistake for present, and are not. A histogram and clipping indicators do +exist — `dr-gpu/src/histogram.rs`, `ui/dr-ui/src/histogram.rs`, the panel in `histogram.slint` — +but they are tagged FR-DSP-7 and they answer the opposite question. They read `AdjustPass`'s 8-bit +output and count clipping as `r == 255`, which is to say they describe **the frame the display is +about to show**, after the whole develop chain has run. FR-CULL-3 asks for the histogram of the +*sensor data*, on the explicit grounds that a rendered image "systematically lies about what is +recoverable in the raw". A readout that measures the render cannot answer that however it is +presented, so this is not a matter of moving an existing widget into the culling view. + +The requirement exists because a culling decision made against a rendered preview is a decision made +against the wrong image, and the whole of it is still to build. **⟳ in progress** (focus peaking). + +**FR-CULL-5 — Burst and near-duplicate grouping.** Absent. Worth knowing before it is built: +`core/dr-face/src/calibrate.rs` already *assumes* it exists — "since FR-CULL-5 already groups +bursts, positives are bootstrapped from bursts" — and in fact bootstraps from confirmed labels +instead. That comment is a forward reference to this requirement and will need correcting either +way. `core/dr-catalog/src/dedup.rs` is not this: it is re-import detection under FR-CAT-11, matching +a file against one already catalogued, not two photographs against each other. **⟳ in progress**. + +**FR-CULL-6 — Compare and survey.** Absent. No side-by-side view, no synchronised zoom or pan. +This is the one of the four with no adjacent machinery at all, and it is also the one that most +directly distinguishes culling from browsing. + +**FR-CULL-7 — Culling on tablet.** Absent, and blocked by the three above rather than independent +of them: there is no separate tablet culling surface to build until there is something to put on it. + +--- + +## 3. FR-DEV-3g — AI denoise + +Promoted into v1 by [D11](requirements.md), and named there as the precondition for deferring AI +masking — the argument being that one learned stage earns the runtime that a second could then +reuse. Only classical noise reduction exists: `ops/noise_reduction.rs`, a bilateral filter in two +arrangements, exact for luminance and separable for chroma. It is good, and it is not this. + +`models/` holds two face models and nothing else; `core/dr-segment/models/` holds a YOLO +segmentation model for subject masks. There is no denoise model, no learned demosaic, and no +inference path that is not face or segmentation. + +The obstacle is not the pipeline. It is that [D13](requirements.md) — model licensing — is still +open for the models that already ship, and adding a third learned stage adds a third licence to +answer for. Building the runtime before that is settled means owning the same problem in one more +place. + +--- + +## 4. The render path — FR-DSP-2, FR-DSP-4, NFR-RES-2 + +**FR-DSP-2 — Tiled computation. Unbuilt, and under challenge.** [architecture.md §6.2](architecture.md) +calls for tiling "from day one" on the grounds that retrofitting it is a rewrite. It was not built, +and the evidence has since moved. `core/dr-gpu/tests/frame_budget.rs` carries the argument in its +own header: one fused dispatch over a viewport-sized target is comfortably inside the frame budget, +and "if that stops being true, the recommendation to strike tiled computation from the interactive +path stops being supported, and this test is what says so." +[technical-debt.md TD-4](technical-debt.md) reaches the same place from the other direction — a +tiled convolution at clarity's radius reads nearly twice the taps that an untiled one does, so the +stage that looks most like it wants a tile cache is the stage that would be hurt most by one. + +What exists is the declaration and not the mechanism: `DetailPass::radius` is documented as the halo +a tile would have to be grown by, with a test that pins it, and there is no scheduler to read it. +That is deliberate plumbing, not an oversight. + +**So the open question here is not "when is tiling built" but "is FR-DSP-2 still a requirement".** +Two measurements say it costs more than it saves on the interactive path. Neither says anything +about the export path or about a device under memory pressure, which is where the case for it +actually lives — and that is spike S6, which has not run. + +**FR-DSP-4 — Progressive refinement.** Unbuilt. FR-DSP-1's proxy rendering and TD-4's +quarter-resolution base are adjacent and are not it: both are fixed choices about what resolution to +compute at, where FR-DSP-4 asks for a first frame that is deliberately cheap and a second that +replaces it. Nothing tracks a "this frame is provisional" state. + +**NFR-RES-2 — Images larger than GPU memory.** No answer, and §4.3 knows it: the requirement text +itself asks the reader to "decide explicitly" how ARCH §6.4 and NFR-RES-2 are reconciled. There is +no headroom budget, no allocation-failure fallback, and no spill. Spike S6 — a tiled pipeline on a +mid-range Android device with an image larger than available GPU memory — is the one that would +settle both this and FR-DSP-2, and there is no evidence it has run. + +--- + +## 5. Android beyond running, and Flatpak + +The Android app is not a stub — it builds an APK, runs the whole application, unpacks bundled face +models, and has been measured on a tablet ([faces.md §12.1](faces.md), +[technical-debt.md TD-1](technical-debt.md)). What is missing is the platform contract around it. + +**FR-PLAT-AND-1 is tagged and should not be relied on.** The requirement demands that library access +be obtained *exclusively* through the Storage Access Framework. There is no SAF code: no +`ACTION_OPEN_DOCUMENT_TREE`, no `takePersistableUriPermission`, no `DocumentsContract`. The two tags +rest on a `SourceRef::Document` variant that nothing constructs and a volumes helper, which is the +"plumbing a future feature would use" case [CONTRIBUTING.md](../CONTRIBUTING.md) and +[code-health.md CH-4](code-health.md) both warn about. Android reaches a library through a Nextcloud +account or a folder, over paths, like the desktop. + +That has a consequence for the rest of the cluster: **FR-PLAT-AND-2** — detecting the loss of a +granted tree permission and marking images offline rather than deleting rows — cannot be built until +there is a permission to lose. It is listed here as unbuilt, but it is blocked, not skipped. + +**FR-PLAT-AND-4** (managed background execution, foreground service for exports, stated Doze +behaviour): the manifest declares one activity, no service, and neither `FOREGROUND_SERVICE` nor +`POST_NOTIFICATIONS`. **FR-PLAT-AND-5** (`onTrimMemory` with a stated eviction order): no callback +is registered, though the eviction order it is supposed to drive is specified in FR-NC-6's text. +**FR-PLAT-AND-6** (view and share intents, `FileProvider`): the only intent filter is +`MAIN`/`LAUNCHER`. **⟳ in progress** for this group. + +**FR-PLAT-LIN-3 — Flatpak.** `packaging/` holds an Arch `PKGBUILD` and a `.desktop` entry. There is +no Flatpak manifest, nothing goes through a portal, and `platform/dr-plat/src/secrets.rs` talks to +the Secret Service directly rather than through the portal the requirement names. **⟳ in progress**. + +**NFR-COMPAT-2 — distribution channels.** Unstated, and this is the requirement that makes the +others binding: §4.8 observes that the decision to publish on Play is what turns SAF from a +preference into a constraint. Spike S11, the Play permissions dry-run that would settle it, has not +run. Related, NFR-COMPAT-1's baseline is real but scattered — API 28/36 live in the Android +Dockerfile and are checked in CI against the built ELF, which is good — while the items the +requirement singles out are missing: whether `shaderFloat16` and 16-bit storage are required (the +one it flags as jeopardising R1), minimum RAM, minimum desktop Mesa, and a named reference device +from a second GPU vendor. + +**NFR-OPS-2 and NFR-OPS-4.** Crash reporting is a `log::error!` panic hook on Android and nothing at +all on desktop: no local crash record, no backtrace capture, no upload path and therefore no opt-in +gate to guard it. Update and first run are undefined; the concrete reason NFR-OPS-4 gives — that D2 +pins rawler at a non-SemVer alpha whose camera-support fixes users will need — is unaddressed, and +there is no update mechanism of any kind. + +--- + +## 6. Accessibility and internationalisation — the hard half is done and the easy half is not + +**NFR-A11Y-1 — Localisation.** `@tr(` appears **zero** times across 14,482 lines of Slint. That +number overstates the problem, because the part that is genuinely architectural was got right: +`LocalizedKey` keeps display strings out of `core/` entirely, every operation publishes a key rather +than a label, and `labels::resolve` is the single point where a key becomes text. What that single +point does, however, is a hardcoded English `match` in Rust source — so changing a translation +requires a recompile, which is the one thing the requirement explicitly forbids. There is no message +catalogue in any format, no locale-resolution rule, and no decision recorded about RTL. + +The work left is therefore smaller than it looks and entirely mechanical: a catalogue format, a load +path behind `resolve`, and `@tr(` around the Slint literals. The design it needs already exists. + +**NFR-A11Y-2 — Accessibility.** `accessible-*` appears five times in the whole interface, all five +on one control — the parameter slider in `adjust.slint` — and nothing is set from the Rust side at +all. Everything else in eighteen Slint files is unnamed to AT-SPI and TalkBack. The requirement's own +caveat, that Slint's Android accessibility needs verifying, is spike S13, which has not run. + +**NFR-A11Y-3 — Colour-independent status.** No compliance work found. This is cheap to satisfy while +a control is being written and expensive to retrofit across forty of them, which is an argument for +doing it as part of the NFR-A11Y-2 pass rather than after it. + +--- + +## 7. Catalog and sync + +**FR-CAT-14 — Migration import.** Reading ratings, labels, keywords and collections out of a +Lightroom `.lrcat` or a darktable `library.db`. Unbuilt. The destination is not: keywords, +collections, ratings and the cross-device merge rules are all built and tested, and +`keywords.rs` already anticipates the arrival ("an import from Lightroom can bring in…"). What is +missing is only the two source adapters — which is a comparatively contained piece of work for a +requirement that decides whether somebody can try this software on a library they already have. + +**FR-NC-11 — Initial catalog build.** Using WebDAV `SEARCH` (RFC 5323) against `/remote.php/dav/`, +filtered by mimetype and paginated, in preference to walking folders with PROPFIND. Unbuilt: no +`SEARCH` request is issued anywhere. The PROPFIND walk this exists to replace is fully built and +well optimised — ETag pruning under FR-NC-4 turns an unchanged 50k library into one request — so the +gap is narrower than it reads. It is the *first* build against a large remote library that pays, and +that is the moment a new user meets. + +**FR-CAT-13 — XMP interoperability, tagged and not met.** Read and write standard XMP sidecars. The +single tag sits on `keywords.rs`, which stores keywords; no XMP is parsed or written anywhere in the +tree, and `dr-export`'s metadata module says so about its own half ("neither is read by `dr-decode` +today"). Listed here rather than silently, because a tag makes a gap invisible and this one is +load-bearing for interoperating with the editors FR-CAT-14 imports from. + +--- + +## 8. The performance targets are unverified, not unmet + +Eleven of the fifteen §4.1 targets carry no tag: NFR-P2, -P3, -P4, -P6, -P7, -P8, -P10, -P11, -P12, +-P14, -P15. That is the uninteresting part of this section. + +The interesting part is that §8 and §4.1 both require the same thing, in the same words, and it does +not exist: an automated benchmark suite against a synthetic 50k catalog, run per commit, where **"a +regression beyond a stated tolerance is a build failure, not a notification."** There is no +`benches/` directory in the workspace, no criterion dependency, and no synthetic catalog. The three +CI workflows run `cargo fmt --check`, clippy, `cargo test --workspace`, a release build, an Android +cross-build and a layering check. None of them measures anything, so there is no baseline to +regress against and no tolerance to exceed. + +What does exist is narrower and genuinely good: `dr-gpu/examples/frame_budget` is a real instrument, +its results are committed in [frame-budget.md](frame-budget.md) with the machine and profile named, +and TD-4's before-and-after was measured with it. But it is run by hand — frame-budget.md's own +instruction is "rerun and diff this file" — and the guard version that does live in CI skips itself +where there is no GPU adapter, which the workflow notes is the normal case on a runner, while +asserting its CPU half only when `debug_assertions` is off, which a dev-profile `cargo test` is not. +In CI it therefore asserts approximately nothing. + +**The claim to take from this is precise.** Nothing here says the performance targets are missed. +Several are plausibly met. It says that if one were broken tomorrow, nobody would find out — which +is the failure mode §8 was written to prevent, and the reason it belongs in this document rather +than in a backlog. + +--- + +## 9. Two core requirements that cannot be closed as written + +**R1 — Cross-platform output within a bounded tolerance.** §2 states that the threshold "must be +fixed before spike S9", because S9 both validates R1 and calibrates what tolerance is achievable. +The threshold was never fixed and S9 has not run, so R1 currently has no acceptance criterion at +all — there is nothing a test could assert. + +Worse, the matrix reports R1 as *covered*. Both of its tags are string literals inside the +traceability tool's own unit tests (`tools/traceability/src/lib.rs`), which the tool scans along with +everything else, because a fixture demonstrating tag extraction is indistinguishable from a tag. +NFR-OPS-1 is covered the same way, from a tag on `compute_coverage` — and no rotating, size-capped +on-disk log exists; logging goes to stderr and logcat. These are two of the cases +[CONTRIBUTING.md](../CONTRIBUTING.md) already warns about, now named. + +**R2 — Efficient display of huge RAW libraries.** Its acceptance criterion contains "*(figure +TBD)*" — the scroll velocity below which no cell may render as a placeholder — and asks for a stated +prefetch margin and cache-hit rate. No figure is stated anywhere in the tree, neither quantity is +measured, and [TD-2](technical-debt.md) and [TD-3](technical-debt.md) both describe the thumbnail +path falling short of it in ways that were measured. R2 was deliberately left untagged on this branch +for that reason: the machinery is substantial and the criterion is unmet and partly undefined. + +Both belong with §8 above. A requirement whose threshold was never chosen and a target nothing +measures fail in the same way — not by being wrong, but by being unfalsifiable. + +--- + +## 10. Spikes + +§9 defines fourteen validation spikes and says of three of them: "S1, S2 and S10 are the three that +can invalidate the architecture." + +Only **S1** (Slint + wgpu zero-copy on Linux) and **S14** (the face pipeline on a real library) have +recorded results. S14's are the best evidence of any spike — a dedicated document, a measured pass +over an 18,143-face library, a named device and a reproducible command — though D13's licensing half +remains open. + +**S6, S9, S10, S11 and S13 show no evidence of having run at all.** Each is referenced only from the +requirement text that asks for it: + +| Spike | Would settle | Blocked on | +|---|---|---| +| S6 | FR-DSP-2, NFR-RES-2 — tiling and images larger than GPU memory | Nothing; needs a device and a large image | +| S9 | R1's tolerance threshold, and therefore R1 | Nothing; the threshold is defined *by* running it | +| S10 | Whether SAF at 10k files meets NFR-P1/P3 | §5 — there is no SAF code to measure | +| S11 | NFR-COMPAT-2, and whether Play makes SAF binding | Nothing | +| S13 | NFR-A11Y-2 on Android | §6 — there is almost nothing to test with | + +S2, S3, S4, S5, S7, S8 and S12 are also unrun, several with acknowledgements in the code that say +so (`dr-sync/src/upload.rs` on S8, `dr-sync-nextcloud/src/lib.rs` on S3). S2 is one of the three +architecture-invalidating spikes and needs Adreno and Mali hardware, which the manifest notes no +emulator represents. + +The pattern is worth stating rather than leaving to be inferred: the spikes that ran are the ones +whose subject was being built anyway. The ones that did not are the ones that would have said +whether something *should* be built — which is the opposite of the order §9 asks for. + +--- + +## 11. D12, which governs all of the above + +[Decision D12 — scope versus pace](requirements.md) is still **OPEN**, and says: + +> The calibration selected an ambitious feature set — full tablet editing, full ingest, culling as a +> differentiator, complete GPU masking, AI denoise, Fuji-first colour, deep sync, sidecar durability +> — against a stated pace of evenings and weekends, indefinitely. +> +> **Those are not compatible as stated.** + +Sections 1 through 10 are what that incompatibility looks like eleven versions later, and they land +almost exactly where D12 predicted: tablet editing carries SAF at unproven scale, background +execution limits and two GPU vendors to validate (§5), and every one of those is unbuilt or unrun. +The parts that *were* built — the develop pipeline, sync, faces, the catalog — are the parts that +did not need a decision first. + +D12 is not resolved by choosing to work faster. It is resolved by moving requirements across the +line into §7, which costs nothing but the admission, and which this document is intended to make +easy: every cluster above is a candidate, and each says what it would take to build and what it +would cost to drop. Resolving D12 sets D3 and [architecture.md §10](architecture.md)'s Phase 2. diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 057157c..fb972b7 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -55,6 +55,27 @@ readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in not the bill. **It has not been measured on the device**, which is the first thing to do if the develop view feels heavy on the tablet; do not assume this is the cause without a number. +### And a second transfer, while focus peaking is on + +Added 2026-08-29 with FR-CULL-3. The focus-peaking overlay is a compute pass writing its own +`Rgba8Unorm` texture, which on desktop reaches the compositor with no copy — but on Android there is +no more a path for *that* texture than for the frame it belongs to, and an overlay that stayed on +the device while the picture underneath it did not would simply never be seen. So +`FocusPeakPass::read_overlay` follows the frame back through memory, and the Android frame path +carries **two** full-resolution `copy_texture_to_buffer` transfers instead of one. + +This is recorded under TD-1 rather than as its own entry because it is not an independent choice. +It exists only because TD-1 exists, it is bounded by the same thing — `render` fits the pass to the +canvas, so both transfers are at viewport resolution — and TD-1's "Done when" already covers it: +whichever of the three fixes above lands removes the readback for the frame and the overlay +together, because both are the same missing capability. + +Two things worth saying plainly. The doubling is **reasoned, not measured on the device** — the same +gap TD-1 admits about its own cost, and the reason neither number should be quoted as a measurement. +And it is paid only while the photographer has the overlay switched on: `DevelopSession::focus_overlay` +returns on its first line when peaking is off, so with it off there is no dispatch and no transfer, +and the Android frame path is exactly what it was before this feature existed. + ### Paying it off Any one of these removes it: diff --git a/docs/traceability.md b/docs/traceability.md index da4402a..bdb31eb 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,19 +9,19 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 290 | -| TRACES tags found | 899 | +| Source files scanned | 299 | +| TRACES tags found | 977 | | Requirements defined | 179 | -| Requirements covered | 107 | -| **Coverage** | **59.8%** (107/179) | +| Requirements covered | 120 | +| **Coverage** | **67.0%** (120/179) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 85 | 124 | -| NFR | 20 | 49 | -| R | 2 | 6 | +| FR | 92 | 124 | +| NFR | 24 | 49 | +| R | 4 | 6 | ## Orphan tags @@ -34,133 +34,140 @@ _None._ | ID | Tagged in | |---|---| | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`core/dr-types/src/lib.rs:270`](../core/dr-types/src/lib.rs#L270), [`core/dr-types/src/lib.rs:303`](../core/dr-types/src/lib.rs#L303), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | -| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1179`](../ui/dr-ui/src/lib.rs#L1179), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1024`](../ui/dr-ui/ui/library.slint#L1024), [`ui/dr-ui/ui/library.slint:1186`](../ui/dr-ui/ui/library.slint#L1186), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873) | -| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1179`](../ui/dr-ui/src/lib.rs#L1179), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:2375`](../ui/dr-ui/src/library.rs#L2375), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | -| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) | +| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1051`](../ui/dr-ui/ui/library.slint#L1051), [`ui/dr-ui/ui/library.slint:1218`](../ui/dr-ui/ui/library.slint#L1218), [`ui/dr-ui/ui/library.slint:886`](../ui/dr-ui/ui/library.slint#L886) | +| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:2491`](../ui/dr-ui/src/library.rs#L2491), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119) | | FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:641`](../core/dr-catalog/src/schema.rs#L641), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:456`](../core/dr-sync-nextcloud/src/lib.rs#L456), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:429`](../core/dr-sync/src/scan.rs#L429), [`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:1307`](../ui/dr-ui/src/collections_ui.rs#L1307), [`ui/dr-ui/src/collections_ui.rs:2221`](../ui/dr-ui/src/collections_ui.rs#L2221), [`ui/dr-ui/src/library.rs:162`](../ui/dr-ui/src/library.rs#L162), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:208`](../ui/dr-ui/src/library.rs#L208), [`ui/dr-ui/src/library.rs:3874`](../ui/dr-ui/src/library.rs#L3874), [`ui/dr-ui/src/library.rs:3908`](../ui/dr-ui/src/library.rs#L3908), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:756`](../ui/dr-ui/src/library_ui.rs#L756), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:720`](../core/dr-catalog/src/schema.rs#L720), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:457`](../core/dr-sync-nextcloud/src/lib.rs#L457), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:563`](../core/dr-sync/src/scan.rs#L563), [`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:1307`](../ui/dr-ui/src/collections_ui.rs#L1307), [`ui/dr-ui/src/collections_ui.rs:2221`](../ui/dr-ui/src/collections_ui.rs#L2221), [`ui/dr-ui/src/library.rs:175`](../ui/dr-ui/src/library.rs#L175), [`ui/dr-ui/src/library.rs:192`](../ui/dr-ui/src/library.rs#L192), [`ui/dr-ui/src/library.rs:241`](../ui/dr-ui/src/library.rs#L241), [`ui/dr-ui/src/library.rs:3992`](../ui/dr-ui/src/library.rs#L3992), [`ui/dr-ui/src/library.rs:4026`](../ui/dr-ui/src/library.rs#L4026), [`ui/dr-ui/src/library_ui.rs:190`](../ui/dr-ui/src/library_ui.rs#L190), [`ui/dr-ui/src/library_ui.rs:778`](../ui/dr-ui/src/library_ui.rs#L778), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:54`](../core/dr-types/src/lib.rs#L54), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | -| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4599`](../ui/dr-ui/src/library_ui.rs#L4599), [`ui/dr-ui/ui/app.slint:356`](../ui/dr-ui/ui/app.slint#L356), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | -| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:189`](../ui/dr-ui/src/library.rs#L189), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1059`](../core/dr-catalog/src/schema.rs#L1059), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1650`](../ui/dr-ui/src/collections_ui.rs#L1650), [`ui/dr-ui/src/collections_ui.rs:1756`](../ui/dr-ui/src/collections_ui.rs#L1756), [`ui/dr-ui/src/collections_ui.rs:1805`](../ui/dr-ui/src/collections_ui.rs#L1805), [`ui/dr-ui/src/collections_ui.rs:254`](../ui/dr-ui/src/collections_ui.rs#L254), [`ui/dr-ui/src/collections_ui.rs:383`](../ui/dr-ui/src/collections_ui.rs#L383), [`ui/dr-ui/src/collections_ui.rs:90`](../ui/dr-ui/src/collections_ui.rs#L90), [`ui/dr-ui/src/library.rs:3922`](../ui/dr-ui/src/library.rs#L3922), [`ui/dr-ui/src/library_ui.rs:631`](../ui/dr-ui/src/library_ui.rs#L631), [`ui/dr-ui/src/library_ui.rs:6498`](../ui/dr-ui/src/library_ui.rs#L6498), [`ui/dr-ui/src/library_ui.rs:6509`](../ui/dr-ui/src/library_ui.rs#L6509), [`ui/dr-ui/src/library_ui.rs:6522`](../ui/dr-ui/src/library_ui.rs#L6522), [`ui/dr-ui/src/library_ui.rs:6537`](../ui/dr-ui/src/library_ui.rs#L6537), [`ui/dr-ui/src/library_ui.rs:6546`](../ui/dr-ui/src/library_ui.rs#L6546), [`ui/dr-ui/ui/app.slint:292`](../ui/dr-ui/ui/app.slint#L292), [`ui/dr-ui/ui/app.slint:491`](../ui/dr-ui/ui/app.slint#L491), [`ui/dr-ui/ui/library.slint:1243`](../ui/dr-ui/ui/library.slint#L1243), [`ui/dr-ui/ui/library.slint:1267`](../ui/dr-ui/ui/library.slint#L1267), [`ui/dr-ui/ui/library.slint:1309`](../ui/dr-ui/ui/library.slint#L1309), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:866`](../ui/dr-ui/ui/library.slint#L866), [`ui/dr-ui/ui/library.slint:918`](../ui/dr-ui/ui/library.slint#L918) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:4313`](../ui/dr-ui/src/library.rs#L4313), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:393`](../ui/dr-ui/src/library_ui.rs#L393), [`ui/dr-ui/src/library_ui.rs:5238`](../ui/dr-ui/src/library_ui.rs#L5238), [`ui/dr-ui/src/library_ui.rs:5291`](../ui/dr-ui/src/library_ui.rs#L5291), [`ui/dr-ui/src/library_ui.rs:6638`](../ui/dr-ui/src/library_ui.rs#L6638), [`ui/dr-ui/ui/app.slint:304`](../ui/dr-ui/ui/app.slint#L304), [`ui/dr-ui/ui/app.slint:491`](../ui/dr-ui/ui/app.slint#L491), [`ui/dr-ui/ui/app.slint:740`](../ui/dr-ui/ui/app.slint#L740), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1402`](../ui/dr-ui/ui/library.slint#L1402), [`ui/dr-ui/ui/library.slint:918`](../ui/dr-ui/ui/library.slint#L918), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) | -| 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:1669`](../ui/dr-ui/src/collections_ui.rs#L1669), [`ui/dr-ui/src/collections_ui.rs:1919`](../ui/dr-ui/src/collections_ui.rs#L1919), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:987`](../ui/dr-ui/src/collections_ui.rs#L987), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:4118`](../ui/dr-ui/src/library.rs#L4118), [`ui/dr-ui/src/library.rs:4144`](../ui/dr-ui/src/library.rs#L4144), [`ui/dr-ui/src/library.rs:4199`](../ui/dr-ui/src/library.rs#L4199), [`ui/dr-ui/src/library_ui.rs:3489`](../ui/dr-ui/src/library_ui.rs#L3489), [`ui/dr-ui/src/library_ui.rs:4182`](../ui/dr-ui/src/library_ui.rs#L4182), [`ui/dr-ui/ui/app.slint:295`](../ui/dr-ui/ui/app.slint#L295), [`ui/dr-ui/ui/app.slint:486`](../ui/dr-ui/ui/app.slint#L486), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2835`](../ui/dr-ui/ui/library.slint#L2835), [`ui/dr-ui/ui/library.slint:889`](../ui/dr-ui/ui/library.slint#L889), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908) | -| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3570`](../ui/dr-ui/src/develop.rs#L3570), [`ui/dr-ui/src/develop.rs:3599`](../ui/dr-ui/src/develop.rs#L3599), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1432`](../ui/dr-ui/src/lib.rs#L1432), [`ui/dr-ui/src/lib.rs:1833`](../ui/dr-ui/src/lib.rs#L1833), [`ui/dr-ui/src/lib.rs:1968`](../ui/dr-ui/src/lib.rs#L1968), [`ui/dr-ui/src/lib.rs:553`](../ui/dr-ui/src/lib.rs#L553), [`ui/dr-ui/src/lib.rs:978`](../ui/dr-ui/src/lib.rs#L978), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library_ui.rs:4891`](../ui/dr-ui/src/library_ui.rs#L4891), [`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:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`ui/dr-ui/src/develop.rs:2997`](../ui/dr-ui/src/develop.rs#L2997), [`ui/dr-ui/src/library.rs:148`](../ui/dr-ui/src/library.rs#L148), [`ui/dr-ui/src/library.rs:1747`](../ui/dr-ui/src/library.rs#L1747), [`ui/dr-ui/src/library.rs:1823`](../ui/dr-ui/src/library.rs#L1823), [`ui/dr-ui/src/library.rs:233`](../ui/dr-ui/src/library.rs#L233), [`ui/dr-ui/src/library.rs:4420`](../ui/dr-ui/src/library.rs#L4420), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:774`](../ui/dr-ui/src/library.rs#L774), [`ui/dr-ui/src/library.rs:790`](../ui/dr-ui/src/library.rs#L790), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1580`](../ui/dr-ui/src/library_ui.rs#L1580), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1622`](../ui/dr-ui/src/library_ui.rs#L1622), [`ui/dr-ui/src/library_ui.rs:1716`](../ui/dr-ui/src/library_ui.rs#L1716), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:2332`](../ui/dr-ui/src/library_ui.rs#L2332), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:2770`](../ui/dr-ui/src/library_ui.rs#L2770), [`ui/dr-ui/src/library_ui.rs:2992`](../ui/dr-ui/src/library_ui.rs#L2992), [`ui/dr-ui/src/library_ui.rs:3270`](../ui/dr-ui/src/library_ui.rs#L3270), [`ui/dr-ui/src/library_ui.rs:3358`](../ui/dr-ui/src/library_ui.rs#L3358), [`ui/dr-ui/src/library_ui.rs:3540`](../ui/dr-ui/src/library_ui.rs#L3540), [`ui/dr-ui/src/library_ui.rs:3654`](../ui/dr-ui/src/library_ui.rs#L3654), [`ui/dr-ui/src/library_ui.rs:446`](../ui/dr-ui/src/library_ui.rs#L446), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/library_ui.rs:5336`](../ui/dr-ui/src/library_ui.rs#L5336), [`ui/dr-ui/src/library_ui.rs:5451`](../ui/dr-ui/src/library_ui.rs#L5451), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:172`](../ui/dr-ui/src/library_ui.rs#L172), [`ui/dr-ui/src/library_ui.rs:3709`](../ui/dr-ui/src/library_ui.rs#L3709), [`ui/dr-ui/src/library_ui.rs:4715`](../ui/dr-ui/src/library_ui.rs#L4715), [`ui/dr-ui/ui/app.slint:374`](../ui/dr-ui/ui/app.slint#L374), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:330`](../core/dr-catalog/src/schema.rs#L330), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:222`](../ui/dr-ui/src/library.rs#L222), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1138`](../core/dr-catalog/src/schema.rs#L1138), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/collections_ui.rs:1650`](../ui/dr-ui/src/collections_ui.rs#L1650), [`ui/dr-ui/src/collections_ui.rs:1756`](../ui/dr-ui/src/collections_ui.rs#L1756), [`ui/dr-ui/src/collections_ui.rs:1805`](../ui/dr-ui/src/collections_ui.rs#L1805), [`ui/dr-ui/src/collections_ui.rs:254`](../ui/dr-ui/src/collections_ui.rs#L254), [`ui/dr-ui/src/collections_ui.rs:383`](../ui/dr-ui/src/collections_ui.rs#L383), [`ui/dr-ui/src/collections_ui.rs:90`](../ui/dr-ui/src/collections_ui.rs#L90), [`ui/dr-ui/src/library.rs:4040`](../ui/dr-ui/src/library.rs#L4040), [`ui/dr-ui/src/library_ui.rs:653`](../ui/dr-ui/src/library_ui.rs#L653), [`ui/dr-ui/src/library_ui.rs:6642`](../ui/dr-ui/src/library_ui.rs#L6642), [`ui/dr-ui/src/library_ui.rs:6653`](../ui/dr-ui/src/library_ui.rs#L6653), [`ui/dr-ui/src/library_ui.rs:6666`](../ui/dr-ui/src/library_ui.rs#L6666), [`ui/dr-ui/src/library_ui.rs:6681`](../ui/dr-ui/src/library_ui.rs#L6681), [`ui/dr-ui/src/library_ui.rs:6690`](../ui/dr-ui/src/library_ui.rs#L6690), [`ui/dr-ui/ui/app.slint:310`](../ui/dr-ui/ui/app.slint#L310), [`ui/dr-ui/ui/app.slint:509`](../ui/dr-ui/ui/app.slint#L509), [`ui/dr-ui/ui/library.slint:1275`](../ui/dr-ui/ui/library.slint#L1275), [`ui/dr-ui/ui/library.slint:1299`](../ui/dr-ui/ui/library.slint#L1299), [`ui/dr-ui/ui/library.slint:1341`](../ui/dr-ui/ui/library.slint#L1341), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:879`](../ui/dr-ui/ui/library.slint#L879), [`ui/dr-ui/ui/library.slint:931`](../ui/dr-ui/ui/library.slint#L931) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:246`](../ui/dr-ui/src/library.rs#L246), [`ui/dr-ui/src/library.rs:4437`](../ui/dr-ui/src/library.rs#L4437), [`ui/dr-ui/src/library_ui.rs:337`](../ui/dr-ui/src/library_ui.rs#L337), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:5382`](../ui/dr-ui/src/library_ui.rs#L5382), [`ui/dr-ui/src/library_ui.rs:5435`](../ui/dr-ui/src/library_ui.rs#L5435), [`ui/dr-ui/src/library_ui.rs:6782`](../ui/dr-ui/src/library_ui.rs#L6782), [`ui/dr-ui/ui/app.slint:322`](../ui/dr-ui/ui/app.slint#L322), [`ui/dr-ui/ui/app.slint:509`](../ui/dr-ui/ui/app.slint#L509), [`ui/dr-ui/ui/app.slint:789`](../ui/dr-ui/ui/app.slint#L789), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1434`](../ui/dr-ui/ui/library.slint#L1434), [`ui/dr-ui/ui/library.slint:931`](../ui/dr-ui/ui/library.slint#L931), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) | +| 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:1669`](../ui/dr-ui/src/collections_ui.rs#L1669), [`ui/dr-ui/src/collections_ui.rs:1919`](../ui/dr-ui/src/collections_ui.rs#L1919), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/collections_ui.rs:987`](../ui/dr-ui/src/collections_ui.rs#L987), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:4242`](../ui/dr-ui/src/library.rs#L4242), [`ui/dr-ui/src/library.rs:4268`](../ui/dr-ui/src/library.rs#L4268), [`ui/dr-ui/src/library.rs:4323`](../ui/dr-ui/src/library.rs#L4323), [`ui/dr-ui/src/library_ui.rs:3571`](../ui/dr-ui/src/library_ui.rs#L3571), [`ui/dr-ui/src/library_ui.rs:4298`](../ui/dr-ui/src/library_ui.rs#L4298), [`ui/dr-ui/ui/app.slint:313`](../ui/dr-ui/ui/app.slint#L313), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2871`](../ui/dr-ui/ui/library.slint#L2871), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/library.slint:921`](../ui/dr-ui/ui/library.slint#L921) | +| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3720`](../ui/dr-ui/src/develop.rs#L3720), [`ui/dr-ui/src/develop.rs:3749`](../ui/dr-ui/src/develop.rs#L3749), [`ui/dr-ui/src/export.rs:750`](../ui/dr-ui/src/export.rs#L750), [`ui/dr-ui/src/lib.rs:1484`](../ui/dr-ui/src/lib.rs#L1484), [`ui/dr-ui/src/lib.rs:1941`](../ui/dr-ui/src/lib.rs#L1941), [`ui/dr-ui/src/lib.rs:2076`](../ui/dr-ui/src/lib.rs#L2076), [`ui/dr-ui/src/lib.rs:563`](../ui/dr-ui/src/lib.rs#L563), [`ui/dr-ui/src/lib.rs:988`](../ui/dr-ui/src/lib.rs#L988), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library_ui.rs:5007`](../ui/dr-ui/src/library_ui.rs#L5007), [`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:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:728`](../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:175`](../core/dr-sync/src/scan.rs#L175), [`core/dr-sync/src/scan.rs:464`](../core/dr-sync/src/scan.rs#L464), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`ui/dr-ui/src/develop.rs:3147`](../ui/dr-ui/src/develop.rs#L3147), [`ui/dr-ui/src/library.rs:1222`](../ui/dr-ui/src/library.rs#L1222), [`ui/dr-ui/src/library.rs:1275`](../ui/dr-ui/src/library.rs#L1275), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:161`](../ui/dr-ui/src/library.rs#L161), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library.rs:1939`](../ui/dr-ui/src/library.rs#L1939), [`ui/dr-ui/src/library.rs:266`](../ui/dr-ui/src/library.rs#L266), [`ui/dr-ui/src/library.rs:4545`](../ui/dr-ui/src/library.rs#L4545), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:807`](../ui/dr-ui/src/library.rs#L807), [`ui/dr-ui/src/library.rs:823`](../ui/dr-ui/src/library.rs#L823), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1045`](../ui/dr-ui/src/library_ui.rs#L1045), [`ui/dr-ui/src/library_ui.rs:1636`](../ui/dr-ui/src/library_ui.rs#L1636), [`ui/dr-ui/src/library_ui.rs:1680`](../ui/dr-ui/src/library_ui.rs#L1680), [`ui/dr-ui/src/library_ui.rs:1696`](../ui/dr-ui/src/library_ui.rs#L1696), [`ui/dr-ui/src/library_ui.rs:1790`](../ui/dr-ui/src/library_ui.rs#L1790), [`ui/dr-ui/src/library_ui.rs:232`](../ui/dr-ui/src/library_ui.rs#L232), [`ui/dr-ui/src/library_ui.rs:2414`](../ui/dr-ui/src/library_ui.rs#L2414), [`ui/dr-ui/src/library_ui.rs:265`](../ui/dr-ui/src/library_ui.rs#L265), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:2852`](../ui/dr-ui/src/library_ui.rs#L2852), [`ui/dr-ui/src/library_ui.rs:3074`](../ui/dr-ui/src/library_ui.rs#L3074), [`ui/dr-ui/src/library_ui.rs:3352`](../ui/dr-ui/src/library_ui.rs#L3352), [`ui/dr-ui/src/library_ui.rs:3440`](../ui/dr-ui/src/library_ui.rs#L3440), [`ui/dr-ui/src/library_ui.rs:3622`](../ui/dr-ui/src/library_ui.rs#L3622), [`ui/dr-ui/src/library_ui.rs:3736`](../ui/dr-ui/src/library_ui.rs#L3736), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:526`](../ui/dr-ui/src/library_ui.rs#L526), [`ui/dr-ui/src/library_ui.rs:5480`](../ui/dr-ui/src/library_ui.rs#L5480), [`ui/dr-ui/src/library_ui.rs:5595`](../ui/dr-ui/src/library_ui.rs#L5595), [`ui/dr-ui/src/presets.rs:337`](../ui/dr-ui/src/presets.rs#L337), [`ui/dr-ui/src/presets.rs:349`](../ui/dr-ui/src/presets.rs#L349), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | -| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1920`](../ui/dr-ui/src/develop.rs#L1920), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:687`](../ui/dr-ui/src/develop.rs#L687), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1940`](../ui/dr-ui/src/lib.rs#L1940), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | -| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:284`](../ui/dr-ui/src/library.rs#L284), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | -| FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:357`](../core/dr-catalog/src/schema.rs#L357), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:369`](../core/dr-catalog/src/schema.rs#L369), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/develop.rs:120`](../ui/dr-ui/src/develop.rs#L120), [`ui/dr-ui/src/develop.rs:129`](../ui/dr-ui/src/develop.rs#L129), [`ui/dr-ui/src/develop.rs:1944`](../ui/dr-ui/src/develop.rs#L1944), [`ui/dr-ui/src/develop.rs:195`](../ui/dr-ui/src/develop.rs#L195), [`ui/dr-ui/src/develop.rs:688`](../ui/dr-ui/src/develop.rs#L688), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2048`](../ui/dr-ui/src/lib.rs#L2048), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:287`](../ui/dr-ui/src/library.rs#L287), [`ui/dr-ui/src/library.rs:317`](../ui/dr-ui/src/library.rs#L317), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:369`](../core/dr-catalog/src/schema.rs#L369), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463) | -| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:213`](../ui/dr-ui/src/library.rs#L213), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459) | -| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:401`](../core/dr-catalog/src/schema.rs#L401), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2849`](../ui/dr-ui/src/library.rs#L2849), [`ui/dr-ui/src/library.rs:2953`](../ui/dr-ui/src/library.rs#L2953), [`ui/dr-ui/ui/settings.slint:416`](../ui/dr-ui/ui/settings.slint#L416), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | -| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:260`](../ui/dr-ui/ui/identity.slint#L260) | +| FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:2930`](../ui/dr-ui/src/develop.rs#L2930), [`ui/dr-ui/src/develop.rs:2941`](../ui/dr-ui/src/develop.rs#L2941), [`ui/dr-ui/src/develop.rs:2947`](../ui/dr-ui/src/develop.rs#L2947), [`ui/dr-ui/src/develop.rs:2964`](../ui/dr-ui/src/develop.rs#L2964), [`ui/dr-ui/src/develop.rs:726`](../ui/dr-ui/src/develop.rs#L726), [`ui/dr-ui/src/develop.rs:732`](../ui/dr-ui/src/develop.rs#L732), [`ui/dr-ui/src/lib.rs:1523`](../ui/dr-ui/src/lib.rs#L1523), [`ui/dr-ui/src/lib.rs:1601`](../ui/dr-ui/src/lib.rs#L1601), [`ui/dr-ui/src/lib.rs:1674`](../ui/dr-ui/src/lib.rs#L1674), [`ui/dr-ui/src/lib.rs:1708`](../ui/dr-ui/src/lib.rs#L1708), [`ui/dr-ui/src/lib.rs:2951`](../ui/dr-ui/src/lib.rs#L2951), [`ui/dr-ui/src/lib.rs:323`](../ui/dr-ui/src/lib.rs#L323), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1716`](../ui/dr-ui/ui/app.slint#L1716), [`ui/dr-ui/ui/app.slint:2261`](../ui/dr-ui/ui/app.slint#L2261), [`ui/dr-ui/ui/app.slint:75`](../ui/dr-ui/ui/app.slint#L75), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../ui/dr-ui/ui/peaking.slint#L56) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:136`](../core/dr-pipeline/src/sidecar.rs#L136), [`ui/dr-ui/src/library.rs:246`](../ui/dr-ui/src/library.rs#L246), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492) | +| FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:412`](../core/dr-catalog/src/schema.rs#L412), [`ui/dr-ui/src/bursts.rs:1`](../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library.rs:202`](../ui/dr-ui/src/library.rs#L202), [`ui/dr-ui/src/library.rs:5146`](../ui/dr-ui/src/library.rs#L5146) | +| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:480`](../core/dr-catalog/src/schema.rs#L480), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2965`](../ui/dr-ui/src/library.rs#L2965), [`ui/dr-ui/src/library.rs:3069`](../ui/dr-ui/src/library.rs#L3069), [`ui/dr-ui/ui/settings.slint:416`](../ui/dr-ui/ui/settings.slint#L416), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) | +| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:260`](../ui/dr-ui/ui/identity.slint#L260) | +| FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../core/dr-pipeline/src/sidecar.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1424`](../ui/dr-ui/src/develop.rs#L1424), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1902`](../ui/dr-ui/src/develop.rs#L1902), [`ui/dr-ui/src/develop.rs:1920`](../ui/dr-ui/src/develop.rs#L1920), [`ui/dr-ui/src/develop.rs:1934`](../ui/dr-ui/src/develop.rs#L1934), [`ui/dr-ui/src/develop.rs:1956`](../ui/dr-ui/src/develop.rs#L1956), [`ui/dr-ui/src/develop.rs:2102`](../ui/dr-ui/src/develop.rs#L2102), [`ui/dr-ui/src/develop.rs:2200`](../ui/dr-ui/src/develop.rs#L2200), [`ui/dr-ui/src/develop.rs:3268`](../ui/dr-ui/src/develop.rs#L3268), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3298`](../ui/dr-ui/src/develop.rs#L3298), [`ui/dr-ui/src/develop.rs:3364`](../ui/dr-ui/src/develop.rs#L3364), [`ui/dr-ui/src/develop.rs:3378`](../ui/dr-ui/src/develop.rs#L3378), [`ui/dr-ui/src/develop.rs:3570`](../ui/dr-ui/src/develop.rs#L3570), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:4230`](../ui/dr-ui/src/develop.rs#L4230), [`ui/dr-ui/src/develop.rs:4284`](../ui/dr-ui/src/develop.rs#L4284), [`ui/dr-ui/src/develop.rs:4328`](../ui/dr-ui/src/develop.rs#L4328), [`ui/dr-ui/src/develop.rs:4378`](../ui/dr-ui/src/develop.rs#L4378), [`ui/dr-ui/src/develop.rs:586`](../ui/dr-ui/src/develop.rs#L586), [`ui/dr-ui/src/develop.rs:647`](../ui/dr-ui/src/develop.rs#L647), [`ui/dr-ui/src/develop.rs:726`](../ui/dr-ui/src/develop.rs#L726), [`ui/dr-ui/src/develop.rs:773`](../ui/dr-ui/src/develop.rs#L773), [`ui/dr-ui/src/develop.rs:802`](../ui/dr-ui/src/develop.rs#L802), [`ui/dr-ui/src/lib.rs:1517`](../ui/dr-ui/src/lib.rs#L1517), [`ui/dr-ui/src/lib.rs:2218`](../ui/dr-ui/src/lib.rs#L2218), [`ui/dr-ui/src/lib.rs:2414`](../ui/dr-ui/src/lib.rs#L2414), [`ui/dr-ui/src/lib.rs:2586`](../ui/dr-ui/src/lib.rs#L2586), [`ui/dr-ui/src/lib.rs:2650`](../ui/dr-ui/src/lib.rs#L2650), [`ui/dr-ui/src/lib.rs:2704`](../ui/dr-ui/src/lib.rs#L2704), [`ui/dr-ui/src/lib.rs:319`](../ui/dr-ui/src/lib.rs#L319), [`ui/dr-ui/src/lib.rs:362`](../ui/dr-ui/src/lib.rs#L362), [`ui/dr-ui/src/lib.rs:385`](../ui/dr-ui/src/lib.rs#L385), [`ui/dr-ui/src/library.rs:506`](../ui/dr-ui/src/library.rs#L506), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/adjust.slint:517`](../ui/dr-ui/ui/adjust.slint#L517), [`ui/dr-ui/ui/adjust.slint:635`](../ui/dr-ui/ui/adjust.slint#L635), [`ui/dr-ui/ui/app.slint:118`](../ui/dr-ui/ui/app.slint#L118), [`ui/dr-ui/ui/app.slint:164`](../ui/dr-ui/ui/app.slint#L164), [`ui/dr-ui/ui/app.slint:1847`](../ui/dr-ui/ui/app.slint#L1847), [`ui/dr-ui/ui/app.slint:850`](../ui/dr-ui/ui/app.slint#L850), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1321`](../ui/dr-ui/src/develop.rs#L1321), [`ui/dr-ui/src/lib.rs:668`](../ui/dr-ui/src/lib.rs#L668), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | -| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4957`](../ui/dr-ui/src/develop.rs#L4957) | -| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/framing.rs:177`](../core/dr-pipeline/src/framing.rs#L177), [`core/dr-pipeline/src/framing.rs:234`](../core/dr-pipeline/src/framing.rs#L234), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-pipeline/src/framing.rs:743`](../core/dr-pipeline/src/framing.rs#L743), [`core/dr-pipeline/src/graph.rs:170`](../core/dr-pipeline/src/graph.rs#L170), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:1637`](../core/dr-pipeline/src/sidecar.rs#L1637), [`core/dr-pipeline/src/sidecar.rs:1697`](../core/dr-pipeline/src/sidecar.rs#L1697), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:102`](../ui/dr-ui/src/develop.rs#L102), [`ui/dr-ui/src/develop.rs:1448`](../ui/dr-ui/src/develop.rs#L1448), [`ui/dr-ui/src/develop.rs:164`](../ui/dr-ui/src/develop.rs#L164), [`ui/dr-ui/src/develop.rs:1926`](../ui/dr-ui/src/develop.rs#L1926), [`ui/dr-ui/src/develop.rs:1944`](../ui/dr-ui/src/develop.rs#L1944), [`ui/dr-ui/src/develop.rs:1958`](../ui/dr-ui/src/develop.rs#L1958), [`ui/dr-ui/src/develop.rs:1980`](../ui/dr-ui/src/develop.rs#L1980), [`ui/dr-ui/src/develop.rs:2126`](../ui/dr-ui/src/develop.rs#L2126), [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/develop.rs:327`](../ui/dr-ui/src/develop.rs#L327), [`ui/dr-ui/src/develop.rs:3418`](../ui/dr-ui/src/develop.rs#L3418), [`ui/dr-ui/src/develop.rs:3448`](../ui/dr-ui/src/develop.rs#L3448), [`ui/dr-ui/src/develop.rs:3514`](../ui/dr-ui/src/develop.rs#L3514), [`ui/dr-ui/src/develop.rs:3528`](../ui/dr-ui/src/develop.rs#L3528), [`ui/dr-ui/src/develop.rs:364`](../ui/dr-ui/src/develop.rs#L364), [`ui/dr-ui/src/develop.rs:3720`](../ui/dr-ui/src/develop.rs#L3720), [`ui/dr-ui/src/develop.rs:4380`](../ui/dr-ui/src/develop.rs#L4380), [`ui/dr-ui/src/develop.rs:4434`](../ui/dr-ui/src/develop.rs#L4434), [`ui/dr-ui/src/develop.rs:4478`](../ui/dr-ui/src/develop.rs#L4478), [`ui/dr-ui/src/develop.rs:4528`](../ui/dr-ui/src/develop.rs#L4528), [`ui/dr-ui/src/develop.rs:587`](../ui/dr-ui/src/develop.rs#L587), [`ui/dr-ui/src/develop.rs:648`](../ui/dr-ui/src/develop.rs#L648), [`ui/dr-ui/src/develop.rs:746`](../ui/dr-ui/src/develop.rs#L746), [`ui/dr-ui/src/develop.rs:793`](../ui/dr-ui/src/develop.rs#L793), [`ui/dr-ui/src/develop.rs:822`](../ui/dr-ui/src/develop.rs#L822), [`ui/dr-ui/src/lib.rs:1582`](../ui/dr-ui/src/lib.rs#L1582), [`ui/dr-ui/src/lib.rs:2344`](../ui/dr-ui/src/lib.rs#L2344), [`ui/dr-ui/src/lib.rs:2540`](../ui/dr-ui/src/lib.rs#L2540), [`ui/dr-ui/src/lib.rs:2712`](../ui/dr-ui/src/lib.rs#L2712), [`ui/dr-ui/src/lib.rs:2776`](../ui/dr-ui/src/lib.rs#L2776), [`ui/dr-ui/src/lib.rs:2830`](../ui/dr-ui/src/lib.rs#L2830), [`ui/dr-ui/src/lib.rs:329`](../ui/dr-ui/src/lib.rs#L329), [`ui/dr-ui/src/lib.rs:372`](../ui/dr-ui/src/lib.rs#L372), [`ui/dr-ui/src/lib.rs:395`](../ui/dr-ui/src/lib.rs#L395), [`ui/dr-ui/src/library.rs:539`](../ui/dr-ui/src/library.rs#L539), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/adjust.slint:517`](../ui/dr-ui/ui/adjust.slint#L517), [`ui/dr-ui/ui/adjust.slint:635`](../ui/dr-ui/ui/adjust.slint#L635), [`ui/dr-ui/ui/app.slint:136`](../ui/dr-ui/ui/app.slint#L136), [`ui/dr-ui/ui/app.slint:182`](../ui/dr-ui/ui/app.slint#L182), [`ui/dr-ui/ui/app.slint:1918`](../ui/dr-ui/ui/app.slint#L1918), [`ui/dr-ui/ui/app.slint:899`](../ui/dr-ui/ui/app.slint#L899), [`ui/dr-ui/ui/masks.slint:524`](../ui/dr-ui/ui/masks.slint#L524) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:24`](../core/dr-pipeline/src/graph.rs#L24), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1345`](../ui/dr-ui/src/develop.rs#L1345), [`ui/dr-ui/src/lib.rs:678`](../ui/dr-ui/src/lib.rs#L678), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385), [`core/dr-pipeline/src/graph.rs:59`](../core/dr-pipeline/src/graph.rs#L59), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:251`](../core/dr-pipeline/src/graph.rs#L251), [`core/dr-pipeline/src/graph.rs:46`](../core/dr-pipeline/src/graph.rs#L46), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:5107`](../ui/dr-ui/src/develop.rs#L5107) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:1079`](../core/dr-gpu/src/adjust.rs#L1079), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:558`](../core/dr-gpu/tests/local_contrast.rs#L558), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:314`](../core/dr-pipeline/src/framing.rs#L314), [`core/dr-pipeline/src/graph.rs:617`](../core/dr-pipeline/src/graph.rs#L617), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | -| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3012`](../ui/dr-ui/src/develop.rs#L3012), [`ui/dr-ui/src/develop.rs:3029`](../ui/dr-ui/src/develop.rs#L3029), [`ui/dr-ui/src/develop.rs:3041`](../ui/dr-ui/src/develop.rs#L3041), [`ui/dr-ui/src/develop.rs:3079`](../ui/dr-ui/src/develop.rs#L3079), [`ui/dr-ui/src/develop.rs:3088`](../ui/dr-ui/src/develop.rs#L3088), [`ui/dr-ui/src/develop.rs:3191`](../ui/dr-ui/src/develop.rs#L3191), [`ui/dr-ui/src/develop.rs:3607`](../ui/dr-ui/src/develop.rs#L3607), [`ui/dr-ui/src/develop.rs:3622`](../ui/dr-ui/src/develop.rs#L3622), [`ui/dr-ui/src/lib.rs:2192`](../ui/dr-ui/src/lib.rs#L2192), [`ui/dr-ui/src/lib.rs:601`](../ui/dr-ui/src/lib.rs#L601), [`ui/dr-ui/src/lib.rs:659`](../ui/dr-ui/src/lib.rs#L659), [`ui/dr-ui/src/library.rs:497`](../ui/dr-ui/src/library.rs#L497), [`ui/dr-ui/src/library.rs:745`](../ui/dr-ui/src/library.rs#L745), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:1020`](../ui/dr-ui/ui/adjust.slint#L1020), [`ui/dr-ui/ui/adjust.slint:1102`](../ui/dr-ui/ui/adjust.slint#L1102), [`ui/dr-ui/ui/app.slint:2353`](../ui/dr-ui/ui/app.slint#L2353), [`ui/dr-ui/ui/app.slint:621`](../ui/dr-ui/ui/app.slint#L621) | -| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1050`](../core/dr-pipeline/src/framing.rs#L1050), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-types/src/lib.rs:337`](../core/dr-types/src/lib.rs#L337), [`core/dr-types/src/lib.rs:445`](../core/dr-types/src/lib.rs#L445), [`core/dr-types/src/lib.rs:457`](../core/dr-types/src/lib.rs#L457), [`core/dr-types/src/lib.rs:473`](../core/dr-types/src/lib.rs#L473), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:2119`](../ui/dr-ui/src/develop.rs#L2119), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | -| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:335`](../core/dr-gpu/src/lib.rs#L335), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | -| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3041`](../ui/dr-ui/src/develop.rs#L3041), [`ui/dr-ui/src/develop.rs:3622`](../ui/dr-ui/src/develop.rs#L3622), [`ui/dr-ui/src/develop.rs:3652`](../ui/dr-ui/src/develop.rs#L3652), [`ui/dr-ui/src/develop.rs:3665`](../ui/dr-ui/src/develop.rs#L3665), [`ui/dr-ui/src/develop.rs:3677`](../ui/dr-ui/src/develop.rs#L3677), [`ui/dr-ui/src/develop.rs:3693`](../ui/dr-ui/src/develop.rs#L3693), [`ui/dr-ui/src/develop.rs:3725`](../ui/dr-ui/src/develop.rs#L3725), [`ui/dr-ui/src/develop.rs:3729`](../ui/dr-ui/src/develop.rs#L3729), [`ui/dr-ui/src/develop.rs:3748`](../ui/dr-ui/src/develop.rs#L3748), [`ui/dr-ui/src/develop.rs:3764`](../ui/dr-ui/src/develop.rs#L3764), [`ui/dr-ui/src/develop.rs:708`](../ui/dr-ui/src/develop.rs#L708), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1465`](../ui/dr-ui/src/lib.rs#L1465), [`ui/dr-ui/src/lib.rs:1495`](../ui/dr-ui/src/lib.rs#L1495), [`ui/dr-ui/src/lib.rs:1503`](../ui/dr-ui/src/lib.rs#L1503), [`ui/dr-ui/src/lib.rs:2388`](../ui/dr-ui/src/lib.rs#L2388), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:272`](../core/dr-types/src/settings.rs#L272), [`ui/dr-ui/src/develop.rs:3564`](../ui/dr-ui/src/develop.rs#L3564), [`ui/dr-ui/src/develop.rs:3585`](../ui/dr-ui/src/develop.rs#L3585), [`ui/dr-ui/src/lib.rs:1432`](../ui/dr-ui/src/lib.rs#L1432), [`ui/dr-ui/src/library.rs:1787`](../ui/dr-ui/src/library.rs#L1787), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:487`](../ui/dr-ui/src/library.rs#L487), [`ui/dr-ui/src/library_ui.rs:2592`](../ui/dr-ui/src/library_ui.rs#L2592), [`ui/dr-ui/src/library_ui.rs:2992`](../ui/dr-ui/src/library_ui.rs#L2992), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:636`](../ui/dr-ui/src/settings_ui.rs#L636), [`ui/dr-ui/ui/adjust.slint:709`](../ui/dr-ui/ui/adjust.slint#L709), [`ui/dr-ui/ui/library.slint:1429`](../ui/dr-ui/ui/library.slint#L1429), [`ui/dr-ui/ui/library.slint:847`](../ui/dr-ui/ui/library.slint#L847), [`ui/dr-ui/ui/library.slint:929`](../ui/dr-ui/ui/library.slint#L929), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | -| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3693`](../ui/dr-ui/src/develop.rs#L3693), [`ui/dr-ui/src/develop.rs:3725`](../ui/dr-ui/src/develop.rs#L3725), [`ui/dr-ui/src/lib.rs:1503`](../ui/dr-ui/src/lib.rs#L1503), [`ui/dr-ui/src/lib.rs:2388`](../ui/dr-ui/src/lib.rs#L2388), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | -| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:387`](../core/dr-gpu/src/detail.rs#L387), [`core/dr-gpu/src/detail.rs:592`](../core/dr-gpu/src/detail.rs#L592), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2271`](../ui/dr-ui/src/develop.rs#L2271), [`ui/dr-ui/src/develop.rs:2309`](../ui/dr-ui/src/develop.rs#L2309), [`ui/dr-ui/src/develop.rs:2374`](../ui/dr-ui/src/develop.rs#L2374), [`ui/dr-ui/src/develop.rs:2457`](../ui/dr-ui/src/develop.rs#L2457), [`ui/dr-ui/src/develop.rs:2471`](../ui/dr-ui/src/develop.rs#L2471), [`ui/dr-ui/src/develop.rs:756`](../ui/dr-ui/src/develop.rs#L756), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1524`](../ui/dr-ui/src/lib.rs#L1524), [`ui/dr-ui/src/lib.rs:2506`](../ui/dr-ui/src/lib.rs#L2506), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:796`](../ui/dr-ui/ui/adjust.slint#L796), [`ui/dr-ui/ui/app.slint:108`](../ui/dr-ui/ui/app.slint#L108), [`ui/dr-ui/ui/app.slint:1750`](../ui/dr-ui/ui/app.slint#L1750), [`ui/dr-ui/ui/app.slint:1925`](../ui/dr-ui/ui/app.slint#L1925), [`ui/dr-ui/ui/app.slint:2259`](../ui/dr-ui/ui/app.slint#L2259), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:156`](../core/dr-gpu/src/lib.rs#L156), [`core/dr-gpu/src/lib.rs:176`](../core/dr-gpu/src/lib.rs#L176), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:77`](../core/dr-gpu/src/lib.rs#L77), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2795`](../ui/dr-ui/src/develop.rs#L2795), [`ui/dr-ui/src/develop.rs:4019`](../ui/dr-ui/src/develop.rs#L4019), [`ui/dr-ui/src/develop.rs:4502`](../ui/dr-ui/src/develop.rs#L4502), [`ui/dr-ui/src/develop.rs:4536`](../ui/dr-ui/src/develop.rs#L4536), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:809`](../ui/dr-ui/src/lib.rs#L809), [`ui/dr-ui/src/lib.rs:868`](../ui/dr-ui/src/lib.rs#L868) | +| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:102`](../core/dr-pipeline/src/graph.rs#L102), [`core/dr-pipeline/src/graph.rs:125`](../core/dr-pipeline/src/graph.rs#L125), [`core/dr-pipeline/src/graph.rs:325`](../core/dr-pipeline/src/graph.rs#L325), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:112`](../core/dr-pipeline/src/sidecar.rs#L112), [`core/dr-pipeline/src/sidecar.rs:168`](../core/dr-pipeline/src/sidecar.rs#L168), [`core/dr-pipeline/src/sidecar.rs:1958`](../core/dr-pipeline/src/sidecar.rs#L1958), [`core/dr-pipeline/src/sidecar.rs:2034`](../core/dr-pipeline/src/sidecar.rs#L2034), [`core/dr-pipeline/src/sidecar.rs:534`](../core/dr-pipeline/src/sidecar.rs#L534), [`core/dr-pipeline/src/sidecar.rs:661`](../core/dr-pipeline/src/sidecar.rs#L661), [`core/dr-pipeline/src/sidecar.rs:793`](../core/dr-pipeline/src/sidecar.rs#L793), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:3162`](../ui/dr-ui/src/develop.rs#L3162), [`ui/dr-ui/src/develop.rs:3179`](../ui/dr-ui/src/develop.rs#L3179), [`ui/dr-ui/src/develop.rs:3191`](../ui/dr-ui/src/develop.rs#L3191), [`ui/dr-ui/src/develop.rs:3229`](../ui/dr-ui/src/develop.rs#L3229), [`ui/dr-ui/src/develop.rs:3238`](../ui/dr-ui/src/develop.rs#L3238), [`ui/dr-ui/src/develop.rs:3341`](../ui/dr-ui/src/develop.rs#L3341), [`ui/dr-ui/src/develop.rs:3757`](../ui/dr-ui/src/develop.rs#L3757), [`ui/dr-ui/src/develop.rs:3772`](../ui/dr-ui/src/develop.rs#L3772), [`ui/dr-ui/src/lib.rs:2318`](../ui/dr-ui/src/lib.rs#L2318), [`ui/dr-ui/src/lib.rs:611`](../ui/dr-ui/src/lib.rs#L611), [`ui/dr-ui/src/lib.rs:669`](../ui/dr-ui/src/lib.rs#L669), [`ui/dr-ui/src/library.rs:530`](../ui/dr-ui/src/library.rs#L530), [`ui/dr-ui/src/library.rs:778`](../ui/dr-ui/src/library.rs#L778), [`ui/dr-ui/src/presets.rs:286`](../ui/dr-ui/src/presets.rs#L286), [`ui/dr-ui/ui/adjust.slint:1034`](../ui/dr-ui/ui/adjust.slint#L1034), [`ui/dr-ui/ui/adjust.slint:1116`](../ui/dr-ui/ui/adjust.slint#L1116), [`ui/dr-ui/ui/app.slint:2453`](../ui/dr-ui/ui/app.slint#L2453), [`ui/dr-ui/ui/app.slint:641`](../ui/dr-ui/ui/app.slint#L641) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:1050`](../core/dr-pipeline/src/framing.rs#L1050), [`core/dr-pipeline/src/framing.rs:328`](../core/dr-pipeline/src/framing.rs#L328), [`core/dr-pipeline/src/framing.rs:488`](../core/dr-pipeline/src/framing.rs#L488), [`core/dr-types/src/lib.rs:337`](../core/dr-types/src/lib.rs#L337), [`core/dr-types/src/lib.rs:445`](../core/dr-types/src/lib.rs#L445), [`core/dr-types/src/lib.rs:457`](../core/dr-types/src/lib.rs#L457), [`core/dr-types/src/lib.rs:473`](../core/dr-types/src/lib.rs#L473), [`ui/dr-ui/src/develop.rs:139`](../ui/dr-ui/src/develop.rs#L139), [`ui/dr-ui/src/develop.rs:2143`](../ui/dr-ui/src/develop.rs#L2143), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) | +| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:338`](../core/dr-gpu/src/lib.rs#L338), [`ui/dr-ui/ui/crop.slint:1`](../ui/dr-ui/ui/crop.slint#L1) | +| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:346`](../core/dr-pipeline/src/graph.rs#L346), [`core/dr-pipeline/src/graph.rs:385`](../core/dr-pipeline/src/graph.rs#L385), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:3191`](../ui/dr-ui/src/develop.rs#L3191), [`ui/dr-ui/src/develop.rs:3772`](../ui/dr-ui/src/develop.rs#L3772), [`ui/dr-ui/src/develop.rs:3802`](../ui/dr-ui/src/develop.rs#L3802), [`ui/dr-ui/src/develop.rs:3815`](../ui/dr-ui/src/develop.rs#L3815), [`ui/dr-ui/src/develop.rs:3827`](../ui/dr-ui/src/develop.rs#L3827), [`ui/dr-ui/src/develop.rs:3843`](../ui/dr-ui/src/develop.rs#L3843), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/develop.rs:3879`](../ui/dr-ui/src/develop.rs#L3879), [`ui/dr-ui/src/develop.rs:3898`](../ui/dr-ui/src/develop.rs#L3898), [`ui/dr-ui/src/develop.rs:3914`](../ui/dr-ui/src/develop.rs#L3914), [`ui/dr-ui/src/develop.rs:709`](../ui/dr-ui/src/develop.rs#L709), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1517`](../ui/dr-ui/src/lib.rs#L1517), [`ui/dr-ui/src/lib.rs:1560`](../ui/dr-ui/src/lib.rs#L1560), [`ui/dr-ui/src/lib.rs:1568`](../ui/dr-ui/src/lib.rs#L1568), [`ui/dr-ui/src/lib.rs:2514`](../ui/dr-ui/src/lib.rs#L2514), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:249`](../core/dr-pipeline/src/preset.rs#L249), [`core/dr-pipeline/src/preset.rs:282`](../core/dr-pipeline/src/preset.rs#L282), [`core/dr-types/src/settings.rs:272`](../core/dr-types/src/settings.rs#L272), [`ui/dr-ui/src/develop.rs:3714`](../ui/dr-ui/src/develop.rs#L3714), [`ui/dr-ui/src/develop.rs:3735`](../ui/dr-ui/src/develop.rs#L3735), [`ui/dr-ui/src/lib.rs:1484`](../ui/dr-ui/src/lib.rs#L1484), [`ui/dr-ui/src/lib.rs:2136`](../ui/dr-ui/src/lib.rs#L2136), [`ui/dr-ui/src/library.rs:1903`](../ui/dr-ui/src/library.rs#L1903), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library.rs:520`](../ui/dr-ui/src/library.rs#L520), [`ui/dr-ui/src/library_ui.rs:2674`](../ui/dr-ui/src/library_ui.rs#L2674), [`ui/dr-ui/src/library_ui.rs:3074`](../ui/dr-ui/src/library_ui.rs#L3074), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:546`](../ui/dr-ui/src/presets.rs#L546), [`ui/dr-ui/src/settings_ui.rs:636`](../ui/dr-ui/src/settings_ui.rs#L636), [`ui/dr-ui/ui/adjust.slint:709`](../ui/dr-ui/ui/adjust.slint#L709), [`ui/dr-ui/ui/adjust.slint:736`](../ui/dr-ui/ui/adjust.slint#L736), [`ui/dr-ui/ui/adjust.slint:779`](../ui/dr-ui/ui/adjust.slint#L779), [`ui/dr-ui/ui/app.slint:1496`](../ui/dr-ui/ui/app.slint#L1496), [`ui/dr-ui/ui/app.slint:2542`](../ui/dr-ui/ui/app.slint#L2542), [`ui/dr-ui/ui/library.slint:1461`](../ui/dr-ui/ui/library.slint#L1461), [`ui/dr-ui/ui/library.slint:1468`](../ui/dr-ui/ui/library.slint#L1468), [`ui/dr-ui/ui/library.slint:857`](../ui/dr-ui/ui/library.slint#L857), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:942`](../ui/dr-ui/ui/library.slint#L942), [`ui/dr-ui/ui/library.slint:957`](../ui/dr-ui/ui/library.slint#L957), [`ui/dr-ui/ui/presets.slint:4`](../ui/dr-ui/ui/presets.slint#L4), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) | +| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3843`](../ui/dr-ui/src/develop.rs#L3843), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/lib.rs:1568`](../ui/dr-ui/src/lib.rs#L1568), [`ui/dr-ui/src/lib.rs:2514`](../ui/dr-ui/src/lib.rs#L2514), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) | +| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:114`](../core/dr-pipeline/src/graph.rs#L114), [`core/dr-pipeline/src/graph.rs:192`](../core/dr-pipeline/src/graph.rs#L192), [`core/dr-pipeline/src/graph.rs:686`](../core/dr-pipeline/src/graph.rs#L686), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:673`](../core/dr-pipeline/src/sidecar.rs#L673), [`core/dr-pipeline/src/sidecar.rs:809`](../core/dr-pipeline/src/sidecar.rs#L809), [`core/dr-pipeline/src/sidecar.rs:863`](../core/dr-pipeline/src/sidecar.rs#L863), [`core/dr-pipeline/src/sidecar.rs:893`](../core/dr-pipeline/src/sidecar.rs#L893), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2295`](../ui/dr-ui/src/develop.rs#L2295), [`ui/dr-ui/src/develop.rs:2333`](../ui/dr-ui/src/develop.rs#L2333), [`ui/dr-ui/src/develop.rs:2398`](../ui/dr-ui/src/develop.rs#L2398), [`ui/dr-ui/src/develop.rs:2481`](../ui/dr-ui/src/develop.rs#L2481), [`ui/dr-ui/src/develop.rs:2495`](../ui/dr-ui/src/develop.rs#L2495), [`ui/dr-ui/src/develop.rs:776`](../ui/dr-ui/src/develop.rs#L776), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1589`](../ui/dr-ui/src/lib.rs#L1589), [`ui/dr-ui/src/lib.rs:2632`](../ui/dr-ui/src/lib.rs#L2632), [`ui/dr-ui/src/lib.rs:334`](../ui/dr-ui/src/lib.rs#L334), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:810`](../ui/dr-ui/ui/adjust.slint#L810), [`ui/dr-ui/ui/app.slint:126`](../ui/dr-ui/ui/app.slint#L126), [`ui/dr-ui/ui/app.slint:1821`](../ui/dr-ui/ui/app.slint#L1821), [`ui/dr-ui/ui/app.slint:1996`](../ui/dr-ui/ui/app.slint#L1996), [`ui/dr-ui/ui/app.slint:2359`](../ui/dr-ui/ui/app.slint#L2359), [`ui/dr-ui/ui/spots.slint:180`](../ui/dr-ui/ui/spots.slint#L180), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2126`](../core/dr-gpu/src/adjust.rs#L2126), [`core/dr-gpu/src/adjust.rs:2203`](../core/dr-gpu/src/adjust.rs#L2203), [`core/dr-gpu/src/adjust.rs:2288`](../core/dr-gpu/src/adjust.rs#L2288), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:159`](../core/dr-gpu/src/lib.rs#L159), [`core/dr-gpu/src/lib.rs:179`](../core/dr-gpu/src/lib.rs#L179), [`core/dr-gpu/src/lib.rs:57`](../core/dr-gpu/src/lib.rs#L57), [`core/dr-gpu/src/lib.rs:80`](../core/dr-gpu/src/lib.rs#L80), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:548`](../core/dr-pipeline/src/graph.rs#L548), [`core/dr-pipeline/src/graph.rs:578`](../core/dr-pipeline/src/graph.rs#L578), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2819`](../ui/dr-ui/src/develop.rs#L2819), [`ui/dr-ui/src/develop.rs:4169`](../ui/dr-ui/src/develop.rs#L4169), [`ui/dr-ui/src/develop.rs:4652`](../ui/dr-ui/src/develop.rs#L4652), [`ui/dr-ui/src/develop.rs:4686`](../ui/dr-ui/src/develop.rs#L4686), [`ui/dr-ui/src/lib.rs:76`](../ui/dr-ui/src/lib.rs#L76), [`ui/dr-ui/src/lib.rs:819`](../ui/dr-ui/src/lib.rs#L819), [`ui/dr-ui/src/lib.rs:878`](../ui/dr-ui/src/lib.rs#L878) | | FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/local_contrast.rs:329`](../core/dr-gpu/tests/local_contrast.rs#L329), [`core/dr-pipeline/src/ops/local_contrast.rs:447`](../core/dr-pipeline/src/ops/local_contrast.rs#L447) | | FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2825`](../ui/dr-ui/src/develop.rs#L2825), [`ui/dr-ui/src/develop.rs:5794`](../ui/dr-ui/src/develop.rs#L5794), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/lib.rs:2825`](../ui/dr-ui/src/lib.rs#L2825) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2874`](../ui/dr-ui/src/develop.rs#L2874), [`ui/dr-ui/src/develop.rs:5604`](../ui/dr-ui/src/develop.rs#L5604), [`ui/dr-ui/src/develop.rs:5636`](../ui/dr-ui/src/develop.rs#L5636), [`ui/dr-ui/src/develop.rs:719`](../ui/dr-ui/src/develop.rs#L719), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1580`](../ui/dr-ui/src/lib.rs#L1580), [`ui/dr-ui/src/lib.rs:314`](../ui/dr-ui/src/lib.rs#L314), [`ui/dr-ui/ui/app.slint:68`](../ui/dr-ui/ui/app.slint#L68), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | -| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2771`](../ui/dr-ui/src/develop.rs#L2771), [`ui/dr-ui/src/develop.rs:2825`](../ui/dr-ui/src/develop.rs#L2825), [`ui/dr-ui/src/develop.rs:5794`](../ui/dr-ui/src/develop.rs#L5794), [`ui/dr-ui/src/develop.rs:5840`](../ui/dr-ui/src/develop.rs#L5840), [`ui/dr-ui/src/develop.rs:5859`](../ui/dr-ui/src/develop.rs#L5859), [`ui/dr-ui/src/develop.rs:787`](../ui/dr-ui/src/develop.rs#L787), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1454`](../ui/dr-ui/src/lib.rs#L1454), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/lib.rs:2802`](../ui/dr-ui/src/lib.rs#L2802), [`ui/dr-ui/src/lib.rs:2825`](../ui/dr-ui/src/lib.rs#L2825), [`ui/dr-ui/ui/app.slint:1610`](../ui/dr-ui/ui/app.slint#L1610), [`ui/dr-ui/ui/app.slint:47`](../ui/dr-ui/ui/app.slint#L47), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:772`](../ui/dr-ui/ui/settings.slint#L772) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2849`](../ui/dr-ui/src/develop.rs#L2849), [`ui/dr-ui/src/develop.rs:5944`](../ui/dr-ui/src/develop.rs#L5944), [`ui/dr-ui/src/lib.rs:1545`](../ui/dr-ui/src/lib.rs#L1545), [`ui/dr-ui/src/lib.rs:3015`](../ui/dr-ui/src/lib.rs#L3015) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2898`](../ui/dr-ui/src/develop.rs#L2898), [`ui/dr-ui/src/develop.rs:5754`](../ui/dr-ui/src/develop.rs#L5754), [`ui/dr-ui/src/develop.rs:5786`](../ui/dr-ui/src/develop.rs#L5786), [`ui/dr-ui/src/develop.rs:720`](../ui/dr-ui/src/develop.rs#L720), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1655`](../ui/dr-ui/src/lib.rs#L1655), [`ui/dr-ui/src/lib.rs:318`](../ui/dr-ui/src/lib.rs#L318), [`ui/dr-ui/ui/app.slint:70`](../ui/dr-ui/ui/app.slint#L70), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2795`](../ui/dr-ui/src/develop.rs#L2795), [`ui/dr-ui/src/develop.rs:2849`](../ui/dr-ui/src/develop.rs#L2849), [`ui/dr-ui/src/develop.rs:5944`](../ui/dr-ui/src/develop.rs#L5944), [`ui/dr-ui/src/develop.rs:5990`](../ui/dr-ui/src/develop.rs#L5990), [`ui/dr-ui/src/develop.rs:6009`](../ui/dr-ui/src/develop.rs#L6009), [`ui/dr-ui/src/develop.rs:807`](../ui/dr-ui/src/develop.rs#L807), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1506`](../ui/dr-ui/src/lib.rs#L1506), [`ui/dr-ui/src/lib.rs:1545`](../ui/dr-ui/src/lib.rs#L1545), [`ui/dr-ui/src/lib.rs:2928`](../ui/dr-ui/src/lib.rs#L2928), [`ui/dr-ui/src/lib.rs:3015`](../ui/dr-ui/src/lib.rs#L3015), [`ui/dr-ui/ui/app.slint:1669`](../ui/dr-ui/ui/app.slint#L1669), [`ui/dr-ui/ui/app.slint:49`](../ui/dr-ui/ui/app.slint#L49), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:772`](../ui/dr-ui/ui/settings.slint#L772) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:690`](../core/dr-types/src/settings.rs#L690), [`ui/dr-ui/src/develop.rs:5859`](../ui/dr-ui/src/develop.rs#L5859), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2436`](../core/dr-gpu/src/adjust.rs#L2436), [`core/dr-pipeline/src/graph.rs:538`](../core/dr-pipeline/src/graph.rs#L538), [`core/dr-pipeline/src/graph.rs:592`](../core/dr-pipeline/src/graph.rs#L592), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:690`](../core/dr-types/src/settings.rs#L690), [`ui/dr-ui/src/develop.rs:6009`](../ui/dr-ui/src/develop.rs#L6009), [`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:182`](../core/dr-export/src/lib.rs#L182), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:136`](../core/dr-export/src/size.rs#L136), [`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-export/src/size.rs:84`](../core/dr-export/src/size.rs#L84), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:735`](../core/dr-types/src/settings.rs#L735), [`core/dr-types/src/settings.rs:743`](../core/dr-types/src/settings.rs#L743), [`core/dr-types/src/settings.rs:759`](../core/dr-types/src/settings.rs#L759), [`core/dr-types/src/settings.rs:828`](../core/dr-types/src/settings.rs#L828), [`core/dr-types/src/settings.rs:856`](../core/dr-types/src/settings.rs#L856), [`core/dr-types/src/settings.rs:867`](../core/dr-types/src/settings.rs#L867), [`core/dr-types/src/settings.rs:873`](../core/dr-types/src/settings.rs#L873), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:205`](../ui/dr-ui/src/settings_ui.rs#L205), [`ui/dr-ui/src/settings_ui.rs:218`](../ui/dr-ui/src/settings_ui.rs#L218), [`ui/dr-ui/src/settings_ui.rs:233`](../ui/dr-ui/src/settings_ui.rs#L233), [`ui/dr-ui/src/settings_ui.rs:563`](../ui/dr-ui/src/settings_ui.rs#L563), [`ui/dr-ui/ui/settings.slint:675`](../ui/dr-ui/ui/settings.slint#L675), [`ui/dr-ui/ui/settings.slint:685`](../ui/dr-ui/ui/settings.slint#L685) | | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:414`](../ui/dr-ui/src/lib.rs#L414), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:691`](../ui/dr-ui/src/settings_ui.rs#L691) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204), [`ui/dr-ui/src/lib.rs:2134`](../ui/dr-ui/src/lib.rs#L2134), [`ui/dr-ui/src/lib.rs:414`](../ui/dr-ui/src/lib.rs#L414), [`ui/dr-ui/src/lib.rs:449`](../ui/dr-ui/src/lib.rs#L449), [`ui/dr-ui/src/lib.rs:476`](../ui/dr-ui/src/lib.rs#L476), [`ui/dr-ui/src/library_ui.rs:3375`](../ui/dr-ui/src/library_ui.rs#L3375), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`ui/dr-ui/src/library_ui.rs:6480`](../ui/dr-ui/src/library_ui.rs#L6480), [`ui/dr-ui/src/library_ui.rs:6557`](../ui/dr-ui/src/library_ui.rs#L6557), [`ui/dr-ui/src/library_ui.rs:6569`](../ui/dr-ui/src/library_ui.rs#L6569), [`ui/dr-ui/src/library_ui.rs:663`](../ui/dr-ui/src/library_ui.rs#L663), [`ui/dr-ui/src/library_ui.rs:720`](../ui/dr-ui/src/library_ui.rs#L720), [`ui/dr-ui/ui/app.slint:1448`](../ui/dr-ui/ui/app.slint#L1448), [`ui/dr-ui/ui/app.slint:986`](../ui/dr-ui/ui/app.slint#L986), [`ui/dr-ui/ui/library.slint:1437`](../ui/dr-ui/ui/library.slint#L1437), [`ui/dr-ui/ui/library.slint:851`](../ui/dr-ui/ui/library.slint#L851), [`ui/dr-ui/ui/library.slint:944`](../ui/dr-ui/ui/library.slint#L944) | +| 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:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:691`](../ui/dr-ui/src/settings_ui.rs#L691) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/lib.rs:208`](../ui/dr-ui/src/lib.rs#L208), [`ui/dr-ui/src/lib.rs:2260`](../ui/dr-ui/src/lib.rs#L2260), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424), [`ui/dr-ui/src/lib.rs:459`](../ui/dr-ui/src/lib.rs#L459), [`ui/dr-ui/src/lib.rs:486`](../ui/dr-ui/src/lib.rs#L486), [`ui/dr-ui/src/library_ui.rs:3457`](../ui/dr-ui/src/library_ui.rs#L3457), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`ui/dr-ui/src/library_ui.rs:6624`](../ui/dr-ui/src/library_ui.rs#L6624), [`ui/dr-ui/src/library_ui.rs:6701`](../ui/dr-ui/src/library_ui.rs#L6701), [`ui/dr-ui/src/library_ui.rs:6713`](../ui/dr-ui/src/library_ui.rs#L6713), [`ui/dr-ui/src/library_ui.rs:685`](../ui/dr-ui/src/library_ui.rs#L685), [`ui/dr-ui/src/library_ui.rs:742`](../ui/dr-ui/src/library_ui.rs#L742), [`ui/dr-ui/ui/app.slint:1035`](../ui/dr-ui/ui/app.slint#L1035), [`ui/dr-ui/ui/app.slint:1506`](../ui/dr-ui/ui/app.slint#L1506), [`ui/dr-ui/ui/library.slint:1471`](../ui/dr-ui/ui/library.slint#L1471), [`ui/dr-ui/ui/library.slint:861`](../ui/dr-ui/ui/library.slint#L861), [`ui/dr-ui/ui/library.slint:971`](../ui/dr-ui/ui/library.slint#L971) | | FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:653`](../core/dr-types/src/lib.rs#L653), [`core/dr-types/src/settings.rs:403`](../core/dr-types/src/settings.rs#L403), [`ui/dr-ui/src/export.rs:619`](../ui/dr-ui/src/export.rs#L619), [`ui/dr-ui/src/export.rs:647`](../ui/dr-ui/src/export.rs#L647), [`ui/dr-ui/src/export.rs:777`](../ui/dr-ui/src/export.rs#L777), [`ui/dr-ui/src/export.rs:794`](../ui/dr-ui/src/export.rs#L794), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2956`](../ui/dr-ui/src/develop.rs#L2956), [`ui/dr-ui/src/lib.rs:414`](../ui/dr-ui/src/lib.rs#L414) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1106`](../core/dr-gpu/src/adjust.rs#L1106), [`ui/dr-ui/src/develop.rs:3106`](../ui/dr-ui/src/develop.rs#L3106), [`ui/dr-ui/src/lib.rs:424`](../ui/dr-ui/src/lib.rs#L424) | | 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/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`ui/dr-ui/src/launch.rs:277`](../ui/dr-ui/src/launch.rs#L277), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:417`](../ui/dr-ui/src/launch_ui.rs#L417) | -| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:476`](../ui/dr-ui/src/lib.rs#L476), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068), [`ui/dr-ui/src/library.rs:1823`](../ui/dr-ui/src/library.rs#L1823), [`ui/dr-ui/src/library.rs:543`](../ui/dr-ui/src/library.rs#L543), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library_ui.rs:1622`](../ui/dr-ui/src/library_ui.rs#L1622), [`ui/dr-ui/src/library_ui.rs:3375`](../ui/dr-ui/src/library_ui.rs#L3375), [`ui/dr-ui/src/library_ui.rs:504`](../ui/dr-ui/src/library_ui.rs#L504), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:39`](../core/dr-sync-nextcloud/src/lib.rs#L39), [`core/dr-sync-nextcloud/src/lib.rs:913`](../core/dr-sync-nextcloud/src/lib.rs#L913), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | +| FR-NC-10 | [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:486`](../ui/dr-ui/src/lib.rs#L486), [`ui/dr-ui/src/library.rs:1101`](../ui/dr-ui/src/library.rs#L1101), [`ui/dr-ui/src/library.rs:1939`](../ui/dr-ui/src/library.rs#L1939), [`ui/dr-ui/src/library.rs:576`](../ui/dr-ui/src/library.rs#L576), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1696`](../ui/dr-ui/src/library_ui.rs#L1696), [`ui/dr-ui/src/library_ui.rs:3457`](../ui/dr-ui/src/library_ui.rs#L3457), [`ui/dr-ui/src/library_ui.rs:526`](../ui/dr-ui/src/library_ui.rs#L526), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:40`](../core/dr-sync-nextcloud/src/lib.rs#L40), [`core/dr-sync-nextcloud/src/lib.rs:914`](../core/dr-sync-nextcloud/src/lib.rs#L914), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:81`](../core/dr-sync/src/account.rs#L81), [`core/dr-sync/src/lib.rs:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:316`](../ui/dr-ui/src/launch_ui.rs#L316), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.rs#L1) | | FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`core/dr-sync/src/account.rs:355`](../core/dr-sync/src/account.rs#L355), [`core/dr-sync/src/account.rs:59`](../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/src/library_ui.rs:4599`](../ui/dr-ui/src/library_ui.rs#L4599), [`ui/dr-ui/ui/app.slint:356`](../ui/dr-ui/ui/app.slint#L356), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3709`](../ui/dr-ui/src/library_ui.rs#L3709), [`ui/dr-ui/src/library_ui.rs:4715`](../ui/dr-ui/src/library_ui.rs#L4715), [`ui/dr-ui/ui/app.slint:374`](../ui/dr-ui/ui/app.slint#L374), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../core/dr-sync-nextcloud/src/propfind.rs#L103), [`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:218`](../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:61`](../ui/dr-ui/src/launch.rs#L61) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:488`](../ui/dr-ui/src/import.rs#L488) | | 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/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1014`](../core/dr-catalog/src/schema.rs#L1014), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3467`](../ui/dr-ui/src/collections_ui.rs#L3467), [`ui/dr-ui/src/collections_ui.rs:664`](../ui/dr-ui/src/collections_ui.rs#L664), [`ui/dr-ui/src/collections_ui.rs:732`](../ui/dr-ui/src/collections_ui.rs#L732), [`ui/dr-ui/src/lib.rs:1863`](../ui/dr-ui/src/lib.rs#L1863), [`ui/dr-ui/src/lib.rs:2888`](../ui/dr-ui/src/lib.rs#L2888), [`ui/dr-ui/src/library.rs:1449`](../ui/dr-ui/src/library.rs#L1449), [`ui/dr-ui/src/library.rs:1472`](../ui/dr-ui/src/library.rs#L1472), [`ui/dr-ui/src/library.rs:1747`](../ui/dr-ui/src/library.rs#L1747), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1429`](../ui/dr-ui/src/library_ui.rs#L1429), [`ui/dr-ui/src/library_ui.rs:1547`](../ui/dr-ui/src/library_ui.rs#L1547), [`ui/dr-ui/src/library_ui.rs:2074`](../ui/dr-ui/src/library_ui.rs#L2074), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:284`](../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:304`](../ui/dr-ui/src/library_ui.rs#L304), [`ui/dr-ui/src/library_ui.rs:313`](../ui/dr-ui/src/library_ui.rs#L313), [`ui/dr-ui/src/library_ui.rs:405`](../ui/dr-ui/src/library_ui.rs#L405), [`ui/dr-ui/src/library_ui.rs:415`](../ui/dr-ui/src/library_ui.rs#L415), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:520`](../ui/dr-ui/src/library_ui.rs#L520), [`ui/dr-ui/src/library_ui.rs:5353`](../ui/dr-ui/src/library_ui.rs#L5353), [`ui/dr-ui/src/library_ui.rs:5371`](../ui/dr-ui/src/library_ui.rs#L5371), [`ui/dr-ui/src/library_ui.rs:5383`](../ui/dr-ui/src/library_ui.rs#L5383), [`ui/dr-ui/src/library_ui.rs:551`](../ui/dr-ui/src/library_ui.rs#L551), [`ui/dr-ui/src/library_ui.rs:563`](../ui/dr-ui/src/library_ui.rs#L563), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2432`](../ui/dr-ui/ui/app.slint#L2432), [`ui/dr-ui/ui/app.slint:480`](../ui/dr-ui/ui/app.slint#L480), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:990`](../ui/dr-ui/ui/library.slint#L990) | -| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) | -| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:803`](../core/dr-sync-folder/src/lib.rs#L803), [`core/dr-sync-folder/src/lib.rs:842`](../core/dr-sync-folder/src/lib.rs#L842), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`core/dr-types/src/lib.rs:202`](../core/dr-types/src/lib.rs#L202), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3467`](../ui/dr-ui/src/collections_ui.rs#L3467), [`ui/dr-ui/src/collections_ui.rs:664`](../ui/dr-ui/src/collections_ui.rs#L664), [`ui/dr-ui/src/collections_ui.rs:732`](../ui/dr-ui/src/collections_ui.rs#L732), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/derived_sync.rs:627`](../ui/dr-ui/src/derived_sync.rs#L627), [`ui/dr-ui/src/library.rs:1569`](../ui/dr-ui/src/library.rs#L1569), [`ui/dr-ui/src/library.rs:1659`](../ui/dr-ui/src/library.rs#L1659), [`ui/dr-ui/src/library.rs:3138`](../ui/dr-ui/src/library.rs#L3138), [`ui/dr-ui/src/library.rs:3476`](../ui/dr-ui/src/library.rs#L3476), [`ui/dr-ui/src/library.rs:948`](../ui/dr-ui/src/library.rs#L948), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1337`](../ui/dr-ui/src/library_ui.rs#L1337), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | -| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:104`](../core/dr-sync-nextcloud/src/lib.rs#L104), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3356`](../ui/dr-ui/src/library.rs#L3356), [`ui/dr-ui/src/library_ui.rs:3627`](../ui/dr-ui/src/library_ui.rs#L3627), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384) | -| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1179`](../ui/dr-ui/src/lib.rs#L1179), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | -| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1179`](../ui/dr-ui/src/lib.rs#L1179) | -| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1833`](../ui/dr-ui/src/lib.rs#L1833), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library_ui.rs:472`](../ui/dr-ui/src/library_ui.rs#L472) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:556`](../core/dr-catalog/src/schema.rs#L556), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/library.rs:844`](../ui/dr-ui/src/library.rs#L844), [`ui/dr-ui/src/library.rs:998`](../ui/dr-ui/src/library.rs#L998) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1093`](../core/dr-catalog/src/schema.rs#L1093), [`core/dr-catalog/src/schema.rs:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3467`](../ui/dr-ui/src/collections_ui.rs#L3467), [`ui/dr-ui/src/collections_ui.rs:664`](../ui/dr-ui/src/collections_ui.rs#L664), [`ui/dr-ui/src/collections_ui.rs:732`](../ui/dr-ui/src/collections_ui.rs#L732), [`ui/dr-ui/src/lib.rs:1971`](../ui/dr-ui/src/lib.rs#L1971), [`ui/dr-ui/src/lib.rs:3078`](../ui/dr-ui/src/lib.rs#L3078), [`ui/dr-ui/src/library.rs:1565`](../ui/dr-ui/src/library.rs#L1565), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:1863`](../ui/dr-ui/src/library.rs#L1863), [`ui/dr-ui/src/library_ui.rs:1121`](../ui/dr-ui/src/library_ui.rs#L1121), [`ui/dr-ui/src/library_ui.rs:1194`](../ui/dr-ui/src/library_ui.rs#L1194), [`ui/dr-ui/src/library_ui.rs:1295`](../ui/dr-ui/src/library_ui.rs#L1295), [`ui/dr-ui/src/library_ui.rs:1350`](../ui/dr-ui/src/library_ui.rs#L1350), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1603`](../ui/dr-ui/src/library_ui.rs#L1603), [`ui/dr-ui/src/library_ui.rs:2148`](../ui/dr-ui/src/library_ui.rs#L2148), [`ui/dr-ui/src/library_ui.rs:289`](../ui/dr-ui/src/library_ui.rs#L289), [`ui/dr-ui/src/library_ui.rs:300`](../ui/dr-ui/src/library_ui.rs#L300), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:320`](../ui/dr-ui/src/library_ui.rs#L320), [`ui/dr-ui/src/library_ui.rs:329`](../ui/dr-ui/src/library_ui.rs#L329), [`ui/dr-ui/src/library_ui.rs:422`](../ui/dr-ui/src/library_ui.rs#L422), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:479`](../ui/dr-ui/src/library_ui.rs#L479), [`ui/dr-ui/src/library_ui.rs:542`](../ui/dr-ui/src/library_ui.rs#L542), [`ui/dr-ui/src/library_ui.rs:5497`](../ui/dr-ui/src/library_ui.rs#L5497), [`ui/dr-ui/src/library_ui.rs:5515`](../ui/dr-ui/src/library_ui.rs#L5515), [`ui/dr-ui/src/library_ui.rs:5527`](../ui/dr-ui/src/library_ui.rs#L5527), [`ui/dr-ui/src/library_ui.rs:573`](../ui/dr-ui/src/library_ui.rs#L573), [`ui/dr-ui/src/library_ui.rs:585`](../ui/dr-ui/src/library_ui.rs#L585), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2532`](../ui/dr-ui/ui/app.slint#L2532), [`ui/dr-ui/ui/app.slint:498`](../ui/dr-ui/ui/app.slint#L498), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:1017`](../ui/dr-ui/ui/library.slint#L1017) | +| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1350`](../ui/dr-ui/src/library_ui.rs#L1350), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:167`](../core/dr-sync-nextcloud/src/desktop_client.rs#L167), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:120`](../core/dr-types/src/lib.rs#L120), [`core/dr-types/src/lib.rs:202`](../core/dr-types/src/lib.rs#L202), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3467`](../ui/dr-ui/src/collections_ui.rs#L3467), [`ui/dr-ui/src/collections_ui.rs:664`](../ui/dr-ui/src/collections_ui.rs#L664), [`ui/dr-ui/src/collections_ui.rs:732`](../ui/dr-ui/src/collections_ui.rs#L732), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/derived_sync.rs:627`](../ui/dr-ui/src/derived_sync.rs#L627), [`ui/dr-ui/src/library.rs:1685`](../ui/dr-ui/src/library.rs#L1685), [`ui/dr-ui/src/library.rs:1775`](../ui/dr-ui/src/library.rs#L1775), [`ui/dr-ui/src/library.rs:3254`](../ui/dr-ui/src/library.rs#L3254), [`ui/dr-ui/src/library.rs:3592`](../ui/dr-ui/src/library.rs#L3592), [`ui/dr-ui/src/library.rs:981`](../ui/dr-ui/src/library.rs#L981), [`ui/dr-ui/src/library_ui.rs:1121`](../ui/dr-ui/src/library_ui.rs#L1121), [`ui/dr-ui/src/library_ui.rs:1194`](../ui/dr-ui/src/library_ui.rs#L1194), [`ui/dr-ui/src/library_ui.rs:1393`](../ui/dr-ui/src/library_ui.rs#L1393), [`ui/dr-ui/src/remote.rs:40`](../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | +| FR-NC-6d | [`core/dr-sync-folder/src/borrow.rs:1`](../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:1`](../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/vfs.rs:1`](../core/dr-sync-folder/src/vfs.rs#L1) | +| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3472`](../ui/dr-ui/src/library.rs#L3472), [`ui/dr-ui/src/library_ui.rs:3709`](../ui/dr-ui/src/library_ui.rs#L3709), [`ui/dr-ui/ui/settings.slint:384`](../ui/dr-ui/ui/settings.slint#L384) | +| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:119`](../core/dr-pipeline/src/sidecar.rs#L119), [`core/dr-pipeline/src/sidecar.rs:93`](../core/dr-pipeline/src/sidecar.rs#L93), [`ui/dr-ui/src/lib.rs:1941`](../ui/dr-ui/src/lib.rs#L1941), [`ui/dr-ui/src/library.rs:492`](../ui/dr-ui/src/library.rs#L492), [`ui/dr-ui/src/library_ui.rs:494`](../ui/dr-ui/src/library_ui.rs#L494) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:157`](../core/dr-pipeline/src/sidecar.rs#L157), [`core/dr-pipeline/src/sidecar.rs:184`](../core/dr-pipeline/src/sidecar.rs#L184), [`core/dr-pipeline/src/sidecar.rs:2034`](../core/dr-pipeline/src/sidecar.rs#L2034), [`core/dr-pipeline/src/sidecar.rs:353`](../core/dr-pipeline/src/sidecar.rs#L353), [`core/dr-pipeline/src/sidecar.rs:451`](../core/dr-pipeline/src/sidecar.rs#L451), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/derived_sync.rs:555`](../ui/dr-ui/src/derived_sync.rs#L555), [`ui/dr-ui/src/library.rs:1031`](../ui/dr-ui/src/library.rs#L1031), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:54`](../core/dr-types/src/lib.rs#L54), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | +| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:175`](../core/dr-sync/src/scan.rs#L175), [`core/dr-sync/src/scan.rs:464`](../core/dr-sync/src/scan.rs#L464), [`core/dr-sync/src/scan.rs:490`](../core/dr-sync/src/scan.rs#L490), [`core/dr-sync/src/scan.rs:519`](../core/dr-sync/src/scan.rs#L519), [`ui/dr-ui/src/library.rs:1222`](../ui/dr-ui/src/library.rs#L1222), [`ui/dr-ui/src/library.rs:1275`](../ui/dr-ui/src/library.rs#L1275), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library_ui.rs:1045`](../ui/dr-ui/src/library_ui.rs#L1045), [`ui/dr-ui/src/library_ui.rs:1644`](../ui/dr-ui/src/library_ui.rs#L1644), [`ui/dr-ui/src/library_ui.rs:273`](../ui/dr-ui/src/library_ui.rs#L273), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:966`](../ui/dr-ui/src/library_ui.rs#L966) | | 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), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:918`](../ui/dr-ui/src/lib.rs#L918), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | +| FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:54`](../apps/darkroom-android/src/lib.rs#L54), [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-gpu/src/detail.rs:161`](../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop.rs:3069`](../ui/dr-ui/src/develop.rs#L3069), [`ui/dr-ui/src/identity_ui.rs:115`](../ui/dr-ui/src/identity_ui.rs#L115), [`ui/dr-ui/src/lib.rs:1029`](../ui/dr-ui/src/lib.rs#L1029), [`ui/dr-ui/src/lib.rs:1458`](../ui/dr-ui/src/lib.rs#L1458), [`ui/dr-ui/src/memory.rs:163`](../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:928`](../ui/dr-ui/src/lib.rs#L928), [`ui/dr-ui/src/preset_store.rs:1`](../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) | | FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) | | FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:130`](../core/dr-types/src/lib.rs#L130), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | -| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:204`](../ui/dr-ui/src/lib.rs#L204) | +| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:208`](../ui/dr-ui/src/lib.rs#L208) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2970`](../ui/dr-ui/src/lib.rs#L2970), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:165`](../ui/dr-ui/ui/identity.slint#L165), [`ui/dr-ui/ui/library.slint:1056`](../ui/dr-ui/ui/library.slint#L1056) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1067`](../ui/dr-ui/src/collections_ui.rs#L1067), [`ui/dr-ui/src/collections_ui.rs:1093`](../ui/dr-ui/src/collections_ui.rs#L1093), [`ui/dr-ui/src/collections_ui.rs:119`](../ui/dr-ui/src/collections_ui.rs#L119), [`ui/dr-ui/src/collections_ui.rs:1580`](../ui/dr-ui/src/collections_ui.rs#L1580), [`ui/dr-ui/src/collections_ui.rs:1594`](../ui/dr-ui/src/collections_ui.rs#L1594), [`ui/dr-ui/src/collections_ui.rs:1640`](../ui/dr-ui/src/collections_ui.rs#L1640), [`ui/dr-ui/src/collections_ui.rs:170`](../ui/dr-ui/src/collections_ui.rs#L170), [`ui/dr-ui/src/collections_ui.rs:1892`](../ui/dr-ui/src/collections_ui.rs#L1892), [`ui/dr-ui/src/collections_ui.rs:528`](../ui/dr-ui/src/collections_ui.rs#L528), [`ui/dr-ui/src/collections_ui.rs:557`](../ui/dr-ui/src/collections_ui.rs#L557), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/lib.rs:87`](../ui/dr-ui/src/lib.rs#L87), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:5238`](../ui/dr-ui/src/library_ui.rs#L5238), [`ui/dr-ui/src/library_ui.rs:5383`](../ui/dr-ui/src/library_ui.rs#L5383), [`ui/dr-ui/src/library_ui.rs:6588`](../ui/dr-ui/src/library_ui.rs#L6588), [`ui/dr-ui/ui/adjust.slint:1058`](../ui/dr-ui/ui/adjust.slint#L1058), [`ui/dr-ui/ui/adjust.slint:544`](../ui/dr-ui/ui/adjust.slint#L544), [`ui/dr-ui/ui/adjust.slint:737`](../ui/dr-ui/ui/adjust.slint#L737), [`ui/dr-ui/ui/app.slint:2031`](../ui/dr-ui/ui/app.slint#L2031), [`ui/dr-ui/ui/app.slint:510`](../ui/dr-ui/ui/app.slint#L510), [`ui/dr-ui/ui/app.slint:517`](../ui/dr-ui/ui/app.slint#L517), [`ui/dr-ui/ui/app.slint:54`](../ui/dr-ui/ui/app.slint#L54), [`ui/dr-ui/ui/app.slint:821`](../ui/dr-ui/ui/app.slint#L821), [`ui/dr-ui/ui/controls.slint:474`](../ui/dr-ui/ui/controls.slint#L474), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1220`](../ui/dr-ui/ui/library.slint#L1220), [`ui/dr-ui/ui/library.slint:1227`](../ui/dr-ui/ui/library.slint#L1227), [`ui/dr-ui/ui/library.slint:1233`](../ui/dr-ui/ui/library.slint#L1233), [`ui/dr-ui/ui/library.slint:1274`](../ui/dr-ui/ui/library.slint#L1274), [`ui/dr-ui/ui/library.slint:2585`](../ui/dr-ui/ui/library.slint#L2585), [`ui/dr-ui/ui/library.slint:839`](../ui/dr-ui/ui/library.slint#L839), [`ui/dr-ui/ui/library.slint:889`](../ui/dr-ui/ui/library.slint#L889), [`ui/dr-ui/ui/masks.slint:304`](../ui/dr-ui/ui/masks.slint#L304), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) | -| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2200`](../ui/dr-ui/src/develop.rs#L2200), [`ui/dr-ui/src/develop.rs:2309`](../ui/dr-ui/src/develop.rs#L2309), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1847`](../ui/dr-ui/ui/app.slint#L1847), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/masks.slint:490`](../ui/dr-ui/ui/masks.slint#L490) | -| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1067`](../ui/dr-ui/src/collections_ui.rs#L1067), [`ui/dr-ui/src/collections_ui.rs:1077`](../ui/dr-ui/src/collections_ui.rs#L1077), [`ui/dr-ui/src/collections_ui.rs:1093`](../ui/dr-ui/src/collections_ui.rs#L1093), [`ui/dr-ui/src/collections_ui.rs:119`](../ui/dr-ui/src/collections_ui.rs#L119), [`ui/dr-ui/src/collections_ui.rs:132`](../ui/dr-ui/src/collections_ui.rs#L132), [`ui/dr-ui/src/collections_ui.rs:1580`](../ui/dr-ui/src/collections_ui.rs#L1580), [`ui/dr-ui/src/collections_ui.rs:1594`](../ui/dr-ui/src/collections_ui.rs#L1594), [`ui/dr-ui/src/collections_ui.rs:160`](../ui/dr-ui/src/collections_ui.rs#L160), [`ui/dr-ui/src/collections_ui.rs:1640`](../ui/dr-ui/src/collections_ui.rs#L1640), [`ui/dr-ui/src/collections_ui.rs:170`](../ui/dr-ui/src/collections_ui.rs#L170), [`ui/dr-ui/src/collections_ui.rs:1756`](../ui/dr-ui/src/collections_ui.rs#L1756), [`ui/dr-ui/src/collections_ui.rs:1892`](../ui/dr-ui/src/collections_ui.rs#L1892), [`ui/dr-ui/src/collections_ui.rs:1919`](../ui/dr-ui/src/collections_ui.rs#L1919), [`ui/dr-ui/src/collections_ui.rs:2804`](../ui/dr-ui/src/collections_ui.rs#L2804), [`ui/dr-ui/src/collections_ui.rs:346`](../ui/dr-ui/src/collections_ui.rs#L346), [`ui/dr-ui/src/collections_ui.rs:528`](../ui/dr-ui/src/collections_ui.rs#L528), [`ui/dr-ui/src/collections_ui.rs:557`](../ui/dr-ui/src/collections_ui.rs#L557), [`ui/dr-ui/src/collections_ui.rs:625`](../ui/dr-ui/src/collections_ui.rs#L625), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/library_ui.rs:4414`](../ui/dr-ui/src/library_ui.rs#L4414), [`ui/dr-ui/src/library_ui.rs:4439`](../ui/dr-ui/src/library_ui.rs#L4439), [`ui/dr-ui/src/library_ui.rs:4542`](../ui/dr-ui/src/library_ui.rs#L4542), [`ui/dr-ui/src/library_ui.rs:4570`](../ui/dr-ui/src/library_ui.rs#L4570), [`ui/dr-ui/src/library_ui.rs:5371`](../ui/dr-ui/src/library_ui.rs#L5371), [`ui/dr-ui/src/library_ui.rs:5383`](../ui/dr-ui/src/library_ui.rs#L5383), [`ui/dr-ui/ui/app.slint:1687`](../ui/dr-ui/ui/app.slint#L1687), [`ui/dr-ui/ui/app.slint:486`](../ui/dr-ui/ui/app.slint#L486), [`ui/dr-ui/ui/app.slint:517`](../ui/dr-ui/ui/app.slint#L517), [`ui/dr-ui/ui/library.slint:1220`](../ui/dr-ui/ui/library.slint#L1220), [`ui/dr-ui/ui/library.slint:1227`](../ui/dr-ui/ui/library.slint#L1227), [`ui/dr-ui/ui/library.slint:1233`](../ui/dr-ui/ui/library.slint#L1233), [`ui/dr-ui/ui/library.slint:1267`](../ui/dr-ui/ui/library.slint#L1267), [`ui/dr-ui/ui/library.slint:1274`](../ui/dr-ui/ui/library.slint#L1274), [`ui/dr-ui/ui/library.slint:2585`](../ui/dr-ui/ui/library.slint#L2585), [`ui/dr-ui/ui/library.slint:839`](../ui/dr-ui/ui/library.slint#L839), [`ui/dr-ui/ui/library.slint:889`](../ui/dr-ui/ui/library.slint#L889), [`ui/dr-ui/ui/library.slint:908`](../ui/dr-ui/ui/library.slint#L908) | -| 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:2471`](../ui/dr-ui/src/lib.rs#L2471), [`ui/dr-ui/src/lib.rs:3009`](../ui/dr-ui/src/lib.rs#L3009), [`ui/dr-ui/src/lib.rs:3194`](../ui/dr-ui/src/lib.rs#L3194), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:89`](../ui/dr-ui/ui/app.slint#L89), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:3160`](../ui/dr-ui/src/lib.rs#L3160), [`ui/dr-ui/src/lib.rs:84`](../ui/dr-ui/src/lib.rs#L84), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/identity.slint:165`](../ui/dr-ui/ui/identity.slint#L165), [`ui/dr-ui/ui/library.slint:1083`](../ui/dr-ui/ui/library.slint#L1083) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1067`](../ui/dr-ui/src/collections_ui.rs#L1067), [`ui/dr-ui/src/collections_ui.rs:1093`](../ui/dr-ui/src/collections_ui.rs#L1093), [`ui/dr-ui/src/collections_ui.rs:119`](../ui/dr-ui/src/collections_ui.rs#L119), [`ui/dr-ui/src/collections_ui.rs:1580`](../ui/dr-ui/src/collections_ui.rs#L1580), [`ui/dr-ui/src/collections_ui.rs:1594`](../ui/dr-ui/src/collections_ui.rs#L1594), [`ui/dr-ui/src/collections_ui.rs:1640`](../ui/dr-ui/src/collections_ui.rs#L1640), [`ui/dr-ui/src/collections_ui.rs:170`](../ui/dr-ui/src/collections_ui.rs#L170), [`ui/dr-ui/src/collections_ui.rs:1892`](../ui/dr-ui/src/collections_ui.rs#L1892), [`ui/dr-ui/src/collections_ui.rs:528`](../ui/dr-ui/src/collections_ui.rs#L528), [`ui/dr-ui/src/collections_ui.rs:557`](../ui/dr-ui/src/collections_ui.rs#L557), [`ui/dr-ui/src/lib.rs:84`](../ui/dr-ui/src/lib.rs#L84), [`ui/dr-ui/src/lib.rs:91`](../ui/dr-ui/src/lib.rs#L91), [`ui/dr-ui/src/library_ui.rs:308`](../ui/dr-ui/src/library_ui.rs#L308), [`ui/dr-ui/src/library_ui.rs:5382`](../ui/dr-ui/src/library_ui.rs#L5382), [`ui/dr-ui/src/library_ui.rs:5527`](../ui/dr-ui/src/library_ui.rs#L5527), [`ui/dr-ui/src/library_ui.rs:6732`](../ui/dr-ui/src/library_ui.rs#L6732), [`ui/dr-ui/ui/adjust.slint:1072`](../ui/dr-ui/ui/adjust.slint#L1072), [`ui/dr-ui/ui/adjust.slint:544`](../ui/dr-ui/ui/adjust.slint#L544), [`ui/dr-ui/ui/adjust.slint:741`](../ui/dr-ui/ui/adjust.slint#L741), [`ui/dr-ui/ui/app.slint:2102`](../ui/dr-ui/ui/app.slint#L2102), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/app.slint:535`](../ui/dr-ui/ui/app.slint#L535), [`ui/dr-ui/ui/app.slint:56`](../ui/dr-ui/ui/app.slint#L56), [`ui/dr-ui/ui/app.slint:870`](../ui/dr-ui/ui/app.slint#L870), [`ui/dr-ui/ui/controls.slint:474`](../ui/dr-ui/ui/controls.slint#L474), [`ui/dr-ui/ui/develop.slint:223`](../ui/dr-ui/ui/develop.slint#L223), [`ui/dr-ui/ui/histogram.slint:127`](../ui/dr-ui/ui/histogram.slint#L127), [`ui/dr-ui/ui/history.slint:118`](../ui/dr-ui/ui/history.slint#L118), [`ui/dr-ui/ui/library.slint:1252`](../ui/dr-ui/ui/library.slint#L1252), [`ui/dr-ui/ui/library.slint:1259`](../ui/dr-ui/ui/library.slint#L1259), [`ui/dr-ui/ui/library.slint:1265`](../ui/dr-ui/ui/library.slint#L1265), [`ui/dr-ui/ui/library.slint:1306`](../ui/dr-ui/ui/library.slint#L1306), [`ui/dr-ui/ui/library.slint:2621`](../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:849`](../ui/dr-ui/ui/library.slint#L849), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/masks.slint:314`](../ui/dr-ui/ui/masks.slint#L314), [`ui/dr-ui/ui/peaking.slint:82`](../ui/dr-ui/ui/peaking.slint#L82), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106), [`ui/dr-ui/ui/spots.slint:88`](../ui/dr-ui/ui/spots.slint#L88) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/develop.rs:2333`](../ui/dr-ui/src/develop.rs#L2333), [`ui/dr-ui/src/library_ui.rs:4503`](../ui/dr-ui/src/library_ui.rs#L4503), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:1918`](../ui/dr-ui/ui/app.slint#L1918), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/masks.slint:524`](../ui/dr-ui/ui/masks.slint#L524) | +| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1067`](../ui/dr-ui/src/collections_ui.rs#L1067), [`ui/dr-ui/src/collections_ui.rs:1077`](../ui/dr-ui/src/collections_ui.rs#L1077), [`ui/dr-ui/src/collections_ui.rs:1093`](../ui/dr-ui/src/collections_ui.rs#L1093), [`ui/dr-ui/src/collections_ui.rs:119`](../ui/dr-ui/src/collections_ui.rs#L119), [`ui/dr-ui/src/collections_ui.rs:132`](../ui/dr-ui/src/collections_ui.rs#L132), [`ui/dr-ui/src/collections_ui.rs:1580`](../ui/dr-ui/src/collections_ui.rs#L1580), [`ui/dr-ui/src/collections_ui.rs:1594`](../ui/dr-ui/src/collections_ui.rs#L1594), [`ui/dr-ui/src/collections_ui.rs:160`](../ui/dr-ui/src/collections_ui.rs#L160), [`ui/dr-ui/src/collections_ui.rs:1640`](../ui/dr-ui/src/collections_ui.rs#L1640), [`ui/dr-ui/src/collections_ui.rs:170`](../ui/dr-ui/src/collections_ui.rs#L170), [`ui/dr-ui/src/collections_ui.rs:1756`](../ui/dr-ui/src/collections_ui.rs#L1756), [`ui/dr-ui/src/collections_ui.rs:1892`](../ui/dr-ui/src/collections_ui.rs#L1892), [`ui/dr-ui/src/collections_ui.rs:1919`](../ui/dr-ui/src/collections_ui.rs#L1919), [`ui/dr-ui/src/collections_ui.rs:2804`](../ui/dr-ui/src/collections_ui.rs#L2804), [`ui/dr-ui/src/collections_ui.rs:346`](../ui/dr-ui/src/collections_ui.rs#L346), [`ui/dr-ui/src/collections_ui.rs:528`](../ui/dr-ui/src/collections_ui.rs#L528), [`ui/dr-ui/src/collections_ui.rs:557`](../ui/dr-ui/src/collections_ui.rs#L557), [`ui/dr-ui/src/collections_ui.rs:625`](../ui/dr-ui/src/collections_ui.rs#L625), [`ui/dr-ui/src/library_ui.rs:4503`](../ui/dr-ui/src/library_ui.rs#L4503), [`ui/dr-ui/src/library_ui.rs:4530`](../ui/dr-ui/src/library_ui.rs#L4530), [`ui/dr-ui/src/library_ui.rs:4555`](../ui/dr-ui/src/library_ui.rs#L4555), [`ui/dr-ui/src/library_ui.rs:4658`](../ui/dr-ui/src/library_ui.rs#L4658), [`ui/dr-ui/src/library_ui.rs:4686`](../ui/dr-ui/src/library_ui.rs#L4686), [`ui/dr-ui/src/library_ui.rs:5515`](../ui/dr-ui/src/library_ui.rs#L5515), [`ui/dr-ui/src/library_ui.rs:5527`](../ui/dr-ui/src/library_ui.rs#L5527), [`ui/dr-ui/ui/app.slint:1758`](../ui/dr-ui/ui/app.slint#L1758), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/app.slint:535`](../ui/dr-ui/ui/app.slint#L535), [`ui/dr-ui/ui/library.slint:1252`](../ui/dr-ui/ui/library.slint#L1252), [`ui/dr-ui/ui/library.slint:1259`](../ui/dr-ui/ui/library.slint#L1259), [`ui/dr-ui/ui/library.slint:1265`](../ui/dr-ui/ui/library.slint#L1265), [`ui/dr-ui/ui/library.slint:1299`](../ui/dr-ui/ui/library.slint#L1299), [`ui/dr-ui/ui/library.slint:1306`](../ui/dr-ui/ui/library.slint#L1306), [`ui/dr-ui/ui/library.slint:2621`](../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:849`](../ui/dr-ui/ui/library.slint#L849), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/library.slint:921`](../ui/dr-ui/ui/library.slint#L921) | +| 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:2597`](../ui/dr-ui/src/lib.rs#L2597), [`ui/dr-ui/src/lib.rs:3199`](../ui/dr-ui/src/lib.rs#L3199), [`ui/dr-ui/src/lib.rs:3384`](../ui/dr-ui/src/lib.rs#L3384), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:107`](../ui/dr-ui/ui/app.slint#L107), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-6 | [`ui/dr-ui/ui/widgets.slint:1`](../ui/dr-ui/ui/widgets.slint#L1) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:385`](../core/dr-pipeline/src/framing.rs#L385) | -| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2953`](../ui/dr-ui/src/library.rs#L2953) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2168`](../ui/dr-ui/src/lib.rs#L2168), [`ui/dr-ui/ui/app.slint:997`](../ui/dr-ui/ui/app.slint#L997), [`ui/dr-ui/ui/library.slint:944`](../ui/dr-ui/ui/library.slint#L944) | +| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:3069`](../ui/dr-ui/src/library.rs#L3069) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1553`](../ui/dr-ui/src/export.rs#L1553), [`ui/dr-ui/src/export.rs:1579`](../ui/dr-ui/src/export.rs#L1579), [`ui/dr-ui/src/export.rs:409`](../ui/dr-ui/src/export.rs#L409), [`ui/dr-ui/src/export.rs:435`](../ui/dr-ui/src/export.rs#L435), [`ui/dr-ui/src/lib.rs:2294`](../ui/dr-ui/src/lib.rs#L2294), [`ui/dr-ui/ui/app.slint:1046`](../ui/dr-ui/ui/app.slint#L1046), [`ui/dr-ui/ui/library.slint:971`](../ui/dr-ui/ui/library.slint#L971) | | 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), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:499`](../ui/dr-ui/src/export.rs#L499) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | +| NFR-OPS-3 | [`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) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | | NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) | -| NFR-P5 | [`core/dr-catalog/src/schema.rs:318`](../core/dr-catalog/src/schema.rs#L318), [`ui/dr-ui/src/library.rs:4013`](../ui/dr-ui/src/library.rs#L4013), [`ui/dr-ui/src/library.rs:4945`](../ui/dr-ui/src/library.rs#L4945), [`ui/dr-ui/src/library_ui.rs:4072`](../ui/dr-ui/src/library_ui.rs#L4072), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | +| NFR-P14 | [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop.rs:2964`](../ui/dr-ui/src/develop.rs#L2964), [`ui/dr-ui/src/lib.rs:1674`](../ui/dr-ui/src/lib.rs#L1674) | +| NFR-P5 | [`core/dr-catalog/src/schema.rs:330`](../core/dr-catalog/src/schema.rs#L330), [`ui/dr-ui/src/library.rs:4136`](../ui/dr-ui/src/library.rs#L4136), [`ui/dr-ui/src/library.rs:5070`](../ui/dr-ui/src/library.rs#L5070), [`ui/dr-ui/src/library_ui.rs:4176`](../ui/dr-ui/src/library_ui.rs#L4176), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) | | NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:942`](../ui/dr-ui/src/export.rs#L942), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:270`](../core/dr-types/src/lib.rs#L270), [`core/dr-types/src/lib.rs:303`](../core/dr-types/src/lib.rs#L303), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) | +| NFR-PORT-2 | [`core/dr-gpu/src/lib.rs:1`](../core/dr-gpu/src/lib.rs#L1) | | NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) | -| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:176`](../core/dr-gpu/src/lib.rs#L176), [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/library.rs:1068`](../ui/dr-ui/src/library.rs#L1068) | +| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:179`](../core/dr-gpu/src/lib.rs#L179), [`core/dr-sync/src/account.rs:220`](../core/dr-sync/src/account.rs#L220), [`ui/dr-ui/src/library.rs:1101`](../ui/dr-ui/src/library.rs#L1101) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | | NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72) | -| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:613`](../core/dr-catalog/src/schema.rs#L613), [`core/dr-gpu/src/lib.rs:77`](../core/dr-gpu/src/lib.rs#L77), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2848`](../ui/dr-ui/src/library.rs#L2848) | +| NFR-RES-1 | [`core/dr-gpu/src/adjust.rs:1029`](../core/dr-gpu/src/adjust.rs#L1029), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/develop.rs:3069`](../ui/dr-ui/src/develop.rs#L3069), [`ui/dr-ui/src/identity_ui.rs:115`](../ui/dr-ui/src/identity_ui.rs#L115), [`ui/dr-ui/src/lib.rs:76`](../ui/dr-ui/src/lib.rs#L76), [`ui/dr-ui/src/memory.rs:1`](../ui/dr-ui/src/memory.rs#L1) | +| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:692`](../core/dr-catalog/src/schema.rs#L692), [`core/dr-gpu/src/lib.rs:80`](../core/dr-gpu/src/lib.rs#L80), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2964`](../ui/dr-ui/src/library.rs#L2964) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | NFR-SEC-2 | [`core/dr-sync/src/account.rs:298`](../core/dr-sync/src/account.rs#L298), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) | -| NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:442`](../core/dr-catalog/src/schema.rs#L442), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | +| NFR-SEC-3 | [`core/dr-sync-nextcloud/src/lib.rs:1`](../core/dr-sync-nextcloud/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/provider.rs:1`](../core/dr-sync-nextcloud/src/provider.rs#L1) | +| NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:521`](../core/dr-catalog/src/schema.rs#L521), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | 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:335`](../core/dr-gpu/src/lib.rs#L335) | +| R3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-pipeline/src/lib.rs:1`](../core/dr-pipeline/src/lib.rs#L1) | +| R4 | [`core/dr-gpu/src/lib.rs:338`](../core/dr-gpu/src/lib.rs#L338) | +| R6 | [`core/dr-sync-nextcloud/src/lib.rs:1`](../core/dr-sync-nextcloud/src/lib.rs#L1) | ## Not yet tagged -72 of 179 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +59 of 179 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements - FR-CAT-14 -- FR-CULL-3 -- FR-CULL-5 - FR-CULL-6 - FR-CULL-7 -- FR-DEV-1 - FR-DEV-3g - FR-DSP-2 - FR-DSP-4 - FR-NC-11 -- FR-NC-6d -- FR-PLAT-AND-2 - FR-PLAT-AND-4 -- FR-PLAT-AND-5 - FR-PLAT-AND-6 - FR-PLAT-LIN-3 - FR-PLG-1 @@ -185,7 +192,6 @@ _None._ - FR-PLG-8 - FR-PLG-9 - FR-RAW-2 -- FR-UI-6 - NFR-A11Y-1 - NFR-A11Y-2 - NFR-A11Y-3 @@ -193,12 +199,10 @@ _None._ - NFR-COMPAT-1 - NFR-COMPAT-2 - NFR-OPS-2 -- NFR-OPS-3 - NFR-OPS-4 - NFR-P10 - NFR-P11 - NFR-P12 -- NFR-P14 - NFR-P15 - NFR-P2 - NFR-P3 @@ -206,18 +210,14 @@ _None._ - NFR-P6 - NFR-P7 - NFR-P8 -- NFR-PORT-2 - NFR-R3 - NFR-R4 - NFR-R6 - NFR-RES-2 - NFR-RES-3 -- NFR-SEC-3 - NFR-SEC-4 - NFR-SEC-6 - R2 -- R3 - R5 -- R6
diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index b75f03d..f5fde21 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -37,6 +37,14 @@ package() { install -Dm644 "packaging/paris.tourolle.darkroom.desktop" \ "${pkgdir}/usr/share/applications/paris.tourolle.darkroom.desktop" + # The same AppStream file the Flatpak installs, so a software centre + # describes the two packages identically instead of falling back to the + # desktop entry's one-line Comment for this one. Installed here rather than + # written twice: the description, the licence fields and the OARS rating + # are facts about the application, not about how it was packaged. + install -Dm644 "packaging/paris.tourolle.darkroom.metainfo.xml" \ + "${pkgdir}/usr/share/metainfo/paris.tourolle.darkroom.metainfo.xml" + # The icon's *name* is the contract, not its path: the desktop entry says # `Icon=paris.tourolle.darkroom` and the compositor resolves that through # the hicolor theme. Installed under 256x256 because that is the source's diff --git a/packaging/flatpak/paris.tourolle.darkroom.yml b/packaging/flatpak/paris.tourolle.darkroom.yml new file mode 100644 index 0000000..c6b9c27 --- /dev/null +++ b/packaging/flatpak/paris.tourolle.darkroom.yml @@ -0,0 +1,186 @@ +# Flatpak manifest — FR-PLAT-LIN-3 (sandboxed distribution), NFR-COMPAT-2. +# +# YAML rather than JSON because this file has more to explain than to declare, +# and JSON cannot hold a comment. flatpak-builder reads both. +# +# Build it, from the repository root: +# +# flatpak-builder --user --install --force-clean \ +# build/flatpak packaging/flatpak/paris.tourolle.darkroom.yml +# +# Read docs/distribution.md before changing any permission below. Every line in +# `finish-args` is a hole in the sandbox, and the one that is conspicuously +# absent — `--filesystem=` — is absent on purpose and is explained there. +id: paris.tourolle.darkroom + +# 25.08 is the current freedesktop runtime, and the choice is made by what the +# binary needs rather than by what is newest. `ldd` on a release build names +# fontconfig, freetype, expat, libpng, zlib, brotli and bzip2 — all in the +# Platform — and nothing else: Vulkan, libxkbcommon and the two display-server +# protocols are reached without a link-time dependency (wgpu dlopens +# libvulkan.so.1, and x11rb and wayland-client speak the wire protocols in Rust +# rather than binding libxcb or libwayland). So the runtime has to supply a +# Vulkan loader and an ICD at *runtime*, which is the GL extension's job, and +# not much else. +runtime: org.freedesktop.Platform +runtime-version: '25.08' +sdk: org.freedesktop.Sdk + +# The Rust toolchain is an SDK extension rather than something this manifest +# installs, so the build is offline-capable in the part that matters and the +# compiler is the one freedesktop tested against its own glibc. +# +# Note what this quietly overrides: `rust-toolchain.toml` pins 1.92.0, and that +# pin is honoured by *rustup*, which is not what the extension provides. The +# extension's cargo therefore ignores the file and builds with its own stable +# (1.98.0 on 25.08). That is fine here and deliberately different from +# CONTRIBUTING.md's "do not override the toolchain": the pin exists so `cargo +# fmt --check` and `clippy -D warnings` agree between a laptop and CI, and +# neither runs in this build. A *release binary* only needs a compiler at or +# above the workspace's `rust-version`. +sdk-extensions: + - org.freedesktop.Sdk.Extension.rust-stable + +command: darkroom-desktop + +finish-args: + # FR-PLAT-LIN-2 asks for both display servers. `fallback-x11` rather than + # `x11`: it grants the X socket only when Wayland is unavailable, so a + # Wayland session does not leave an X11 hole open beside the socket actually + # in use. `--share=ipc` goes with it — without it X11 cannot use shared + # memory and every frame is pushed through the socket instead. + - --socket=wayland + - --socket=fallback-x11 + - --share=ipc + + # The GPU. The develop pipeline is compute shaders through wgpu and there is + # no CPU renderer behind it, so this is not an optimisation: without + # /dev/dri the application starts and cannot develop anything. + # + # `dri` rather than `all`: it covers the render nodes and the NVIDIA device + # nodes, which is the whole of what a Vulkan ICD opens. `all` would add every + # other device on the machine for no gain. + - --device=dri + + # Nextcloud (FR-NC-*). Nothing else here reaches the network — face grouping, + # segmentation and lens correction are all local and stay local (NFR-SEC-5). + - --share=network + + # Credential storage (FR-NC-2). The keyring crate speaks the Secret Service + # D-Bus interface directly, which GNOME Keyring and KWallet's `ksecretd` both + # implement, so what it needs is a talk hole to that well-known name. + # + # Worth being precise, because FR-PLAT-LIN-3 says "Secret Service portal" and + # these are two different things: xdg-desktop-portal's `org.freedesktop. + # portal.Secret` hands an application a master key for a store it keeps + # itself, whereas this talks to the session's secret daemon. Only the latter + # puts the app password where `secret-tool` and Seahorse can see it, which is + # what makes a credential individually revocable by the user rather than + # opaque inside our own data directory. If no daemon answers, FR-NC-2's + # degraded mode is what the user gets — the same behaviour as outside a + # sandbox, which is the point. + - --talk-name=org.freedesktop.secrets + + # No `--filesystem=` line of any kind, and this is the substance of + # FR-PLAT-LIN-3 rather than an omission. + # + # What that leaves working: /run/user/$UID/doc is mounted in every sandbox, so + # a photograph opened from a file manager — the .desktop entry declares the + # RAW MIME types and `Exec=darkroom-desktop %F` — arrives as a document-portal + # path in argv and opens. That path is genuinely portal-mediated and needs no + # code change. + # + # What that leaves broken: choosing a *library root*. The folder connector + # takes a typed absolute path (`SignIn::EndpointOnly`, placeholder + # `/home/you/Pictures`) and checks it with `std::fs`, and nothing in the tree + # calls the FileChooser portal — there is no ashpd, no rfd, no toolkit dialog. + # A path typed into that field does not exist in this sandbox, so the launch + # screen refuses it with "that folder does not exist", which is at least an + # honest error. + # + # `--filesystem=host` would make that work today and is exactly what the + # requirement forbids, so it is not here. docs/distribution.md §4 records what + # closes the gap and how to run a Flatpak build in the meantime. + +modules: + - name: darkroom + buildsystem: simple + + build-options: + append-path: /usr/lib/sdk/rust-stable/bin + env: + # Inside the build sandbox rather than in $HOME, so a rebuild starts + # from the state flatpak-builder is managing and not from whatever the + # host's cargo cache happens to hold. + CARGO_HOME: /run/build/darkroom/cargo + # Cargo fetches 826 crates, and Flathub's builders forbid this — a + # submission there needs `cargo-sources.json` generated by + # flatpak-builder-tools' `flatpak-cargo-generator.py` from Cargo.lock, + # listing every crate as its own source, plus a vendored-registry + # `.cargo/config.toml`. That file is ~30k lines, has to be regenerated on + # every dependency change, and buys nothing for a build from a local + # checkout, which is what this manifest is for and what packaging/PKGBUILD + # is for as well. Add it when there is a Flathub submission, not before. + build-args: + - --share=network + + build-commands: + # `--locked` for the reason CI uses it: a lockfile that resolves + # differently in the packaging build than in the tree is a release whose + # dependency versions nobody chose. + - cargo build --release --locked -p darkroom-desktop + + - install -Dm755 target/release/darkroom-desktop /app/bin/darkroom-desktop + + - install -Dm644 packaging/paris.tourolle.darkroom.desktop + /app/share/applications/paris.tourolle.darkroom.desktop + + - install -Dm644 packaging/paris.tourolle.darkroom.metainfo.xml + /app/share/metainfo/paris.tourolle.darkroom.metainfo.xml + + # The icon's name is the contract, not its path — the desktop entry says + # `Icon=paris.tourolle.darkroom` and the shell resolves that through the + # hicolor theme. 256x256 because that is the source's actual size; + # installing it under a size it is not makes scaled icons look wrong. + - install -Dm644 ui/dr-ui/ui/app-icon.png + /app/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png + + # The face models, where `system_face_models_dirs()` looks: it reads + # $XDG_DATA_DIRS, which includes /app/share inside a Flatpak, so this is + # the same lookup that finds /usr/share/darkroom/models from the Arch + # package. Last in the search order, so a pair the user dropped in their + # own data directory still outranks these. + # + # These live in Git LFS. A checkout made without `git lfs pull` has + # ~130-byte pointers here, and `type: dir` below would copy the pointers + # in without complaint — producing a Flatpak whose face indexing fails + # inside the graph loader on the user's machine. Refuse instead, with the + # command that fixes it. + - | + for m in scrfd_500m_640.onnx arcface_mbf_b1.onnx; do + if [ "$(stat -c%s "models/face/$m")" -lt 100000 ]; then + echo "error: $m is an LFS pointer, not a model — run: git lfs pull" >&2 + exit 1 + fi + install -Dm644 "models/face/$m" "/app/share/darkroom/models/$m" + done + + - install -Dm644 README.md /app/share/doc/darkroom/README.md + + sources: + # The local checkout, for the same reason packaging/PKGBUILD builds from + # one: this makes a Flatpak of what you are actually working on. Swap it + # for an `archive` or `git` source with a tag when there is a release to + # point at. + # + # `skip` is not tidiness. `target/` is tens of gigabytes and `.git` with + # LFS objects is not small; flatpak-builder copies a `dir` source + # wholesale, so without these two lines the copy is the slowest part of + # the build by a wide margin. + - type: dir + path: ../.. + skip: + - target + - target-android + - .git + - build diff --git a/packaging/paris.tourolle.darkroom.metainfo.xml b/packaging/paris.tourolle.darkroom.metainfo.xml new file mode 100644 index 0000000..864c662 --- /dev/null +++ b/packaging/paris.tourolle.darkroom.metainfo.xml @@ -0,0 +1,97 @@ + + + + + paris.tourolle.darkroom + + + CC0-1.0 + GPL-3.0-or-later + + DarkRoom + Non-destructive RAW photo library and editor + + +

+ DarkRoom catalogues, culls and develops RAW photographs. Edits are stored + as a graph of operations beside the original rather than baked into it, + so every change stays reversible and the file the camera wrote is never + rewritten. +

+

+ The library can live in a plain directory — a local disk, an external + drive, an NFS or SMB mount — or on a Nextcloud server, browsed and edited + without downloading whole RAW files first. +

+

Where it differs from the tools it sits beside:

+
    +
  • Culling shows the camera's embedded preview immediately and replaces it with a full render when one is ready, so moving to the next frame does not wait on a demosaic
  • +
  • Sidecars are the record of an edit; the catalog is a cache that can be deleted and rebuilt
  • +
  • The develop pipeline runs on the GPU through Vulkan, including drawn masks — a working Vulkan driver is required, not merely preferred, because there is no CPU renderer behind it
  • +
  • Faces are detected and grouped locally — nothing is uploaded to identify anybody
  • +
+
+ + paris.tourolle.darkroom.desktop + + darkroom-desktop + + + https://gitea.tourolle.paris/dtourolle/DarkRoom + https://gitea.tourolle.paris/dtourolle/DarkRoom/issues + https://gitea.tourolle.paris/dtourolle/DarkRoom + + + Duncan Tourolle + + + + Graphics + Photography + + + + RAW + photography + develop + darkroom + catalog + + + + + 768 + + + pointing + keyboard + touch + + + + + + + +
diff --git a/ui/dr-ui/src/bursts.rs b/ui/dr-ui/src/bursts.rs new file mode 100644 index 0000000..b83253b --- /dev/null +++ b/ui/dr-ui/src/bursts.rs @@ -0,0 +1,531 @@ +//! TRACES: FR-CULL-5 +//! Burst grouping, as the library screen uses it. +//! +//! [`dr_catalog::bursts`] holds the grouping itself and knows nothing about +//! pixels. This is the other half: where the similarity signal comes from, and +//! how a group reaches a grid cell. +//! +//! # The signal comes out of the thumbnail store +//! +//! A perceptual signature needs pixels, and the cheapest pixels in the app are +//! the ones already sitting in `dr-thumbs`: a 256px JPEG per photograph, built +//! for the grid, shared between devices, and vastly more resolution than a 9×8 +//! reduction can use. So this pass decodes thumbnails, never originals. A +//! library that has been browsed — or that has synced somebody else's shards — +//! has already paid for every signature it is about to get. +//! +//! The consequence, stated rather than hidden: **an image with no thumbnail +//! gets no signature, and a frame with no signature never joins a burst.** That +//! is self-correcting rather than permanent — the next pass finds the thumbnail +//! the sweep has since built — and it is the reason this runs when the +//! thumbnail sweep finishes rather than on a timer. +//! +//! # Why a pass and not a job +//! +//! Hashing is per-image and would make a perfectly good job kind. Grouping is +//! not: a burst is a property of a *run* of frames, so a per-image job would +//! regroup the library once per photograph. Since the two have to happen in that +//! order and the second cannot be split, both live in one pass — the same +//! argument docs/catalog.md §10.2 makes for face clustering. +//! +//! # It is never on the UI thread +//! +//! Decoding tens of thousands of thumbnails is bounded only by library size, and +//! the one thing that must not grow with library size is how long the window +//! stops answering (NFR-P9). Cancellation is dropping the receiver; a pass +//! abandoned half way leaves the signatures it did compute — they are permanent +//! and correct — and the previous grouping intact. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::mpsc::Receiver; + +use dr_catalog::bursts::{self, Rules, Signature}; +use dr_catalog::Catalog; +use dr_thumbs::{ThumbSize, ThumbStore}; +use dr_types::ImageId; +use slint::Model as _; + +use crate::AppWindow; + +/// How many signatures are written per transaction. +/// +/// One transaction per image costs a WAL commit per thumbnail and turns a pass +/// over a real library into minutes of fsync; one transaction for the whole pass +/// holds a write lock for the duration and loses everything if the app closes. +/// A few hundred is the usual answer to that trade. +const WRITE_BATCH: usize = 256; + +/// Progress from a grouping pass. +#[derive(Debug, Clone, PartialEq)] +pub enum BurstMessage { + /// How many images still need a signature. Sent once, before any decoding. + Started { to_hash: usize }, + /// Cumulative signatures written. + Progress { hashed: usize }, + /// The pass finished, and this is what the library now looks like. + Finished { + hashed: usize, + bursts: usize, + frames: usize, + }, + /// It did not. + Failed(String), +} + +/// Hash whatever is missing a signature, then rebuild the grouping. +/// +/// Both halves run on a worker thread. The catalog is opened here rather than +/// shared with the UI's connection: SQLite connections are not `Send`, and WAL +/// is what makes a second one safe while the grid reads (NFR-R1). +pub fn spawn_grouping(catalog_path: PathBuf, thumbs_dir: PathBuf) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let catalog = match Catalog::open(&catalog_path) { + Ok(c) => c, + Err(e) => { + let _ = tx.send(BurstMessage::Failed(format!("cannot open catalog: {e}"))); + return; + } + }; + let conn = catalog.connection(); + + let outstanding = match bursts::images_without_signature(conn) { + Ok(v) => v, + Err(e) => { + let _ = tx.send(BurstMessage::Failed(e.to_string())); + return; + } + }; + if tx + .send(BurstMessage::Started { + to_hash: outstanding.len(), + }) + .is_err() + { + return; + } + + // A broken or absent store costs signatures, never correctness: the + // grouping still runs over whatever is already hashed, and the images + // that missed out are picked up by the next pass. + let store = match ThumbStore::open(&thumbs_dir) { + Ok(s) => Some(s), + Err(e) => { + log::warn!("thumbnail store unavailable, not hashing: {e}"); + None + } + }; + + let hashed = match store { + Some(store) => hash_all(conn, &store, &outstanding, &tx), + None => 0, + }; + + match bursts::regroup(conn, Rules::default()) { + Ok(report) => { + log::info!( + "bursts: {hashed} signature(s) added, {} group(s) over {} frame(s), \ + largest {}", + report.bursts, + report.frames, + report.largest + ); + let _ = tx.send(BurstMessage::Finished { + hashed, + bursts: report.bursts, + frames: report.frames, + }); + } + Err(e) => { + let _ = tx.send(BurstMessage::Failed(e.to_string())); + } + } + }); + + rx +} + +thread_local! { + /// The running pass's drain timer, and whether one is running. + /// + /// Module-local rather than a pair of fields on the library controller, so + /// that everything this feature needs to run lives in this file and the + /// screen that starts it is left holding nothing. Safe as a thread local + /// because Slint's event loop is single-threaded (NFR-P9) and this is only + /// ever touched from it. + /// + /// The flag is separate because stopping a timer does not drop it: a slot + /// tested for emptiness would refuse every pass after the first. + static DRAIN: RefCell> = const { RefCell::new(None) }; + static RUNNING: Cell = const { Cell::new(false) }; +} + +/// Hash what has become hashable, then rebuild the library's burst grouping. +/// +/// Fired when the thumbnail sweep finishes, because that is the moment the +/// signatures can all be computed: the pass reads thumbnails, and until the +/// sweep has run most images have none. Not on a timer, and not after every +/// scan — a regroup is cheap but not free, and nothing is waiting on it. +/// +/// `grouped` is called once, with the number of bursts the library now has, if +/// the pass finishes. It is where the caller reloads the grid: the cells hold +/// the same photographs they held before — a new burst arrives open — but every +/// run of frames now carries a mark it did not have a moment ago, and only a +/// reload carries it. +/// +/// Deliberately silent otherwise. The sweeps around it report progress because +/// they run for tens of minutes; this is seconds, and a status line for it would +/// be a line the user must read in order to learn nothing. +pub fn start_pass(catalog_path: PathBuf, thumbs_dir: PathBuf, grouped: impl Fn(usize) + 'static) { + // A second pass would read the same rows and write the same answer over the + // first one's transactions. + if RUNNING.get() { + return; + } + RUNNING.set(true); + + let rx = spawn_grouping(catalog_path, thumbs_dir); + let timer = slint::Timer::default(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(400), + move || loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + finish(); + return; + } + }; + + match msg { + BurstMessage::Started { to_hash } => { + log::info!("burst grouping: {to_hash} image(s) still to hash"); + } + // Nothing on screen is showing this. Drained rather than + // ignored, because an unread channel is a worker that stalls. + BurstMessage::Progress { hashed } => { + log::debug!("burst grouping: {hashed} hashed so far"); + } + BurstMessage::Finished { + hashed, + bursts, + frames, + } => { + log::info!( + "burst grouping: {hashed} signature(s) added, \ + {bursts} group(s) over {frames} frame(s)" + ); + finish(); + grouped(bursts); + return; + } + BurstMessage::Failed(e) => { + log::warn!("burst grouping: {e}"); + finish(); + return; + } + } + }, + ); + + DRAIN.with(|slot| *slot.borrow_mut() = Some(timer)); +} + +/// Stop draining, and let another pass start. +/// +/// Stops the timer without dropping it — dropping one from inside its own +/// callback is not something to rely on — which is why the flag beside it is +/// what actually says whether a pass is running. +fn finish() { + RUNNING.set(false); + DRAIN.with(|slot| { + if let Some(timer) = slot.borrow().as_ref() { + timer.stop(); + } + }); +} + +/// Decode each image's stored thumbnail and record its signature. +/// +/// Returns how many were written. Anything without a stored thumbnail, or whose +/// blob will not decode, is simply skipped — it keeps its NULL and comes back +/// next time, which is the same treatment `spawn_thumbnails` gives a corrupt +/// blob. +fn hash_all( + conn: &rusqlite::Connection, + store: &ThumbStore, + outstanding: &[ImageId], + tx: &std::sync::mpsc::Sender, +) -> usize { + let keys = thumbnail_keys(conn); + let mut pending: Vec<(ImageId, Signature)> = Vec::with_capacity(WRITE_BATCH); + let mut written = 0usize; + + for image in outstanding { + let Some(file_id) = keys.get(image).copied() else { + continue; + }; + // The grid class, not the large one: 256px is already thirty times the + // detail the reduction keeps, and asking for `Large` would miss most of + // the store, which is filled at `Grid`. + let Ok(Some(thumb)) = store.get(file_id, ThumbSize::Grid) else { + continue; + }; + let Ok((w, h, rgba)) = dr_thumbs::decode_rgba(&thumb.bytes) else { + continue; + }; + let Some(signature) = bursts::signature_of_rgba(&rgba, w, h) else { + continue; + }; + pending.push((*image, signature)); + + if pending.len() >= WRITE_BATCH { + written += flush(conn, &mut pending); + if tx.send(BurstMessage::Progress { hashed: written }).is_err() { + // The receiver is gone: the screen has moved on, and finishing + // the pass would be work nobody is waiting for. + return written; + } + } + } + written += flush(conn, &mut pending); + written +} + +/// Write one batch of signatures, emptying `pending`. +fn flush(conn: &rusqlite::Connection, pending: &mut Vec<(ImageId, Signature)>) -> usize { + if pending.is_empty() { + return 0; + } + let n = pending.len(); + let tx = match conn.unchecked_transaction() { + Ok(t) => t, + Err(e) => { + log::warn!("recording signatures: {e}"); + pending.clear(); + return 0; + } + }; + for (image, signature) in pending.drain(..) { + if let Err(e) = bursts::set_signature(&tx, image, signature) { + log::debug!("recording signature for {}: {e}", image.0); + } + } + match tx.commit() { + Ok(()) => n, + Err(e) => { + log::warn!("committing signatures: {e}"); + 0 + } + } +} + +/// Every image's thumbnail-store key, in one query. +/// +/// Read whole rather than asked per image: the table is one small row per +/// photograph, and a query per image would be tens of thousands of statements +/// to save a megabyte. +fn thumbnail_keys(conn: &rusqlite::Connection) -> HashMap { + let mut out = HashMap::new(); + let Ok(mut stmt) = conn.prepare("SELECT image_id, file_id FROM remote") else { + return out; + }; + let Ok(rows) = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?))) else { + return out; + }; + for (image, file) in rows.flatten() { + out.insert(ImageId(image as u64), file as u64); + } + out +} + +/// Fill in the burst badge on every cell of the loaded window. +/// +/// One query for the window, in the same style as the collection badges and the +/// rating counts beside it — a grid that asks the catalog a question per cell is +/// a grid that stutters under a finger. +/// +/// `ids` is the window in grid order, so the row index into the model is the +/// index into it. +pub fn sync_badges(window: &AppWindow, catalog: &Catalog, ids: &[ImageId]) { + if ids.is_empty() { + return; + } + let found = match bursts::memberships(catalog.connection(), ids) { + Ok(m) => m, + Err(e) => { + log::debug!("reading burst membership: {e}"); + return; + } + }; + + let model = window.get_library_cells(); + for (row, id) in ids.iter().enumerate() { + let (count, expanded) = match found.get(id) { + Some(m) => (m.size as i32, m.expanded), + // Not in a burst at all, which is most of a library. + None => (0, false), + }; + if let Some(mut cell) = model.row_data(row) { + if cell.burst_count != count || cell.burst_expanded != expanded { + cell.burst_count = count; + cell.burst_expanded = expanded; + model.set_row_data(row, cell); + } + } + } +} + +/// Open or close the burst one cell belongs to. +/// +/// Which direction is decided from what the catalog says the group is currently +/// doing, rather than from the cell's own `burst-expanded`. The cell is a copy +/// of that state and can be one reload behind; the table cannot. +/// +/// The caller reloads the grid rather than repainting it, because collapsing +/// changes what the grid's *query* returns: the row count, the scrollbar and +/// the ordinal a scrub resolves all move together, and they can only stay in +/// step by being read again together. +/// +/// Returns whether anything changed, so a click on a cell that is in no burst — +/// an entirely normal thing to happen — costs no round trip through the grid. +pub fn toggle(catalog: &Catalog, image: ImageId) -> bool { + let conn = catalog.connection(); + let Ok(found) = bursts::memberships(conn, &[image]) else { + return false; + }; + let Some(membership) = found.get(&image) else { + return false; + }; + match bursts::set_expanded(conn, membership.burst_id, !membership.expanded) { + Ok(()) => true, + Err(e) => { + log::warn!("toggling burst {}: {e}", membership.burst_id.0); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A catalog with two frames of one burst, and a thumbnail store holding a + /// picture for each. + /// + /// `name` keeps two tests from sharing a directory, since they run in + /// parallel. Same shape as the store tests in `library`, which is also why + /// this reaches for `temp_dir` rather than a crate: nothing else here needs + /// one. + fn library(name: &str) -> (Catalog, PathBuf) { + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + for (id, at) in [(1i64, 1000i64), (2, 1001)] { + c.execute( + "INSERT INTO images(id, root_id, source_ref, captured_at, camera, added_at) + VALUES (?1, 1, ?2, ?3, 'Canon EOS R5', 0)", + rusqlite::params![id, format!("IMG_{id}.CR3"), at], + ) + .unwrap(); + c.execute( + "INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)", + rusqlite::params![id, 100 + id], + ) + .unwrap(); + } + + let dir = std::env::temp_dir().join(format!("dr-ui-bursts-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + { + let mut store = ThumbStore::open(&dir).unwrap(); + for (id, shift) in [(1u64, 0usize), (2, 1)] { + let rgba = picture(shift); + let bytes = dr_thumbs::encode_rgba(64, 64, &rgba).unwrap(); + store + .put( + 100 + id, + ThumbSize::Grid, + &dr_thumbs::Thumbnail { + width: 64, + height: 64, + bytes, + }, + ) + .unwrap(); + } + } + (cat, dir) + } + + /// A blocky scene, moved sideways by `shift` pixels — one frame of a burst + /// and then the next. + fn picture(shift: usize) -> Vec { + let mut out = vec![0u8; 64 * 64 * 4]; + for y in 0..64 { + for x in 0..64 { + let v = (((x + shift) / 8) * 37 + (y / 8) * 91) as u8; + let p = (y * 64 + x) * 4; + out[p] = v; + out[p + 1] = v; + out[p + 2] = v; + out[p + 3] = 255; + } + } + out + } + + #[test] + fn the_pass_hashes_from_thumbnails_and_groups_what_it_hashed() { + let (cat, dir) = library("hashes"); + let conn = cat.connection(); + let store = ThumbStore::open(&dir).unwrap(); + let outstanding = bursts::images_without_signature(conn).unwrap(); + assert_eq!(outstanding.len(), 2); + + let (tx, _rx) = std::sync::mpsc::channel(); + let hashed = hash_all(conn, &store, &outstanding, &tx); + assert_eq!(hashed, 2, "both thumbnails should have yielded a signature"); + + let report = bursts::regroup(conn, Rules::default()).unwrap(); + assert_eq!(report.bursts, 1, "the two frames were not grouped"); + assert_eq!(report.frames, 2); + } + + #[test] + fn an_image_with_no_thumbnail_keeps_its_null() { + let (cat, dir) = library("no-thumb"); + let conn = cat.connection(); + conn.execute( + "INSERT INTO images(id, root_id, source_ref, captured_at, added_at) + VALUES (9, 1, 'IMG_9.CR3', 1002, 0)", + [], + ) + .unwrap(); + + let store = ThumbStore::open(&dir).unwrap(); + let outstanding = bursts::images_without_signature(conn).unwrap(); + let (tx, _rx) = std::sync::mpsc::channel(); + assert_eq!(hash_all(conn, &store, &outstanding, &tx), 2); + // And it is still offered next time, rather than being written off. + assert_eq!( + bursts::images_without_signature(conn).unwrap(), + vec![ImageId(9)] + ); + } + + #[test] + fn toggling_a_cell_that_is_in_no_burst_changes_nothing() { + let (cat, _dir) = library("no-burst"); + assert!(!toggle(&cat, ImageId(1))); + } +} diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 199dac9..42d8fe8 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -14,7 +14,8 @@ use std::sync::Arc; use dr_decode::RawImage; use dr_gpu::{ - AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, MaskPass, + AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram, + HistogramPass, MaskPass, }; use dr_pipeline::mask::{MaskLayer, MaskSource}; @@ -722,6 +723,25 @@ pub struct DevelopSession { /// old driver, a device without the storage-buffer atomics it needs — the /// photographer loses the histogram and keeps the photograph. histogram: Option, + /// TRACES: FR-CULL-3 + /// The focus-peaking overlay, on the same terms as the histogram above: + /// optional, because a device that cannot compile the pass is still a + /// device that can develop the photograph. What is lost is an instrument, + /// not the picture. + peak: Option, + /// TRACES: FR-CULL-3 + /// What the photographer asked the overlay to look like, or `None` for + /// off. + /// + /// **Interface state, not part of the edit** — the same category as + /// `show_overlay` beside it. It changes no pixel of the photograph, it is + /// not in the sidecar, and it is not on the undo stack: pressing undo + /// after switching peaking on should take back the last *edit*, not the + /// last thing looked at. + /// + /// An `Option` rather than a bool plus a settings field, so that "off" and + /// "on, in some configuration" cannot disagree with each other. + peaking: Option, /// TRACES: FR-DEV-3 /// The region map local masks select from, once it has been computed. @@ -889,6 +909,10 @@ impl DevelopSession { histogram: HistogramPass::new(ctx) .inspect_err(|e| log::warn!("no histogram on this device: {e}")) .ok(), + peak: FocusPeakPass::new(ctx) + .inspect_err(|e| log::warn!("no focus peaking on this device: {e}")) + .ok(), + peaking: None, segmentation: None, masks: None, subjects: None, @@ -2903,6 +2927,105 @@ impl DevelopSession { .ok() } + /// TRACES: FR-CULL-3 + /// Whether this device could build the focus-peaking overlay. + /// + /// Asked by the interface so that it can say the overlay is unavailable + /// rather than offer a switch that does nothing. The same courtesy the + /// histogram is not paid, and should be: a control that silently does + /// nothing is worse than one that is visibly absent. + pub fn peaking_available(&self) -> bool { + self.peak.is_some() + } + + /// TRACES: FR-CULL-3 + /// What the overlay is set to, or `None` when it is off. + pub fn peaking(&self) -> Option { + self.peaking + } + + /// TRACES: FR-CULL-3 + /// Switch the overlay on with these settings, or off. + /// + /// Asking for peaking on a device that could not build the pass leaves it + /// off, so that [`Self::peaking`] never claims something is being drawn + /// that is not. Switching off drops the overlay textures rather than + /// merely stopping drawing them: a resident overlay from the last frame is + /// one interface bug away from being laid over the next photograph. + pub fn set_peaking(&mut self, settings: Option) { + self.peaking = settings.filter(|_| self.peak.is_some()); + if self.peaking.is_none() { + if let Some(pass) = self.peak.as_mut() { + pass.clear(); + } + } + } + + /// TRACES: FR-CULL-3 | NFR-P14 + /// Mark the in-focus regions of the frame that is currently on the canvas. + /// + /// **Reads the frame [`Self::render`] last produced**, exactly as + /// [`Self::histogram`] does and for the same reason: the overlay has to + /// describe what the photographer is looking at, and rendering a second + /// time to measure it would cost a pass and admit the possibility of the + /// two disagreeing about the picture. + /// + /// That the frame is the displayed one is what makes the marks land where + /// the eye is. It is at viewport resolution, cropped and zoomed as the + /// view is, and — the point of FR-CULL-3 — descended from sensor data + /// through the demosaic rather than from the camera's embedded JPEG, whose + /// in-body sharpening this would otherwise be measuring at least as much + /// as the lens. + /// + /// **Call this only after a settled render.** See + /// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame + /// cannot be measured for sharpness. + /// + /// `None` where nothing has been rendered, where peaking is off, or where + /// the device could not build the pass. + pub fn focus_overlay(&mut self) -> Option { + let settings = self.peaking?; + // Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`, + // and holding a shared borrow of `self.adjust` across the mutable + // borrow of `self.peak` would cost a `Self { .. }` destructure to say + // something the clone says in one word. + let frame = self.adjust.output()?.clone(); + let pass = self.peak.as_mut()?; + let overlay = pass + .render(&frame, settings) + .inspect_err(|e| log::warn!("focus peaking failed: {e}")) + .ok()? + .clone(); + + #[cfg(not(target_os = "android"))] + { + // A layer over the canvas rather than a tint in it, so nothing + // here reaches the histogram or an export — see `FocusPeakPass` + // for the whole of that argument. + slint::Image::try_from(overlay) + .inspect_err(|e| log::warn!("the focus overlay is not importable: {e}")) + .ok() + } + + // Android draws with Skia over OpenGL and cannot sample a + // `wgpu::Texture`, so the overlay follows the frame it belongs to back + // through memory (technical-debt.md TD-1). The measurement still + // happens on the GPU; only this last hop does not. + #[cfg(target_os = "android")] + { + let _ = overlay; + let (rgba, w, h) = pass + .read_overlay() + .inspect_err(|e| log::warn!("reading the focus overlay back: {e}")) + .ok()?; + let mut buf = slint::SharedPixelBuffer::::new(w, h); + let wanted = (w as usize) * (h as usize) * 4; + let src = &rgba[..wanted.min(rgba.len())]; + buf.make_mut_bytes()[..src.len()].copy_from_slice(src); + Some(slint::Image::from_rgba8(buf)) + } + } + /// Render the *whole* frame for the crop overlay to be drawn over. /// /// Crop mode cannot use [`Self::render`]: that applies the crop, so the @@ -2943,6 +3066,33 @@ impl DevelopSession { Ok((image, rw, rh)) } + /// TRACES: FR-PLAT-AND-5 | NFR-RES-1 + /// Give back the GPU memory this session is holding only to be fast. + /// + /// The edit is untouched: the graph and its history are CPU-side by + /// design (ARCH §6.1), so the photograph, the undo stack and the viewport + /// all survive and the next frame simply costs what the first one did. + /// + /// # What is not released, and what it is waiting on + /// + /// The demosaiced source is the largest single allocation a session holds + /// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is + /// deliberately kept. Dropping it would need the session to be able to + /// rebuild itself from the file, and rebuilding a session from a durable + /// record is FR-PLAT-AND-3, which is not built. Freeing it now would not + /// be an eviction; it would be closing the photograph without telling + /// anyone. Likewise the subject distance fields and the segmentation map: + /// each is guarded by a key recording what it was built from, and freeing + /// one without invalidating its key is the failure `AdjustPass` documents + /// under `colour_key`. + /// + /// So this is the part of the GPU tier that can be given back and asked + /// for again with no other machinery, which is exactly as far as an + /// eviction should go. + pub fn release_gpu_caches(&mut self) { + self.adjust.release_caches(); + } + /// The displayed size, for sizing the viewport. /// /// The *framed* size, not the sensor's: cropping and quarter turns change diff --git a/ui/dr-ui/src/identity_ui.rs b/ui/dr-ui/src/identity_ui.rs index 5f8415f..68b0803 100644 --- a/ui/dr-ui/src/identity_ui.rs +++ b/ui/dr-ui/src/identity_ui.rs @@ -111,6 +111,21 @@ impl IdentityController { fn clear_picks(&self) { self.picked.borrow_mut().clear(); } + + /// TRACES: FR-PLAT-AND-5 | NFR-RES-1 + /// Drop the decoded rail portraits. + /// + /// The one in-memory image cache in this crate that is unbounded by + /// anything but the library: one decoded portrait per person, kept for as + /// long as the person exists. On a library with a few hundred named people + /// that is worth tens of megabytes of nothing but a saved decode. + /// + /// Costless to lose. `refresh` rebuilds any portrait it does not find, so + /// the only consequence is the JPEG decode this cache exists to skip, and + /// only for the people the rail is actually showing at the time. + pub fn clear_covers(&self) { + self.covers.borrow_mut().clear(); + } } /// Push the people rail and the face grid into the window. diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index b16968b..e5355d2 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -20,6 +20,7 @@ //! in `ui/` names an operation or knows a shader exists (FR-DEV-3a). mod activity; +mod bursts; mod collections_ui; mod derived_sync; mod develop; @@ -38,7 +39,10 @@ mod library_ui; #[cfg(live_style)] mod live_style; mod masks_ui; +pub mod memory; mod net_runtime; +mod peaking; +mod preset_store; mod presets; mod remote; mod segmentation; @@ -316,6 +320,12 @@ fn reset_view_state(window: &AppWindow) { // beside the next one's filename is a confident, precise lie, and the gap // before the new frame settles is exactly long enough to read it. window.set_histogram(histogram::empty()); + // TRACES: FR-CULL-3 + // The marks go down with it, and for the same reason. What is *not* reset + // is whether peaking is switched on: that is a way of looking at a folder + // rather than a property of one photograph, so it survives to the next + // frame — see `chosen_peaking` for the whole of that argument. + window.set_focus_overlay_ready(false); // TRACES: FR-DEV-3 // The region map belongs to one photograph. Carrying the stack, the // overlay or the crosshair to the next one would offer a selection of @@ -1016,6 +1026,22 @@ pub fn run(paths: Vec) -> Result<()> { // each save over the other. let settings = settings_ui::SettingsController::new(); + // TRACES: FR-PLAT-AND-5 + // The thumbnail tier. Registered here, beside the thing it frees, so that + // a controller which grows another cache is one line from offering it up. + // + // Weak, not strong: `run` returns when the window closes, and a registry + // holding the last reference to a controller would keep it — and every + // decoded portrait in it — alive past the interface it belonged to. + { + let identity = std::rc::Rc::downgrade(&identity); + memory::evict_at(memory::Tier::Thumbnails, move || { + if let Some(ctl) = identity.upgrade() { + ctl.clear_covers(); + } + }); + } + // Launch screen: shown when there is nothing to display — no local paths // and no configured library. A user who has already signed in and chosen // a folder goes straight to their images (FR-NC-1). @@ -1429,6 +1455,32 @@ pub fn run(paths: Vec) -> Result<()> { // The current develop session, if the file yielded sensor data. let session: Rc>> = Rc::new(RefCell::new(None)); + // TRACES: FR-PLAT-AND-5 + // The GPU tier — the first thing given back under memory pressure, and on + // Android the only thing given back merely for going into the background. + // + // `try_borrow_mut` rather than `borrow_mut`, and the miss is not an error + // worth reporting. A memory warning can land in the middle of a render, at + // which point the slot is already borrowed and freeing its textures under + // the code drawing with them is not something to do politely — skipping is + // correct, because the pass that is running will have finished by the time + // the platform asks again, and a warning that has not been acted on is + // always followed by another one. + { + let session = Rc::downgrade(&session); + memory::evict_at(memory::Tier::Gpu, move || { + let Some(session) = session.upgrade() else { + return; + }; + let Ok(mut slot) = session.try_borrow_mut() else { + return; + }; + if let Some(open) = slot.as_mut() { + open.release_gpu_caches(); + } + }); + } + // TRACES: FR-DEV-6 | FR-CAT-8 // The settings clipboard, and where the open image's edit is stored. // @@ -1468,11 +1520,24 @@ pub fn run(paths: Vec) -> Result<()> { // is redrawn, and those are very different rates. let drawn_history: Rc>> = Rc::new(Cell::new(None)); + // TRACES: FR-CULL-3 + // How the photographer wants focus peaking drawn, or `None` for off. + // + // **Held here rather than on the session, which is the opposite of where + // every edit lives.** A session is one photograph; peaking is a way of + // *looking* at a folder of them. Someone culling three thousand frames + // switches it on once, and a flag that reset with the session would ask + // them to switch it on three thousand times — which is why + // `reset_view_state` deliberately leaves it alone while emptying the + // histogram beside it. + let chosen_peaking: Rc>> = Rc::new(Cell::new(None)); + let render_now: Render = { let session = session.clone(); let viewport = viewport.clone(); let drawn_history = drawn_history.clone(); let display = display.clone(); + let chosen_peaking = chosen_peaking.clone(); Rc::new(move |window: &AppWindow, draft: bool| { let mut slot = session.borrow_mut(); let Some(s) = slot.as_mut() else { return }; @@ -1533,6 +1598,16 @@ pub fn run(paths: Vec) -> Result<()> { // arrival takes. spots_ui::sync_panel(window, s); + // TRACES: FR-CULL-3 + // The session owns the pass and the interface owns the choice, so + // they are joined here — on the one path every frame takes, which + // is also what makes a photograph opened with peaking already on + // arrive with its marks rather than without them. + if s.peaking() != chosen_peaking.get() { + s.set_peaking(chosen_peaking.get()); + } + window.set_peaking_available(s.peaking_available()); + let (mut w, mut h) = *viewport.borrow(); // **Half resolution while the gesture is still moving.** @@ -1595,6 +1670,34 @@ pub fn run(paths: Vec) -> Result<()> { .map_or_else(histogram::empty, histogram::view), ); } + + // TRACES: FR-CULL-3 | NFR-P14 + // **Marked on the settled frame and no other**, and unlike + // the histogram beside it the marks are taken *down* in + // between rather than left standing. + // + // The reason is not budget — the dispatch is a fraction of + // a millisecond and would fit inside a draft frame + // comfortably. It is that peaking measures the top octave + // of the frame it is given, and a draft frame is rendered + // at half resolution: a defocused edge that spans four + // pixels there spans two, which is the signature of a + // sharp one. Measuring it would mark the out-of-focus + // background of every photograph, briefly, during every + // drag. A stale overlay is no better, because a pan moves + // the picture out from under it. + // + // So the marks pause while a control is moving and return + // when it stops, which the panel says out loud rather than + // leaving to be discovered. + let overlay = (!draft).then(|| s.focus_overlay()).flatten(); + match overlay { + Some(image) => { + window.set_focus_overlay(image); + window.set_focus_overlay_ready(true); + } + None => window.set_focus_overlay_ready(false), + } } Err(e) => { log::warn!("render failed: {e}"); @@ -1602,6 +1705,11 @@ pub fn run(paths: Vec) -> Result<()> { // No frame, so nothing to describe. The stale plot would // otherwise sit beside the error message looking current. window.set_histogram(histogram::empty()); + // TRACES: FR-CULL-3 + // And nothing to mark. Focus marks over the last frame + // that rendered, beside a message saying this one did not, + // is the same confident lie in a second instrument. + window.set_focus_overlay_ready(false); } } }) @@ -2025,6 +2133,24 @@ pub fn run(paths: Vec) -> Result<()> { collections.clone(), ); + // TRACES: FR-DEV-6 + // The saved half of the same requirement, wired from the same bundle: + // applying a named preset to the open image is the paste path with a + // different source. + presets::wire_named( + &window, + presets::NamedPresets::open(), + presets::Develop { + session: session.clone(), + rows: rows.clone(), + redraw: redraw.clone(), + open: open_image.clone(), + }, + settings.clone(), + library.clone(), + collections.clone(), + ); + // Close the knot left open beside `open_from_library`: the grid's // "‹ Library" button was wired before there was a session to save. let weak = window.as_weak(); @@ -2822,6 +2948,70 @@ pub fn run(paths: Vec) -> Result<()> { }); } + // TRACES: FR-CULL-3 + // The peaking switch and its two choices. + // + // All three write `chosen_peaking` and then redraw, because the marks are + // produced by a compute pass over the rendered frame: there is nothing the + // interface can change about the overlay that does not require the frame + // to be measured again. Turning peaking *off* redraws for the same reason + // — that render is what drops the overlay textures and clears the flag. + { + let weak = window.as_weak(); + let chosen = chosen_peaking.clone(); + let redraw = redraw.clone(); + window.on_peaking_toggled(move |on| { + let Some(w) = weak.upgrade() else { return }; + // Built from the chips as they currently stand rather than from a + // remembered value: they are what the photographer can see, and an + // overlay that came back in a configuration the panel is not + // showing would be the panel lying about itself. + let next = on.then(|| dr_gpu::FocusPeaking { + sensitivity: peaking::sensitivity(w.get_peaking_sensitivity()), + colour: peaking::colour(w.get_peaking_colour()), + }); + chosen.set(next); + w.set_peaking_on(next.is_some()); + redraw(&w); + }); + } + { + let weak = window.as_weak(); + let chosen = chosen_peaking.clone(); + let redraw = redraw.clone(); + window.on_peaking_sensitivity_picked(move |index| { + let Some(w) = weak.upgrade() else { return }; + w.set_peaking_sensitivity(index); + // Only reachable while peaking is on — the chips are not drawn + // otherwise — but written as a conditional rather than an + // `expect`, because a panel is free to change its mind about that + // and nothing here should fall over when it does. + if let Some(mut current) = chosen.get() { + current.sensitivity = peaking::sensitivity(index); + chosen.set(Some(current)); + redraw(&w); + } + }); + } + { + let weak = window.as_weak(); + let chosen = chosen_peaking.clone(); + let redraw = redraw.clone(); + window.on_peaking_colour_picked(move |index| { + let Some(w) = weak.upgrade() else { return }; + w.set_peaking_colour(index); + if let Some(mut current) = chosen.get() { + current.colour = peaking::colour(index); + chosen.set(Some(current)); + redraw(&w); + } + }); + } + // The chips open on whatever the vocabulary calls its default, so the + // panel and the pass agree before anything has been pressed. + window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default())); + window.set_peaking_colour(peaking::colour_index(Default::default())); + // TRACES: FR-DSP-8 | FR-DSP-6 // And which display that canvas is on, from now until the window closes. display_ui::attach(&window, &display, &viewport, redraw.clone()); diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index 25e4473..5f1b657 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -80,7 +80,20 @@ pub enum ScanMessage { /// amount of string matching on the far side can reliably recover it. /// Without the flag a dead connection and a bad password produce the same /// banner, which sends the user to re-enter a credential that was fine. - Failed { message: String, offline: bool }, + /// + /// `lost_root` is the same idea one step further out, and it is carried + /// separately from `offline` rather than folded into it because the two + /// end differently. An offline library comes back when the network does, + /// with nothing asked of anyone; a library whose root cannot be opened + /// comes back only when someone restores access to it — a share put back + /// on the server, a drive plugged in, and in time a document tree granted + /// again once one can be (FR-PLAT-AND-2). Both show the same grid of what + /// is stored locally, and they must not offer the same explanation. + Failed { + message: String, + offline: bool, + lost_root: bool, + }, } /// One decoded thumbnail, ready for the grid. @@ -186,6 +199,26 @@ const VISIBLE_UNALIASED: &str = "shadowed_by IS NULL AND trashed_at IS NULL"; /// restore the same frame twice. const TRASHED: &str = "i.shadowed_by IS NULL AND i.trashed_at IS NOT NULL"; +/// TRACES: FR-CULL-5 +/// The clause that hides the frames a collapsed burst is standing in for. +/// +/// Subject to exactly the discipline [`VISIBLE`] is under, and for the same +/// reason: the header's count, the scrollbar's size, the run a shift-click +/// resolves and the ordinal a scrub lands on are four answers about one list. +/// A burst folded away in the cells but still counted in the total would leave +/// the grid ending in rows that draw nothing, with no clue why. +/// +/// The predicate itself is `dr_catalog::bursts`'s, not this file's, so the +/// interface and the pass that writes the table cannot come to disagree about +/// what collapsed means. +/// +/// A function rather than a constant because it has to name the image table, +/// and the grid aliases it as `i` where the timeline's queries do not. `image` +/// is a table name from this file and never anything a user supplied. +fn uncollapsed(image: &str) -> String { + format!(" AND {}", dr_catalog::bursts::not_collapsed_away(image)) +} + /// TRACES: FR-CAT-4 /// The order the grid lists photographs in: when they were taken. /// @@ -1146,6 +1179,7 @@ pub fn spawn_scan( let _ = tx.send(ScanMessage::Failed { message: e.message, offline: e.offline, + lost_root: e.lost_root, }); } }); @@ -1160,6 +1194,7 @@ pub fn spawn_scan( struct ScanFailure { message: String, offline: bool, + lost_root: bool, } impl ScanFailure { @@ -1169,6 +1204,7 @@ impl ScanFailure { Self { message: message.to_string(), offline: false, + lost_root: false, } } } @@ -1177,11 +1213,48 @@ impl From for ScanFailure { fn from(e: dr_sync::RemoteError) -> Self { Self { offline: e.indicates_offline(), + lost_root: e.indicates_lost_root(), message: e.to_string(), } } } +/// TRACES: FR-PLAT-AND-2 | FR-CAT-9 +/// Record that a library can no longer be opened, without losing it. +/// +/// Called on the worker, before the failure crosses the channel, because this +/// is where the catalog handle is — and because the marking must be durable +/// whether or not anyone is left to draw a banner. A process killed between +/// the failure and the next launch must still come back knowing what it could +/// not reach. +/// +/// Nothing is deleted. Every rating, every edit and every row stays exactly +/// where it was; what changes is that the images now say they are offline, so +/// the grid can show them as held-not-here rather than as ordinary +/// photographs whose thumbnails happen to be failing one at a time. +/// +/// A root with no row yet is the first scan of a library that has never +/// succeeded, and there is nothing to mark — the failure alone is the whole +/// story, and the launch screen is where it is told. +fn mark_library_offline(catalog: &Catalog, root: &str) { + let conn = catalog.connection(); + let root_id: Option = conn + .query_row( + "SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'", + [root], + |r| r.get(0), + ) + .ok(); + let Some(root_id) = root_id else { + log::info!("library {root} has no catalog root yet; nothing to mark offline"); + return; + }; + match dr_catalog::mark_root_offline(conn, dr_types::RootId(root_id as u64)) { + Ok(()) => log::warn!("library {root} is unreachable; its images are marked offline"), + Err(e) => log::error!("could not mark {root} offline: {e}"), + } +} + fn run_scan( tx: &Sender, conn: Connection, @@ -1199,21 +1272,50 @@ fn run_scan( let rt = crate::net_runtime::build().map_err(ScanFailure::local)?; rt.block_on(async { - let backend = crate::remote::connect(&conn).map_err(ScanFailure::local)?; + // TRACES: FR-PLAT-AND-2 | FR-CAT-9 + // Classified rather than flattened to a local failure, because the + // removed-card case never gets as far as a request: the folder + // connector checks its root when it is constructed, so a library on an + // ejected card fails here and not in the walk. Reported as an ordinary + // error it left the grid showing a healthy library of images that + // could no longer be opened, one silent thumbnail failure at a time. + let backend = match crate::remote::connect(&conn) { + Ok(b) => b, + Err(e) => { + if e.indicates_lost_root() { + mark_library_offline(&catalog, &root); + } + return Err(e.into()); + } + }; // Stored folder ETags, so an unchanged subtree is skipped whole. On a // first run this is empty and the walk is complete; on every run after // it is what keeps cost proportional to what changed (ARCH §8.4). let known = load_folder_etags(&catalog, &root); - let result = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| { + let scanned = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| { let _ = tx.send(ScanMessage::Progress { directories: p.directories_listed, pruned: p.directories_pruned, images: p.images_found, }); }) - .await?; + .await; + + // TRACES: FR-PLAT-AND-2 | FR-CAT-9 + // Written before the failure is reported, not after: the banner is a + // consequence of the catalog state and not the other way round, and a + // process that dies between the two must come back knowing. + let result = match scanned { + Ok(r) => r, + Err(e) => { + if e.indicates_lost_root() { + mark_library_offline(&catalog, &root); + } + return Err(e.into()); + } + }; persist(&catalog, &root, &result).map_err(ScanFailure::local)?; @@ -1306,13 +1408,27 @@ fn persist( .ok() }); + // TRACES: FR-PLAT-AND-2 | FR-CAT-9 + // The `availability` arm is what ends an offline library, and it does + // it one photograph at a time. 3 is `Availability::Offline` and 0 is + // `MetadataOnly`, the same code this statement inserts new rows with — + // so a row that was marked offline when the root became unreachable is + // returned to exactly the state a fresh scan would have given it, and + // a row that was never marked is not touched at all. + // + // Conditional rather than a blanket reset for the same reason + // `dr_catalog::walk` restores per file rather than per root: the only + // thing that may clear "I could not reach this" is having reached it, + // and this statement runs precisely once per file the scan listed. tx.execute( "INSERT INTO images(root_id, folder_id, source_ref, format, file_size, availability, metadata_state, added_at) VALUES (?1, ?2, ?3, ?4, ?5, 0, 1, ?6) ON CONFLICT(root_id, source_ref) DO UPDATE SET file_size = excluded.file_size, - folder_id = excluded.folder_id", + folder_id = excluded.folder_id, + availability = CASE WHEN images.availability = 3 + THEN 0 ELSE images.availability END", rusqlite::params![ root_id, folder_id, @@ -3816,10 +3932,11 @@ pub fn read_cells_scoped( .join(","); let rated = filter.sql(); let (order, order_params) = grid_order_for(catalog, Some(scope)); + let folded = uncollapsed("i"); let sql = format!( "SELECT {CELL_COLUMNS} FROM images i - WHERE {VISIBLE}{rated} + WHERE {VISIBLE}{rated}{folded} AND i.id IN (SELECT image_id FROM collection_members WHERE collection_id IN ({placeholders})) {order} @@ -3854,11 +3971,12 @@ fn read_cells_all( limit: usize, ) -> Result, dr_catalog::CatalogError> { let rated = filter.sql(); + let folded = uncollapsed("i"); let mut rows = { let mut stmt = catalog.connection().prepare(&format!( "SELECT {CELL_COLUMNS} FROM images i - WHERE {VISIBLE}{rated} + WHERE {VISIBLE}{rated}{folded} {GRID_ORDER} LIMIT ?1 OFFSET ?2" ))?; @@ -3964,10 +4082,15 @@ pub fn read_ids_span( // different ORDER BY names a different photograph. let (order, order_params) = grid_order_for(catalog, scope); params.extend(order_params); + // And the same folding, for the same reason one step further on: a + // collapsed burst is one cell in the grid, so an ordinal counted over + // a list that still held every frame of it would name a photograph + // several places away from the one the user pointed at. + let folded = uncollapsed("i"); ( format!( "SELECT i.id FROM images i - WHERE {VISIBLE}{rated}{clause} + WHERE {VISIBLE}{rated}{folded}{clause} {order} LIMIT ? OFFSET ?" ), @@ -4096,9 +4219,10 @@ pub fn total_images_scoped( // Counted through `images` rather than over `collection_members` alone, so // `VISIBLE` applies — a trashed photograph is still a member row, and // counting it made the header claim images the grid would not draw. + let folded = uncollapsed("i"); let sql = format!( "SELECT count(DISTINCT i.id) FROM images i - WHERE {VISIBLE}{rated} + WHERE {VISIBLE}{rated}{folded} AND i.id IN (SELECT image_id FROM collection_members WHERE collection_id IN ({placeholders}))" ); @@ -4409,8 +4533,9 @@ fn total_images_filtered( filter: &RatingFilter, ) -> Result { let rated = filter.sql(); + let folded = uncollapsed("i"); let n: i64 = catalog.connection().query_row( - &format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}"), + &format!("SELECT count(*) FROM images i WHERE {VISIBLE}{rated}{folded}"), [], |r| r.get(0), )?; @@ -4956,12 +5081,16 @@ mod tests { #[test] fn the_window_read_walks_the_ordering_index() { let catalog = with_images(20); + // Including the burst clause, because the grid includes it: a + // predicate that quietly cost the ordering index would put the sort + // back and this is the only place that would notice. + let folded = uncollapsed("i"); let plan: Vec = catalog .connection() .prepare(&format!( "EXPLAIN QUERY PLAN SELECT {CELL_COLUMNS} FROM images i - WHERE {VISIBLE} + WHERE {VISIBLE}{folded} {GRID_ORDER} LIMIT 10 OFFSET 5" )) @@ -5014,6 +5143,58 @@ mod tests { catalog } + /// TRACES: FR-CULL-5 + /// A folded burst takes rows out of the cells, the count and the range a + /// shift-click resolves — all three, together. + /// + /// This is the test that would fail if the clause were added to four of + /// the five queries that need it. That failure has no other symptom: the + /// header claims images the grid will not draw, the scrollbar sizes itself + /// for rows that are not there, and neither number looks wrong on its own. + #[test] + fn folding_a_burst_takes_the_same_rows_out_of_every_answer() { + use dr_catalog::bursts::{self, Rules, Signature}; + + let catalog = with_images(4); + // Three of the four are one burst: a second apart, one signature. + let ids = image_ids(&catalog); + for (n, id) in ids.iter().enumerate() { + let hash = if n < 3 { 0xFF00 } else { 0x00FF }; + catalog + .connection() + .execute( + "UPDATE images SET captured_at = ?2, camera = 'Canon EOS R5', + perceptual_hash = ?3 + WHERE id = ?1", + rusqlite::params![id.0 as i64, 1_000 + n as i64, Signature(hash).to_stored()], + ) + .unwrap(); + } + bursts::regroup(catalog.connection(), Rules::default()).unwrap(); + + let filter = RatingFilter::default(); + // Open, as a new burst is: nothing has been taken away yet. + assert_eq!(read_cells(&catalog, 0, 50).unwrap().len(), 4); + assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 4); + + bursts::set_expanded(catalog.connection(), ids[0], false).unwrap(); + + let cells = read_cells(&catalog, 0, 50).unwrap(); + assert_eq!(cells.len(), 2, "the folded frames are still in the cells"); + assert_eq!( + total_images_filtered(&catalog, &filter).unwrap(), + cells.len(), + "the header's count and the cells disagree" + ); + assert_eq!( + read_ids_span(&catalog, None, &filter, false, 0, 49) + .unwrap() + .len(), + cells.len(), + "a shift-click over the whole grid would select frames it cannot show" + ); + } + fn image_ids(catalog: &Catalog) -> Vec { let mut stmt = catalog .connection() diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 348273c..15224f2 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -270,6 +270,22 @@ pub struct LibraryController { /// judgement, and carrying the old one over would report a server down /// that was never contacted. reachability: RefCell, + /// TRACES: FR-PLAT-AND-2 | FR-CAT-9 + /// Why the library folder itself could not be opened, if it could not. + /// + /// Beside [`Self::reachability`] rather than inside it, because + /// `dr_sync::Reachability` models *the server*, and it is deliberately + /// unmoved by a refusal — a forbidden file must not report the network as + /// down (see its own tests). A revoked tree grant is a refusal, so folding + /// it in would either break that rule or need an exception carved through + /// it. + /// + /// Set only by a scan that failed at the root, and cleared only by one + /// that succeeded. Both states drive the same banner as being offline + /// does, because what the user can do is the same — carry on with what is + /// stored on the device — but the sentence under it is different, and so + /// is what will end it. + root_lost: RefCell>, /// TRACES: FR-NC-6a /// Drains the pin downloader. Held so a second pin replaces the timer /// rather than leaving two draining the same finished channel. @@ -372,6 +388,7 @@ impl LibraryController { sidecar_timer: RefCell::new(None), generation: std::cell::Cell::new(0), reachability: RefCell::new(dr_sync::Reachability::new()), + root_lost: RefCell::new(None), outbox_timer: RefCell::new(None), outbox_maybe_dirty: std::cell::Cell::new(true), geometry_timer: RefCell::new(None), @@ -443,10 +460,15 @@ impl LibraryController { } } - /// TRACES: FR-CAT-9 - /// Whether the app currently believes the server is unreachable. + /// TRACES: FR-CAT-9 | FR-PLAT-AND-2 + /// Whether the library cannot be reached, for either of the two reasons. + /// + /// One answer rather than two because every caller asks it for the same + /// purpose: to decide whether starting a transfer is worth attempting. + /// A revoked grant fails that question exactly as a dead network does, and + /// a sync started against it would spend its retries proving it. pub fn is_offline(&self) -> bool { - self.reachability.borrow().is_offline() + self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some() } /// Whether the grid is narrowed to locally-stored originals. @@ -941,6 +963,17 @@ fn drain_scan( { log::info!("back online"); } + // TRACES: FR-PLAT-AND-2 + // And it is the only evidence that clears a lost root, + // for the same reason: the walk began by listing the + // root, so a scan that finished is a root that opened. + // The rows it marked offline are restored one at a + // time by `library::persist`, as each file is listed + // again — this only stops the banner claiming what is + // no longer true. + if ctl.root_lost.borrow_mut().take().is_some() { + log::info!("library folder is readable again"); + } refresh_offline(&w, ctl); // An incremental rescan lists almost nothing, so @@ -995,7 +1028,11 @@ fn drain_scan( stop(&ctl.scan_timer); return; } - ScanMessage::Failed { message, offline } => { + ScanMessage::Failed { + message, + offline, + lost_root, + } => { log::warn!("scan failed: {message}"); w.set_library_scanning(false); // Recorded as a failure even where it is only the @@ -1004,7 +1041,26 @@ fn drain_scan( // stopped because of it. job.fail(message.clone()); - if offline { + if lost_root { + // TRACES: FR-PLAT-AND-2 | FR-CAT-9 + // The library folder itself could not be opened — + // a share withdrawn, an unplugged drive, and in + // time a revoked document-tree grant. The worker + // has already marked every row under this root + // offline and deleted none of them; this is the + // half the user sees. + // + // Tested first because it is also true that the + // library is unreachable, and the generic answer + // would be reached first and be less useful. + *ctl.root_lost.borrow_mut() = Some(message); + refresh_offline(&w, ctl); + // Same reason as the offline arm below: without + // this a launch that began with a revoked grant + // shows an empty grid, which is the one impression + // this whole path exists to avoid. + open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl); + } else if offline { // Not an error state. The catalog from the last // successful scan is still on disk and still // accurate for everything already indexed, so the @@ -1585,17 +1641,35 @@ fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool { /// which is what keeps it testable without a display server. fn refresh_offline(window: &AppWindow, ctl: &Rc) { let reach = ctl.reachability.borrow(); - let offline = reach.is_offline(); + // TRACES: FR-PLAT-AND-2 + // A lost root wins over a dead network, and does so even when both are + // true — which is the ordinary case, since the scan that discovered the + // grant was gone was also the last request the app made. Reported the + // other way round the user is told to wait for a connection that is + // working, and the thing that would actually fix it is never mentioned. + let lost = ctl.root_lost.borrow(); + let offline = reach.is_offline() || lost.is_some(); window.set_library_offline(offline); - window.set_library_offline_reason(reach.reason().unwrap_or_default().into()); + window.set_library_offline_reason(match lost.as_deref() { + Some(why) => why.into(), + None => reach.reason().unwrap_or_default().into(), + }); window.set_library_offline_since( - reach - .offline_for(std::time::Instant::now()) - .map(describe_duration) - .unwrap_or_default() - .into(), + // A duration is what a network outage has and a revoked permission + // does not: "for 4 minutes" invites waiting, and waiting is precisely + // what will not help here. + if lost.is_some() { + slint::SharedString::default() + } else { + reach + .offline_for(std::time::Instant::now()) + .map(describe_duration) + .unwrap_or_default() + .into() + }, ); + drop(lost); // A stale scan error under an offline banner reports one problem twice. if offline { @@ -2214,6 +2288,11 @@ fn load_window(window: &AppWindow, ctl: &Rc) { // window, not one per cell. rating: 0, flag: 0, + // And by `bursts::sync_badges`, in one more query for the + // window. Zero is "not in a burst", which is what almost every + // photograph in a library is. + burst_count: 0, + burst_expanded: false, } }) .collect(); @@ -2246,6 +2325,9 @@ fn load_window(window: &AppWindow, ctl: &Rc) { .collect(); crate::collections_ui::sync_badges(window, catalog, &ids); sync_ratings(window, catalog, &ids); + // How many frames each cell stands for, where it stands for several + // (FR-CULL-5). + crate::bursts::sync_badges(window, catalog, &ids); // The rebuilt cells all carry `selected: false`, but the selection itself // is a set of image ids and survives untouched. Without this the ticks // vanished on every scroll — the selection was still there and still acted @@ -3769,6 +3851,28 @@ fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc) { if !offline { start_derived_sync(&w, &ctl_cb); } + // And now every photograph in the library has a + // thumbnail, which is the only moment all of its burst + // signatures can be computed. See `bursts::start_pass`, + // which owns the pass and everything it needs to drain + // itself; what it wants from here is the paths and a + // way to say the grid has something new to draw. + if let Some((conn, _)) = ctl_cb.session.borrow().clone() { + let weak_after = w.as_weak(); + let ctl_after = ctl_cb.clone(); + crate::bursts::start_pass( + library::catalog_path(&conn.account), + library::thumbs_dir(&conn.account), + move |bursts| { + let Some(w) = weak_after.upgrade() else { + return; + }; + if bursts > 0 && w.get_show_library() { + schedule_reload(&w, &ctl_after); + } + }, + ); + } return; } } @@ -4119,10 +4223,14 @@ fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option { catalog .connection() .query_row( - "SELECT captured_at FROM images - WHERE shadowed_by IS NULL AND captured_at IS NOT NULL - ORDER BY captured_at - LIMIT 1 OFFSET ?1", + &format!( + "SELECT captured_at FROM images + WHERE shadowed_by IS NULL AND captured_at IS NOT NULL + AND {} + ORDER BY captured_at + LIMIT 1 OFFSET ?1", + dr_catalog::bursts::not_collapsed_away("images") + ), [ordinal as i64], |r| r.get::<_, i64>(0), ) @@ -4153,13 +4261,21 @@ fn scrub_to(window: &AppWindow, ctl: &Rc, when: i64) { // BY), so they never precede a dated one and the predicate below stays // a simple `<`. Shadowed rows are excluded here exactly as the grid // excludes them. + // + // A burst folded up occupies one row of the grid, so it must occupy one + // row of this count as well: an ordinal taken over the unfolded library + // would overshoot by every frame hidden earlier in it. catalog .connection() .query_row( - "SELECT count(*) FROM images - WHERE shadowed_by IS NULL - AND captured_at IS NOT NULL - AND captured_at < ?1", + &format!( + "SELECT count(*) FROM images + WHERE shadowed_by IS NULL + AND captured_at IS NOT NULL + AND captured_at < ?1 + AND {}", + dr_catalog::bursts::not_collapsed_away("images") + ), [when], |r| r.get::<_, i64>(0), ) @@ -4953,6 +5069,34 @@ pub fn wire( }); } + // Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because + // it changes what the grid's query returns — see [`crate::bursts::toggle`]. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_burst_toggled(move |row| { + let Some(w) = weak.upgrade() else { return }; + let id = ctl + .image_ids + .borrow() + .get(row as usize) + .map(|id| dr_types::ImageId(*id as u64)); + let Some(id) = id else { return }; + + let changed = { + let borrow = ctl.catalog.borrow(); + match borrow.as_ref() { + Some(catalog) => crate::bursts::toggle(catalog, id), + None => false, + } + }; + if changed { + ctl.requested.borrow_mut().clear(); + load_window(&w, &ctl); + } + }); + } + // A rating or flag key. Applies to the whole selection, which is what // makes judging a run of frames one keystroke rather than forty. { diff --git a/ui/dr-ui/src/memory.rs b/ui/dr-ui/src/memory.rs new file mode 100644 index 0000000..37e2a9a --- /dev/null +++ b/ui/dr-ui/src/memory.rs @@ -0,0 +1,276 @@ +//! TRACES: FR-PLAT-AND-5 | NFR-RES-1 | FR-NC-6b +//! Giving memory back when the platform asks for it. +//! +//! Android kills the process that will not shrink. It does not negotiate and +//! it does not warn twice, and the app it kills is the one holding the most — +//! which, on a photo editor, is always this one. So the question this module +//! answers is not "how much can be freed" but "in what order", because the +//! caches differ enormously in what losing them costs. +//! +//! # The order, and why it is that order +//! +//! FR-PLAT-AND-5 states it: GPU tiles first, then proxies, then thumbnails. +//! Read as a rule rather than a list, it is *cheapest to rebuild goes first* — +//! a GPU allocation is remade from data already in memory, a proxy is remade +//! from a file already on disk, and a thumbnail may cost a network fetch. +//! [`Tier`] is that order written down where the code can be held to it, so +//! adding a cache means choosing its tier rather than choosing its position in +//! a hand-maintained sequence. +//! +//! What each tier actually reaches in this build is documented on the variant, +//! including where it reaches nothing yet. An empty tier is worth keeping +//! visible: it says the order is complete and the coverage is not. +//! +//! # Why this is a registry rather than a function that frees things +//! +//! Every cache worth evicting lives behind an `Rc>` owned by a +//! local in [`crate::run`], which is a two-thousand-line function whose +//! callbacks each hold their own handle. There is no central object to reach +//! them through, and inventing one to serve eviction alone would be a large +//! change to how the interface is wired for a small change in what it does. +//! +//! So `run` hands this module a closure per cache as it builds each one, and +//! this module owns only the ordering. The registration is next to the thing +//! being registered, which is also the property that keeps it honest: a cache +//! added later is one line away from being evictable, and a cache removed +//! takes its sink with it. +//! +//! # Everything here is single-threaded, and that is not a limitation +//! +//! The registry is a `thread_local`, holding `Fn()` rather than `Fn() + Send`, +//! because the pressure signal already arrives on the thread that owns the +//! caches. Slint's Android backend calls the event listener from inside +//! `poll_events`, which runs on the same thread as the event loop, which is +//! the thread `run` built everything on. Marshalling through +//! `invoke_from_event_loop` would add a hop and a lifetime question to solve a +//! problem that does not exist — and would arrive *after* the moment the +//! system asked, which for a memory warning is the one thing that matters. +//! +//! Anything reached from a worker thread — the thumbnail store, the original +//! cache — is on disk and bounded by its own budget (NFR-RES-4), and is not +//! what a memory warning is about. + +use std::cell::RefCell; + +/// How hard the platform is asking. +/// +/// Two levels rather than Android's eight, because two is what the platform +/// actually delivers to this app. `ComponentCallbacks2.onTrimMemory` and its +/// `TRIM_MEMORY_*` grades are a Java callback on an `Activity` or +/// `Application`; a `NativeActivity` receives only `ANativeActivityCallbacks`, +/// whose memory callback is the ungraded `onLowMemory` — which is what +/// android-activity surfaces as `MainEvent::LowMemory`. Modelling grades the +/// entry point cannot observe would be modelling a wish. +/// +/// [`Self::UiHidden`] recovers the one distinction that *is* observable and is +/// worth acting on, because it is the cheapest moment to give memory back: +/// nothing is on screen, so nothing that is freed has to be drawn again before +/// the user notices. It corresponds to `TRIM_MEMORY_UI_HIDDEN` in intent and +/// is derived from the activity being stopped rather than from a memory +/// warning at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + /// The app is no longer on screen. Free what only a visible window needs. + UiHidden, + /// The system says it is short of memory. Free everything that can be + /// rebuilt. + Critical, +} + +/// What a cache costs to lose, as an order. +/// +/// Declared in eviction order and iterated in declaration order by +/// [`Tier::ORDER`], so the sequence FR-PLAT-AND-5 specifies is a property of +/// this type rather than of each call site. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tier { + /// GPU allocations that are rebuilt from data the process still holds. + /// + /// The develop session's compiled pipelines, its detail intermediates and + /// its output textures. Rebuilt by the next render from the demosaiced + /// source, which is still resident — see + /// [`DevelopSession::release_gpu_caches`](crate::DevelopSession::release_gpu_caches) + /// for what is deliberately kept and what that is waiting on. + /// + /// First because it is both the largest evictable pool on a mobile GPU and + /// the cheapest to refill: no I/O, no network, one frame's work. + Gpu, + /// Decoded image data rebuilt by reading a file again. + /// + /// **Nothing registers here in this build, and the tier is kept anyway.** + /// There is no in-memory proxy cache: the only decoded full-size frame in + /// the process is the open develop session's, which belongs to + /// [`Tier::Gpu`] and cannot be dropped until a session can be rebuilt from + /// a durable record (FR-PLAT-AND-3). The on-disk original cache is a + /// different thing wearing the same word — freeing disk relieves no memory + /// pressure, and it already has a budget and an LRU of its own + /// (`dr_catalog::Cache`, NFR-RES-4). + Proxies, + /// Decoded thumbnails, rebuilt by decoding a stored JPEG again — or, at + /// worst, by fetching one. + /// + /// Last because this is the tier a user sees losing: an evicted portrait + /// is a rail that redraws, and an evicted grid cell is a photograph that + /// greys out and comes back. + Thumbnails, +} + +impl Tier { + /// The eviction order, in one place. + pub const ORDER: [Tier; 3] = [Tier::Gpu, Tier::Proxies, Tier::Thumbnails]; + + /// Whether this tier is given up at this level of pressure. + /// + /// Hiding the window frees the GPU tier and nothing else. That is not + /// caution about the rest — it is that a backgrounded app has no window to + /// draw and therefore no use at all for a render pipeline, while its + /// thumbnails are exactly what the user will be looking at half a second + /// after they come back. Under [`Level::Critical`] the process is being + /// measured against being killed, and a slow return beats no return. + fn evicted_at(self, level: Level) -> bool { + match level { + Level::UiHidden => matches!(self, Tier::Gpu), + Level::Critical => true, + } + } +} + +/// A cache that has offered itself up, and the tier it goes in. +/// +/// Named because the registry is a `Vec` of these and the nested type is hard +/// to read at the use site rather than because either half means anything on +/// its own. +type Sink = (Tier, Box); + +thread_local! { + /// Registered sinks, in the order they were registered within a tier. + /// + /// Within a tier the order is registration order and nothing depends on + /// it; between tiers it is [`Tier::ORDER`], which everything depends on. + static SINKS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Offer a cache up for eviction at `tier`. +/// +/// Called as each cache is built, so that the registration reads next to the +/// thing it is about. The closure is kept for the life of the thread; it must +/// therefore hold weak or shared handles rather than borrow anything, which is +/// the natural shape here because everything it can reach is already an `Rc`. +pub(crate) fn evict_at(tier: Tier, sink: impl Fn() + 'static) { + SINKS.with_borrow_mut(|sinks| sinks.push((tier, Box::new(sink)))); +} + +/// TRACES: FR-PLAT-AND-5 +/// Give memory back, in [`Tier::ORDER`], as far down as `level` calls for. +/// +/// Safe to call when nothing is registered — before the window is built, or on +/// a platform that never asks — in which case it does nothing at all. +/// +/// The registry is taken out of the cell for the duration rather than borrowed +/// across the calls. A sink runs arbitrary interface code, and interface code +/// that registered another cache, or called this again, would otherwise meet a +/// `RefCell` it had already borrowed and abort the process. Freeing memory is +/// the wrong moment to be brittle about re-entry. +pub fn relieve(level: Level) { + let taken: Vec<(Tier, Box)> = SINKS.with_borrow_mut(std::mem::take); + let mut run = 0usize; + for tier in Tier::ORDER { + if !tier.evicted_at(level) { + continue; + } + for (t, sink) in &taken { + if *t == tier { + sink(); + run += 1; + } + } + } + // Put them back, keeping anything a sink registered while it ran — after, + // so the order within a tier stays registration order. + SINKS.with_borrow_mut(|sinks| { + let added = std::mem::replace(sinks, taken); + sinks.extend(added); + }); + log::info!("memory pressure ({level:?}): ran {run} eviction(s)"); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::rc::Rc; + + /// Registers one sink per tier, backwards, and hands back what they saw. + fn recorder() -> Rc>> { + let seen = Rc::new(RefCell::new(Vec::new())); + for tier in [Tier::Thumbnails, Tier::Proxies, Tier::Gpu] { + let seen = seen.clone(); + evict_at(tier, move || seen.borrow_mut().push(tier)); + } + seen + } + + fn reset() { + SINKS.with_borrow_mut(|s| s.clear()); + } + + #[test] + fn eviction_runs_cheapest_to_rebuild_first() { + // Registered deliberately backwards, because the guarantee is about + // the tier and not about who registered first. A handler that simply + // ran its list would pass every other assertion here and fail this + // one — and on a device it would throw away thumbnails to keep a + // render pipeline that nothing was going to draw. + reset(); + let seen = recorder(); + relieve(Level::Critical); + assert_eq!( + *seen.borrow(), + vec![Tier::Gpu, Tier::Proxies, Tier::Thumbnails] + ); + reset(); + } + + #[test] + fn hiding_the_window_costs_only_the_gpu() { + // The cheap moment: give back what a window that is not on screen + // cannot use, and keep what the user will be looking at when they come + // back. Widening this to everything would make every task switch a + // reload of the grid. + reset(); + let seen = recorder(); + relieve(Level::UiHidden); + assert_eq!(*seen.borrow(), vec![Tier::Gpu]); + reset(); + } + + #[test] + fn pressure_before_anything_is_registered_is_not_a_failure() { + // The launch window: `android_main` installs the listener before + // `run` builds a single cache, so the first minutes of a cold start + // can deliver a warning to an empty registry. + reset(); + relieve(Level::Critical); + } + + #[test] + fn a_sink_may_register_another_without_deadlocking() { + // Guards the re-entry the take-and-restore exists for: a sink is + // interface code, and interface code that reached this module again + // would otherwise meet a borrow it already held. + reset(); + let seen = Rc::new(RefCell::new(0usize)); + { + let seen = seen.clone(); + evict_at(Tier::Gpu, move || { + *seen.borrow_mut() += 1; + evict_at(Tier::Thumbnails, || {}); + }); + } + relieve(Level::Critical); + assert_eq!(*seen.borrow(), 1); + // And the one it added survived, rather than being dropped with the + // temporary list. + assert_eq!(SINKS.with_borrow(|sinks| sinks.len()), 2); + reset(); + } +} diff --git a/ui/dr-ui/src/peaking.rs b/ui/dr-ui/src/peaking.rs new file mode 100644 index 0000000..70666d6 --- /dev/null +++ b/ui/dr-ui/src/peaking.rs @@ -0,0 +1,160 @@ +//! TRACES: FR-CULL-3 +//! The focus-peaking vocabulary, as the indices a chip row can carry. +//! +//! `dr_gpu` decides what peaking *is* — the measure, the thresholds, the +//! marks. This decides how a menu of three sensitivities and four colours +//! crosses the boundary into Slint, which has no notion of a Rust enum and +//! carries the choice as an `int` into an array of labels. +//! +//! That translation is small and it is the kind of small that goes wrong +//! silently. An index the interface sends that Rust reads as a different +//! variant produces a control that changes something other than what it says, +//! which nobody notices as a bug — they notice it as peaking behaving oddly. +//! So the order lives in one place here, both directions are asserted to round +//! trip, and a test checks that the labels in `ui/peaking.slint` still number +//! the same as the vocabularies they claim to name. +//! +//! Free-standing functions over plain integers, deliberately, for the reason +//! `crate::histogram` gives: none of this needs a GPU, a window or a +//! photograph to be checked, and all of it is invisible when wrong. + +use dr_gpu::{PeakColour, PeakSensitivity}; + +/// The sensitivities, in the order the chip row shows them. +/// +/// Least sensitive first, so the row reads left to right as "mark less" to +/// "mark more" — the axis the photographer is actually moving along. +pub(crate) const SENSITIVITIES: [PeakSensitivity; 3] = [ + PeakSensitivity::Low, + PeakSensitivity::Medium, + PeakSensitivity::High, +]; + +/// The mark colours, in the order the chip row shows them. +pub(crate) const COLOURS: [PeakColour; 4] = [ + PeakColour::Red, + PeakColour::Yellow, + PeakColour::Cyan, + PeakColour::Magenta, +]; + +/// The sensitivity an index names. +/// +/// Out of range falls back to the default rather than panicking. The index +/// arrives from the interface, and the interface is the half of this that can +/// be recompiled without recompiling the other — a chip row that grew an entry +/// should degrade to a sane setting, not take the application down mid-cull. +pub(crate) fn sensitivity(index: i32) -> PeakSensitivity { + usize::try_from(index) + .ok() + .and_then(|i| SENSITIVITIES.get(i).copied()) + .unwrap_or_default() +} + +/// The colour an index names, on the same terms. +pub(crate) fn colour(index: i32) -> PeakColour { + usize::try_from(index) + .ok() + .and_then(|i| COLOURS.get(i).copied()) + .unwrap_or_default() +} + +/// Which chip is lit for this sensitivity. +pub(crate) fn sensitivity_index(value: PeakSensitivity) -> i32 { + SENSITIVITIES.iter().position(|s| *s == value).unwrap_or(0) as i32 +} + +/// Which chip is lit for this colour. +pub(crate) fn colour_index(value: PeakColour) -> i32 { + COLOURS.iter().position(|c| *c == value).unwrap_or(0) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_variant_appears_exactly_once_in_its_row() { + // A variant missing from the row is a setting the photographer cannot + // reach; one listed twice is two chips that do the same thing, of + // which only the first can ever look selected. Both are invisible in + // the running application until somebody presses the wrong chip. + for s in SENSITIVITIES { + assert_eq!( + SENSITIVITIES.iter().filter(|x| **x == s).count(), + 1, + "{s:?} is listed more than once" + ); + } + for c in COLOURS { + assert_eq!(COLOURS.iter().filter(|x| **x == c).count(), 1); + } + // Named rather than counted, so adding a variant to `dr_gpu` without + // adding it here fails to compile instead of passing quietly. + assert!(SENSITIVITIES.contains(&PeakSensitivity::Low)); + assert!(SENSITIVITIES.contains(&PeakSensitivity::Medium)); + assert!(SENSITIVITIES.contains(&PeakSensitivity::High)); + assert!(COLOURS.contains(&PeakColour::Red)); + assert!(COLOURS.contains(&PeakColour::Yellow)); + assert!(COLOURS.contains(&PeakColour::Cyan)); + assert!(COLOURS.contains(&PeakColour::Magenta)); + } + + #[test] + fn an_index_and_its_variant_agree_in_both_directions() { + // The failure this catches is a chip that lights up under the pointer + // while a different setting takes effect — the two directions drifting + // apart is exactly what one shared array is here to prevent, and the + // only way to see it is to go round. + for (i, s) in SENSITIVITIES.iter().enumerate() { + assert_eq!(sensitivity(i as i32), *s); + assert_eq!(sensitivity_index(*s), i as i32); + } + for (i, c) in COLOURS.iter().enumerate() { + assert_eq!(colour(i as i32), *c); + assert_eq!(colour_index(*c), i as i32); + } + } + + #[test] + fn an_index_from_nowhere_lands_on_the_default_rather_than_panicking() { + // Slint has no bound on the `int` it sends and Rust has no way to + // refuse one. A panic here would be an application that closes because + // a chip row was edited. + assert_eq!(sensitivity(-1), PeakSensitivity::default()); + assert_eq!(sensitivity(99), PeakSensitivity::default()); + assert_eq!(colour(-1), PeakColour::default()); + assert_eq!(colour(99), PeakColour::default()); + } + + #[test] + fn the_panel_offers_exactly_the_choices_this_module_knows_about() { + // **The one seam neither compiler checks.** The labels live in + // `ui/peaking.slint` and the meanings live here, joined only by an + // integer; a fifth colour added to the chip row would send index 4 to + // `colour`, which would quietly answer Red. Reading the file is + // clumsier than a derive, and it is what there is. + let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/peaking.slint")) + .expect("the panel this module serves"); + + let listed = |line_start: &str| -> usize { + let line = src + .lines() + .map(str::trim) + .find(|l| l.starts_with(line_start)) + .unwrap_or_else(|| panic!("no `{line_start}` row in peaking.slint")); + line.matches('"').count() / 2 + }; + + assert_eq!( + listed("options: [\"Low\""), + SENSITIVITIES.len(), + "the sensitivity chips and `SENSITIVITIES` disagree" + ); + assert_eq!( + listed("options: [\"Red\""), + COLOURS.len(), + "the colour chips and `COLOURS` disagree" + ); + } +} diff --git a/ui/dr-ui/src/preset_store.rs b/ui/dr-ui/src/preset_store.rs new file mode 100644 index 0000000..710133a --- /dev/null +++ b/ui/dr-ui/src/preset_store.rs @@ -0,0 +1,192 @@ +//! TRACES: FR-DEV-6 | FR-PLAT-LIN-1 +//! Reads and writes the named preset library beside the other config. +//! +//! A near-twin of [`SettingsStore`](crate::settings_store::SettingsStore), and +//! separate from it for the reason that one is: two files, two lifetimes. +//! Resetting preferences must not destroy a photographer's presets, and a +//! preset library is the one file here that represents work rather than +//! configuration — it is what someone would carry to another machine. +//! +//! # Why the library is loaded whole and saved whole +//! +//! There is no incremental path. The document is kilobytes (see +//! [`PresetLibrary`]), the caller is holding the copy the user just edited, +//! and a merge would let a preset the window has not drawn yet resurrect +//! after a delete. The same argument the settings store makes about fields, +//! made about entries. + +use std::path::{Path, PathBuf}; + +use dr_pipeline::PresetLibrary; + +/// Loads and saves the named preset library. +pub struct PresetStore { + path: PathBuf, +} + +impl PresetStore { + /// Open the store at the platform config location. + /// + /// Linux: `$XDG_CONFIG_HOME/darkroom/presets.drpl`, falling back to + /// `~/.config` — the same resolution `SettingsStore` does, so the files sit + /// together and a user backing up one takes all of them. + pub fn open() -> Self { + let dir = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config") + }) + .join("darkroom"); + Self::open_at(dir.join(format!( + "presets.{}", + dr_pipeline::preset::LIBRARY_EXTENSION + ))) + } + + /// Open at an explicit path — for tests, and for a non-default location. + pub fn open_at(path: PathBuf) -> Self { + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// The stored library, or an empty one. + /// + /// A missing file is a first run. An unparseable one is answered with an + /// empty library rather than an error, on the same reasoning the settings + /// store gives — the alternative is an app that will not start until the + /// user hand-edits a file. + /// + /// The difference worth stating: settings are regenerated on the next + /// save, where a preset library is *work*, and rewriting it whole would + /// destroy whatever was in there. So a library that failed to parse is + /// held empty in memory and **not** written back over until the user saves + /// a preset, at which point they have chosen to. Nothing here deletes the + /// file, and the warning names the path so it can be recovered by hand. + pub fn load(&self) -> PresetLibrary { + match std::fs::read_to_string(&self.path) { + Ok(text) => match PresetLibrary::parse(&text) { + Ok(library) => library, + Err(e) => { + log::warn!( + "{} is not a readable preset library ({e}); \ + starting empty, the file is left alone", + self.path.display() + ); + PresetLibrary::default() + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => PresetLibrary::default(), + Err(e) => { + log::warn!("reading {}: {e}; starting empty", self.path.display()); + PresetLibrary::default() + } + } + } + + /// Persist the library, replacing whatever was there. + pub fn save(&self, library: &PresetLibrary) -> Result<(), PresetStoreError> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + + // Write and rename, so an interrupted save cannot truncate the + // existing file — the same discipline the settings and session stores + // use, and it matters more here because what would be truncated is + // every preset the user has ever made rather than a set of + // preferences that rebuild themselves. + let tmp = self.path.with_extension("tmp"); + std::fs::write(&tmp, library.to_text())?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PresetStoreError { + #[error("preset library io: {0}")] + Io(#[from] std::io::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + use dr_pipeline::Preset; + + /// The same hand-rolled temp directory the settings store's tests use — + /// unique per process and thread, so a parallel run cannot collide. + fn tempdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "dr-presets-test-{name}-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn store(name: &str) -> (PresetStore, PathBuf) { + let dir = tempdir(name); + ( + PresetStore::open_at(dir.join("nested").join("presets.drpl")), + dir, + ) + } + + fn library() -> PresetLibrary { + let mut lib = PresetLibrary::default(); + let mut params = std::collections::BTreeMap::new(); + params.insert(("exposure".to_string(), "exposure".to_string()), 0.75); + lib.insert("Warm", Preset::from_params(params)).unwrap(); + lib.insert("Neutral", Preset::default()).unwrap(); + lib + } + + #[test] + fn a_library_round_trips_through_the_store() { + let (store, _dir) = store("a-library-round-trips-through-the-store"); + store.save(&library()).unwrap(); + assert_eq!(store.load(), library()); + } + + #[test] + fn a_first_run_loads_an_empty_library() { + let (store, _dir) = store("a-first-run-loads-an-empty-library"); + assert!(!store.path().exists()); + assert!(store.load().is_empty()); + } + + #[test] + fn saving_creates_the_config_directory() { + let (store, _dir) = store("saving-creates-the-config-directory"); + store.save(&library()).unwrap(); + assert!(store.path().exists()); + } + + #[test] + fn an_unreadable_file_is_left_alone_rather_than_overwritten() { + // The difference from the settings store, and the reason this test + // exists: what is on disk is work, so a parse failure must not be the + // moment it is destroyed. + let (store, _dir) = store("an-unreadable-file-is-left-alone-rather-than-overwritten"); + std::fs::create_dir_all(store.path().parent().unwrap()).unwrap(); + std::fs::write(store.path(), "this is not a preset library").unwrap(); + + assert!(store.load().is_empty()); + assert_eq!( + std::fs::read_to_string(store.path()).unwrap(), + "this is not a preset library" + ); + } + + #[test] + fn a_neutral_preset_survives_a_save_and_load() { + // It is a real entry, not an absence — see `PresetLibrary::insert`. + let (store, _dir) = store("a-neutral-preset-survives-a-save-and-load"); + store.save(&library()).unwrap(); + assert_eq!(store.load().get("Neutral"), Some(&Preset::default())); + } +} diff --git a/ui/dr-ui/src/presets.rs b/ui/dr-ui/src/presets.rs index c761389..ee2a657 100644 --- a/ui/dr-ui/src/presets.rs +++ b/ui/dr-ui/src/presets.rs @@ -35,10 +35,11 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::rc::Rc; -use dr_pipeline::{Preset, Scope, Sidecar}; +use dr_pipeline::{NameError, Preset, PresetLibrary, Scope, Sidecar}; use slint::ComponentHandle; use crate::develop::DevelopSession; +use crate::preset_store::PresetStore; use crate::{library, library_ui, settings_ui, AppWindow, ParamRow}; /// Where the develop view's current edit is stored. @@ -121,11 +122,21 @@ impl Clipboard { let Some(preset) = self.preset.borrow().clone() else { return String::new(); }; - match preset.op_count(scope) { - 0 => "Neutral".to_string(), - 1 => "1 adjustment".to_string(), - n => format!("{n} adjustments"), - } + describe(&preset, scope) + } +} + +/// A short description of a preset's contents, for a label. +/// +/// Free rather than a method on [`Clipboard`] because the named-preset sheet +/// describes what *saving* would capture, and a second phrasing of the same +/// count is a second thing to keep in step — the sheet saying "3 settings" +/// beside a panel saying "3 adjustments" would read as two different numbers. +pub fn describe(preset: &Preset, scope: Scope) -> String { + match preset.op_count(scope) { + 0 => "Neutral".to_string(), + 1 => "1 adjustment".to_string(), + n => format!("{n} adjustments"), } } @@ -528,6 +539,308 @@ pub fn wire( } } +// --------------------------------------------------------------------------- +// Named presets (FR-DEV-6) +// --------------------------------------------------------------------------- + +/// TRACES: FR-DEV-6 +/// The saved preset library, and the file it lives in. +/// +/// # Why the library is held in memory as well as on disk +/// +/// Every change writes the whole file (see [`PresetStore`]), so the in-memory +/// copy is what the sheet is drawn from and what the next edit is applied to. +/// Reading the file back after each change would be the same bytes and one +/// more chance for a failed read to empty a list the user is looking at. +/// +/// # A failed save is reported, not swallowed +/// +/// The clipboard cannot fail — it is memory. This can: a full disk, a config +/// directory that is not writable. Losing a preset the user just named, with +/// the sheet cheerfully listing it, would be discovered at the worst possible +/// moment, so the write's result reaches the window. +pub struct NamedPresets { + store: PresetStore, + library: RefCell, +} + +impl NamedPresets { + /// Load the library from its usual place. + pub fn open() -> Rc { + Self::at(PresetStore::open()) + } + + /// Load from an explicit store — for tests, and for a non-default location. + #[cfg(test)] + pub fn open_at(path: PathBuf) -> Rc { + Self::at(PresetStore::open_at(path)) + } + + fn at(store: PresetStore) -> Rc { + let library = RefCell::new(store.load()); + Rc::new(Self { store, library }) + } + + /// The stored names, in the order they are written. + pub fn names(&self) -> Vec { + self.library.borrow().names().map(String::from).collect() + } + + /// The preset stored under `name`. + pub fn get(&self, name: &str) -> Option { + self.library.borrow().get(name).cloned() + } + + /// Store `preset` under `name` and persist. + /// + /// The in-memory library is updated first and rolled back if the write + /// fails, so what the sheet lists is always what is on disk. The + /// alternative — writing first — would mean holding a preset the file does + /// not have on every failure path. + fn insert(&self, name: &str, preset: Preset) -> Result<(), SaveError> { + let previous = { + let mut library = self.library.borrow_mut(); + let existing = library.get(name.trim()).cloned(); + library.insert(name, preset).map_err(SaveError::Name)?; + existing + }; + self.persist(|library| match previous { + Some(p) => { + let _ = library.insert(name, p); + } + None => { + library.remove(name.trim()); + } + }) + } + + /// Save the library, undoing the in-memory change if the write fails. + fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> { + let result = self.store.save(&self.library.borrow()); + match result { + Ok(()) => Ok(()), + Err(e) => { + rollback(&mut self.library.borrow_mut()); + // Named, the way the settings page names its file: "could not + // save" without saying where leaves the user nothing to check + // and nothing to fix. + Err(SaveError::Write(format!( + "{}: {e}", + self.store.path().display() + ))) + } + } + } +} + +/// Why a preset could not be saved. +enum SaveError { + /// The name itself was refused. + Name(NameError), + /// The library could not be written. + Write(String), +} + +impl SaveError { + /// What to put in front of the user. + /// + /// The prose lives here rather than in `dr-pipeline`, which depends on + /// nothing and has no business holding user-facing strings. + fn message(&self) -> String { + match self { + Self::Name(NameError::Empty) => "Give the preset a name.".to_string(), + Self::Name(NameError::Unrepresentable) => { + "A preset name cannot contain brackets or line breaks.".to_string() + } + Self::Write(e) => format!("Could not save presets: {e}"), + } + } +} + +/// Push the stored names onto the window. +pub fn render_named(window: &AppWindow, named: &Rc) { + let names: Vec = named.names().into_iter().map(Into::into).collect(); + window.set_preset_names(slint::ModelRc::new(slint::VecModel::from(names))); +} + +/// Wire saving, applying, renaming and deleting named presets. +/// +/// Takes the same [`Develop`] bundle the clipboard wiring does, and for the +/// same reason: applying a preset to the open image changes the graph, so it +/// has to rebuild the panel, redraw the canvas and know where to save. +pub fn wire_named( + window: &AppWindow, + named: Rc, + develop: Develop, + settings: Rc, + library: Rc, + collections: Rc, +) { + let Develop { + session, + rows, + redraw, + open, + } = develop; + + render_named(window, &named); + + // --- save the open edit under a name --------------------------------- + { + let weak = window.as_weak(); + let named = named.clone(); + let session = session.clone(); + window.on_save_preset(move |name| { + let Some(w) = weak.upgrade() else { return }; + let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else { + w.set_preset_name_error("Open a photograph first.".into()); + return; + }; + // Captured at full scope, exactly as a copy is: the scope is a + // decision about applying, and a preset that had already discarded + // the crop could never grow it back (see `Preset::capture`). + match named.insert(&name, preset) { + Ok(()) => { + w.set_preset_name_error(Default::default()); + render_named(&w, &named); + } + Err(e) => w.set_preset_name_error(e.message().into()), + } + }); + } + + // A refusal the user has started correcting is stale, and a message that + // outlives its cause is one the user learns to ignore. + { + let weak = window.as_weak(); + window.on_preset_name_edited(move |_| { + if let Some(w) = weak.upgrade() { + w.set_preset_name_error(Default::default()); + } + }); + } + + // --- what saving would capture ---------------------------------------- + { + let weak = window.as_weak(); + let session = session.clone(); + window.on_presets_opened(move || { + let Some(w) = weak.upgrade() else { return }; + // At the scope a save would use, which is full: a preset keeps + // the framing it was captured with and drops it at apply time. + let summary = session + .borrow() + .as_ref() + .map(|s| describe(&s.copy_settings(), Scope::Everything)) + .unwrap_or_default(); + w.set_preset_capture_summary(summary.into()); + }); + } + + // --- apply ------------------------------------------------------------ + // + // One callback for both targets. Which one is meant is not a guess: the + // sheet was opened from a view that set `preset-apply-count`, and the + // label the user just read said "Applies to 12 selected photographs" or + // said nothing. Deciding here from the same number keeps the promise. + { + let weak = window.as_weak(); + let named = named.clone(); + let settings = settings.clone(); + let library = library.clone(); + let collections = collections.clone(); + let session = session.clone(); + let rows = rows.clone(); + let redraw = redraw.clone(); + let open = open.clone(); + window.on_apply_preset(move |name| { + let Some(w) = weak.upgrade() else { return }; + let Some(preset) = named.get(&name) else { + // Another window may have deleted it since this list was drawn. + render_named(&w, &named); + return; + }; + let scope = scope_for(&settings.snapshot()); + + if w.get_preset_apply_count() > 0 { + library_ui::paste_settings_to_selection( + &w, + &library, + &collections.selected(), + &preset, + scope, + ); + } else { + { + let mut slot = session.borrow_mut(); + let Some(s) = slot.as_mut() else { return }; + s.apply_settings(&preset, scope); + } + // The same three steps a paste takes, for the same reasons: + // many controls moved without any of them being touched, and + // a deliberate discrete action is saved immediately. + crate::sync_rows(&w, &rows, &session); + redraw(&w); + save_open_edit(&w, &open.borrow(), &session, &library); + } + + w.set_presets_open(false); + }); + } + + // --- rename ----------------------------------------------------------- + { + let weak = window.as_weak(); + let named = named.clone(); + window.on_rename_preset(move |from, to| { + let Some(w) = weak.upgrade() else { return }; + let renamed = { + let mut library = named.library.borrow_mut(); + library.rename(&from, &to) + }; + match renamed { + Ok(_) => { + // Nothing to roll back to on a failed write beyond the + // name it had, which is what this restores. + let from = from.to_string(); + let to = to.to_string(); + if let Err(e) = named.persist(move |library| { + let _ = library.rename(&to, &from); + }) { + w.set_preset_name_error(e.message().into()); + } + } + Err(e) => w.set_preset_name_error(SaveError::Name(e).message().into()), + } + render_named(&w, &named); + }); + } + + // --- delete ----------------------------------------------------------- + { + let weak = window.as_weak(); + let named = named.clone(); + window.on_delete_preset(move |name| { + let Some(w) = weak.upgrade() else { return }; + let removed = { + let mut library = named.library.borrow_mut(); + let previous = library.get(&name).cloned(); + library.remove(&name); + previous + }; + if let Some(previous) = removed { + let name = name.to_string(); + if let Err(e) = named.persist(move |library| { + let _ = library.insert(&name, previous); + }) { + w.set_preset_name_error(e.message().into()); + } + } + render_named(&w, &named); + }); + } +} + /// Seconds since the epoch, or zero if the clock is before it. /// /// Zero rather than a panic: a wrong timestamp costs a tie-break in the merge, @@ -804,4 +1117,106 @@ mod tests { assert!(clipboard.is_armed()); assert_eq!(clipboard.describe(Scope::Adjustments), "Neutral"); } + + // ----------------------------------------------------------------------- + // Named presets + // ----------------------------------------------------------------------- + + fn named(name: &str) -> (Rc, PathBuf) { + let dir = tempdir(name); + (NamedPresets::open_at(dir.join("presets.drpl")), dir) + } + + #[test] + fn a_saved_preset_is_on_disk_before_the_call_returns() { + // Not on the way out, and not on a timer: a preset the user named and + // then lost to a crash is the one failure this feature cannot have. + let (presets, dir) = named("saved-immediately"); + presets + .insert("Warm", Preset::capture(&edited())) + .ok() + .expect("saved"); + + let reloaded = NamedPresets::open_at(dir.join("presets.drpl")); + assert_eq!(reloaded.names(), vec!["Warm".to_string()]); + } + + #[test] + fn a_saved_preset_carries_the_edit_it_captured() { + let (presets, _dir) = named("carries-the-edit"); + presets.insert("Warm", Preset::capture(&edited())).ok(); + + let preset = presets.get("Warm").expect("stored"); + let mut target = EditGraph::default_chain(); + preset.apply(&mut target, Scope::Adjustments); + assert_eq!( + target.param( + dr_pipeline::ops::exposure::ID, + dr_pipeline::ops::exposure::EXPOSURE + ), + Some(1.5) + ); + } + + #[test] + fn a_name_that_cannot_be_stored_is_refused_rather_than_mangled() { + let (presets, _dir) = named("refused-name"); + assert!(presets.insert("", Preset::default()).is_err()); + assert!(presets.insert("bracket]", Preset::default()).is_err()); + assert!(presets.names().is_empty()); + } + + #[test] + fn a_write_that_fails_leaves_the_list_showing_what_is_on_disk() { + // The rollback. A sheet listing a preset the file does not have is a + // loss the user discovers later, at the moment they reach for it. + let dir = tempdir("failed-write"); + // A *file* where the store wants a directory, so `create_dir_all` + // fails and the save cannot succeed. + let blocked = dir.join("blocked"); + std::fs::write(&blocked, b"not a directory").unwrap(); + + let presets = NamedPresets::open_at(blocked.join("presets.drpl")); + assert!(presets.insert("Warm", Preset::default()).is_err()); + assert!( + presets.names().is_empty(), + "the failed save left a preset behind" + ); + } + + #[test] + fn a_failed_overwrite_puts_the_original_back() { + // The other half of the rollback, and the one that loses work if it is + // wrong: overwriting is destructive, so a failed overwrite has to + // restore what was there rather than leave the name holding the new + // value the file never received. + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir("failed-overwrite"); + let path = dir.join("presets.drpl"); + let presets = NamedPresets::open_at(path.clone()); + presets.insert("Warm", Preset::capture(&edited())).ok(); + let original = presets.get("Warm").expect("stored"); + + // The directory exists, so `create_dir_all` still succeeds and it is + // the write of the temporary file that fails — which is the path a + // full disk takes. + let mut perms = std::fs::metadata(&dir).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&dir, perms.clone()).unwrap(); + + let failed = presets.insert("Warm", Preset::default()).is_err(); + + // Restore the permissions before asserting, so a failure here does not + // leave an undeletable directory behind for the next run. + perms.set_mode(0o700); + std::fs::set_permissions(&dir, perms).unwrap(); + + assert!(failed, "the write should have failed"); + assert_eq!( + presets.get("Warm"), + Some(original), + "the failed overwrite kept the new value" + ); + } } diff --git a/ui/dr-ui/src/settings_store.rs b/ui/dr-ui/src/settings_store.rs index 755772d..d8b9c54 100644 --- a/ui/dr-ui/src/settings_store.rs +++ b/ui/dr-ui/src/settings_store.rs @@ -1,4 +1,4 @@ -//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 +//! TRACES: FR-PLAT-LIN-1 | FR-NC-6a | FR-EXP-5 | NFR-OPS-3 //! Reads and writes `settings.json` beside the session config. //! //! Deliberately a near-twin of [`SessionStore`](dr_sync_nextcloud::SessionStore) diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index e96eed9..ecbb95d 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -733,6 +733,10 @@ export component TransferPanel inherits VerticalLayout { callback copy(); callback paste(); + /// TRACES: FR-DEV-6 + /// Open the named-preset sheet. Beside copy and paste because it is the + /// same thought given a name — this edit, kept. + callback open-presets(); /// TRACES: FR-UI-2 /// How wide this panel has to be before it starts clipping itself — the @@ -772,6 +776,16 @@ export component TransferPanel inherits VerticalLayout { } } + // TRACES: FR-DEV-6 + // The saved half. On its own row rather than a third of the one above, + // because copy and paste are a pair — one arms the other — and a button + // that does neither sitting between them would read as part of that pair. + Button { + text: "Presets…"; + enabled: root.enabled; + clicked => { root.open-presets(); } + } + if root.armed: Caption { text: root.summary + (root.framing-withheld ? " · crop not included" : ""); } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index ab474af..022980d 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -10,6 +10,8 @@ import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChi import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint"; import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint"; import { HistogramPanel, HistogramView } from "histogram.slint"; +import { PresetSheet } from "presets.slint"; +import { FocusMarks, FocusPanel } from "peaking.slint"; import { SettingsPage } from "settings.slint"; import { ImportPage } from "import.slint"; import { StatusBar, InfoPanel } from "develop.slint"; @@ -70,6 +72,22 @@ export component AppWindow inherits Window { /// of a draft frame is a histogram of an image nobody is reading. in property histogram; + /// TRACES: FR-CULL-3 + /// Focus peaking: the marks, whether they describe *this* frame, and the + /// three things the photographer chose. All of them are Rust's, because + /// the marks come from a compute pass — see `peaking.slint` for why + /// `focus-overlay-ready` is a separate question from `peaking-on`. + in property focus-overlay; + in property focus-overlay-ready: false; + in property peaking-on: false; + in property peaking-available: true; + in property peaking-sensitivity: 1; + in property peaking-colour: 0; + + callback peaking-toggled(bool); + callback peaking-sensitivity-picked(int); + callback peaking-colour-picked(int); + // --- zoom, pan and crop (FR-DEV-4) --- // // Zoom is a *viewing* state, not an edit: it changes the resolution the @@ -585,6 +603,8 @@ export component AppWindow inherits Window { callback library-cell-rated(int, int); /// The trash target was clicked on one cell, by row. callback library-cell-trashed(int); + /// The burst mark was clicked on one cell, by row (FR-CULL-5). + callback library-burst-toggled(int); /// Move the grid selection to the trash — the `Delete` key. callback library-trash-selection(); /// A judgement key was pressed, applying to the whole selection. One of @@ -661,6 +681,35 @@ export component AppWindow inherits Window { /// Apply the clipboard to every selected image in the grid. callback paste-settings-to-selection(); + // --- named presets (FR-DEV-6) --- + // + // The saved half of the same requirement. On the window rather than in a + // view for the reason the clipboard is: a preset is saved in develop, + // where there is an edit to capture, and applied most often in the grid, + // where there is a selection to apply it to. + in-out property presets-open: false; + in property <[string]> preset-names; + /// Whether there is an edit in hand to save, set by whichever view opened + /// the sheet. `in-out` because that is where the answer is known. + in-out property preset-can-save: false; + /// What applying would act on: 0 is the open photograph, higher is that + /// many selected. Set at open, and read back by Rust when one is picked — + /// so the action matches the count the user read on the way in. + in-out property preset-apply-count: 0; + /// What saving would capture, from the routine the clipboard summary uses. + in property preset-capture-summary; + /// Why the last name was refused, cleared by the next keystroke. + in property preset-name-error; + callback save-preset(string); + callback apply-preset(string); + callback rename-preset(string, string); + callback delete-preset(string); + callback preset-name-edited(string); + /// Raised as the sheet opens over an open photograph, so Rust can say what + /// saving would capture. A property refreshed on every slider drag would + /// be recomputing a string nobody is looking at. + callback presets-opened(); + // --- settings (FR-EXP-1, FR-EXP-3, FR-NC-6a) --- // // A page rather than an overlay, and the outermost of the view conditions @@ -1444,6 +1493,15 @@ in property panel-visible: true; paste-settings-to-selection => { root.paste-settings-to-selection(); } + // TRACES: FR-DEV-6 + // Opened with the selection's size, which is both what the + // sheet says it would act on and what the apply handler + // reads back to decide it means the batch. + open-presets => { + root.preset-apply-count = root.library-selected-count; + root.preset-can-save = false; + root.presets-open = true; + } // TRACES: FR-EXP-7 exporting: root.library-exporting; @@ -1527,6 +1585,7 @@ in property panel-visible: true; cell-rated(i, n) => { root.library-cell-rated(i, n); } cell-trashed(i) => { root.library-cell-trashed(i); } + burst-toggled(i) => { root.library-burst-toggled(i); } trash-selection() => { root.library-trash-selection(); } // Derived from the sidebar's own selection rather than // mirrored in a second property: `-1` is already the sentinel @@ -1654,6 +1713,18 @@ in property panel-visible: true; image-rendering: ImageRendering.pixelated; } + // TRACES: FR-CULL-3 + // The focus marks, over the same fitted rect. See + // `peaking.slint` for why they are a layer over the canvas + // rather than a tint in it. + if root.focus-overlay-ready && root.total > 0: FocusMarks { + x: parent.shown-x; + y: parent.shown-y; + width: parent.shown-w; + height: parent.shown-h; + marks: root.focus-overlay; + } + // Where the photograph actually sits inside this box. // // `image-fit: contain` letterboxes, and Slint does not report @@ -2187,6 +2258,26 @@ in property panel-visible: true; background: Theme.rule; } + // TRACES: FR-CULL-3 + // Under the histogram, because the two are the same + // kind of thing: instruments that report on the + // photograph rather than change it. Kept in every + // mode for the same reason the histogram is. + FocusPanel { + available: root.peaking-available; + showing: root.peaking-on; + sensitivity: root.peaking-sensitivity; + colour: root.peaking-colour; + toggled(v) => { root.peaking-toggled(v); } + sensitivity-picked(i) => { root.peaking-sensitivity-picked(i); } + colour-picked(i) => { root.peaking-colour-picked(i); } + } + + Rectangle { + height: 1px; + background: Theme.rule; + } + // Framing above the colour work, matching how the edit is // made rather than how it is applied: the frame is decided // by eye first and the pipeline runs it last (see @@ -2240,6 +2331,15 @@ in property panel-visible: true; framing-withheld: root.settings-framing-withheld; copy => { root.copy-settings(); } paste => { root.paste-settings(); } + // Opened with no count, which is what tells + // the apply handler this means the open + // photograph rather than a selection. + open-presets => { + root.preset-apply-count = 0; + root.preset-can-save = root.adjust-enabled; + root.presets-opened(); + root.presets-open = true; + } } Rectangle { @@ -2439,6 +2539,26 @@ in property panel-visible: true; // Below the load bar, above everything else: a download started from // here shows its progress in that bar, and the bar must not be the // thing the dialogue covers. + // TRACES: FR-DEV-6 + // Over the shell rather than inside a view, because both views open + // it — and because the develop column is 320px wide, which is not + // enough to list presets and rename one in. + if root.presets-open: PresetSheet { + width: 100%; + height: 100%; + names: root.preset-names; + can-save: root.preset-can-save; + apply-count: root.preset-apply-count; + capture-summary: root.preset-capture-summary; + name-error: root.preset-name-error; + save(name) => { root.save-preset(name); } + apply(name) => { root.apply-preset(name); } + rename(from, to) => { root.rename-preset(from, to); } + remove(name) => { root.delete-preset(name); } + name-edited(text) => { root.preset-name-edited(text); } + dismiss => { root.presets-open = false; } + } + OfflinePrompt { width: 100%; height: 100%; diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 5a16a5d..d3c08f8 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -468,6 +468,16 @@ export struct LibraryCell { // 0 unflagged, 1 pick, 2 reject. Independent of the stars: rejecting a // four-star frame is a normal thing to do mid-cull. flag: int, + // Frames in the burst this cell belongs to (FR-CULL-5), itself included; 0 where it + // belongs to none, which is most of a library. A burst that is collapsed + // draws only its representative, so on that cell this is the count of what + // is hidden behind it — the reason it is shown at all. + burst-count: int, + // Whether the group is currently open. Drawn differently rather than + // hidden: a burst the user has expanded is the one thing on screen that + // needs a way back, and a control that disappears once used is a control + // nobody finds twice. + burst-expanded: bool, } // The photo roll: the grid's loaded window along the foot of the develop view. @@ -860,6 +870,9 @@ component HeaderActions inherits HorizontalLayout { callback export-selection(); callback cancel-export(); callback paste-settings-to-selection(); + /// TRACES: FR-DEV-6 + /// Open the named-preset sheet over the selection. + callback open-presets(); callback remove-from-collection(); /// Open the sheet that files the selection in a collection. callback add-to-collection(); @@ -941,6 +954,20 @@ component HeaderActions inherits HorizontalLayout { clicked => { root.paste-settings-to-selection(); } } + // TRACES: FR-DEV-6 + // The saved settings, beside the copied ones. + // + // Gated on the selection alone, unlike the paste beside it: that button + // needs a clipboard *this session*, where the preset list is whatever the + // photographer saved last month. Requiring an armed clipboard here would + // hide the saved presets behind an unrelated action — which is the shape + // of bug that makes a feature only its author knows about (FR-UI-4). + if root.selected-count > 0: Button { + text: "Presets"; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.open-presets(); } + } + // TRACES: FR-EXP-7 | NFR-ARCH-3 // Export the selection, and stop the batch that is running. // @@ -1148,6 +1175,11 @@ export component LibraryGrid inherits Rectangle { callback cell-clicked(int); /// A star was clicked on a cell: row, and the rating 0..5. callback cell-rated(int, int); + /// The burst mark on a cell was clicked (FR-CULL-5): open the group, or fold it back + /// up. Which of the two is decided in Rust, from what the catalog says the + /// group is currently doing, so the mark cannot get out of step with the + /// query that actually hides the frames. + callback burst-toggled(int); /// Whether the grid is currently listing the trash rather than the /// library. Suppresses the per-cell trash target, which would be inert /// there — `plan_trash` skips an already-trashed image — and offering a @@ -1433,6 +1465,8 @@ export component LibraryGrid inherits Rectangle { in property settings-armed: false; in property settings-summary; callback paste-settings-to-selection(); + /// TRACES: FR-DEV-6 + callback open-presets(); // TRACES: FR-EXP-7 // Exporting the selection. The grid owns neither the settings that decide @@ -1819,6 +1853,7 @@ export component LibraryGrid inherits Rectangle { export-selection => { root.export-selection(); } cancel-export => { root.cancel-export(); } paste-settings-to-selection => { root.paste-settings-to-selection(); } + open-presets => { root.open-presets(); } remove-from-collection => { root.remove-from-collection(); } select-mode: root.select-mode; toggle-select-mode => { root.toggle-select-mode(); } @@ -1904,6 +1939,7 @@ export component LibraryGrid inherits Rectangle { export-selection => { root.export-selection(); } cancel-export => { root.cancel-export(); } paste-settings-to-selection => { root.paste-settings-to-selection(); } + open-presets => { root.open-presets(); } remove-from-collection => { root.remove-from-collection(); } select-mode: root.select-mode; toggle-select-mode => { root.toggle-select-mode(); } @@ -3226,6 +3262,75 @@ export component LibraryGrid inherits Rectangle { rate(n) => { root.cell-rated(i, n); } trash() => { root.cell-trashed(i); } } + + // The burst mark (FR-CULL-5): how many frames this + // moment holds, + // and the way in and out of them. + // + // A child of `cell-touch` for exactly the reason the + // stars above are: a click here must not also reach + // `cell-clicked` and throw the user into develop, and + // children are hit-tested before the element they sit + // in. Unlike the stars it is never hidden — a collapsed + // burst is standing in for frames that are not on + // screen, and there has to be something visible saying + // so whether or not a pointer is anywhere near. + // + // Bottom left, clear of the centred star strip and of + // both top corners, which the flag and the collection + // badge already have. + if cell.burst-count > 1: Rectangle { + x: 6px; + y: parent.height - self.height - 26px; + width: 30px; + height: 18px; + + // The pile behind the top card, drawn only while the + // group is folded up. It is the whole of the "there + // is more than one of these" cue; once the burst is + // open the frames themselves say it. + Rectangle { + x: 3px; + y: -3px; + width: parent.width - 3px; + height: parent.height; + visible: !cell.burst-expanded; + background: Theme.surface; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: Theme.rule; + } + + Rectangle { + width: 100%; + height: 100%; + background: cell.burst-expanded ? Theme.selected + : Theme.surface; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: cell.burst-expanded ? Theme.selected-ring + : Theme.rule; + + Text { + width: 100%; + height: 100%; + horizontal-alignment: center; + vertical-alignment: center; + // No "of": the number is the size of the + // group, and a cell this small cannot + // afford a word to say so. + text: cell.burst-count; + color: Theme.ink; + font-size: 10px; + font-weight: 700; + } + } + + TouchArea { + mouse-cursor: pointer; + clicked => { root.burst-toggled(i); } + } + } } } // Where the run would land. A bar in the gutter beside the diff --git a/ui/dr-ui/ui/masks.slint b/ui/dr-ui/ui/masks.slint index 1851779..aacccea 100644 --- a/ui/dr-ui/ui/masks.slint +++ b/ui/dr-ui/ui/masks.slint @@ -137,10 +137,18 @@ component MaskEntry inherits Rectangle { alignment: center; spacing: 0px; + // Both lines below are bounded for the reason the subject + // row is: a mask's label and kind come from the model, and an + // unbounded `Text` asks for its whole string at layout time + // even when `elide` means it will never draw it. A mask *is* a + // segmentation result, so without this the column moved when a + // subject was clicked as well as when one was found. Label { text: root.data.label; emphasised: root.data.selected || touch.has-hover; overflow: elide; + min-width: 0px; + max-width: 160px; } Caption { @@ -148,6 +156,8 @@ component MaskEntry inherits Rectangle { // targets in a row, and wrapping would give the rows of a // stack different heights for no gain. overflow: elide; + min-width: 0px; + max-width: 160px; // Three states worth distinguishing, and each has a // different remedy: stale needs the segmentation re-run, // unadjusted needs a slider moved, and the ordinary case @@ -418,6 +428,30 @@ export component MaskPanel inherits Rectangle { emphasised: subject-row.has-hover; horizontal-stretch: 1; overflow: elide; + // **`elide` is a paint-time behaviour, and this is a + // layout-time problem.** A `Text` asks for the width of + // its whole string whether or not it will draw all of + // it, so without a stated maximum this row asked for + // whatever the model happened to return, that became + // `layout.preferred-width`, the panel publishes that as + // its `min-width`, and the develop column takes the + // widest minimum any panel declares. The column + // therefore moved the instant segmentation finished — + // a photograph the user was looking at, jumping + // sideways because a label said "traffic light". + // + // Stated as a maximum for the reason `ChipGrid` + // declares its width from its column count rather than + // from its options: what a panel asks for must follow + // from its structure, never from its data. Past this + // the row elides, which is what `elide` was for. + // + // 160px is the same judgement as `ChipGrid`'s 88px + // chip — comfortable for the class names this model + // returns, and narrow enough that a subject list + // cannot be what sets the column. + min-width: 0px; + max-width: 160px; } Value { text: round(subject.score * 100) + "%"; } } diff --git a/ui/dr-ui/ui/peaking.slint b/ui/dr-ui/ui/peaking.slint new file mode 100644 index 0000000..3821e55 --- /dev/null +++ b/ui/dr-ui/ui/peaking.slint @@ -0,0 +1,150 @@ +// TRACES: FR-CULL-3 +// The focus-peaking switch, and the two choices it exposes. +// +// **An instrument, not an operation**, exactly as the histogram above it is: +// it has no parameters in the edit graph, changes nothing about the +// photograph, and answers a question rather than asking one. So it is written +// by hand rather than generated from a descriptor, and FR-DEV-3a is untroubled +// by it — nothing here names an operation or reads a parameter out of one. +// +// **Both choices are words, not swatches.** The colour picker is the obvious +// place to draw four coloured squares, and NFR-A11Y-3 is the reason not to: +// a control for choosing between hues, presented only as hues, is unusable by +// the person most likely to need to change it. The chips say "Red" and "Cyan". +// +// **Why the two chip rows only exist while peaking is on.** They are settings +// for something that is not happening, and the develop column is the +// photographer's instrument panel — every row it holds is a slider pushed +// below the fold. The panel's own height is bound to its content, so the +// column reflows rather than leaving a gap. + +import { Theme } from "theme.slint"; +import { Button, PanelHeading, Caption } from "widgets.slint"; +import { Segmented } from "controls.slint"; + +// TRACES: FR-CULL-3 +// The marks themselves, composited over the canvas. +// +// **A layer over the photograph and not a tint in it**, which is the same rule +// `app.slint` states on the region map: a diagnostic "must not reach the +// histogram, an export, or the texture the develop pass hands the compositor". +// `HistogramPass` counts whatever the develop pass last rendered, so marks +// painted into that frame would arrive in the histogram as a spike and in the +// clipping figure as blown highlights. The layer is transparent everywhere +// except where something is in focus. +// +// **No `source-clip` and no rotation**, unlike the region map. That is a +// source-space picture being windowed down to the visible part; this was +// measured on the rendered frame itself, so it is already cropped, zoomed and +// turned exactly as the canvas is. One fewer thing that can drift out of +// registration. +// +// The caller places it on `canvas-area`'s fitted rect, which is the shared +// contract for anything that lands on the picture. +export component FocusMarks inherits Image { + /// The overlay `DevelopSession::focus_overlay` produced for this frame. + in property marks; + + source: root.marks; + image-fit: fill; + // Nearest-neighbour: a mark is one pixel wide, and smoothing spreads it + // into a grey haze that reads as softness — the opposite of what it is + // reporting. + image-rendering: ImageRendering.pixelated; +} + +// TRACES: FR-CULL-3 +export component FocusPanel inherits Rectangle { + /// Whether this device could build the overlay at all. + /// + /// A compute pass can fail to compile on a driver nobody here has, and the + /// honest response is to say so rather than to offer a switch that does + /// nothing when pressed. The develop view keeps working without it; only + /// this panel changes. + in property available: true; + /// Whether the overlay is currently being drawn. + /// + /// `showing` rather than the obvious `on`: Slint has no reserved word + /// there today, and a one-word property that might become one is not worth + /// the bet on a panel this small. + in property showing: false; + /// Index into `PeakSensitivity`, in the order Rust declares it. + in property sensitivity: 1; + /// Index into `PeakColour`, likewise. + in property colour: 0; + + callback toggled(bool); + callback sensitivity-picked(int); + callback colour-picked(int); + + background: Theme.surface; + + /// TRACES: FR-UI-2 + /// How wide this panel has to be before it clips itself. The develop + /// column is the largest of these and nothing else; see `SpotPanel` and + /// `HistogramPanel` for the whole protocol. + /// + /// Both chip rows wrap at three, which is what keeps this number at the + /// narrowest column the application supports rather than at four chips + /// abreast — a single row of four would set the width of the entire + /// sidebar for every other panel in it. + out property content-width: layout.preferred-width; + min-width: root.content-width; + + // Flat rather than nested, for the reason `SpotPanel` and `MaskPanel` both + // give: a nested conditional layout under-reports its height here and the + // rows below it get drawn on top of one another. Every row carries its own + // `if`. + layout := VerticalLayout { + padding: Theme.gap; + spacing: Theme.gap-sm; + alignment: start; + + PanelHeading { text: "FOCUS"; } + + if !root.available: Caption { + text: "This device could not build the overlay."; + wrap: word-wrap; + } + + if root.available: Button { + // The label states the action rather than the state, as the mask + // overlay's does: a photographer reads a button for what pressing + // it will do. + text: root.showing ? "Hide focus peaking" : "Show focus peaking"; + active: root.showing; + clicked => { root.toggled(!root.showing); } + } + + if root.available && root.showing: Segmented { + label: "Sensitivity"; + hint: "lower on a noisy frame"; + options: ["Low", "Medium", "High"]; + selected: root.sensitivity; + columns: 3; + picked(i) => { root.sensitivity-picked(i); } + } + + if root.available && root.showing: Segmented { + label: "Marks"; + hint: "pick what the subject is not"; + options: ["Red", "Yellow", "Cyan", "Magenta"]; + selected: root.colour; + columns: 3; + picked(i) => { root.colour-picked(i); } + } + + if root.available && root.showing: Caption { + // Said once, here, rather than left to be discovered: the marks go + // away while a control is moving because a half-resolution draft + // frame cannot be measured for sharpness (see `FocusPeakPass`). + // + // Kept to one short sentence on purpose. A wrapping Text reports + // its *unwrapped* width as its preferred one, and this panel's + // `content-width` is what the develop column sizes itself from — + // a paragraph here would hold the whole sidebar open. + text: "Marks pause while a control is dragged."; + wrap: word-wrap; + } + } +} diff --git a/ui/dr-ui/ui/presets.slint b/ui/dr-ui/ui/presets.slint new file mode 100644 index 0000000..4359a20 --- /dev/null +++ b/ui/dr-ui/ui/presets.slint @@ -0,0 +1,210 @@ +import { Theme } from "theme.slint"; +import { Button, Field, Caption } from "widgets.slint"; + +// TRACES: FR-DEV-6 +// The named preset sheet: save the edit in hand, and apply a saved one. +// +// # One sheet for both views +// +// A preset is saved in develop, where there is an edit to capture, and applied +// most often in the library, where there is a selection to apply it to. Those +// are two different moments and it is the same list, so this is one component +// mounted at the shell rather than a panel in each view — the same reasoning +// the clipboard's properties are declared on the window for. +// +// What differs between the two is not the sheet but its *answers*: `can-save` +// is false with nothing open, and `apply-count` says whether applying means +// this photograph or those forty. Both are facts the shell already holds. +// +// # The same card, scrim and dismissal as the filing and keywording sheets +// +// Deliberately. A user who has filed a selection knows how this works, and a +// second idiom for the same gesture would be a second thing to learn for no +// gain. +export component PresetSheet inherits Rectangle { + /// The saved names, in the order they are stored. + in property <[string]> names; + /// Whether there is an edit in hand to save. False in the library, where + /// nothing is open, and with an image that failed to decode. + in property can-save: false; + /// What a preset would be applied to: 0 means the open photograph, and + /// anything higher means that many selected ones. + in property apply-count: 0; + /// What saving would capture — "3 adjustments" — from the same routine the + /// clipboard's summary comes from, so the two cannot disagree. + in property capture-summary; + /// Set while a name is refused, and cleared by the next keystroke. Prose + /// rather than a code, because the shell knows why and this does not. + in property name-error; + + callback save(string); + callback apply(string); + callback rename(string, string); + callback remove(string); + callback dismiss(); + /// Every keystroke in the name field, so the shell can clear a refusal the + /// user has started correcting. + callback name-edited(string); + + background: #000000CC; + + // Swallows the taps that miss the card, and closes. First, so the card's + // own controls sit above it. + TouchArea { + clicked => { root.dismiss(); } + } + + // Which row is being renamed, by name. Empty means none. + // + // A name rather than an index: the list is rebuilt from Rust after every + // change, and an index would point at whatever moved into that slot. + property renaming: ""; + + Rectangle { + width: min(420px, parent.width - 2 * Theme.gap-lg); + height: min(sheet.preferred-height, parent.height - 2 * Theme.gap-lg); + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.surface; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.rule; + + // Stops a press on the card reaching the scrim behind it. + TouchArea { } + + sheet := VerticalLayout { + padding: Theme.gap-lg; + spacing: Theme.gap; + + Text { + text: "Presets"; + color: Theme.ink; + font-size: Theme.text-lg; + font-weight: 600; + } + + // Saving, first: it is the half that has something to say about + // the photograph currently open, and it disappears entirely in the + // library rather than sitting there disabled — a permanently dead + // control teaches the reader that the sheet lies. + if root.can-save: VerticalLayout { + spacing: Theme.gap-sm; + + name := Field { + placeholder: "Name this edit and press return"; + accepted(text) => { + root.save(text); + // Cleared only once the shell has accepted it. A + // refused name the user has to retype is a refusal + // that costs more than the mistake did, so the field + // keeps the text and `name-error` says why. + if (root.name-error == "") { + self.text = ""; + } + } + edited(text) => { root.name-edited(text); } + } + + if root.name-error != "": Caption { + text: root.name-error; + warn: true; + } + + // The bare summary, phrased exactly as the develop panel + // phrases the clipboard's — same routine, same words, so the + // two cannot appear to disagree about one edit. + if root.name-error == "": Caption { + text: root.capture-summary; + } + } + + if root.can-save: Rectangle { height: 1px; background: Theme.rule; } + + Flickable { + vertical-stretch: 1; + // A floor, so the list is not squeezed out of existence by the + // field and the button around it on a short window. + min-height: 120px; + viewport-height: root.names.length * (Theme.touch-target + 2px); + + for entry[i] in root.names: Rectangle { + y: i * (Theme.touch-target + 2px); + width: parent.width; + height: Theme.touch-target; + + if root.renaming != entry: HorizontalLayout { + spacing: Theme.gap-sm; + + // The name is the apply button rather than a label + // beside one. Applying is what this list is for, and a + // row whose largest target does nothing is a row that + // gets pressed by accident and then distrusted. + Button { + text: entry; + horizontal-stretch: 1; + clicked => { root.apply(entry); } + } + + Button { + text: "Rename"; + clicked => { root.renaming = entry; } + } + + Button { + text: "Delete"; + clicked => { root.remove(entry); } + } + } + + // Renaming in place rather than in a second sheet: a + // dialogue over a dialogue is where a user loses track of + // which one Escape closes. + if root.renaming == entry: HorizontalLayout { + spacing: Theme.gap-sm; + + rename-field := Field { + text: entry; + horizontal-stretch: 1; + accepted(text) => { + root.rename(entry, text); + root.renaming = ""; + } + } + + Button { + text: "Cancel"; + clicked => { root.renaming = ""; } + } + } + } + } + + if root.names.length == 0: Text { + text: root.can-save + ? "No presets yet. Name the edit above to make the first." + : "No presets yet. Open a photograph and save one from the develop panel."; + color: Theme.ink-faint; + font-size: Theme.text-sm; + wrap: word-wrap; + width: parent.width; + } + + // Says what applying would do *before* it is done, the same way + // the grid's "Paste to 40" does — a count in the label is worth + // more than a confirmation asking the same question afterwards. + if root.names.length > 0 && root.apply-count > 0: Caption { + text: root.apply-count == 1 + ? "Applies to 1 selected photograph" + : "Applies to " + root.apply-count + " selected photographs"; + } + + Rectangle { height: 1px; background: Theme.rule; } + + Button { + text: "Done"; + clicked => { root.dismiss(); } + } + } + } +} diff --git a/ui/dr-ui/ui/widgets.slint b/ui/dr-ui/ui/widgets.slint index 7b20509..18c32a6 100644 --- a/ui/dr-ui/ui/widgets.slint +++ b/ui/dr-ui/ui/widgets.slint @@ -1,3 +1,4 @@ +// TRACES: FR-UI-6 // Shared chrome primitives and the style layer. // // Before this file every button was a Rectangle + TouchArea written out where