Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9c7aba8b6 | ||
|
|
fb4b05fb6f | ||
|
|
5a9beedca6 | ||
|
|
db43d8ec8b | ||
|
|
3a42c63b5b | ||
|
|
b3641b5307 | ||
|
|
e6b01226eb | ||
|
|
4b7648082e | ||
|
|
fbf286d504 | ||
|
|
f4c7afb74c | ||
|
|
5807b67693 | ||
|
|
a8969c149e | ||
|
|
7a5e1adf51 | ||
|
|
5a0a9719eb | ||
|
|
7fcb8dc107 | ||
|
|
ec8582032e | ||
|
|
b7bd2356be | ||
|
|
e25f0ad2c6 | ||
|
|
2a4a48c0fb | ||
|
|
85aae25d0f | ||
|
|
11a63fc7a8 | ||
|
|
1297e3259b | ||
|
|
d78c9a27fd | ||
|
|
89f0f1fb4b | ||
|
|
83528c068a | ||
|
|
67f15bffb7 | ||
|
|
c75849040c | ||
|
|
d04087af83 |
@@ -29,7 +29,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
# `lfs: true` is load-bearing, not hygiene. `core/dr-segment/models/*.onnx`
|
||||||
|
# is tracked in LFS (see .gitattributes), and a checkout without it writes
|
||||||
|
# a ~130-byte pointer where the 11 MB model should be. `dr-segment`'s build
|
||||||
|
# script detects that and panics by design, so every job that compiles it
|
||||||
|
# fails at the build script with a message about git-lfs — which reads like
|
||||||
|
# a broken build rather than a missing fetch.
|
||||||
|
#
|
||||||
|
# It stayed hidden while the Android job built `-p dr-gpu`, which does not
|
||||||
|
# reach dr-segment. Building the app crate does.
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -84,7 +95,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
# `lfs: true` is load-bearing, not hygiene. `core/dr-segment/models/*.onnx`
|
||||||
|
# is tracked in LFS (see .gitattributes), and a checkout without it writes
|
||||||
|
# a ~130-byte pointer where the 11 MB model should be. `dr-segment`'s build
|
||||||
|
# script detects that and panics by design, so every job that compiles it
|
||||||
|
# fails at the build script with a message about git-lfs — which reads like
|
||||||
|
# a broken build rather than a missing fetch.
|
||||||
|
#
|
||||||
|
# It stayed hidden while the Android job built `-p dr-gpu`, which does not
|
||||||
|
# reach dr-segment. Building the app crate does.
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
- name: Cache cargo
|
- name: Cache cargo
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -94,8 +116,15 @@ jobs:
|
|||||||
target-android
|
target-android
|
||||||
key: android-${{ hashFiles('**/Cargo.lock') }}
|
key: android-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
# Only the core crates cross-compile today; the UI and app crates join
|
# A fast gate on the crates most likely to break the cross-compile, run
|
||||||
# once the Android shell exists (milestone v0.1, FR-PLAT-AND-*).
|
# before the expensive part. It is `cargo check`, so it type-checks
|
||||||
|
# without linking and returns in a fraction of the time the step below
|
||||||
|
# takes.
|
||||||
|
#
|
||||||
|
# Not a statement that only these crates cross-compile — `darkroom-android`
|
||||||
|
# and the whole UI stack beneath it build for aarch64 too, which is what
|
||||||
|
# the API-level step below does. This one exists to fail fast and name a
|
||||||
|
# smaller suspect when it does.
|
||||||
- name: Cross-compile core
|
- name: Cross-compile core
|
||||||
env:
|
env:
|
||||||
CARGO_TARGET_DIR: target-android
|
CARGO_TARGET_DIR: target-android
|
||||||
@@ -117,7 +146,18 @@ jobs:
|
|||||||
CARGO_TARGET_DIR: target-android
|
CARGO_TARGET_DIR: target-android
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
cargo ndk -t arm64-v8a build -p dr-gpu --release
|
# `darkroom-android`, not a core crate: this step reads the API level
|
||||||
|
# out of a *linked* object, and only that crate produces one. It is
|
||||||
|
# the workspace's single `crate-type = ["cdylib"]`; a library crate
|
||||||
|
# builds an rlib, which is an archive of object files that no linker
|
||||||
|
# has yet touched and that `file` therefore has nothing to say about.
|
||||||
|
# Asking for `-p dr-gpu` here could only ever reach the "no aarch64
|
||||||
|
# .so was produced" branch below, whatever the linker did.
|
||||||
|
#
|
||||||
|
# It is also the honest artefact to check: the .so this names is the
|
||||||
|
# one that ships in the APK, so the API level verified here is the
|
||||||
|
# API level a device will refuse to install against.
|
||||||
|
cargo ndk -t arm64-v8a build -p darkroom-android --release
|
||||||
MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)
|
MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)
|
||||||
# Empty on both sides would compare equal and pass, so neither side
|
# Empty on both sides would compare equal and pass, so neither side
|
||||||
# is allowed to be the result of a failed parse.
|
# is allowed to be the result of a failed parse.
|
||||||
|
|||||||
@@ -2,3 +2,12 @@
|
|||||||
/target-android
|
/target-android
|
||||||
Cargo.lock.bak
|
Cargo.lock.bak
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# makepkg build products. `packaging/PKGBUILD` and the .desktop entry are
|
||||||
|
# sources and belong in the tree; everything makepkg derives from them does
|
||||||
|
# not — `pkg/` and `src/` are staging directories it recreates on every run,
|
||||||
|
# and the package itself is 33 MB of compiled output.
|
||||||
|
/packaging/pkg/
|
||||||
|
/packaging/src/
|
||||||
|
/packaging/*.pkg.tar.*
|
||||||
|
/packaging/*.log
|
||||||
|
|||||||
Generated
+17
-17
@@ -1221,7 +1221,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-sync-nextcloud",
|
"dr-sync-nextcloud",
|
||||||
@@ -1232,7 +1232,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-ui",
|
"dr-ui",
|
||||||
@@ -1404,7 +1404,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1417,7 +1417,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1431,7 +1431,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1449,7 +1449,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1465,7 +1465,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1477,7 +1477,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1485,7 +1485,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1494,7 +1494,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1507,7 +1507,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"log",
|
"log",
|
||||||
@@ -1520,7 +1520,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1531,7 +1531,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1552,7 +1552,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1564,7 +1564,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1573,7 +1573,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -6896,7 +6896,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.3.0"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|||||||
@@ -17,5 +17,12 @@ fn main() -> anyhow::Result<()> {
|
|||||||
eprintln!("usage: darkroom-desktop <file-or-directory>...");
|
eprintln!("usage: darkroom-desktop <file-or-directory>...");
|
||||||
}
|
}
|
||||||
|
|
||||||
dr_ui::run(paths)
|
dr_ui::run(paths)?;
|
||||||
|
|
||||||
|
// Skip Rust's normal static/thread-local teardown on the way out: a
|
||||||
|
// background zbus/keyring connection opened by dr_ui::launch_ui can
|
||||||
|
// still be alive here, and unwinding through it races its async-io
|
||||||
|
// reactor thread, panicking with "thread local ... during or after
|
||||||
|
// destruction" when the window is closed.
|
||||||
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,13 +114,78 @@ impl Granularity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A sensible bucket size for a span of seconds, so the UI need not guess.
|
/// A sensible bucket size for a span of seconds, so the UI need not guess.
|
||||||
|
///
|
||||||
|
/// # Chosen by how many bars it produces, not by fixed cut-offs
|
||||||
|
///
|
||||||
|
/// This used to be four thresholds on the span, which reads sensibly and
|
||||||
|
/// behaves badly under zoom. Each zoom step halves the span, so the bar
|
||||||
|
/// count halves with it until a threshold is crossed — a fifteen-year
|
||||||
|
/// library went 15 bars, 8, then 46, 23, 11, and finally *6*. Zooming in
|
||||||
|
/// made the picture coarser, which is the opposite of what zooming is for.
|
||||||
|
///
|
||||||
|
/// So the choice is made on the axis's terms: of the four bucket sizes,
|
||||||
|
/// take the one whose bar count comes nearest [`Self::TARGET_BARS`]. The
|
||||||
|
/// count then stays in the same neighbourhood at every zoom level, and
|
||||||
|
/// each step in genuinely shows finer structure rather than the same
|
||||||
|
/// structure drawn wider.
|
||||||
|
///
|
||||||
|
/// Nearest in *ratio*, not in difference: the counts available for a given
|
||||||
|
/// span are orders of magnitude apart — a span is either about 4 years or
|
||||||
|
/// about 48 months — and on a linear measure the larger count always looks
|
||||||
|
/// further away, which would bias every choice towards too few bars.
|
||||||
pub fn for_span(seconds: i64) -> Self {
|
pub fn for_span(seconds: i64) -> Self {
|
||||||
|
let seconds = seconds.max(1) as f64;
|
||||||
|
// Finest first, so that when two options are equally far from the
|
||||||
|
// target the finer one wins: `min_by` keeps the first minimum it saw,
|
||||||
|
// and more detail is the better failure.
|
||||||
|
[
|
||||||
|
Granularity::Hour,
|
||||||
|
Granularity::Day,
|
||||||
|
Granularity::Month,
|
||||||
|
Granularity::Year,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.min_by(|a, b| {
|
||||||
|
let cost = |g: Granularity| {
|
||||||
|
let bars = seconds / g.approx_seconds() as f64;
|
||||||
|
// How far off the target, measured multiplicatively: twice as
|
||||||
|
// many and half as many are equally wrong.
|
||||||
|
//
|
||||||
|
// Deliberately not clamped to at least one bar. A span shorter
|
||||||
|
// than a bucket scores *worse* the coarser the bucket, which is
|
||||||
|
// what makes an hour of photographs pick hourly bars instead of
|
||||||
|
// every option tying at "one bar" and the coarsest winning.
|
||||||
|
(bars / Self::TARGET_BARS as f64).ln().abs()
|
||||||
|
};
|
||||||
|
cost(*a)
|
||||||
|
.partial_cmp(&cost(*b))
|
||||||
|
// Ties cannot arise from real spans, but a NaN would; falling
|
||||||
|
// back to the coarser option keeps the axis drawable.
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.unwrap_or(Granularity::Day)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many bars the timeline wants across its axis.
|
||||||
|
///
|
||||||
|
/// Not a hard count — the bucket sizes are calendar units, so the actual
|
||||||
|
/// number lands where the calendar puts it. It is the figure the choice
|
||||||
|
/// aims at: enough bars that a busy fortnight is visibly busier than a
|
||||||
|
/// quiet one, few enough that each is wide enough to hit with a finger.
|
||||||
|
const TARGET_BARS: i64 = 40;
|
||||||
|
|
||||||
|
/// Nominal length of one bucket, for choosing between them.
|
||||||
|
///
|
||||||
|
/// Approximate on purpose: months and years vary and it does not matter
|
||||||
|
/// here, because this only ranks four options that are a factor of ~12 or
|
||||||
|
/// ~30 apart. The exact boundaries come from `strftime` on the real dates.
|
||||||
|
fn approx_seconds(self) -> i64 {
|
||||||
const DAY: i64 = 86_400;
|
const DAY: i64 = 86_400;
|
||||||
match seconds {
|
match self {
|
||||||
s if s > 5 * 365 * DAY => Granularity::Year,
|
Granularity::Year => 365 * DAY,
|
||||||
s if s > 90 * DAY => Granularity::Month,
|
Granularity::Month => 30 * DAY,
|
||||||
s if s > 2 * DAY => Granularity::Day,
|
Granularity::Day => DAY,
|
||||||
_ => Granularity::Hour,
|
Granularity::Hour => 3600,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,10 +487,34 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn timeline_granularity_follows_the_span() {
|
fn timeline_granularity_follows_the_span() {
|
||||||
const DAY: i64 = 86_400;
|
const DAY: i64 = 86_400;
|
||||||
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Year);
|
// Chosen by how many bars it makes, not by fixed cut-offs — see
|
||||||
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Month);
|
// `for_span`. Ten years of yearly bars is ten bars, which says almost
|
||||||
|
// nothing about a library; monthly is 122, which is a shape.
|
||||||
|
assert_eq!(Granularity::for_span(10 * 365 * DAY), Granularity::Month);
|
||||||
|
assert_eq!(Granularity::for_span(120 * DAY), Granularity::Day);
|
||||||
assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day);
|
assert_eq!(Granularity::for_span(10 * DAY), Granularity::Day);
|
||||||
assert_eq!(Granularity::for_span(3600), Granularity::Hour);
|
assert_eq!(Granularity::for_span(3600), Granularity::Hour);
|
||||||
|
|
||||||
|
// The property the target exists for: zooming in never coarsens the
|
||||||
|
// axis. Under the old thresholds a fifteen-year library went 15 bars,
|
||||||
|
// then 8, then 46, 23, 11 — finer spans drawn with wider bars.
|
||||||
|
let mut span = 15 * 365 * DAY;
|
||||||
|
let mut previous = Granularity::for_span(span).approx_seconds();
|
||||||
|
for _ in 0..10 {
|
||||||
|
span /= 2;
|
||||||
|
let bucket = Granularity::for_span(span).approx_seconds();
|
||||||
|
assert!(
|
||||||
|
bucket <= previous,
|
||||||
|
"halving the span to {span}s coarsened the bucket \
|
||||||
|
from {previous}s to {bucket}s"
|
||||||
|
);
|
||||||
|
previous = bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a span shorter than any bucket still picks the finest, rather
|
||||||
|
// than every option tying at one bar and the coarsest winning.
|
||||||
|
assert_eq!(Granularity::for_span(60), Granularity::Hour);
|
||||||
|
assert_eq!(Granularity::for_span(1), Granularity::Hour);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+175
-17
@@ -383,29 +383,69 @@ fn merge_collections_within(tx: &Connection, report: &mut MergeReport) -> Result
|
|||||||
|
|
||||||
// ---- membership -------------------------------------------------------
|
// ---- membership -------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Set union, keyed on (collection uuid, image content hash). The hash
|
// Set union, keyed on (collection uuid, image identity). Not the image id,
|
||||||
// rather than the image id, for the same reason collections use a uuid:
|
// for the same reason collections use a uuid: image ids are local. An image
|
||||||
// image ids are local. An image the remote has and we do not is skipped —
|
// the remote has and we do not is skipped — it will join when a scan or
|
||||||
// it will join when a scan or sync catalogues it, and the next merge picks
|
// sync catalogues it, and the next merge picks it up.
|
||||||
// it up.
|
//
|
||||||
|
// Both identities are tried, and the file id first. See
|
||||||
|
// [`MEMBERS_BY_FILE_ID`] for why keying on the content hash alone made this
|
||||||
|
// whole union a no-op on every ordinary library.
|
||||||
//
|
//
|
||||||
// Tombstoned collections are excluded, or a merge would repopulate a
|
// Tombstoned collections are excluded, or a merge would repopulate a
|
||||||
// collection it had just deleted.
|
// collection it had just deleted.
|
||||||
let added = tx.execute(
|
let added = tx.execute(MEMBERS_BY_FILE_ID, [])? + tx.execute(MEMBERS_BY_CONTENT_HASH, [])?;
|
||||||
"INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added)
|
report.members_added = added;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take membership for images both devices know by the server's file id.
|
||||||
|
///
|
||||||
|
/// **This is the identity that exists.** Membership was keyed on
|
||||||
|
/// `images.content_hash` alone, and the schema is explicit that the column is
|
||||||
|
/// "computed only when something needs it (import dedup, reconnect-by-hash),
|
||||||
|
/// never in a scan" — so on an ordinary library it is NULL for every row, the
|
||||||
|
/// join matched nothing, and `WHERE ri.content_hash IS NOT NULL` discarded what
|
||||||
|
/// little was left. Collections synced their names, because those are keyed on
|
||||||
|
/// a uuid, and arrived empty on every device. A 23,000-image library had a
|
||||||
|
/// content hash for none of them and an `oc:fileid` for all of them.
|
||||||
|
///
|
||||||
|
/// `oc:fileid` is recorded for every image the moment a remote scan sees it, is
|
||||||
|
/// stable across server-side rename and move (FR-NC-5), and is the same integer
|
||||||
|
/// on every device pointed at the same Nextcloud — which is exactly the
|
||||||
|
/// situation where two devices share collections. It is already what the
|
||||||
|
/// thumbnail shards are keyed by, and what [`ASSIGN_BY_FILE_ID`] uses for
|
||||||
|
/// keywords; membership was the one thing left behind.
|
||||||
|
const MEMBERS_BY_FILE_ID: &str = "
|
||||||
|
INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added)
|
||||||
|
SELECT lc.id, li.id, rm.position, rm.added
|
||||||
|
FROM remote_cat.collection_members rm
|
||||||
|
JOIN remote_cat.collections rc ON rc.id = rm.collection_id
|
||||||
|
JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0
|
||||||
|
JOIN remote_cat.remote rr ON rr.image_id = rm.image_id
|
||||||
|
JOIN main.remote lr ON lr.file_id = rr.file_id
|
||||||
|
JOIN main.images li ON li.id = lr.image_id";
|
||||||
|
|
||||||
|
/// The same union for a library with no server behind it.
|
||||||
|
///
|
||||||
|
/// A local-only library has no `remote` rows at all, so [`MEMBERS_BY_FILE_ID`]
|
||||||
|
/// matches nothing and the content hash is the only identity available. Kept
|
||||||
|
/// rather than replaced: where a hash *has* been computed — an imported card,
|
||||||
|
/// a reconnect — it is a true identity, and one that survives a library moving
|
||||||
|
/// between servers.
|
||||||
|
///
|
||||||
|
/// Both statements run. `INSERT OR IGNORE` against the
|
||||||
|
/// `(collection_id, image_id)` primary key makes the overlap free.
|
||||||
|
const MEMBERS_BY_CONTENT_HASH: &str = "
|
||||||
|
INSERT OR IGNORE INTO main.collection_members(collection_id, image_id, position, added)
|
||||||
SELECT lc.id, li.id, rm.position, rm.added
|
SELECT lc.id, li.id, rm.position, rm.added
|
||||||
FROM remote_cat.collection_members rm
|
FROM remote_cat.collection_members rm
|
||||||
JOIN remote_cat.collections rc ON rc.id = rm.collection_id
|
JOIN remote_cat.collections rc ON rc.id = rm.collection_id
|
||||||
JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0
|
JOIN main.collections lc ON lc.uuid = rc.uuid AND lc.deleted = 0
|
||||||
JOIN remote_cat.images ri ON ri.id = rm.image_id
|
JOIN remote_cat.images ri ON ri.id = rm.image_id
|
||||||
JOIN main.images li ON li.content_hash = ri.content_hash
|
JOIN main.images li ON li.content_hash = ri.content_hash
|
||||||
WHERE ri.content_hash IS NOT NULL",
|
WHERE ri.content_hash IS NOT NULL";
|
||||||
[],
|
|
||||||
)?;
|
|
||||||
report.members_added = added;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schema name the downloaded remote catalog is attached under.
|
/// Schema name the downloaded remote catalog is attached under.
|
||||||
///
|
///
|
||||||
@@ -615,9 +655,9 @@ const ASSIGN_BY_FILE_ID: &str = "
|
|||||||
/// The same union for a library with no server behind it.
|
/// The same union for a library with no server behind it.
|
||||||
///
|
///
|
||||||
/// A local-only library has no `remote` rows at all, so [`ASSIGN_BY_FILE_ID`]
|
/// A local-only library has no `remote` rows at all, so [`ASSIGN_BY_FILE_ID`]
|
||||||
/// matches nothing and this is the only identity available — and it is the one
|
/// matches nothing and this is the only identity available — the same pair
|
||||||
/// collection membership already uses, so a library where membership merges
|
/// collection membership uses, so a library where membership merges has
|
||||||
/// has keywords that merge too.
|
/// keywords that merge too.
|
||||||
const ASSIGN_BY_CONTENT_HASH: &str = "
|
const ASSIGN_BY_CONTENT_HASH: &str = "
|
||||||
INSERT OR IGNORE INTO main.keywords(version_id, keyword)
|
INSERT OR IGNORE INTO main.keywords(version_id, keyword)
|
||||||
SELECT lv.id, rk.keyword
|
SELECT lv.id, rk.keyword
|
||||||
@@ -741,6 +781,43 @@ mod tests {
|
|||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An image with no content hash — which is every image in a library that
|
||||||
|
/// has only ever been scanned.
|
||||||
|
fn add_image_without_hash(c: &Connection, db: &str, id: i64) {
|
||||||
|
c.execute(
|
||||||
|
&format!(
|
||||||
|
"INSERT INTO {db}.roots(id, kind, label) VALUES (1, 'remote', 'lib')
|
||||||
|
ON CONFLICT(id) DO NOTHING"
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
c.execute(
|
||||||
|
&format!(
|
||||||
|
"INSERT INTO {db}.images(id, root_id, source_ref, added_at)
|
||||||
|
VALUES (?1, 1, ?2, 0)"
|
||||||
|
),
|
||||||
|
rusqlite::params![id, format!("img{id}.CR3")],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(c: &Connection, sql: &str) -> i64 {
|
||||||
|
c.query_row(sql, [], |r| r.get(0)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Give an image the `oc:fileid` a remote scan records for it.
|
||||||
|
///
|
||||||
|
/// What every image in a real synced library has, and what none of them
|
||||||
|
/// has a content hash for.
|
||||||
|
fn add_remote_id(c: &Connection, db: &str, image_id: i64, file_id: i64) {
|
||||||
|
c.execute(
|
||||||
|
&format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"),
|
||||||
|
rusqlite::params![image_id, file_id],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
fn add_image(c: &Connection, db: &str, id: i64, hash: &str) {
|
fn add_image(c: &Connection, db: &str, id: i64, hash: &str) {
|
||||||
c.execute(
|
c.execute(
|
||||||
&format!(
|
&format!(
|
||||||
@@ -910,6 +987,87 @@ mod tests {
|
|||||||
assert_eq!(n, 2, "both devices' additions survive");
|
assert_eq!(n, 2, "both devices' additions survive");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_library_that_never_computed_a_hash_still_merges_membership() {
|
||||||
|
// The bug this replaces. `content_hash` is computed only by import
|
||||||
|
// dedup or a reconnect — never by a scan — so a synced library has one
|
||||||
|
// for no image at all. Keying membership on it alone meant collections
|
||||||
|
// arrived with their names and none of their contents, on every device,
|
||||||
|
// for every user.
|
||||||
|
let c = two_catalogs();
|
||||||
|
add_collection(&c, "main", 1, "shared", "Trip", 1);
|
||||||
|
add_collection(&c, "remote_cat", 1, "shared", "Trip", 1);
|
||||||
|
|
||||||
|
// No hashes anywhere, and a file id for both — a real library.
|
||||||
|
add_image_without_hash(&c, "main", 77);
|
||||||
|
add_image_without_hash(&c, "remote_cat", 3);
|
||||||
|
add_remote_id(&c, "main", 77, 90210);
|
||||||
|
add_remote_id(&c, "remote_cat", 3, 90210);
|
||||||
|
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO remote_cat.collection_members(collection_id, image_id, added)
|
||||||
|
VALUES (1, 3, 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let report = merge_collections(&c).unwrap();
|
||||||
|
assert_eq!(report.members_added, 1);
|
||||||
|
|
||||||
|
let img: i64 = c
|
||||||
|
.query_row("SELECT image_id FROM main.collection_members", [], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(img, 77, "resolved through the server's file id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_different_photograph_on_the_server_is_not_adopted() {
|
||||||
|
// The file id is an identity, so two different ids must not join.
|
||||||
|
let c = two_catalogs();
|
||||||
|
add_collection(&c, "main", 1, "shared", "Trip", 1);
|
||||||
|
add_collection(&c, "remote_cat", 1, "shared", "Trip", 1);
|
||||||
|
add_image_without_hash(&c, "main", 77);
|
||||||
|
add_image_without_hash(&c, "remote_cat", 3);
|
||||||
|
add_remote_id(&c, "main", 77, 111);
|
||||||
|
add_remote_id(&c, "remote_cat", 3, 222);
|
||||||
|
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO remote_cat.collection_members(collection_id, image_id, added)
|
||||||
|
VALUES (1, 3, 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let report = merge_collections(&c).unwrap();
|
||||||
|
assert_eq!(report.members_added, 0);
|
||||||
|
assert_eq!(count(&c, "SELECT COUNT(*) FROM main.collection_members"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_image_identified_both_ways_is_added_once() {
|
||||||
|
// Both statements run, and their overlap must be free rather than a
|
||||||
|
// constraint violation or a double count.
|
||||||
|
let c = two_catalogs();
|
||||||
|
add_collection(&c, "main", 1, "shared", "Trip", 1);
|
||||||
|
add_collection(&c, "remote_cat", 1, "shared", "Trip", 1);
|
||||||
|
add_image(&c, "main", 77, "same-photo");
|
||||||
|
add_image(&c, "remote_cat", 3, "same-photo");
|
||||||
|
add_remote_id(&c, "main", 77, 90210);
|
||||||
|
add_remote_id(&c, "remote_cat", 3, 90210);
|
||||||
|
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO remote_cat.collection_members(collection_id, image_id, added)
|
||||||
|
VALUES (1, 3, 0)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
merge_collections(&c).unwrap();
|
||||||
|
assert_eq!(count(&c, "SELECT COUNT(*) FROM main.collection_members"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn membership_maps_across_devices_by_content_hash() {
|
fn membership_maps_across_devices_by_content_hash() {
|
||||||
// The same photograph carries different integer ids on each device.
|
// The same photograph carries different integer ids on each device.
|
||||||
|
|||||||
@@ -487,7 +487,8 @@ mod tests {
|
|||||||
// And the last span is shallower than the one before it, which is
|
// And the last span is shallower than the one before it, which is
|
||||||
// what a shoulder *is*. Without one the curve clips highlights
|
// what a shoulder *is*. Without one the curve clips highlights
|
||||||
// harder than the linear rendering did.
|
// harder than the linear rendering did.
|
||||||
let slope = |i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
|
let slope =
|
||||||
|
|i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
|
||||||
assert!(
|
assert!(
|
||||||
slope(POINTS - 2) < slope(POINTS - 3),
|
slope(POINTS - 2) < slope(POINTS - 3),
|
||||||
"{name} has no highlight shoulder"
|
"{name} has no highlight shoulder"
|
||||||
@@ -502,13 +503,7 @@ mod tests {
|
|||||||
// as a dark halo in a gradient, which reads as a rendering fault
|
// as a dark halo in a gradient, which reads as a rendering fault
|
||||||
// rather than as a bad profile.
|
// rather than as a bad profile.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
BaseCurve::from_points(&[
|
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.4], [0.5, 0.3], [0.75, 0.8], [1.0, 1.0]]),
|
||||||
[0.0, 0.0],
|
|
||||||
[0.25, 0.4],
|
|
||||||
[0.5, 0.3],
|
|
||||||
[0.75, 0.8],
|
|
||||||
[1.0, 1.0]
|
|
||||||
]),
|
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -540,13 +535,7 @@ mod tests {
|
|||||||
// point above 1.0 would put the shoulder outside the range the curve
|
// point above 1.0 would put the shoulder outside the range the curve
|
||||||
// is defined over and silently flatten everything below it.
|
// is defined over and silently flatten everything below it.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
BaseCurve::from_points(&[
|
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.3], [0.5, 1.4], [0.75, 1.5], [1.0, 1.6]]),
|
||||||
[0.0, 0.0],
|
|
||||||
[0.25, 0.3],
|
|
||||||
[0.5, 1.4],
|
|
||||||
[0.75, 1.5],
|
|
||||||
[1.0, 1.6]
|
|
||||||
]),
|
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,7 +375,10 @@ fn rawler_location(gps: &rawler::exif::ExifGPS) -> Option<Location> {
|
|||||||
(r.d != 0).then(|| r.n as f64 / r.d as f64)
|
(r.d != 0).then(|| r.n as f64 / r.d as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn degrees(dms: &[rawler::formats::tiff::Rational; 3], reference: Option<&String>) -> Option<f64> {
|
fn degrees(
|
||||||
|
dms: &[rawler::formats::tiff::Rational; 3],
|
||||||
|
reference: Option<&String>,
|
||||||
|
) -> Option<f64> {
|
||||||
let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0;
|
let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0;
|
||||||
// South and west are stored as positive magnitudes with a letter.
|
// South and west are stored as positive magnitudes with a letter.
|
||||||
let negative = matches!(
|
let negative = matches!(
|
||||||
@@ -534,7 +537,10 @@ pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
// both callers ask the same object for it.
|
// both callers ask the same object for it.
|
||||||
let profile = profile::CameraProfile::extract(&image, &dng);
|
let profile = profile::CameraProfile::extract(&image, &dng);
|
||||||
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
|
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
|
||||||
let wb_coeffs = sane_wb(image.wb_coeffs, profile.as_ref().map(|p| p.xyz_to_cam()).as_ref());
|
let wb_coeffs = sane_wb(
|
||||||
|
image.wb_coeffs,
|
||||||
|
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
|
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
|
||||||
// are preferred where it has them — they are what the shipped database is
|
// are preferred where it has them — they are what the shipped database is
|
||||||
|
|||||||
@@ -678,7 +678,8 @@ fn read_gps_entries(r: &TiffReader, entries: &[Entry]) -> Option<dr_types::Locat
|
|||||||
// still writes the entry.
|
// still writes the entry.
|
||||||
let degrees = |tag: u16, ref_tag: u16| -> Option<f64> {
|
let degrees = |tag: u16, ref_tag: u16| -> Option<f64> {
|
||||||
let e = find(tag)?;
|
let e = find(tag)?;
|
||||||
let d = r.rational(e, 0)? + r.rational(e, 1).unwrap_or(0.0) / 60.0
|
let d = r.rational(e, 0)?
|
||||||
|
+ r.rational(e, 1).unwrap_or(0.0) / 60.0
|
||||||
+ r.rational(e, 2).unwrap_or(0.0) / 3600.0;
|
+ r.rational(e, 2).unwrap_or(0.0) / 3600.0;
|
||||||
// The magnitude is unsigned; the hemisphere is a letter beside it.
|
// The magnitude is unsigned; the hemisphere is a letter beside it.
|
||||||
let south_or_west = find(ref_tag)
|
let south_or_west = find(ref_tag)
|
||||||
@@ -1412,7 +1413,9 @@ pub fn defects(tiff_data: &[u8]) -> Defects {
|
|||||||
/// skip one this build does not implement rather than abandoning the list —
|
/// skip one this build does not implement rather than abandoning the list —
|
||||||
/// and lists mixing a warp with a defect map are ordinary.
|
/// and lists mixing a warp with a defect map are ordinary.
|
||||||
fn read_opcode_list(bytes: &[u8], out: &mut Defects) {
|
fn read_opcode_list(bytes: &[u8], out: &mut Defects) {
|
||||||
let Some(count) = be_u32(bytes, 0) else { return };
|
let Some(count) = be_u32(bytes, 0) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
// A sensor has a handful of opcodes, not thousands. A huge count is a
|
// A sensor has a handful of opcodes, not thousands. A huge count is a
|
||||||
// corrupt or hostile file.
|
// corrupt or hostile file.
|
||||||
if count > 256 {
|
if count > 256 {
|
||||||
|
|||||||
@@ -1048,7 +1048,11 @@ mod tests {
|
|||||||
// would render black, which is much harder to diagnose than
|
// would render black, which is much harder to diagnose than
|
||||||
// uncalibrated.
|
// uncalibrated.
|
||||||
assert!(!usable(&[[0.0; 3]; 3]));
|
assert!(!usable(&[[0.0; 3]; 3]));
|
||||||
assert!(!usable(&[[f32::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]));
|
assert!(!usable(&[
|
||||||
|
[f32::NAN, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[0.0, 0.0, 1.0]
|
||||||
|
]));
|
||||||
assert!(usable(&SIX_D_D65));
|
assert!(usable(&SIX_D_D65));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,9 +77,7 @@ pub fn encode(
|
|||||||
.map(|m| m.sanitised(settings.strip_location));
|
.map(|m| m.sanitised(settings.strip_location));
|
||||||
// JPEG and PNG take a finished block; TIFF writes the tags into its own
|
// JPEG and PNG take a finished block; TIFF writes the tags into its own
|
||||||
// directory and needs the fields.
|
// directory and needs the fields.
|
||||||
let block = carried
|
let block = carried.as_ref().and_then(|m| exif::block(m, width, height));
|
||||||
.as_ref()
|
|
||||||
.and_then(|m| exif::block(m, width, height));
|
|
||||||
|
|
||||||
match settings.format {
|
match settings.format {
|
||||||
ExportFormat::Jpeg => jpeg(
|
ExportFormat::Jpeg => jpeg(
|
||||||
@@ -303,7 +301,10 @@ where
|
|||||||
W: std::io::Write + std::io::Seek,
|
W: std::io::Write + std::io::Seek,
|
||||||
K: tiff::encoder::TiffKind,
|
K: tiff::encoder::TiffKind,
|
||||||
{
|
{
|
||||||
dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), Undefined(profile))
|
dir.write_tag(
|
||||||
|
tiff::tags::Tag::Unknown(TAG_ICC_PROFILE),
|
||||||
|
Undefined(profile),
|
||||||
|
)
|
||||||
.map_err(|e| ExportError::Encode(e.to_string()))
|
.map_err(|e| ExportError::Encode(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +377,10 @@ where
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
|
if let Some(f) = md.aperture.filter(|f| *f > 0.0) {
|
||||||
dir.write_tag(Tag::Unknown(exif::tag::FNUMBER), Rationals(&[exif::tenths(f)]))?;
|
dir.write_tag(
|
||||||
|
Tag::Unknown(exif::tag::FNUMBER),
|
||||||
|
Rationals(&[exif::tenths(f)]),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
|
if let Some(f) = md.focal_length.filter(|f| *f > 0.0) {
|
||||||
dir.write_tag(
|
dir.write_tag(
|
||||||
@@ -935,11 +939,16 @@ mod tests {
|
|||||||
assert_eq!(md.aperture, Some(2.8), "{format:?}");
|
assert_eq!(md.aperture, Some(2.8), "{format:?}");
|
||||||
assert_eq!(md.focal_length, Some(85.0), "{format:?}");
|
assert_eq!(md.focal_length, Some(85.0), "{format:?}");
|
||||||
|
|
||||||
let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix"));
|
let loc = md
|
||||||
|
.location
|
||||||
|
.unwrap_or_else(|| panic!("{format:?} lost the fix"));
|
||||||
// Within a metre of where it started, which is finer than any
|
// Within a metre of where it started, which is finer than any
|
||||||
// consumer receiver and far finer than the tag's own rounding.
|
// consumer receiver and far finer than the tag's own rounding.
|
||||||
assert!((loc.latitude - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}");
|
assert!((loc.latitude - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}");
|
||||||
assert!((loc.longitude - LONGITUDE).abs() < 1e-5, "{format:?} {loc:?}");
|
assert!(
|
||||||
|
(loc.longitude - LONGITUDE).abs() < 1e-5,
|
||||||
|
"{format:?} {loc:?}"
|
||||||
|
);
|
||||||
assert_eq!(loc.altitude, Some(35.0), "{format:?}");
|
assert_eq!(loc.altitude, Some(35.0), "{format:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,7 +250,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn png_export_produces_a_png() {
|
fn png_export_produces_a_png() {
|
||||||
let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into(), None).unwrap();
|
let out = export(
|
||||||
|
&frame(32, 32),
|
||||||
|
&settings(ExportFormat::Png),
|
||||||
|
"a.png".into(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
|
assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,8 +276,20 @@ mod tests {
|
|||||||
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
|
fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() {
|
||||||
// Both are uncompressed RGB; the only difference is the sample width,
|
// Both are uncompressed RGB; the only difference is the sample width,
|
||||||
// so this is what proves the 16-bit path is not quietly writing 8.
|
// so this is what proves the 16-bit path is not quietly writing 8.
|
||||||
let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into(), None).unwrap();
|
let eight = export(
|
||||||
let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None).unwrap();
|
&frame(16, 16),
|
||||||
|
&settings(ExportFormat::Tiff8),
|
||||||
|
"a".into(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let sixteen = export(
|
||||||
|
&frame(16, 16),
|
||||||
|
&settings(ExportFormat::Tiff16),
|
||||||
|
"a".into(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(sixteen.bytes.len() > eight.bytes.len());
|
assert!(sixteen.bytes.len() > eight.bytes.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,7 @@
|
|||||||
//! mask pinned to the viewport, a slice read from the wrong layer — shows up
|
//! mask pinned to the viewport, a slice read from the wrong layer — shows up
|
||||||
//! here as an adjustment in the wrong place, and nowhere else.
|
//! here as an adjustment in the wrong place, and nowhere else.
|
||||||
|
|
||||||
use dr_gpu::{
|
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, SubjectMasks};
|
||||||
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MaskPass, SubjectMasks,
|
|
||||||
};
|
|
||||||
use dr_pipeline::descriptor::ParamId;
|
use dr_pipeline::descriptor::ParamId;
|
||||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
|
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack, Morphology};
|
||||||
use dr_pipeline::operation::compose_full;
|
use dr_pipeline::operation::compose_full;
|
||||||
@@ -152,7 +150,18 @@ fn main() {
|
|||||||
drain.feather = 0.02;
|
drain.feather = 0.02;
|
||||||
pop.push(drain);
|
pop.push(drain);
|
||||||
|
|
||||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &pop, &alpha, pw, ph, ow, oh);
|
render_stack(
|
||||||
|
&ctx,
|
||||||
|
&source,
|
||||||
|
&mut masks,
|
||||||
|
&mut adjust,
|
||||||
|
&pop,
|
||||||
|
&alpha,
|
||||||
|
pw,
|
||||||
|
ph,
|
||||||
|
ow,
|
||||||
|
oh,
|
||||||
|
);
|
||||||
write(&format!("{prefix}-colour-pop.ppm"), &adjust);
|
write(&format!("{prefix}-colour-pop.ppm"), &adjust);
|
||||||
|
|
||||||
// ---- 2. lift the subject out of its background ------------------------
|
// ---- 2. lift the subject out of its background ------------------------
|
||||||
@@ -170,7 +179,18 @@ fn main() {
|
|||||||
darker.feather = 0.03;
|
darker.feather = 0.03;
|
||||||
lift.push(darker);
|
lift.push(darker);
|
||||||
|
|
||||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &lift, &alpha, pw, ph, ow, oh);
|
render_stack(
|
||||||
|
&ctx,
|
||||||
|
&source,
|
||||||
|
&mut masks,
|
||||||
|
&mut adjust,
|
||||||
|
&lift,
|
||||||
|
&alpha,
|
||||||
|
pw,
|
||||||
|
ph,
|
||||||
|
ow,
|
||||||
|
oh,
|
||||||
|
);
|
||||||
write(&format!("{prefix}-subject-lift.ppm"), &adjust);
|
write(&format!("{prefix}-subject-lift.ppm"), &adjust);
|
||||||
|
|
||||||
// ---- 3. the same edit, grown and shrunk -------------------------------
|
// ---- 3. the same edit, grown and shrunk -------------------------------
|
||||||
@@ -192,7 +212,18 @@ fn main() {
|
|||||||
layer.morph_radius = radius;
|
layer.morph_radius = radius;
|
||||||
stack.push(layer);
|
stack.push(layer);
|
||||||
|
|
||||||
render_stack(&ctx, &source, &mut masks, &mut adjust, &stack, &alpha, pw, ph, ow, oh);
|
render_stack(
|
||||||
|
&ctx,
|
||||||
|
&source,
|
||||||
|
&mut masks,
|
||||||
|
&mut adjust,
|
||||||
|
&stack,
|
||||||
|
&alpha,
|
||||||
|
pw,
|
||||||
|
ph,
|
||||||
|
ow,
|
||||||
|
oh,
|
||||||
|
);
|
||||||
write(&format!("{prefix}-{name}.ppm"), &adjust);
|
write(&format!("{prefix}-{name}.ppm"), &adjust);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +323,18 @@ fn render_stack(
|
|||||||
|
|
||||||
fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) {
|
fn fit(w: u32, h: u32, longest: u32) -> (u32, u32) {
|
||||||
let s = (longest as f32 / w.max(h) as f32).min(1.0);
|
let s = (longest as f32 / w.max(h) as f32).min(1.0);
|
||||||
(((w as f32 * s) as u32).max(1), ((h as f32 * s) as u32).max(1))
|
(
|
||||||
|
((w as f32 * s) as u32).max(1),
|
||||||
|
((h as f32 * s) as u32).max(1),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(path: &str, adjust: &AdjustPass) {
|
fn write(path: &str, adjust: &AdjustPass) {
|
||||||
let (rgba, w, h) = adjust.export_pixels().expect("readback");
|
let (rgba, w, h) = adjust.export_pixels().expect("readback");
|
||||||
let rgb: Vec<u8> = rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
let rgb: Vec<u8> = rgba
|
||||||
|
.chunks_exact(4)
|
||||||
|
.flat_map(|p| [p[0], p[1], p[2]])
|
||||||
|
.collect();
|
||||||
write_ppm(path, &rgb, w, h);
|
write_ppm(path, &rgb, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
//! PPM for the same reason `develop` uses it: no encoder dependency, and
|
//! PPM for the same reason `develop` uses it: no encoder dependency, and
|
||||||
//! every viewer reads it. This is a diagnostic, not an export path.
|
//! every viewer reads it. This is a diagnostic, not an export path.
|
||||||
|
|
||||||
use dr_segment::{MergeTree, RegionField};
|
|
||||||
use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, SegmentOptions, SegmentPass};
|
use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, SegmentOptions, SegmentPass};
|
||||||
|
use dr_segment::{MergeTree, RegionField};
|
||||||
|
|
||||||
/// The ladder the example dumps. Chosen to span "far too fine to be useful"
|
/// The ladder the example dumps. Chosen to span "far too fine to be useful"
|
||||||
/// through "one or two objects", because both ends are informative: if no rung
|
/// through "one or two objects", because both ends are informative: if no rung
|
||||||
|
|||||||
@@ -589,8 +589,8 @@ impl AdjustPass {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if !reuse {
|
if !reuse {
|
||||||
let params_buf = self
|
let params_buf =
|
||||||
.ctx
|
self.ctx
|
||||||
.device
|
.device
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("adjust-params"),
|
label: Some("adjust-params"),
|
||||||
|
|||||||
@@ -123,8 +123,7 @@ impl Intermediates {
|
|||||||
// output these are never handed to a compositor, and no
|
// output these are never handed to a compositor, and no
|
||||||
// COPY_SRC, because nothing reads them back — that is the
|
// COPY_SRC, because nothing reads them back — that is the
|
||||||
// point (ARCH §6.1).
|
// point (ARCH §6.1).
|
||||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
|
||||||
view_formats: &[],
|
view_formats: &[],
|
||||||
});
|
});
|
||||||
let view = texture.create_view(&Default::default());
|
let view = texture.create_view(&Default::default());
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ pub use adjust::AdjustPass;
|
|||||||
// rather than an implementation detail: a detail pass is guaranteed linear,
|
// rather than an implementation detail: a detail pass is guaranteed linear,
|
||||||
// unclipped, full internal precision (FR-DEV-2), and anyone reasoning about
|
// unclipped, full internal precision (FR-DEV-2), and anyone reasoning about
|
||||||
// VRAM at 24 MP needs to know what an intermediate costs.
|
// VRAM at 24 MP needs to know what an intermediate costs.
|
||||||
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
|
||||||
pub use demosaic::{DemosaicedImage, Demosaicer};
|
pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||||
|
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
||||||
pub use error::GpuError;
|
pub use error::GpuError;
|
||||||
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
||||||
// at all at a crate root shared with demosaic and segmentation.
|
// at all at a crate root shared with demosaic and segmentation.
|
||||||
|
|||||||
+25
-14
@@ -582,10 +582,7 @@ impl MaskPass {
|
|||||||
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
|
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
|
||||||
Some(s) => (s, slot),
|
Some(s) => (s, slot),
|
||||||
None => {
|
None => {
|
||||||
log::warn!(
|
log::warn!("mask layer {} has no distance field; skipping", layer.id);
|
||||||
"mask layer {} has no distance field; skipping",
|
|
||||||
layer.id
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -599,7 +596,14 @@ impl MaskPass {
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let selected = self.selection_buffer(layer, field);
|
let selected = self.selection_buffer(layer, field);
|
||||||
self.draw(&mut encoder, slot as u32, ¶ms, field, &selected, subject);
|
self.draw(
|
||||||
|
&mut encoder,
|
||||||
|
slot as u32,
|
||||||
|
¶ms,
|
||||||
|
field,
|
||||||
|
&selected,
|
||||||
|
subject,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -730,8 +734,8 @@ impl MaskPass {
|
|||||||
// Still worth beginning the pass: the slice has to be cleared, or an
|
// Still worth beginning the pass: the slice has to be cleared, or an
|
||||||
// unpainted layer would show whatever the last edit left in it.
|
// unpainted layer would show whatever the last edit left in it.
|
||||||
let bind_group = (!batch.is_empty()).then(|| {
|
let bind_group = (!batch.is_empty()).then(|| {
|
||||||
let params_buf = self
|
let params_buf =
|
||||||
.ctx
|
self.ctx
|
||||||
.device
|
.device
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
label: Some("mask-brush-params"),
|
label: Some("mask-brush-params"),
|
||||||
@@ -901,10 +905,7 @@ impl MaskPass {
|
|||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
binding: 3,
|
binding: 3,
|
||||||
resource: wgpu::BindingResource::TextureView(
|
resource: wgpu::BindingResource::TextureView(
|
||||||
subject
|
subject.0.view(subject.1).unwrap_or_else(|| {
|
||||||
.0
|
|
||||||
.view(subject.1)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
self.empty_subject.view(0).expect("placeholder exists")
|
self.empty_subject.view(0).expect("placeholder exists")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -949,7 +950,11 @@ impl MaskPass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> {
|
fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> {
|
||||||
if self.array.as_ref().is_some_and(|a| a.matches(width, height, layers)) {
|
if self
|
||||||
|
.array
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|a| a.matches(width, height, layers))
|
||||||
|
{
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,7 +1116,9 @@ mod tests {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||||
let array = pass.render(&stack, Some(&field), None, w, h).expect("render");
|
let array = pass
|
||||||
|
.render(&stack, Some(&field), None, w, h)
|
||||||
|
.expect("render");
|
||||||
assert_eq!(array.size(), (w, h));
|
assert_eq!(array.size(), (w, h));
|
||||||
assert_eq!(array.layers(), 1);
|
assert_eq!(array.layers(), 1);
|
||||||
}
|
}
|
||||||
@@ -1201,7 +1208,11 @@ mod tests {
|
|||||||
assert_eq!(batch.headers.len(), 1);
|
assert_eq!(batch.headers.len(), 1);
|
||||||
|
|
||||||
let h = &batch.headers[0];
|
let h = &batch.headers[0];
|
||||||
assert!(h.hi[0] - h.lo[0] > 0.2, "wide enough for the dab: {h:?}", h = (h.lo, h.hi));
|
assert!(
|
||||||
|
h.hi[0] - h.lo[0] > 0.2,
|
||||||
|
"wide enough for the dab: {h:?}",
|
||||||
|
h = (h.lo, h.hi)
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
h.hi[1] - h.lo[1] > h.hi[0] - h.lo[0],
|
h.hi[1] - h.lo[1] > h.hi[0] - h.lo[0],
|
||||||
"and taller than it is wide in normalised units, since the radius \
|
"and taller than it is wide in normalised units, since the radius \
|
||||||
|
|||||||
@@ -62,9 +62,7 @@ fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
|
|||||||
.expect("demosaic");
|
.expect("demosaic");
|
||||||
let shader = EditGraph::default_chain().compose();
|
let shader = EditGraph::default_chain().compose();
|
||||||
let mut adjust = AdjustPass::new(ctx);
|
let mut adjust = AdjustPass::new(ctx);
|
||||||
adjust
|
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||||
.render(&source, &shader, SIZE, SIZE)
|
|
||||||
.expect("render");
|
|
||||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||||
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||||
pixels[centre as usize]
|
pixels[centre as usize]
|
||||||
|
|||||||
@@ -84,12 +84,7 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
|
|||||||
/// The whole calling convention a frontend adopts, in five lines: compose both
|
/// The whole calling convention a frontend adopts, in five lines: compose both
|
||||||
/// halves from one graph at one output space, ask the graph for the scale, and
|
/// halves from one graph at one output space, ask the graph for the scale, and
|
||||||
/// pass the invalidation key through.
|
/// pass the invalidation key through.
|
||||||
fn render(
|
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||||
pass: &mut AdjustPass,
|
|
||||||
graph: &EditGraph,
|
|
||||||
source: &DemosaicedImage,
|
|
||||||
out: u32,
|
|
||||||
) -> Vec<u8> {
|
|
||||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||||
let scale = graph.render_scale(source.size(), (out, out));
|
let scale = graph.render_scale(source.size(), (out, out));
|
||||||
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
|
||||||
@@ -281,7 +276,10 @@ fn a_proxy_and_an_export_sharpen_the_same_photograph() {
|
|||||||
proxy_width > 0.05 && export_width > 0.05,
|
proxy_width > 0.05 && export_width > 0.05,
|
||||||
"{proxy_width:.3} / {export_width:.3}"
|
"{proxy_width:.3} / {export_width:.3}"
|
||||||
);
|
);
|
||||||
assert!(proxy_area > 1.0 && export_area > 1.0, "{proxy_area} / {export_area}");
|
assert!(
|
||||||
|
proxy_area > 1.0 && export_area > 1.0,
|
||||||
|
"{proxy_area} / {export_area}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -80,9 +80,7 @@ fn expected_profile(size: u32, r: i32) -> Vec<u8> {
|
|||||||
let edge = (size / 2) as i32;
|
let edge = (size / 2) as i32;
|
||||||
(0..size as i32)
|
(0..size as i32)
|
||||||
.map(|x| {
|
.map(|x| {
|
||||||
let white = (-r..=r)
|
let white = (-r..=r).filter(|i| (x + i).clamp(0, last) >= edge).count();
|
||||||
.filter(|i| (x + i).clamp(0, last) >= edge)
|
|
||||||
.count();
|
|
||||||
srgb_encode(white as f32 / (2 * r + 1) as f32)
|
srgb_encode(white as f32 / (2 * r + 1) as f32)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -232,7 +230,10 @@ fn an_inactive_detail_operation_costs_exactly_nothing() {
|
|||||||
let mut without = AdjustPass::new(&ctx);
|
let mut without = AdjustPass::new(&ctx);
|
||||||
let b = render(&ctx, &mut without, &plain, &source, SIZE);
|
let b = render(&ctx, &mut without, &plain, &source, SIZE);
|
||||||
|
|
||||||
assert_eq!(a, b, "an operation at its defaults must not touch the image");
|
assert_eq!(
|
||||||
|
a, b,
|
||||||
|
"an operation at its defaults must not touch the image"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -252,7 +253,11 @@ fn moving_a_detail_parameter_does_not_re_run_the_colour_pass() {
|
|||||||
graph.set_param(PROBE, RADIUS, 0.0625);
|
graph.set_param(PROBE, RADIUS, 0.0625);
|
||||||
render(&ctx, &mut pass, &graph, &source, SIZE);
|
render(&ctx, &mut pass, &graph, &source, SIZE);
|
||||||
assert_eq!(pass.colour_dispatches(), 1);
|
assert_eq!(pass.colour_dispatches(), 1);
|
||||||
assert_eq!(pass.detail_dispatches(), 2, "a separable blur is two passes");
|
assert_eq!(
|
||||||
|
pass.detail_dispatches(),
|
||||||
|
2,
|
||||||
|
"a separable blur is two passes"
|
||||||
|
);
|
||||||
|
|
||||||
// Drag the sharpening slider. The colour chain is untouched, so the linear
|
// Drag the sharpening slider. The colour chain is untouched, so the linear
|
||||||
// intermediate it wrote is still exactly right.
|
// intermediate it wrote is still exactly right.
|
||||||
|
|||||||
@@ -65,12 +65,7 @@ fn render_at(
|
|||||||
h: u32,
|
h: u32,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let source = grey_at(ctx, w, h);
|
let source = grey_at(ctx, w, h);
|
||||||
let shader = compose_full(
|
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
|
||||||
&ops::chain(),
|
|
||||||
&Framing::new(),
|
|
||||||
ColourSpace::Srgb,
|
|
||||||
stack,
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||||
let array = masks.render(stack, field, None, w, h).expect("rasterise");
|
let array = masks.render(stack, field, None, w, h).expect("rasterise");
|
||||||
@@ -245,11 +240,14 @@ fn stacked_layers_use_their_own_masks() {
|
|||||||
ids: vec![0],
|
ids: vec![0],
|
||||||
}));
|
}));
|
||||||
// Right half down.
|
// Right half down.
|
||||||
let mut darken = MaskLayer::new("m2", MaskSource::Regions {
|
let mut darken = MaskLayer::new(
|
||||||
|
"m2",
|
||||||
|
MaskSource::Regions {
|
||||||
signature: 1,
|
signature: 1,
|
||||||
level: 2,
|
level: 2,
|
||||||
ids: vec![1],
|
ids: vec![1],
|
||||||
});
|
},
|
||||||
|
);
|
||||||
darken.set_param("exposure", ParamId("exposure"), -2.0);
|
darken.set_param("exposure", ParamId("exposure"), -2.0);
|
||||||
stack.push(darken);
|
stack.push(darken);
|
||||||
|
|
||||||
@@ -412,12 +410,7 @@ fn a_stroke_that_doubles_back_does_not_build_up() {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let once = stack_of(painted(&[(
|
let once = stack_of(painted(&[(false, 0.15, 0.5, vec![(0.1, 0.5), (0.9, 0.5)])]));
|
||||||
false,
|
|
||||||
0.15,
|
|
||||||
0.5,
|
|
||||||
vec![(0.1, 0.5), (0.9, 0.5)],
|
|
||||||
)]));
|
|
||||||
let twice = stack_of(painted(&[(
|
let twice = stack_of(painted(&[(
|
||||||
false,
|
false,
|
||||||
0.15,
|
0.15,
|
||||||
@@ -473,10 +466,17 @@ fn a_dab_is_round_on_a_frame_that_is_not_square() {
|
|||||||
let pixels = render_at(&ctx, &stack, None, W, H);
|
let pixels = render_at(&ctx, &stack, None, W, H);
|
||||||
|
|
||||||
let lit = |v: u8| v > 160;
|
let lit = |v: u8| v > 160;
|
||||||
let across = (0..W).filter(|&x| lit(luma_in(&pixels, W, x, H / 2))).count();
|
let across = (0..W)
|
||||||
let down = (0..H).filter(|&y| lit(luma_in(&pixels, W, W / 2, y))).count();
|
.filter(|&x| lit(luma_in(&pixels, W, x, H / 2)))
|
||||||
|
.count();
|
||||||
|
let down = (0..H)
|
||||||
|
.filter(|&y| lit(luma_in(&pixels, W, W / 2, y)))
|
||||||
|
.count();
|
||||||
|
|
||||||
assert!(across > 4 && down > 4, "the dab should exist: {across}x{down}");
|
assert!(
|
||||||
|
across > 4 && down > 4,
|
||||||
|
"the dab should exist: {across}x{down}"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
across.abs_diff(down) <= 2,
|
across.abs_diff(down) <= 2,
|
||||||
"a dab must be as wide as it is tall, got {across} across and {down} down"
|
"a dab must be as wide as it is tall, got {across} across and {down} down"
|
||||||
|
|||||||
@@ -158,7 +158,12 @@ fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() {
|
|||||||
|
|
||||||
// Far outside the kernel's reach the base equals the pixel, the detail
|
// Far outside the kernel's reach the base equals the pixel, the detail
|
||||||
// signal is zero, and the output must be the input to the last code value.
|
// signal is zero, and the output must be the input to the last code value.
|
||||||
for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] {
|
for x in [
|
||||||
|
0,
|
||||||
|
reach / 2,
|
||||||
|
SIZE as usize - 1 - reach / 2,
|
||||||
|
SIZE as usize - 1,
|
||||||
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
edited[x].abs_diff(plain[x]) <= 1,
|
edited[x].abs_diff(plain[x]) <= 1,
|
||||||
"column {x} moved by {} away from any edge",
|
"column {x} moved by {} away from any edge",
|
||||||
@@ -246,7 +251,10 @@ fn the_soft_limit_bounds_the_halo_at_a_hard_edge() {
|
|||||||
an unlimited unsharp mask would give"
|
an unlimited unsharp mask would give"
|
||||||
);
|
);
|
||||||
// But it is still a real effect, not a control that does nothing.
|
// But it is still a real effect, not a control that does nothing.
|
||||||
assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops");
|
assert!(
|
||||||
|
worst > 0.1,
|
||||||
|
"clarity moved almost nothing: {worst:.3} stops"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -476,7 +484,11 @@ fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
|
|||||||
let mut graph = graph_with(CLARITY, 40.0);
|
let mut graph = graph_with(CLARITY, 40.0);
|
||||||
render(&mut pass, &graph, &source, 256);
|
render(&mut pass, &graph, &source, 256);
|
||||||
assert_eq!(pass.colour_dispatches(), 1);
|
assert_eq!(pass.colour_dispatches(), 1);
|
||||||
assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes");
|
assert_eq!(
|
||||||
|
pass.detail_dispatches(),
|
||||||
|
2,
|
||||||
|
"a separable mask is two passes"
|
||||||
|
);
|
||||||
let pipelines = pass.cached_detail_pipelines();
|
let pipelines = pass.cached_detail_pipelines();
|
||||||
|
|
||||||
for amount in [50.0, 60.0, 70.0] {
|
for amount in [50.0, 60.0, 70.0] {
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ fn ctx() -> Option<GpuContext> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn grey(ctx: &GpuContext, size: u32) -> DemosaicedImage {
|
fn grey(ctx: &GpuContext, size: u32) -> DemosaicedImage {
|
||||||
let data: Vec<u8> = (0..size * size).flat_map(|_| [128, 128, 128, 255]).collect();
|
let data: Vec<u8> = (0..size * size)
|
||||||
|
.flat_map(|_| [128, 128, 128, 255])
|
||||||
|
.collect();
|
||||||
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,11 +112,7 @@ fn split(pixels: &[u8], width: u32, x: u32, y: u32) -> (f32, [f32; 3]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Upload an image built from a per-pixel closure.
|
/// Upload an image built from a per-pixel closure.
|
||||||
fn upload(
|
fn upload(ctx: &GpuContext, size: u32, f: impl Fn(u32, u32) -> [u8; 3]) -> DemosaicedImage {
|
||||||
ctx: &GpuContext,
|
|
||||||
size: u32,
|
|
||||||
f: impl Fn(u32, u32) -> [u8; 3],
|
|
||||||
) -> DemosaicedImage {
|
|
||||||
let data: Vec<u8> = (0..size * size)
|
let data: Vec<u8> = (0..size * size)
|
||||||
.flat_map(|i| {
|
.flat_map(|i| {
|
||||||
let (x, y) = (i % size, i / size);
|
let (x, y) = (i % size, i / size);
|
||||||
@@ -248,7 +244,11 @@ fn modulation(
|
|||||||
let mut count = 0.0;
|
let mut count = 0.0;
|
||||||
for x in margin..(width - margin) {
|
for x in margin..(width - margin) {
|
||||||
let value = sample(linear(pixels, width, x, row));
|
let value = sample(linear(pixels, width, x, row));
|
||||||
let sign = if (x / half_period) % 2 == 0 { 1.0 } else { -1.0 };
|
let sign = if (x / half_period) % 2 == 0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
-1.0
|
||||||
|
};
|
||||||
total += value * sign;
|
total += value * sign;
|
||||||
count += 1.0;
|
count += 1.0;
|
||||||
}
|
}
|
||||||
@@ -305,7 +305,13 @@ fn chroma_noise_reduction_never_moves_lightness() {
|
|||||||
let mut off = AdjustPass::new(&ctx);
|
let mut off = AdjustPass::new(&ctx);
|
||||||
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
|
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
|
||||||
let mut on = AdjustPass::new(&ctx);
|
let mut on = AdjustPass::new(&ctx);
|
||||||
let chroma = render(&ctx, &mut on, &graph_with(0.0, 100.0), &source, (SIZE, SIZE));
|
let chroma = render(
|
||||||
|
&ctx,
|
||||||
|
&mut on,
|
||||||
|
&graph_with(0.0, 100.0),
|
||||||
|
&source,
|
||||||
|
(SIZE, SIZE),
|
||||||
|
);
|
||||||
|
|
||||||
let colour_before = modulation(&plain, SIZE, 4, chroma_r);
|
let colour_before = modulation(&plain, SIZE, 4, chroma_r);
|
||||||
let colour_after = modulation(&chroma, SIZE, 4, chroma_r);
|
let colour_after = modulation(&chroma, SIZE, 4, chroma_r);
|
||||||
@@ -338,7 +344,13 @@ fn luminance_noise_reduction_never_moves_colour() {
|
|||||||
let mut off = AdjustPass::new(&ctx);
|
let mut off = AdjustPass::new(&ctx);
|
||||||
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
|
let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
|
||||||
let mut on = AdjustPass::new(&ctx);
|
let mut on = AdjustPass::new(&ctx);
|
||||||
let luma = render(&ctx, &mut on, &graph_with(100.0, 0.0), &source, (SIZE, SIZE));
|
let luma = render(
|
||||||
|
&ctx,
|
||||||
|
&mut on,
|
||||||
|
&graph_with(100.0, 0.0),
|
||||||
|
&source,
|
||||||
|
(SIZE, SIZE),
|
||||||
|
);
|
||||||
|
|
||||||
// The colour difference — not the raw channel, which follows lightness.
|
// The colour difference — not the raw channel, which follows lightness.
|
||||||
let row = SIZE / 2;
|
let row = SIZE / 2;
|
||||||
@@ -430,14 +442,26 @@ fn the_same_edit_denoises_the_same_at_two_resolutions() {
|
|||||||
|
|
||||||
let proxy_size = SOURCE / 2;
|
let proxy_size = SOURCE / 2;
|
||||||
let mut proxy_pass = AdjustPass::new(&ctx);
|
let mut proxy_pass = AdjustPass::new(&ctx);
|
||||||
let proxy = render(&ctx, &mut proxy_pass, &graph, &source, (proxy_size, proxy_size));
|
let proxy = render(
|
||||||
|
&ctx,
|
||||||
|
&mut proxy_pass,
|
||||||
|
&graph,
|
||||||
|
&source,
|
||||||
|
(proxy_size, proxy_size),
|
||||||
|
);
|
||||||
let proxy_amp = modulation(&proxy, proxy_size, HALF_PERIOD / 2, chroma_r);
|
let proxy_amp = modulation(&proxy, proxy_size, HALF_PERIOD / 2, chroma_r);
|
||||||
|
|
||||||
// Both must be doing something: two flat images would agree perfectly and
|
// Both must be doing something: two flat images would agree perfectly and
|
||||||
// prove nothing. About 0.0098 of 0.0188, by the derivation above.
|
// prove nothing. About 0.0098 of 0.0188, by the derivation above.
|
||||||
let untouched = {
|
let untouched = {
|
||||||
let mut pass = AdjustPass::new(&ctx);
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
let plain = render(&ctx, &mut pass, &graph_with(0.0, 0.0), &source, (SOURCE, SOURCE));
|
let plain = render(
|
||||||
|
&ctx,
|
||||||
|
&mut pass,
|
||||||
|
&graph_with(0.0, 0.0),
|
||||||
|
&source,
|
||||||
|
(SOURCE, SOURCE),
|
||||||
|
);
|
||||||
modulation(&plain, SOURCE, HALF_PERIOD, chroma_r)
|
modulation(&plain, SOURCE, HALF_PERIOD, chroma_r)
|
||||||
};
|
};
|
||||||
assert!(
|
assert!(
|
||||||
@@ -486,7 +510,13 @@ fn each_amount_costs_only_the_dispatches_it_needs() {
|
|||||||
|
|
||||||
for (luminance, chroma, expected) in [(60.0, 0.0, 1), (0.0, 60.0, 2), (60.0, 60.0, 3)] {
|
for (luminance, chroma, expected) in [(60.0, 0.0, 1), (0.0, 60.0, 2), (60.0, 60.0, 3)] {
|
||||||
let mut pass = AdjustPass::new(&ctx);
|
let mut pass = AdjustPass::new(&ctx);
|
||||||
render(&ctx, &mut pass, &graph_with(luminance, chroma), &source, (SIZE, SIZE));
|
render(
|
||||||
|
&ctx,
|
||||||
|
&mut pass,
|
||||||
|
&graph_with(luminance, chroma),
|
||||||
|
&source,
|
||||||
|
(SIZE, SIZE),
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pass.detail_dispatches(),
|
pass.detail_dispatches(),
|
||||||
expected,
|
expected,
|
||||||
@@ -496,7 +526,13 @@ fn each_amount_costs_only_the_dispatches_it_needs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut neutral = AdjustPass::new(&ctx);
|
let mut neutral = AdjustPass::new(&ctx);
|
||||||
let a = render(&ctx, &mut neutral, &graph_with(0.0, 0.0), &source, (SIZE, SIZE));
|
let a = render(
|
||||||
|
&ctx,
|
||||||
|
&mut neutral,
|
||||||
|
&graph_with(0.0, 0.0),
|
||||||
|
&source,
|
||||||
|
(SIZE, SIZE),
|
||||||
|
);
|
||||||
assert_eq!(neutral.detail_dispatches(), 0);
|
assert_eq!(neutral.detail_dispatches(), 0);
|
||||||
assert_eq!(neutral.detail_allocations(), 0, "nothing was allocated");
|
assert_eq!(neutral.detail_allocations(), 0, "nothing was allocated");
|
||||||
|
|
||||||
@@ -504,7 +540,13 @@ fn each_amount_costs_only_the_dispatches_it_needs() {
|
|||||||
// touch the image, and a stage that ran and wrote back the same values
|
// touch the image, and a stage that ran and wrote back the same values
|
||||||
// would still have quantised twice.
|
// would still have quantised twice.
|
||||||
let mut absent = AdjustPass::new(&ctx);
|
let mut absent = AdjustPass::new(&ctx);
|
||||||
let b = render(&ctx, &mut absent, &EditGraph::default_chain(), &source, (SIZE, SIZE));
|
let b = render(
|
||||||
|
&ctx,
|
||||||
|
&mut absent,
|
||||||
|
&EditGraph::default_chain(),
|
||||||
|
&source,
|
||||||
|
(SIZE, SIZE),
|
||||||
|
);
|
||||||
assert_eq!(a, b, "a neutral denoiser changed the picture");
|
assert_eq!(a, b, "a neutral denoiser changed the picture");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ fn ctx() -> Option<GpuContext> {
|
|||||||
/// The centre pixel's red, green and blue, after `graph` has run over flat
|
/// The centre pixel's red, green and blue, after `graph` has run over flat
|
||||||
/// mid-grey.
|
/// mid-grey.
|
||||||
fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) {
|
fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) {
|
||||||
let data: Vec<u8> = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect();
|
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||||
|
.flat_map(|_| [128, 128, 128, 255])
|
||||||
|
.collect();
|
||||||
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
|
let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload");
|
||||||
|
|
||||||
// Composed the way the display path composes it. A curve that generates
|
// Composed the way the display path composes it. A curve that generates
|
||||||
@@ -44,11 +46,7 @@ fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) {
|
|||||||
/// an edit.
|
/// an edit.
|
||||||
fn lifted(channel: Channel) -> EditGraph {
|
fn lifted(channel: Channel) -> EditGraph {
|
||||||
let mut graph = EditGraph::default_chain();
|
let mut graph = EditGraph::default_chain();
|
||||||
graph.set_param(
|
graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.75);
|
||||||
curve::ID,
|
|
||||||
curve::coordinate(channel, 2, Axis::Y),
|
|
||||||
0.75,
|
|
||||||
);
|
|
||||||
graph
|
graph
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +60,10 @@ fn the_master_curve_lifts_every_component_together() {
|
|||||||
let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain());
|
let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain());
|
||||||
let (r, g, b) = rendered(&ctx, &lifted(Channel::Master));
|
let (r, g, b) = rendered(&ctx, &lifted(Channel::Master));
|
||||||
|
|
||||||
assert!(r > r0, "the master curve did not lift the image: {r} vs {r0}");
|
assert!(
|
||||||
|
r > r0,
|
||||||
|
"the master curve did not lift the image: {r} vs {r0}"
|
||||||
|
);
|
||||||
// Grey in, grey out: the master curve is applied as a ratio over
|
// Grey in, grey out: the master curve is applied as a ratio over
|
||||||
// luminance, so it changes tone and not hue. A tolerance of one code
|
// luminance, so it changes tone and not hue. A tolerance of one code
|
||||||
// value, because the components travel through the ratio separately and
|
// value, because the components travel through the ratio separately and
|
||||||
|
|||||||
@@ -1532,7 +1532,10 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
let mut c = a.chars();
|
let mut c = a.chars();
|
||||||
let head = c.next().expect("attribute names are non-empty").to_uppercase();
|
let head = c
|
||||||
|
.next()
|
||||||
|
.expect("attribute names are non-empty")
|
||||||
|
.to_uppercase();
|
||||||
format!("Attribute::{head}{}", c.as_str())
|
format!("Attribute::{head}{}", c.as_str())
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -499,11 +499,7 @@ pub fn compose_detail(
|
|||||||
// case where a photographer has a kernel switched on at a scale that
|
// case where a photographer has a kernel switched on at a scale that
|
||||||
// cannot draw it — against the alternative of the preview failing outright
|
// cannot draw it — against the alternative of the preview failing outright
|
||||||
// or `compose_full` growing a resolution argument it has no other use for.
|
// or `compose_full` growing a resolution argument it has no other use for.
|
||||||
if planned.is_empty()
|
if planned.is_empty() && ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
|
||||||
&& ops
|
|
||||||
.iter()
|
|
||||||
.any(|o| o.is_active() && o.detail().is_some())
|
|
||||||
{
|
|
||||||
return ComposedDetail {
|
return ComposedDetail {
|
||||||
passes: vec![compose_one(
|
passes: vec![compose_one(
|
||||||
RESOLVE_ID,
|
RESOLVE_ID,
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
//! so reordering the pipeline needs no code change.
|
//! so reordering the pipeline needs no code change.
|
||||||
|
|
||||||
use crate::descriptor::{
|
use crate::descriptor::{
|
||||||
Attribute,
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
|
||||||
Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
|
|
||||||
};
|
};
|
||||||
use crate::framing::{CropRect, Framing};
|
use crate::framing::{CropRect, Framing};
|
||||||
use crate::mask::MaskStack;
|
use crate::mask::MaskStack;
|
||||||
@@ -378,7 +377,11 @@ impl EditGraph {
|
|||||||
/// Only the detail stage needs this. Every point operation is scale-free
|
/// Only the detail stage needs this. Every point operation is scale-free
|
||||||
/// — a multiply is a multiply at any resolution — which is why nothing in
|
/// — a multiply is a multiply at any resolution — which is why nothing in
|
||||||
/// the pipeline had to know its own size until a kernel arrived.
|
/// the pipeline had to know its own size until a kernel arrived.
|
||||||
pub fn render_scale(&self, source: (u32, u32), render: (u32, u32)) -> crate::detail::RenderScale {
|
pub fn render_scale(
|
||||||
|
&self,
|
||||||
|
source: (u32, u32),
|
||||||
|
render: (u32, u32),
|
||||||
|
) -> crate::detail::RenderScale {
|
||||||
let (fw, fh) = self.framing.output_size(source.0, source.1);
|
let (fw, fh) = self.framing.output_size(source.0, source.1);
|
||||||
let view = self.framing.view();
|
let view = self.framing.view();
|
||||||
// The *viewed* part of the framed image, at source resolution. Zoom
|
// The *viewed* part of the framed image, at source resolution. Zoom
|
||||||
@@ -397,7 +400,10 @@ impl EditGraph {
|
|||||||
/// Empty for every edit with no active neighbourhood operation, which is
|
/// Empty for every edit with no active neighbourhood operation, which is
|
||||||
/// almost all of them — and in that case [`Self::compose`] emits the
|
/// almost all of them — and in that case [`Self::compose`] emits the
|
||||||
/// single encoded dispatch it always has.
|
/// single encoded dispatch it always has.
|
||||||
pub fn compose_detail(&self, scale: crate::detail::RenderScale) -> crate::detail::ComposedDetail {
|
pub fn compose_detail(
|
||||||
|
&self,
|
||||||
|
scale: crate::detail::RenderScale,
|
||||||
|
) -> crate::detail::ComposedDetail {
|
||||||
self.compose_detail_for(scale, dr_types::ColourSpace::Srgb)
|
self.compose_detail_for(scale, dr_types::ColourSpace::Srgb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ pub mod preset;
|
|||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
|
|
||||||
pub use descriptor::{
|
pub use descriptor::{
|
||||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation,
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
|
||||||
Scale, Unit, WidgetDemand, WidgetKind,
|
Presentation, Scale, Unit, WidgetDemand, WidgetKind,
|
||||||
};
|
};
|
||||||
pub use detail::{
|
pub use detail::{
|
||||||
compose_detail, ComposedDetail, ComposedDetailPass, DetailPass, DetailStage, RenderScale,
|
compose_detail, ComposedDetail, ComposedDetailPass, DetailPass, DetailStage, RenderScale,
|
||||||
|
|||||||
@@ -856,7 +856,11 @@ impl MaskLayer {
|
|||||||
pub fn begin_stroke(&mut self, erase: bool, radius: f32, hardness: f32, flow: f32) -> bool {
|
pub fn begin_stroke(&mut self, erase: bool, radius: f32, hardness: f32, flow: f32) -> bool {
|
||||||
let full = self.stroke_points() >= MAX_LAYER_POINTS;
|
let full = self.stroke_points() >= MAX_LAYER_POINTS;
|
||||||
let MaskSource::Brush { strokes } = &mut self.source else {
|
let MaskSource::Brush { strokes } = &mut self.source else {
|
||||||
log::warn!("layer {} is a {} mask, not a brush", self.id, self.source.kind());
|
log::warn!(
|
||||||
|
"layer {} is a {} mask, not a brush",
|
||||||
|
self.id,
|
||||||
|
self.source.kind()
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if full {
|
if full {
|
||||||
@@ -1121,7 +1125,10 @@ impl MaskStack {
|
|||||||
|
|
||||||
/// Generate a fresh layer id that does not collide with an existing one.
|
/// Generate a fresh layer id that does not collide with an existing one.
|
||||||
pub fn next_id(&self) -> String {
|
pub fn next_id(&self) -> String {
|
||||||
(1..).map(|n| format!("m{n}")).find(|id| self.get(id).is_none()).expect("infinite range")
|
(1..)
|
||||||
|
.map(|n| format!("m{n}"))
|
||||||
|
.find(|id| self.get(id).is_none())
|
||||||
|
.expect("infinite range")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,10 +1185,7 @@ pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader {
|
|||||||
// place. A second copy here would be a second thing to keep in step
|
// place. A second copy here would be a second thing to keep in step
|
||||||
// with `Framing::wgsl_prologue`, and the failure would be a mask that
|
// with `Framing::wgsl_prologue`, and the failure would be a mask that
|
||||||
// is subtly wrong only when straightened.
|
// is subtly wrong only when straightened.
|
||||||
let _ = writeln!(
|
let _ = writeln!(out.body, " var m = sample_mask(uv_src, {slot});");
|
||||||
out.body,
|
|
||||||
" var m = sample_mask(uv_src, {slot});"
|
|
||||||
);
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out.body,
|
out.body,
|
||||||
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
|
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
|
||||||
@@ -1403,7 +1407,11 @@ mod tests {
|
|||||||
assert!(shader.uniform_fields.contains("mask1_exposure_"));
|
assert!(shader.uniform_fields.contains("mask1_exposure_"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shader.uniform_values.len(),
|
shader.uniform_values.len(),
|
||||||
shader.uniform_fields.lines().filter(|l| l.trim_start().starts_with("mask")).count(),
|
shader
|
||||||
|
.uniform_fields
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.trim_start().starts_with("mask"))
|
||||||
|
.count(),
|
||||||
"one value per emitted field"
|
"one value per emitted field"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1448,7 +1456,11 @@ mod tests {
|
|||||||
// A gradient has no region ids, so nothing can go stale about it.
|
// A gradient has no region ids, so nothing can go stale about it.
|
||||||
let grad = MaskLayer::new(
|
let grad = MaskLayer::new(
|
||||||
"m2",
|
"m2",
|
||||||
MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2 },
|
MaskSource::Linear {
|
||||||
|
centre: (0.5, 0.5),
|
||||||
|
angle: 0.0,
|
||||||
|
width: 0.2,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
assert!(!grad.is_stale(999));
|
assert!(!grad.is_stale(999));
|
||||||
}
|
}
|
||||||
@@ -1458,8 +1470,16 @@ mod tests {
|
|||||||
let base = segmentation_signature(1600, 1067, 6730, 2);
|
let base = segmentation_signature(1600, 1067, 6730, 2);
|
||||||
assert_eq!(base, segmentation_signature(1600, 1067, 6730, 2));
|
assert_eq!(base, segmentation_signature(1600, 1067, 6730, 2));
|
||||||
assert_ne!(base, segmentation_signature(1600, 1067, 6730, 5), "tuning");
|
assert_ne!(base, segmentation_signature(1600, 1067, 6730, 5), "tuning");
|
||||||
assert_ne!(base, segmentation_signature(800, 1067, 6730, 2), "proxy size");
|
assert_ne!(
|
||||||
assert_ne!(base, segmentation_signature(1600, 1067, 42, 2), "region count");
|
base,
|
||||||
|
segmentation_signature(800, 1067, 6730, 2),
|
||||||
|
"proxy size"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
base,
|
||||||
|
segmentation_signature(1600, 1067, 42, 2),
|
||||||
|
"region count"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -472,10 +472,7 @@ pub fn compose_full(
|
|||||||
// than from a flag the caller sets, because a caller that got the flag
|
// than from a flag the caller sets, because a caller that got the flag
|
||||||
// wrong would produce a shader whose storage format does not match the
|
// wrong would produce a shader whose storage format does not match the
|
||||||
// texture bound to it.
|
// texture bound to it.
|
||||||
let output_mode = if ops
|
let output_mode = if ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
|
||||||
.iter()
|
|
||||||
.any(|o| o.is_active() && o.detail().is_some())
|
|
||||||
{
|
|
||||||
OutputMode::LinearWorking
|
OutputMode::LinearWorking
|
||||||
} else {
|
} else {
|
||||||
OutputMode::Encoded
|
OutputMode::Encoded
|
||||||
@@ -1389,7 +1386,10 @@ mod tests {
|
|||||||
// how a shoulder rolls off.
|
// how a shoulder rolls off.
|
||||||
let mut curve = crate::ops::ToneCurve::new();
|
let mut curve = crate::ops::ToneCurve::new();
|
||||||
curve.set_param(crate::ops::curve::P2_Y, 0.7);
|
curve.set_param(crate::ops::curve::P2_Y, 0.7);
|
||||||
assert!(curve.is_active(), "the fixture must actually reach the shader");
|
assert!(
|
||||||
|
curve.is_active(),
|
||||||
|
"the fixture must actually reach the shader"
|
||||||
|
);
|
||||||
|
|
||||||
let source = compose(&[Box::new(curve)]).source;
|
let source = compose(&[Box::new(curve)]).source;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -32,7 +32,9 @@
|
|||||||
//! least to perceived sharpness. Scaling all three about a virtual reference
|
//! least to perceived sharpness. Scaling all three about a virtual reference
|
||||||
//! would soften the image even when the correction is right.
|
//! would soften the image even when the correction is right.
|
||||||
|
|
||||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
|
use crate::descriptor::{
|
||||||
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
|
};
|
||||||
use crate::lens::Warp;
|
use crate::lens::Warp;
|
||||||
use crate::operation::{Helper, Uniform};
|
use crate::operation::{Helper, Uniform};
|
||||||
|
|
||||||
|
|||||||
@@ -764,9 +764,11 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(gate_of(0.0), 0.0);
|
assert_eq!(gate_of(0.0), 0.0);
|
||||||
assert!((gate_of(1.0) - 0.25).abs() < 1e-6);
|
assert!((gate_of(1.0) - 0.25).abs() < 1e-6);
|
||||||
assert!(chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0]
|
assert!(
|
||||||
|
chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0]
|
||||||
.source
|
.source
|
||||||
.contains("gate > 0.0"));
|
.contains("gate > 0.0")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -783,7 +785,11 @@ mod tests {
|
|||||||
for scale in scales {
|
for scale in scales {
|
||||||
for pass in chain_at(&sharpening(75.0, 1.0), scale).passes {
|
for pass in chain_at(&sharpening(75.0, 1.0), scale).passes {
|
||||||
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
|
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
|
||||||
assert!(pass.uniforms.iter().all(|v| v.is_finite()), "{}", pass.label);
|
assert!(
|
||||||
|
pass.uniforms.iter().all(|v| v.is_finite()),
|
||||||
|
"{}",
|
||||||
|
pass.label
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -805,10 +811,38 @@ mod tests {
|
|||||||
// word be safe in one stage and not in the other, which is the sort of
|
// word be safe in one stage and not in the other, which is the sort of
|
||||||
// difference nobody discovers until a shader fails to compile.
|
// difference nobody discovers until a shader fails to compile.
|
||||||
const RESERVED: &[&str] = &[
|
const RESERVED: &[&str] = &[
|
||||||
"target", "sample", "filter", "texture", "buffer", "binding", "const", "enum", "mat",
|
"target",
|
||||||
"vec", "ptr", "ref", "shared", "static", "typedef", "union", "unless", "handle",
|
"sample",
|
||||||
"layout", "packed", "premerge", "regardless", "active", "do", "input", "output",
|
"filter",
|
||||||
"private", "resource", "restrict", "self", "std", "where",
|
"texture",
|
||||||
|
"buffer",
|
||||||
|
"binding",
|
||||||
|
"const",
|
||||||
|
"enum",
|
||||||
|
"mat",
|
||||||
|
"vec",
|
||||||
|
"ptr",
|
||||||
|
"ref",
|
||||||
|
"shared",
|
||||||
|
"static",
|
||||||
|
"typedef",
|
||||||
|
"union",
|
||||||
|
"unless",
|
||||||
|
"handle",
|
||||||
|
"layout",
|
||||||
|
"packed",
|
||||||
|
"premerge",
|
||||||
|
"regardless",
|
||||||
|
"active",
|
||||||
|
"do",
|
||||||
|
"input",
|
||||||
|
"output",
|
||||||
|
"private",
|
||||||
|
"resource",
|
||||||
|
"restrict",
|
||||||
|
"self",
|
||||||
|
"std",
|
||||||
|
"where",
|
||||||
];
|
];
|
||||||
|
|
||||||
for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes {
|
for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes {
|
||||||
|
|||||||
@@ -30,7 +30,9 @@
|
|||||||
//! adjusted, each one's share depends on what the other is set to, so turning
|
//! adjusted, each one's share depends on what the other is set to, so turning
|
||||||
//! up one colour's saturation quietly weakened its neighbour's hue shift.
|
//! up one colour's saturation quietly weakened its neighbour's hue shift.
|
||||||
|
|
||||||
use crate::descriptor::{Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
use crate::descriptor::{
|
||||||
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,
|
||||||
|
};
|
||||||
use crate::operation::{Helper, Operation, Uniform};
|
use crate::operation::{Helper, Operation, Uniform};
|
||||||
use crate::ops::helpers;
|
use crate::ops::helpers;
|
||||||
|
|
||||||
|
|||||||
@@ -121,12 +121,7 @@ impl Channel {
|
|||||||
/// Master first because it runs first — see the module documentation for
|
/// Master first because it runs first — see the module documentation for
|
||||||
/// why that is the composition order — and because a list showing the
|
/// why that is the composition order — and because a list showing the
|
||||||
/// grade before the tone would be describing a different operation.
|
/// grade before the tone would be describing a different operation.
|
||||||
pub const ALL: [Channel; 4] = [
|
pub const ALL: [Channel; 4] = [Channel::Master, Channel::Red, Channel::Green, Channel::Blue];
|
||||||
Channel::Master,
|
|
||||||
Channel::Red,
|
|
||||||
Channel::Green,
|
|
||||||
Channel::Blue,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Position in [`Self::ALL`], and so in every array keyed by channel.
|
/// Position in [`Self::ALL`], and so in every array keyed by channel.
|
||||||
pub const fn index(self) -> usize {
|
pub const fn index(self) -> usize {
|
||||||
@@ -771,7 +766,10 @@ impl Operation for ToneCurve {
|
|||||||
// declared from, so a call and its uniform block cannot disagree
|
// declared from, so a call and its uniform block cannot disagree
|
||||||
// about a name.
|
// about a name.
|
||||||
let args = UNIFORM_NAMES[channel.index()].join(", ");
|
let args = UNIFORM_NAMES[channel.index()].join(", ");
|
||||||
let _ = writeln!(body, "c.{component} = channel_curve(c.{component}, {args});");
|
let _ = writeln!(
|
||||||
|
body,
|
||||||
|
"c.{component} = channel_curve(c.{component}, {args});"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whichever curves ran, the result has to be a colour: the spline's
|
// Whichever curves ran, the result has to be a colour: the spline's
|
||||||
@@ -1345,7 +1343,9 @@ mod tests {
|
|||||||
|
|
||||||
let body = c.wgsl_body();
|
let body = c.wgsl_body();
|
||||||
let master = body.find("apply_tone_gain").expect("the master curve runs");
|
let master = body.find("apply_tone_gain").expect("the master curve runs");
|
||||||
let red = body.find("c.r = channel_curve").expect("the red curve runs");
|
let red = body
|
||||||
|
.find("c.r = channel_curve")
|
||||||
|
.expect("the red curve runs");
|
||||||
assert!(master < red, "the master curve must run first:\n{body}");
|
assert!(master < red, "the master curve must run first:\n{body}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
//! profile is a "make the horizon straight" task, which one term does well.
|
//! profile is a "make the horizon straight" task, which one term does well.
|
||||||
//! The full triple is reachable by loading a profile.
|
//! The full triple is reachable by loading a profile.
|
||||||
|
|
||||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
|
use crate::descriptor::{
|
||||||
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
|
};
|
||||||
use crate::lens::Warp;
|
use crate::lens::Warp;
|
||||||
use crate::operation::{Helper, Uniform};
|
use crate::operation::{Helper, Uniform};
|
||||||
|
|
||||||
|
|||||||
@@ -619,7 +619,10 @@ mod tests {
|
|||||||
let scale = RenderScale::full((2000, 1500));
|
let scale = RenderScale::full((2000, 1500));
|
||||||
let only_clarity = composed(50.0, 0.0, scale);
|
let only_clarity = composed(50.0, 0.0, scale);
|
||||||
assert_eq!(only_clarity.len(), 2, "one operation, two passes");
|
assert_eq!(only_clarity.len(), 2, "one operation, two passes");
|
||||||
assert!(only_clarity.passes.iter().all(|p| p.label.starts_with("clarity/")));
|
assert!(only_clarity
|
||||||
|
.passes
|
||||||
|
.iter()
|
||||||
|
.all(|p| p.label.starts_with("clarity/")));
|
||||||
|
|
||||||
let both = composed(50.0, 50.0, scale);
|
let both = composed(50.0, 50.0, scale);
|
||||||
assert_eq!(both.len(), 4);
|
assert_eq!(both.len(), 4);
|
||||||
@@ -701,7 +704,10 @@ mod tests {
|
|||||||
// acutance family's unit.
|
// acutance family's unit.
|
||||||
let proxy = RenderScale::new((2000, 1500), (6000, 4500));
|
let proxy = RenderScale::new((2000, 1500), (6000, 4500));
|
||||||
let clarity = Clarity::with_amount(60.0);
|
let clarity = Clarity::with_amount(60.0);
|
||||||
assert_eq!(clarity.kernel(proxy), clarity.kernel(RenderScale::full((2000, 1500))));
|
assert_eq!(
|
||||||
|
clarity.kernel(proxy),
|
||||||
|
clarity.kernel(RenderScale::full((2000, 1500)))
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
proxy.source_pixels(96.0) < 40.0,
|
proxy.source_pixels(96.0) < 40.0,
|
||||||
"the same length in the other unit would have collapsed"
|
"the same length in the other unit would have collapsed"
|
||||||
@@ -786,7 +792,9 @@ mod tests {
|
|||||||
// slider can produce is `gain * threshold` stops however violent the
|
// slider can produce is `gain * threshold` stops however violent the
|
||||||
// edge — a bound that holds by construction rather than by tuning.
|
// edge — a bound that holds by construction rather than by tuning.
|
||||||
let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
|
let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1];
|
||||||
assert!(combine.wgsl.contains("threshold * tanh(detail / threshold)"));
|
assert!(combine
|
||||||
|
.wgsl
|
||||||
|
.contains("threshold * tanh(detail / threshold)"));
|
||||||
|
|
||||||
let bound = |u: &[Uniform]| {
|
let bound = |u: &[Uniform]| {
|
||||||
let get = |n| u.iter().find(|x| x.name == n).unwrap().value;
|
let get = |n| u.iter().find(|x| x.name == n).unwrap().value;
|
||||||
@@ -819,7 +827,10 @@ mod tests {
|
|||||||
|
|
||||||
// And texture does not carry the helpers it would need for one, so its
|
// And texture does not carry the helpers it would need for one, so its
|
||||||
// shader says what it does rather than merely not calling it.
|
// shader says what it does rather than merely not calling it.
|
||||||
assert!(Texture::new().helpers().iter().any(|h| h.name == "log_luma"));
|
assert!(Texture::new()
|
||||||
|
.helpers()
|
||||||
|
.iter()
|
||||||
|
.any(|h| h.name == "log_luma"));
|
||||||
assert!(!Texture::new()
|
assert!(!Texture::new()
|
||||||
.helpers()
|
.helpers()
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -820,7 +820,13 @@ mod tests {
|
|||||||
// Everything else about the two must match, or one axis is filtered
|
// Everything else about the two must match, or one axis is filtered
|
||||||
// harder than the other and a round blotch comes out oval.
|
// harder than the other and a round blotch comes out oval.
|
||||||
assert_eq!(passes[0].radius, passes[1].radius);
|
assert_eq!(passes[0].radius, passes[1].radius);
|
||||||
for name in ["radius", "inv_spatial", "guide_k", "chroma_k", "noise_floor"] {
|
for name in [
|
||||||
|
"radius",
|
||||||
|
"inv_spatial",
|
||||||
|
"guide_k",
|
||||||
|
"chroma_k",
|
||||||
|
"noise_floor",
|
||||||
|
] {
|
||||||
let of = |p: &DetailPass| {
|
let of = |p: &DetailPass| {
|
||||||
p.uniforms
|
p.uniforms
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ use std::fmt;
|
|||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
use crate::graph::EditGraph;
|
use crate::graph::EditGraph;
|
||||||
use crate::mask::{
|
use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER};
|
||||||
Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER,
|
|
||||||
};
|
|
||||||
use crate::preset::{resolve, Preset};
|
use crate::preset::{resolve, Preset};
|
||||||
|
|
||||||
/// Format version of the document itself.
|
/// Format version of the document itself.
|
||||||
@@ -662,7 +660,6 @@ impl Sidecar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Write one mask layer as its own block.
|
/// Write one mask layer as its own block.
|
||||||
///
|
///
|
||||||
/// The version uuid is repeated in the header rather than relying on the
|
/// The version uuid is repeated in the header rather than relying on the
|
||||||
@@ -1292,7 +1289,11 @@ mod tests {
|
|||||||
// else.
|
// else.
|
||||||
assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15));
|
assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15));
|
||||||
assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85));
|
assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85));
|
||||||
for channel in [curve::Channel::Red, curve::Channel::Green, curve::Channel::Blue] {
|
for channel in [
|
||||||
|
curve::Channel::Red,
|
||||||
|
curve::Channel::Green,
|
||||||
|
curve::Channel::Blue,
|
||||||
|
] {
|
||||||
for point in 0..curve::POINTS {
|
for point in 0..curve::POINTS {
|
||||||
for axis in [curve::Axis::X, curve::Axis::Y] {
|
for axis in [curve::Axis::X, curve::Axis::Y] {
|
||||||
let id = curve::coordinate(channel, point, axis);
|
let id = curve::coordinate(channel, point, axis);
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ fn the_groups_are_derivable_from_the_chain() {
|
|||||||
present.sort();
|
present.sort();
|
||||||
present.dedup();
|
present.dedup();
|
||||||
|
|
||||||
assert!(present.contains(&Attribute::Tone), "the chain has tonal work");
|
assert!(
|
||||||
|
present.contains(&Attribute::Tone),
|
||||||
|
"the chain has tonal work"
|
||||||
|
);
|
||||||
assert!(present.contains(&Attribute::Colour));
|
assert!(present.contains(&Attribute::Colour));
|
||||||
assert!(
|
assert!(
|
||||||
present.contains(&Attribute::Geometry),
|
present.contains(&Attribute::Geometry),
|
||||||
|
|||||||
@@ -55,7 +55,14 @@ fn a_region_mask_survives_a_round_trip() {
|
|||||||
assert_eq!(layer.id, "m1");
|
assert_eq!(layer.id, "m1");
|
||||||
assert_eq!(layer.name, "Subject");
|
assert_eq!(layer.name, "Subject");
|
||||||
assert_eq!(layer.source, regions(&[3, 7, 12]));
|
assert_eq!(layer.source, regions(&[3, 7, 12]));
|
||||||
assert_eq!(layer.ops.iter().find(|o| o.descriptor().id.0 == "exposure").map(|o| o.param(ParamId("exposure"))), Some(0.75));
|
assert_eq!(
|
||||||
|
layer
|
||||||
|
.ops
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.descriptor().id.0 == "exposure")
|
||||||
|
.map(|o| o.param(ParamId("exposure"))),
|
||||||
|
Some(0.75)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -120,7 +127,10 @@ fn flags_and_opacity_survive() {
|
|||||||
let layer = &restored.masks().layers()[0];
|
let layer = &restored.masks().layers()[0];
|
||||||
assert!(layer.invert);
|
assert!(layer.invert);
|
||||||
assert!((layer.opacity - 0.35).abs() < 1e-6);
|
assert!((layer.opacity - 0.35).abs() < 1e-6);
|
||||||
assert!(!layer.enabled, "a disabled layer must stay disabled, not vanish");
|
assert!(
|
||||||
|
!layer.enabled,
|
||||||
|
"a disabled layer must stay disabled, not vanish"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A selection's identity is the *set*. Two files naming the same regions in
|
/// A selection's identity is the *set*. Two files naming the same regions in
|
||||||
@@ -272,7 +282,10 @@ fn unknown_top_level_keys_still_round_trip_alongside_masks() {
|
|||||||
exposure.exposure = 1\n";
|
exposure.exposure = 1\n";
|
||||||
|
|
||||||
let out = Sidecar::parse(text).expect("parse").to_text();
|
let out = Sidecar::parse(text).expect("parse").to_text();
|
||||||
assert!(out.contains("future.thing"), "unknown keys are still preserved");
|
assert!(
|
||||||
|
out.contains("future.thing"),
|
||||||
|
"unknown keys are still preserved"
|
||||||
|
);
|
||||||
assert!(out.contains("[mask default m1]"));
|
assert!(out.contains("[mask default m1]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,7 +374,13 @@ fn a_brush_layer_writes_one_line_per_stroke() {
|
|||||||
.lines()
|
.lines()
|
||||||
.filter(|l| l.starts_with("stroke = "))
|
.filter(|l| l.starts_with("stroke = "))
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(lines, ["stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5", "stroke = erase 0.05 0.5 1 0.5,0.5"]);
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
[
|
||||||
|
"stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5",
|
||||||
|
"stroke = erase 0.05 0.5 1 0.5,0.5"
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ use dr_pipeline::{EditGraph, Sidecar};
|
|||||||
|
|
||||||
/// A version block carrying `params`, in the on-disk spelling.
|
/// A version block carrying `params`, in the on-disk spelling.
|
||||||
fn sidecar_with(params: &[(&str, f32)]) -> String {
|
fn sidecar_with(params: &[(&str, f32)]) -> String {
|
||||||
let mut text = String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n");
|
let mut text =
|
||||||
|
String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n");
|
||||||
for (key, value) in params {
|
for (key, value) in params {
|
||||||
text.push_str(&format!("{key} = {value}\n"));
|
text.push_str(&format!("{key} = {value}\n"));
|
||||||
}
|
}
|
||||||
@@ -199,4 +200,3 @@ fn every_point_of_every_curve_round_trips_through_a_sidecar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ fn main() {
|
|||||||
};
|
};
|
||||||
println!(
|
println!(
|
||||||
"tiling {}",
|
"tiling {}",
|
||||||
if tiled { "grid, 25% overlap" } else { "whole frame" }
|
if tiled {
|
||||||
|
"grid, 25% overlap"
|
||||||
|
} else {
|
||||||
|
"whole frame"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
@@ -57,10 +61,7 @@ fn main() {
|
|||||||
let instances = model
|
let instances = model
|
||||||
.detect(&rgb, width, height, &options)
|
.detect(&rgb, width, height, &options)
|
||||||
.expect("inference");
|
.expect("inference");
|
||||||
println!(
|
println!("detect {:.0} ms", t1.elapsed().as_secs_f32() * 1000.0);
|
||||||
"detect {:.0} ms",
|
|
||||||
t1.elapsed().as_secs_f32() * 1000.0
|
|
||||||
);
|
|
||||||
println!("found {} instances", instances.len());
|
println!("found {} instances", instances.len());
|
||||||
|
|
||||||
for (i, inst) in instances.iter().enumerate() {
|
for (i, inst) in instances.iter().enumerate() {
|
||||||
@@ -131,10 +132,7 @@ fn load_jpeg(path: &str) -> (Vec<f32>, usize, usize) {
|
|||||||
// where *not* linearising is the correct thing to do.
|
// where *not* linearising is the correct thing to do.
|
||||||
let rgb = match pixels.len() / (w * h) {
|
let rgb = match pixels.len() / (w * h) {
|
||||||
3 => pixels.iter().map(|&v| v as f32 / 255.0).collect(),
|
3 => pixels.iter().map(|&v| v as f32 / 255.0).collect(),
|
||||||
1 => pixels
|
1 => pixels.iter().flat_map(|&v| [v as f32 / 255.0; 3]).collect(),
|
||||||
.iter()
|
|
||||||
.flat_map(|&v| [v as f32 / 255.0; 3])
|
|
||||||
.collect(),
|
|
||||||
n => panic!("unexpected {n} components per pixel"),
|
n => panic!("unexpected {n} components per pixel"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -346,8 +346,8 @@ impl Shaped {
|
|||||||
radius_px: f32,
|
radius_px: f32,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let distance = signed_distance(coverage, width, height, threshold);
|
let distance = signed_distance(coverage, width, height, threshold);
|
||||||
let distance = apply_morphology(&distance, width, height, morphology, radius_px)
|
let distance =
|
||||||
.unwrap_or(distance);
|
apply_morphology(&distance, width, height, morphology, radius_px).unwrap_or(distance);
|
||||||
Self {
|
Self {
|
||||||
distance,
|
distance,
|
||||||
width,
|
width,
|
||||||
@@ -403,7 +403,11 @@ mod tests {
|
|||||||
// Distance from the boundary, so the half-pixel comes back off to
|
// Distance from the boundary, so the half-pixel comes back off to
|
||||||
// compare against the centre-to-centre figures.
|
// compare against the centre-to-centre figures.
|
||||||
let at = |x: usize, y: usize| -d[y * 5 + x] + 0.5;
|
let at = |x: usize, y: usize| -d[y * 5 + x] + 0.5;
|
||||||
assert!((at(1, 1) - std::f32::consts::SQRT_2).abs() < 1e-4, "{}", at(1, 1));
|
assert!(
|
||||||
|
(at(1, 1) - std::f32::consts::SQRT_2).abs() < 1e-4,
|
||||||
|
"{}",
|
||||||
|
at(1, 1)
|
||||||
|
);
|
||||||
assert!((at(0, 0) - (8.0f32).sqrt()).abs() < 1e-4, "{}", at(0, 0));
|
assert!((at(0, 0) - (8.0f32).sqrt()).abs() < 1e-4, "{}", at(0, 0));
|
||||||
assert_eq!(at(2, 0), 2.0, "straight up is exactly two");
|
assert_eq!(at(2, 0), 2.0, "straight up is exactly two");
|
||||||
}
|
}
|
||||||
@@ -489,7 +493,10 @@ mod tests {
|
|||||||
m[10 * w + 10] = 0;
|
m[10 * w + 10] = 0;
|
||||||
|
|
||||||
let before = signed_distance(&m, w, h, 128);
|
let before = signed_distance(&m, w, h, 128);
|
||||||
assert!(before[10 * w + 10] < 0.0, "the hole starts outside the mask");
|
assert!(
|
||||||
|
before[10 * w + 10] < 0.0,
|
||||||
|
"the hole starts outside the mask"
|
||||||
|
);
|
||||||
|
|
||||||
let after = apply_morphology(&before, w, h, Morphology::Close, 2.0).expect("recomputed");
|
let after = apply_morphology(&before, w, h, Morphology::Close, 2.0).expect("recomputed");
|
||||||
assert!(after[10 * w + 10] >= 0.0, "closing should have filled it");
|
assert!(after[10 * w + 10] >= 0.0, "closing should have filled it");
|
||||||
|
|||||||
@@ -142,7 +142,11 @@ impl Membership {
|
|||||||
|
|
||||||
/// The instance a region belongs to, if any.
|
/// The instance a region belongs to, if any.
|
||||||
pub fn instance_of(&self, region: u32) -> Option<usize> {
|
pub fn instance_of(&self, region: u32) -> Option<usize> {
|
||||||
self.of.get(region as usize).copied().flatten().map(|(i, _)| i)
|
self.of
|
||||||
|
.get(region as usize)
|
||||||
|
.copied()
|
||||||
|
.flatten()
|
||||||
|
.map(|(i, _)| i)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How two regions relate semantically, in `-1.0..=1.0`.
|
/// How two regions relate semantically, in `-1.0..=1.0`.
|
||||||
@@ -233,11 +237,7 @@ pub fn apply_semantic_prior(
|
|||||||
///
|
///
|
||||||
/// The returned ids are sorted, so the same click always produces the same
|
/// The returned ids are sorted, so the same click always produces the same
|
||||||
/// mask — which is what lets it be a cache key.
|
/// mask — which is what lets it be a cache key.
|
||||||
pub fn regions_for_instance(
|
pub fn regions_for_instance(field: &RegionField, mask: &[f32], options: &PriorOptions) -> Vec<u32> {
|
||||||
field: &RegionField,
|
|
||||||
mask: &[f32],
|
|
||||||
options: &PriorOptions,
|
|
||||||
) -> Vec<u32> {
|
|
||||||
let mut coverage = vec![0.0f32; field.region_count];
|
let mut coverage = vec![0.0f32; field.region_count];
|
||||||
let mut sizes = vec![0u32; field.region_count];
|
let mut sizes = vec![0u32; field.region_count];
|
||||||
|
|
||||||
@@ -278,10 +278,26 @@ mod tests {
|
|||||||
labels: vec![0, 0, 2, 2, 1, 1, 3, 3],
|
labels: vec![0, 0, 2, 2, 1, 1, 3, 3],
|
||||||
region_count: 4,
|
region_count: 4,
|
||||||
adjacency: vec![
|
adjacency: vec![
|
||||||
Edge { a: 0, b: 1, saddle: 1.0 },
|
Edge {
|
||||||
Edge { a: 0, b: 2, saddle: 1.0 },
|
a: 0,
|
||||||
Edge { a: 1, b: 3, saddle: 1.0 },
|
b: 1,
|
||||||
Edge { a: 2, b: 3, saddle: 1.0 },
|
saddle: 1.0,
|
||||||
|
},
|
||||||
|
Edge {
|
||||||
|
a: 0,
|
||||||
|
b: 2,
|
||||||
|
saddle: 1.0,
|
||||||
|
},
|
||||||
|
Edge {
|
||||||
|
a: 1,
|
||||||
|
b: 3,
|
||||||
|
saddle: 1.0,
|
||||||
|
},
|
||||||
|
Edge {
|
||||||
|
a: 2,
|
||||||
|
b: 3,
|
||||||
|
saddle: 1.0,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,7 +377,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn zero_strength_is_arm_a_exactly() {
|
fn zero_strength_is_arm_a_exactly() {
|
||||||
let f = field();
|
let f = field();
|
||||||
let opts = PriorOptions { strength: 0.0, ..PriorOptions::default() };
|
let opts = PriorOptions {
|
||||||
|
strength: 0.0,
|
||||||
|
..PriorOptions::default()
|
||||||
|
};
|
||||||
let m = Membership::compute(&f, &[&left_half()], &opts).unwrap();
|
let m = Membership::compute(&f, &[&left_half()], &opts).unwrap();
|
||||||
assert_eq!(apply_semantic_prior(&f, &m, &opts).adjacency, f.adjacency);
|
assert_eq!(apply_semantic_prior(&f, &m, &opts).adjacency, f.adjacency);
|
||||||
}
|
}
|
||||||
@@ -394,7 +413,11 @@ mod tests {
|
|||||||
let err = Membership::compute(&f, &[&vec![0.0; 3]], &PriorOptions::default()).unwrap_err();
|
let err = Membership::compute(&f, &[&vec![0.0; 3]], &PriorOptions::default()).unwrap_err();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
err,
|
err,
|
||||||
MembershipError::MaskSize { instance: 0, expected: 8, got: 3 }
|
MembershipError::MaskSize {
|
||||||
|
instance: 0,
|
||||||
|
expected: 8,
|
||||||
|
got: 3
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,11 +426,7 @@ mod tests {
|
|||||||
fn different_instances_repel() {
|
fn different_instances_repel() {
|
||||||
let f = field();
|
let f = field();
|
||||||
let right_half = vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
|
let right_half = vec![0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0];
|
||||||
let m = Membership::compute(
|
let m = Membership::compute(&f, &[&left_half(), &right_half], &PriorOptions::default())
|
||||||
&f,
|
|
||||||
&[&left_half(), &right_half],
|
|
||||||
&PriorOptions::default(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(m.instance_of(0), Some(0));
|
assert_eq!(m.instance_of(0), Some(0));
|
||||||
|
|||||||
@@ -145,6 +145,39 @@ impl Instance {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Take in another view of the same object, from an overlapping tile.
|
||||||
|
///
|
||||||
|
/// Pointwise maximum over the coverage, union of the boxes, higher of the
|
||||||
|
/// scores — see `merge_into` for why each is right for its own quantity.
|
||||||
|
/// Both instances describe the whole frame in the same coordinates, so no
|
||||||
|
/// resampling is involved and the masks are already aligned pixel for
|
||||||
|
/// pixel.
|
||||||
|
fn absorb(&mut self, other: Self) {
|
||||||
|
debug_assert_eq!(
|
||||||
|
(self.width, self.height),
|
||||||
|
(other.width, other.height),
|
||||||
|
"instances from one detection run share the frame they are defined over"
|
||||||
|
);
|
||||||
|
for (mine, theirs) in self.mask.iter_mut().zip(&other.mask) {
|
||||||
|
if *theirs > *mine {
|
||||||
|
*mine = *theirs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.bbox = (
|
||||||
|
self.bbox.0.min(other.bbox.0),
|
||||||
|
self.bbox.1.min(other.bbox.1),
|
||||||
|
self.bbox.2.max(other.bbox.2),
|
||||||
|
self.bbox.3.max(other.bbox.3),
|
||||||
|
);
|
||||||
|
if other.score > self.score {
|
||||||
|
self.score = other.score;
|
||||||
|
// The name travels with the score: they are one judgement, and a
|
||||||
|
// mask labelled by the less confident of two detections would be
|
||||||
|
// labelled by the one we just decided to trust less.
|
||||||
|
self.class_name = other.class_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn iou(&self, other: &Self, threshold: f32) -> f32 {
|
fn iou(&self, other: &Self, threshold: f32) -> f32 {
|
||||||
let mut inter = 0usize;
|
let mut inter = 0usize;
|
||||||
let mut union = 0usize;
|
let mut union = 0usize;
|
||||||
@@ -357,7 +390,6 @@ impl SemanticModel {
|
|||||||
options,
|
options,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turn one forward pass into instances in source-image space.
|
/// Turn one forward pass into instances in source-image space.
|
||||||
@@ -543,7 +575,11 @@ fn assemble_mask(
|
|||||||
let (gx1, gy1) = ((gx0 + 1).min(pw - 1), (gy0 + 1).min(ph - 1));
|
let (gx1, gy1) = ((gx0 + 1).min(pw - 1), (gy0 + 1).min(ph - 1));
|
||||||
|
|
||||||
let mut acc = 0.0;
|
let mut acc = 0.0;
|
||||||
for (k, &c) in coeffs.iter().enumerate().take(PROTOTYPES.min(protos.shape()[0])) {
|
for (k, &c) in coeffs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.take(PROTOTYPES.min(protos.shape()[0]))
|
||||||
|
{
|
||||||
if c == 0.0 {
|
if c == 0.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -567,8 +603,33 @@ fn assemble_mask(
|
|||||||
///
|
///
|
||||||
/// Only needed for [`Tiling::Grid`]: an object straddling a seam is seen by
|
/// Only needed for [`Tiling::Grid`]: an object straddling a seam is seen by
|
||||||
/// both tiles, and without this it would appear twice in the list a person
|
/// both tiles, and without this it would appear twice in the list a person
|
||||||
/// chooses from. Keeps the higher-scoring copy, which is generally the tile
|
/// chooses from.
|
||||||
/// that saw more of the object.
|
///
|
||||||
|
/// # Why the two are unioned rather than one of them chosen
|
||||||
|
///
|
||||||
|
/// This kept the higher-scoring copy and discarded the other. That is the
|
||||||
|
/// wrong answer for the case tiling exists to serve, and it quietly threw away
|
||||||
|
/// what tiling had just paid for.
|
||||||
|
///
|
||||||
|
/// A tile sees the part of an object that falls inside it and nothing of the
|
||||||
|
/// rest, so an object on a seam produces *two partial masks*, neither of them
|
||||||
|
/// the object. Keeping the better one keeps the larger fragment — a bird with
|
||||||
|
/// its tail cut off at the tile edge, described by whichever tile held more of
|
||||||
|
/// the bird. Both halves exist; only one survived.
|
||||||
|
///
|
||||||
|
/// Unioning is sound precisely because the tiles overlap. With a 25% overlap
|
||||||
|
/// every pixel is seen by at least one tile at full resolution and pixels near
|
||||||
|
/// a seam by two, so the pointwise maximum of the two coverages is the better
|
||||||
|
/// estimate everywhere rather than a compromise: where only one tile saw a
|
||||||
|
/// pixel it is the only opinion available, and where both did, the higher
|
||||||
|
/// value comes from the tile that had more context around it. Taking a maximum
|
||||||
|
/// of soft coverage also keeps the mask soft, which is what `prior.rs` weights
|
||||||
|
/// merges by and what a mask layer's edge treatment needs.
|
||||||
|
///
|
||||||
|
/// The score is the higher of the two rather than a blend. It is shown to a
|
||||||
|
/// photographer beside the class name and means "how sure the model is this is
|
||||||
|
/// a bird"; averaging in the tile that saw only a wingtip would make a
|
||||||
|
/// confident detection look doubtful for straddling a seam.
|
||||||
fn merge_into(found: &mut Vec<Instance>, batch: Vec<Instance>, options: &SemanticOptions) {
|
fn merge_into(found: &mut Vec<Instance>, batch: Vec<Instance>, options: &SemanticOptions) {
|
||||||
for candidate in batch {
|
for candidate in batch {
|
||||||
let duplicate = found.iter_mut().find(|existing| {
|
let duplicate = found.iter_mut().find(|existing| {
|
||||||
@@ -577,8 +638,7 @@ fn merge_into(found: &mut Vec<Instance>, batch: Vec<Instance>, options: &Semanti
|
|||||||
});
|
});
|
||||||
|
|
||||||
match duplicate {
|
match duplicate {
|
||||||
Some(existing) if existing.score < candidate.score => *existing = candidate,
|
Some(existing) => existing.absorb(candidate),
|
||||||
Some(_) => {}
|
|
||||||
None => found.push(candidate),
|
None => found.push(candidate),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -643,7 +703,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn letterbox_round_trips_a_landscape_window() {
|
fn letterbox_round_trips_a_landscape_window() {
|
||||||
let window = Window { x: 0.0, y: 0.0, w: 1600.0, h: 1067.0 };
|
let window = Window {
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
w: 1600.0,
|
||||||
|
h: 1067.0,
|
||||||
|
};
|
||||||
let lb = Letterbox::fit(window.w, window.h);
|
let lb = Letterbox::fit(window.w, window.h);
|
||||||
|
|
||||||
// A source point maps into the input and back to where it started.
|
// A source point maps into the input and back to where it started.
|
||||||
@@ -663,6 +728,79 @@ mod tests {
|
|||||||
assert!(lb.pad_y > 100.0, "wide image should pad in y: {}", lb.pad_y);
|
assert!(lb.pad_y > 100.0, "wide image should pad in y: {}", lb.pad_y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A subject on a seam, seen in part by each of two tiles.
|
||||||
|
///
|
||||||
|
/// The failure this pins down is not a crash and not a duplicate: it is a
|
||||||
|
/// mask that looks plausible and is missing the half of the subject that
|
||||||
|
/// fell in the other tile. Keeping the higher-scoring detection produced
|
||||||
|
/// exactly that, and it is invisible unless you already know what the
|
||||||
|
/// whole subject should have been.
|
||||||
|
#[test]
|
||||||
|
fn two_tiles_seeing_one_subject_produce_the_whole_subject() {
|
||||||
|
// The left tile sees the left half strongly and nothing of the right;
|
||||||
|
// the right tile sees the right half. Together they are one bar.
|
||||||
|
let left = instance_with(
|
||||||
|
&[0.9, 0.9, 0.9, 0.8, 0.0, 0.0, 0.0, 0.0],
|
||||||
|
0.80,
|
||||||
|
(0.0, 0.0, 4.0, 1.0),
|
||||||
|
);
|
||||||
|
let right = instance_with(
|
||||||
|
&[0.0, 0.0, 0.3, 0.7, 0.9, 0.9, 0.9, 0.0],
|
||||||
|
0.60,
|
||||||
|
(2.0, 0.0, 7.0, 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let options = SemanticOptions {
|
||||||
|
mask_threshold: 0.5,
|
||||||
|
merge_iou: 0.1,
|
||||||
|
..SemanticOptions::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut found = vec![left];
|
||||||
|
merge_into(&mut found, vec![right], &options);
|
||||||
|
|
||||||
|
assert_eq!(found.len(), 1, "one subject, not two");
|
||||||
|
let m = &found[0];
|
||||||
|
|
||||||
|
// Every pixel either tile was sure about survives. Under the old
|
||||||
|
// keep-the-better-one rule, pixels 4..=6 were lost entirely.
|
||||||
|
for i in [0, 1, 2, 3, 4, 5, 6] {
|
||||||
|
assert!(
|
||||||
|
m.mask[i] >= 0.5,
|
||||||
|
"pixel {i} was seen by a tile and must survive the merge: {:?}",
|
||||||
|
m.mask
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(m.mask[7] < 0.5, "a pixel neither tile saw must stay out");
|
||||||
|
|
||||||
|
// Pointwise maximum, not an average: pixel 2 is 0.9 in one tile and
|
||||||
|
// 0.3 in the other, and averaging would report 0.6 — a softer edge
|
||||||
|
// than either tile actually saw.
|
||||||
|
assert!(
|
||||||
|
(m.mask[2] - 0.9).abs() < 1e-6,
|
||||||
|
"expected the max, got {}",
|
||||||
|
m.mask[2]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(m.score - 0.80).abs() < 1e-6,
|
||||||
|
"the confident detection's score survives"
|
||||||
|
);
|
||||||
|
assert_eq!(m.bbox, (0.0, 0.0, 7.0, 1.0), "the box covers both halves");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_with(mask: &[f32], score: f32, bbox: (f32, f32, f32, f32)) -> Instance {
|
||||||
|
Instance {
|
||||||
|
class_id: 14,
|
||||||
|
class_name: "bird".into(),
|
||||||
|
score,
|
||||||
|
bbox,
|
||||||
|
mask: mask.to_vec(),
|
||||||
|
width: mask.len(),
|
||||||
|
height: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The seam case tiling exists for, and the one it must not double-count.
|
/// The seam case tiling exists for, and the one it must not double-count.
|
||||||
#[test]
|
#[test]
|
||||||
fn grid_tiling_covers_the_frame_with_overlap() {
|
fn grid_tiling_covers_the_frame_with_overlap() {
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ pub use settings::{
|
|||||||
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
|
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
|
||||||
ExportTarget, ImportSettings, OutputSharpening, Settings, SizingMode,
|
ExportTarget, ImportSettings, OutputSharpening, Settings, SizingMode,
|
||||||
};
|
};
|
||||||
pub use time::{civil_from_unix, civil_from_unix_at, format_date, Civil};
|
pub use time::{
|
||||||
|
civil_from_unix, civil_from_unix_at, format_date, parse_date, unix_from_civil, Civil,
|
||||||
|
};
|
||||||
|
|
||||||
/// Identifies a granted library location — a directory on Linux, a persisted
|
/// Identifies a granted library location — a directory on Linux, a persisted
|
||||||
/// document tree on Android.
|
/// document tree on Android.
|
||||||
|
|||||||
@@ -62,9 +62,14 @@ pub struct Settings {
|
|||||||
/// TRACES: FR-CAT-10 | FR-NC-7a
|
/// TRACES: FR-CAT-10 | FR-NC-7a
|
||||||
/// What an import defaults to, remembered between cards.
|
/// What an import defaults to, remembered between cards.
|
||||||
///
|
///
|
||||||
/// A photographer imports the same way every time — same folders, same
|
/// A photographer imports the same way every time — same folders, same answer
|
||||||
/// destination, same answer about the card — and retyping a template on every
|
/// about the card — and retyping a template on every card is the kind of
|
||||||
/// card is the kind of friction that makes people stop using the importer.
|
/// friction that makes people stop using the importer.
|
||||||
|
///
|
||||||
|
/// **There is no destination here.** The library is the folder on the server
|
||||||
|
/// (FR-NC-6), so an import has exactly one place to go and nothing to ask.
|
||||||
|
/// What lands on this machine is a staging copy the app owns and removes once
|
||||||
|
/// the server confirms it (FR-NC-7b), which is not a setting either.
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ImportSettings {
|
pub struct ImportSettings {
|
||||||
@@ -76,15 +81,13 @@ pub struct ImportSettings {
|
|||||||
/// defined — so it owns the default too, and this records only a
|
/// defined — so it owns the default too, and this records only a
|
||||||
/// deliberate departure from it.
|
/// deliberate departure from it.
|
||||||
pub folder_template: String,
|
pub folder_template: String,
|
||||||
/// Where originals are copied to on this machine. Empty until chosen.
|
|
||||||
///
|
|
||||||
/// A local folder even on a library that lives on a server: FR-NC-7b has
|
|
||||||
/// the bytes land and be verified here first, and the upload follow.
|
|
||||||
pub destination: String,
|
|
||||||
/// The optional second copy (FR-CAT-10). Empty means none.
|
/// The optional second copy (FR-CAT-10). Empty means none.
|
||||||
|
///
|
||||||
|
/// The only local path an import has, and deliberately so: the library
|
||||||
|
/// lives on the server, so there is no *first* destination for the user to
|
||||||
|
/// choose. This is an extra copy kept somewhere of their choosing — an
|
||||||
|
/// external drive, another disk — and nothing reads it back.
|
||||||
pub backup: String,
|
pub backup: String,
|
||||||
/// Whether to send imported originals on to the server (FR-NC-7b).
|
|
||||||
pub upload: bool,
|
|
||||||
/// Whether the card is to be emptied once everything is confirmed.
|
/// Whether the card is to be emptied once everything is confirmed.
|
||||||
///
|
///
|
||||||
/// **Defaults to false and is the one setting worth not remembering
|
/// **Defaults to false and is the one setting worth not remembering
|
||||||
@@ -109,9 +112,7 @@ impl Default for ImportSettings {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
folder_template: String::new(),
|
folder_template: String::new(),
|
||||||
destination: String::new(),
|
|
||||||
backup: String::new(),
|
backup: String::new(),
|
||||||
upload: true,
|
|
||||||
// The one irreversible thing this application does starts off.
|
// The one irreversible thing this application does starts off.
|
||||||
move_from_card: false,
|
move_from_card: false,
|
||||||
verify: true,
|
verify: true,
|
||||||
@@ -1136,7 +1137,6 @@ mod import_settings_tests {
|
|||||||
let s: Settings = serde_json::from_str(stored).unwrap();
|
let s: Settings = serde_json::from_str(stored).unwrap();
|
||||||
assert!(s.import.verify);
|
assert!(s.import.verify);
|
||||||
assert!(s.import.skip_duplicates);
|
assert!(s.import.skip_duplicates);
|
||||||
assert!(s.import.upload);
|
|
||||||
// And the irreversible one stays off.
|
// And the irreversible one stays off.
|
||||||
assert!(!s.import.move_from_card);
|
assert!(!s.import.move_from_card);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,68 @@ pub fn civil_from_unix_at(t: i64, offset: Option<i32>) -> Civil {
|
|||||||
civil_from_unix(t + offset.unwrap_or(0) as i64 * 60)
|
civil_from_unix(t + offset.unwrap_or(0) as i64 * 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-6
|
||||||
|
/// A civil date back to Unix seconds — midnight UTC on that day.
|
||||||
|
///
|
||||||
|
/// The exact inverse of [`civil_from_unix`]'s date part, by the companion
|
||||||
|
/// era-based algorithm, so a date formatted by [`format_date`] and read back
|
||||||
|
/// here lands on the same day it named. Hand-rolled for the same reason the
|
||||||
|
/// forward direction is: four fields do not justify a date library, and two
|
||||||
|
/// implementations that disagree would put a photograph on a day the timeline
|
||||||
|
/// does not show it.
|
||||||
|
///
|
||||||
|
/// Midnight rather than noon because a range is half-open in spirit: "from the
|
||||||
|
/// 3rd" means from the first instant of the 3rd, and the caller decides what
|
||||||
|
/// "to the 5th" means by adding a day if it wants the 5th included.
|
||||||
|
pub fn unix_from_civil(year: i64, month: i64, day: i64) -> i64 {
|
||||||
|
let y = if month <= 2 { year - 1 } else { year };
|
||||||
|
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||||
|
let yoe = y - era * 400;
|
||||||
|
let mp = if month > 2 { month - 3 } else { month + 9 };
|
||||||
|
let doy = (153 * mp + 2) / 5 + day - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
(era * 146_097 + doe - 719_468) * 86_400
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-6
|
||||||
|
/// `YYYY-MM-DD` to Unix seconds, or `None` if that is not what it is.
|
||||||
|
///
|
||||||
|
/// Strict on purpose. This reads what a person typed into a date field, and
|
||||||
|
/// the useful answer to "13/2026/08" is that it is not a date — guessing at an
|
||||||
|
/// order would silently filter a library to a span nobody asked for, and the
|
||||||
|
/// grid going empty is a poor way to learn your input was misread.
|
||||||
|
///
|
||||||
|
/// The day is checked against the month it is in, so `2026-02-30` is refused
|
||||||
|
/// rather than quietly becoming the 2nd of March.
|
||||||
|
pub fn parse_date(text: &str) -> Option<i64> {
|
||||||
|
let text = text.trim();
|
||||||
|
let mut parts = text.split('-');
|
||||||
|
let year: i64 = parts.next()?.parse().ok()?;
|
||||||
|
let month: i64 = parts.next()?.parse().ok()?;
|
||||||
|
let day: i64 = parts.next()?.parse().ok()?;
|
||||||
|
if parts.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !(1..=12).contains(&month) || day < 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if day > days_in_month(year, month) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(unix_from_civil(year, month, day))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Length of a month, with the full Gregorian leap rule.
|
||||||
|
fn days_in_month(year: i64, month: i64) -> i64 {
|
||||||
|
match month {
|
||||||
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||||
|
4 | 6 | 9 | 11 => 30,
|
||||||
|
2 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) => 29,
|
||||||
|
2 => 28,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A capture instant as `YYYY-MM-DD`.
|
/// A capture instant as `YYYY-MM-DD`.
|
||||||
pub fn format_date(t: i64) -> String {
|
pub fn format_date(t: i64) -> String {
|
||||||
let c = civil_from_unix(t);
|
let c = civil_from_unix(t);
|
||||||
@@ -74,6 +136,51 @@ pub fn format_date(t: i64) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The two directions must agree, or a photograph is filed on a day the
|
||||||
|
/// timeline does not show it on — the failure this module exists to stop.
|
||||||
|
#[test]
|
||||||
|
fn a_date_survives_the_round_trip() {
|
||||||
|
for t in [
|
||||||
|
0, // the epoch
|
||||||
|
1_760_000_000, // an ordinary recent instant
|
||||||
|
951_782_400, // 2000-02-29, the leap day the 100/400 rule keeps
|
||||||
|
-86_400, // the day before the epoch
|
||||||
|
] {
|
||||||
|
let c = civil_from_unix(t);
|
||||||
|
let back = unix_from_civil(c.year, c.month, c.day);
|
||||||
|
assert_eq!(
|
||||||
|
civil_from_unix(back),
|
||||||
|
Civil { hour: 0, ..c },
|
||||||
|
"{t} -> {c:?} -> {back} disagreed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parsing_accepts_a_date_and_refuses_everything_else() {
|
||||||
|
assert_eq!(parse_date("1970-01-01"), Some(0));
|
||||||
|
assert_eq!(parse_date(" 2026-08-23 "), parse_date("2026-08-23"));
|
||||||
|
assert_eq!(format_date(parse_date("2026-08-23").unwrap()), "2026-08-23");
|
||||||
|
|
||||||
|
// A leap day is real in 2024 and not in 2026.
|
||||||
|
assert!(parse_date("2024-02-29").is_some());
|
||||||
|
assert_eq!(parse_date("2026-02-29"), None);
|
||||||
|
|
||||||
|
// Refused rather than guessed at: silently reordering these would
|
||||||
|
// filter the library to a span nobody asked for.
|
||||||
|
assert_eq!(
|
||||||
|
parse_date("23-08-2026"),
|
||||||
|
None,
|
||||||
|
"day-first is not a format here"
|
||||||
|
);
|
||||||
|
assert_eq!(parse_date("2026-13-01"), None, "there is no month 13");
|
||||||
|
assert_eq!(parse_date("2026-08-32"), None);
|
||||||
|
assert_eq!(parse_date("2026-08"), None, "a month is not a date");
|
||||||
|
assert_eq!(parse_date("2026-08-23-01"), None);
|
||||||
|
assert_eq!(parse_date(""), None);
|
||||||
|
assert_eq!(parse_date("yesterday"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_epoch_is_the_first_of_january_1970() {
|
fn the_epoch_is_the_first_of_january_1970() {
|
||||||
let c = civil_from_unix(0);
|
let c = civil_from_unix(0);
|
||||||
|
|||||||
@@ -27,6 +27,26 @@ CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-android"
|
|||||||
OUT="${CACHE}/target/apk"
|
OUT="${CACHE}/target/apk"
|
||||||
APK="${OUT}/darkroom.apk"
|
APK="${OUT}/darkroom.apk"
|
||||||
|
|
||||||
|
# The version, taken from the workspace rather than restated here.
|
||||||
|
#
|
||||||
|
# `AndroidManifest.xml` deliberately declares none: a manifest that states a
|
||||||
|
# version is a second place for one to be wrong, and it was — the APK reported
|
||||||
|
# `versionName=null` and `versionCode=0` on the device while the binary inside
|
||||||
|
# it knew perfectly well what it was.
|
||||||
|
#
|
||||||
|
# `versionCode` must be a single increasing integer, which a semantic version
|
||||||
|
# is not, so it is packed: MAJOR*10000 + MINOR*100 + PATCH. That keeps the
|
||||||
|
# ordering Android needs (it refuses to install an APK whose code is lower than
|
||||||
|
# the installed one) and stays readable — 0.4.0 is 400. It allows 99 minors and
|
||||||
|
# 99 patches per major, which is a limit worth knowing about and a long way off.
|
||||||
|
VERSION_NAME="$(sed -n 's/^version = "\(.*\)"$/\1/p' "${REPO}/Cargo.toml" | head -1)"
|
||||||
|
if [[ -z "${VERSION_NAME}" ]]; then
|
||||||
|
echo "error: no version in Cargo.toml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VERSION_CODE="$(awk -F. '{ print $1 * 10000 + $2 * 100 + $3 }' <<< "${VERSION_NAME}")"
|
||||||
|
echo "==> version ${VERSION_NAME} (code ${VERSION_CODE})"
|
||||||
|
|
||||||
INSTALL=0
|
INSTALL=0
|
||||||
[[ "${1:-}" == "--install" ]] && INSTALL=1
|
[[ "${1:-}" == "--install" ]] && INSTALL=1
|
||||||
|
|
||||||
@@ -100,6 +120,8 @@ echo "==> packaging APK"
|
|||||||
-R "${OUT}/res.zip" \
|
-R "${OUT}/res.zip" \
|
||||||
--min-sdk-version 28 \
|
--min-sdk-version 28 \
|
||||||
--target-sdk-version 36 \
|
--target-sdk-version 36 \
|
||||||
|
--version-name "'"${VERSION_NAME}"'" \
|
||||||
|
--version-code "'"${VERSION_CODE}"'" \
|
||||||
-o "${OUT}/base.apk" \
|
-o "${OUT}/base.apk" \
|
||||||
--auto-add-overlay
|
--auto-add-overlay
|
||||||
|
|
||||||
|
|||||||
+23
-6
@@ -654,14 +654,31 @@ Two consequences the mechanics do not give for free:
|
|||||||
Sidecars follow their image, not the template: they are named from `oc:fileid` (FR-NC-8) and live
|
Sidecars follow their image, not the template: they are named from `oc:fileid` (FR-NC-8) and live
|
||||||
beside the file they describe.
|
beside the file they describe.
|
||||||
|
|
||||||
**FR-NC-7b — Ingest to remote.** Import (FR-CAT-10) and upload (FR-NC-7) shall compose: a card
|
**FR-NC-7b — Ingest to remote.** Import (FR-CAT-10) and upload (FR-NC-7) compose, and the library
|
||||||
import may target a Nextcloud library, in which case files are copied locally first, verified by
|
an import targets is **always** the remote one. There is no local library for a card to land in:
|
||||||
checksum, and only then queued for upload. The local copy is not deleted on a move-import until
|
FR-NC-6 puts the library on the server, so an import has exactly one destination and the interface
|
||||||
the upload is confirmed — a card erased against an in-flight upload is unrecoverable, which is the
|
shall not offer a choice of another.
|
||||||
one failure in this app with no undo.
|
|
||||||
|
What lands on the device is a **staging copy**, in a location the app owns, and it is not a
|
||||||
|
library: no view lists it, and it is removed once the server confirms the file. A staged file the
|
||||||
|
server did not take remains queued, and a later import drains the queue. An import with no
|
||||||
|
reachable server therefore succeeds and defers, rather than failing (FR-NC-10); an import with no
|
||||||
|
*account* has nowhere to go at all and shall be refused.
|
||||||
|
|
||||||
|
The bytes shall reach the device before they reach the network. Streaming a card straight to the
|
||||||
|
server would make a move-import erase a card against an in-flight upload, and would make importing
|
||||||
|
impossible offline.
|
||||||
|
|
||||||
|
**On erasing the card.** A move-import shall delete from the card only those photographs the
|
||||||
|
**server has confirmed** — not those merely written to the staging copy, since that copy is removed
|
||||||
|
as soon as the upload succeeds and would otherwise be the only remaining copy. Anything that does
|
||||||
|
not upload keeps its card copy. A card erased against an unfinished upload is unrecoverable, which
|
||||||
|
is the one failure in this app with no undo.
|
||||||
|
|
||||||
Duplicate detection (FR-CAT-11) runs against the catalog before upload, so re-inserting a card
|
Duplicate detection (FR-CAT-11) runs against the catalog before upload, so re-inserting a card
|
||||||
that was already imported transfers nothing.
|
that was already imported transfers nothing. Where the catalog cannot answer — a file already in
|
||||||
|
the destination folder, locally or on the server — the folder's own listing shall answer instead,
|
||||||
|
so a re-import neither duplicates nor renames what is already held.
|
||||||
|
|
||||||
**FR-NC-8 — Edit metadata sync.** Edit graphs sync bidirectionally as sidecars, one per image,
|
**FR-NC-8 — Edit metadata sync.** Edit graphs sync bidirectionally as sidecars, one per image,
|
||||||
named deterministically from `oc:fileid`. Each sidecar carries a monotonic revision counter, a
|
named deterministically from `oc:fileid`. Each sidecar carries a monotonic revision counter, a
|
||||||
|
|||||||
+53
-53
@@ -10,7 +10,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
|||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source files scanned | 180 |
|
| Source files scanned | 180 |
|
||||||
| TRACES tags found | 516 |
|
| TRACES tags found | 540 |
|
||||||
| Requirements defined | 177 |
|
| Requirements defined | 177 |
|
||||||
| Requirements covered | 89 |
|
| Requirements covered | 89 |
|
||||||
| **Coverage** | **50.3%** (89/177) |
|
| **Coverage** | **50.3%** (89/177) |
|
||||||
@@ -33,92 +33,92 @@ _None._
|
|||||||
|
|
||||||
| ID | Tagged in |
|
| ID | Tagged in |
|
||||||
|---|---|
|
|---|---|
|
||||||
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:198`](../core/dr-types/src/lib.rs#L198), [`core/dr-types/src/lib.rs:267`](../core/dr-types/src/lib.rs#L267), [`core/dr-types/src/lib.rs:300`](../core/dr-types/src/lib.rs#L300), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
||||||
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`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:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:692`](../ui/dr-ui/ui/library.slint#L692), [`ui/dr-ui/ui/library.slint:842`](../ui/dr-ui/ui/library.slint#L842), [`ui/dr-ui/ui/library.slint:990`](../ui/dr-ui/ui/library.slint#L990) |
|
| 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:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:944`](../ui/dr-ui/src/lib.rs#L944), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:692`](../ui/dr-ui/ui/library.slint#L692), [`ui/dr-ui/ui/library.slint:842`](../ui/dr-ui/ui/library.slint#L842), [`ui/dr-ui/ui/library.slint:990`](../ui/dr-ui/ui/library.slint#L990) |
|
||||||
| 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:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2052`](../ui/dr-ui/src/library.rs#L2052), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:944`](../ui/dr-ui/src/lib.rs#L944), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2081`](../ui/dr-ui/src/library.rs#L2081), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||||
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111) |
|
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109) |
|
||||||
| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) |
|
| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) |
|
||||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:365`](../core/dr-catalog/src/schema.rs#L365), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2933`](../ui/dr-ui/src/library.rs#L2933), [`ui/dr-ui/src/library.rs:2965`](../ui/dr-ui/src/library.rs#L2965), [`ui/dr-ui/src/library_ui.rs:128`](../ui/dr-ui/src/library_ui.rs#L128), [`ui/dr-ui/src/library_ui.rs:643`](../ui/dr-ui/src/library_ui.rs#L643), [`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:365`](../core/dr-catalog/src/schema.rs#L365), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1173`](../ui/dr-ui/src/collections_ui.rs#L1173), [`ui/dr-ui/src/collections_ui.rs:1848`](../ui/dr-ui/src/collections_ui.rs#L1848), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2962`](../ui/dr-ui/src/library.rs#L2962), [`ui/dr-ui/src/library.rs:2994`](../ui/dr-ui/src/library.rs#L2994), [`ui/dr-ui/src/library_ui.rs:128`](../ui/dr-ui/src/library_ui.rs#L128), [`ui/dr-ui/src/library_ui.rs:643`](../ui/dr-ui/src/library_ui.rs#L643), [`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:51`](../core/dr-types/src/lib.rs#L51) |
|
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
|
||||||
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||||
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:373`](../ui/dr-ui/src/import.rs#L373), [`ui/dr-ui/src/import.rs:398`](../ui/dr-ui/src/import.rs#L398), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:115`](../ui/dr-ui/src/library_ui.rs#L115), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/src/library_ui.rs:4113`](../ui/dr-ui/src/library_ui.rs#L4113), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:115`](../ui/dr-ui/src/library_ui.rs#L115), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/src/library_ui.rs:4154`](../ui/dr-ui/src/library_ui.rs#L4154), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/settings.slint:330`](../ui/dr-ui/ui/settings.slint#L330), [`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), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
||||||
| FR-CAT-5 | [`core/dr-catalog/src/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:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:401`](../core/dr-decode/src/lib.rs#L401), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library_ui.rs:5391`](../ui/dr-ui/src/library_ui.rs#L5391), [`ui/dr-ui/src/library_ui.rs:5402`](../ui/dr-ui/src/library_ui.rs#L5402), [`ui/dr-ui/src/library_ui.rs:5415`](../ui/dr-ui/src/library_ui.rs#L5415), [`ui/dr-ui/src/library_ui.rs:5430`](../ui/dr-ui/src/library_ui.rs#L5430), [`ui/dr-ui/src/library_ui.rs:5439`](../ui/dr-ui/src/library_ui.rs#L5439), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) |
|
| 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:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`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:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library_ui.rs:5533`](../ui/dr-ui/src/library_ui.rs#L5533), [`ui/dr-ui/src/library_ui.rs:5544`](../ui/dr-ui/src/library_ui.rs#L5544), [`ui/dr-ui/src/library_ui.rs:5557`](../ui/dr-ui/src/library_ui.rs#L5557), [`ui/dr-ui/src/library_ui.rs:5572`](../ui/dr-ui/src/library_ui.rs#L5572), [`ui/dr-ui/src/library_ui.rs:5581`](../ui/dr-ui/src/library_ui.rs#L5581), [`ui/dr-ui/ui/app.slint:576`](../ui/dr-ui/ui/app.slint#L576), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) |
|
||||||
| 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:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) |
|
| 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:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`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:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library_ui.rs:4689`](../ui/dr-ui/src/library_ui.rs#L4689), [`ui/dr-ui/src/library_ui.rs:5673`](../ui/dr-ui/src/library_ui.rs#L5673), [`ui/dr-ui/ui/app.slint:458`](../ui/dr-ui/ui/app.slint#L458), [`ui/dr-ui/ui/app.slint:576`](../ui/dr-ui/ui/app.slint#L576), [`ui/dr-ui/ui/library.slint:1101`](../ui/dr-ui/ui/library.slint#L1101), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) |
|
||||||
| 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:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
|
| 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:1546`](../ui/dr-ui/src/collections_ui.rs#L1546), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3224`](../ui/dr-ui/src/library_ui.rs#L3224), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
|
||||||
| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:141`](../core/dr-pipeline/src/ops/curve.rs#L141), [`core/dr-pipeline/src/ops/curve.rs:657`](../core/dr-pipeline/src/ops/curve.rs#L657), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/tests/tone_curve.rs:32`](../core/dr-pipeline/tests/tone_curve.rs#L32), [`ui/dr-ui/src/develop.rs:2237`](../ui/dr-ui/src/develop.rs#L2237), [`ui/dr-ui/src/export.rs:749`](../ui/dr-ui/src/export.rs#L749), [`ui/dr-ui/src/lib.rs:1140`](../ui/dr-ui/src/lib.rs#L1140), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/lib.rs:1585`](../ui/dr-ui/src/lib.rs#L1585), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:768`](../ui/dr-ui/src/lib.rs#L768), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4402`](../ui/dr-ui/src/library_ui.rs#L4402), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`core/dr-pipeline/tests/tone_curve.rs:33`](../core/dr-pipeline/tests/tone_curve.rs#L33), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:2628`](../ui/dr-ui/src/develop.rs#L2628), [`ui/dr-ui/src/export.rs:749`](../ui/dr-ui/src/export.rs#L749), [`ui/dr-ui/src/lib.rs:1169`](../ui/dr-ui/src/lib.rs#L1169), [`ui/dr-ui/src/lib.rs:1509`](../ui/dr-ui/src/lib.rs#L1509), [`ui/dr-ui/src/lib.rs:1614`](../ui/dr-ui/src/lib.rs#L1614), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:796`](../ui/dr-ui/src/lib.rs#L796), [`ui/dr-ui/src/library.rs:1504`](../ui/dr-ui/src/library.rs#L1504), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:378`](../ui/dr-ui/src/library.rs#L378), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:655`](../ui/dr-ui/src/library.rs#L655), [`ui/dr-ui/src/library_ui.rs:4443`](../ui/dr-ui/src/library_ui.rs#L4443), [`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:337`](../core/dr-catalog/src/schema.rs#L337), [`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:117`](../core/dr-types/src/lib.rs#L117), [`ui/dr-ui/src/develop.rs:1945`](../ui/dr-ui/src/develop.rs#L1945), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:3148`](../ui/dr-ui/src/library.rs#L3148), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1450`](../ui/dr-ui/src/library_ui.rs#L1450), [`ui/dr-ui/src/library_ui.rs:1476`](../ui/dr-ui/src/library_ui.rs#L1476), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:1586`](../ui/dr-ui/src/library_ui.rs#L1586), [`ui/dr-ui/src/library_ui.rs:170`](../ui/dr-ui/src/library_ui.rs#L170), [`ui/dr-ui/src/library_ui.rs:203`](../ui/dr-ui/src/library_ui.rs#L203), [`ui/dr-ui/src/library_ui.rs:2119`](../ui/dr-ui/src/library_ui.rs#L2119), [`ui/dr-ui/src/library_ui.rs:2548`](../ui/dr-ui/src/library_ui.rs#L2548), [`ui/dr-ui/src/library_ui.rs:2730`](../ui/dr-ui/src/library_ui.rs#L2730), [`ui/dr-ui/src/library_ui.rs:3011`](../ui/dr-ui/src/library_ui.rs#L3011), [`ui/dr-ui/src/library_ui.rs:3083`](../ui/dr-ui/src/library_ui.rs#L3083), [`ui/dr-ui/src/library_ui.rs:3248`](../ui/dr-ui/src/library_ui.rs#L3248), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/src/library_ui.rs:358`](../ui/dr-ui/src/library_ui.rs#L358), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/library_ui.rs:4632`](../ui/dr-ui/src/library_ui.rs#L4632), [`ui/dr-ui/src/library_ui.rs:4747`](../ui/dr-ui/src/library_ui.rs#L4747), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`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:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2325`](../ui/dr-ui/src/develop.rs#L2325), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1464`](../ui/dr-ui/src/library.rs#L1464), [`ui/dr-ui/src/library.rs:1541`](../ui/dr-ui/src/library.rs#L1541), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:3177`](../ui/dr-ui/src/library.rs#L3177), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:639`](../ui/dr-ui/src/library.rs#L639), [`ui/dr-ui/src/library.rs:655`](../ui/dr-ui/src/library.rs#L655), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library_ui.rs:1450`](../ui/dr-ui/src/library_ui.rs#L1450), [`ui/dr-ui/src/library_ui.rs:1476`](../ui/dr-ui/src/library_ui.rs#L1476), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:1586`](../ui/dr-ui/src/library_ui.rs#L1586), [`ui/dr-ui/src/library_ui.rs:170`](../ui/dr-ui/src/library_ui.rs#L170), [`ui/dr-ui/src/library_ui.rs:203`](../ui/dr-ui/src/library_ui.rs#L203), [`ui/dr-ui/src/library_ui.rs:2119`](../ui/dr-ui/src/library_ui.rs#L2119), [`ui/dr-ui/src/library_ui.rs:2552`](../ui/dr-ui/src/library_ui.rs#L2552), [`ui/dr-ui/src/library_ui.rs:2734`](../ui/dr-ui/src/library_ui.rs#L2734), [`ui/dr-ui/src/library_ui.rs:3015`](../ui/dr-ui/src/library_ui.rs#L3015), [`ui/dr-ui/src/library_ui.rs:3087`](../ui/dr-ui/src/library_ui.rs#L3087), [`ui/dr-ui/src/library_ui.rs:3275`](../ui/dr-ui/src/library_ui.rs#L3275), [`ui/dr-ui/src/library_ui.rs:3393`](../ui/dr-ui/src/library_ui.rs#L3393), [`ui/dr-ui/src/library_ui.rs:358`](../ui/dr-ui/src/library_ui.rs#L358), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/library_ui.rs:4734`](../ui/dr-ui/src/library_ui.rs#L4734), [`ui/dr-ui/src/library_ui.rs:4849`](../ui/dr-ui/src/library_ui.rs#L4849), [`ui/dr-ui/src/presets.rs:309`](../ui/dr-ui/src/presets.rs#L309), [`ui/dr-ui/src/presets.rs:321`](../ui/dr-ui/src/presets.rs#L321), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||||
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
||||||
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:373`](../ui/dr-ui/src/import.rs#L373) |
|
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) |
|
||||||
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) |
|
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) |
|
||||||
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) |
|
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) |
|
||||||
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`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:1298`](../ui/dr-ui/src/develop.rs#L1298), [`ui/dr-ui/src/develop.rs:1313`](../ui/dr-ui/src/develop.rs#L1313), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1418`](../ui/dr-ui/src/develop.rs#L1418), [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2595`](../ui/dr-ui/src/develop.rs#L2595), [`ui/dr-ui/src/develop.rs:2649`](../ui/dr-ui/src/develop.rs#L2649), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1176`](../ui/dr-ui/src/lib.rs#L1176), [`ui/dr-ui/src/lib.rs:1828`](../ui/dr-ui/src/lib.rs#L1828), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1884`](../ui/dr-ui/ui/app.slint#L1884) |
|
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:120`](../core/dr-pipeline/src/graph.rs#L120), [`core/dr-pipeline/src/graph.rs:397`](../core/dr-pipeline/src/graph.rs#L397), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1273`](../core/dr-pipeline/src/sidecar.rs#L1273), [`core/dr-pipeline/src/sidecar.rs:1329`](../core/dr-pipeline/src/sidecar.rs#L1329), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`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:1111`](../ui/dr-ui/src/develop.rs#L1111), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1584`](../ui/dr-ui/src/develop.rs#L1584), [`ui/dr-ui/src/develop.rs:1599`](../ui/dr-ui/src/develop.rs#L1599), [`ui/dr-ui/src/develop.rs:1621`](../ui/dr-ui/src/develop.rs#L1621), [`ui/dr-ui/src/develop.rs:1753`](../ui/dr-ui/src/develop.rs#L1753), [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/develop.rs:238`](../ui/dr-ui/src/develop.rs#L238), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/develop.rs:270`](../ui/dr-ui/src/develop.rs#L270), [`ui/dr-ui/src/develop.rs:2984`](../ui/dr-ui/src/develop.rs#L2984), [`ui/dr-ui/src/develop.rs:3038`](../ui/dr-ui/src/develop.rs#L3038), [`ui/dr-ui/src/develop.rs:3082`](../ui/dr-ui/src/develop.rs#L3082), [`ui/dr-ui/src/develop.rs:3132`](../ui/dr-ui/src/develop.rs#L3132), [`ui/dr-ui/src/develop.rs:506`](../ui/dr-ui/src/develop.rs#L506), [`ui/dr-ui/src/develop.rs:544`](../ui/dr-ui/src/develop.rs#L544), [`ui/dr-ui/src/lib.rs:1205`](../ui/dr-ui/src/lib.rs#L1205), [`ui/dr-ui/src/lib.rs:1857`](../ui/dr-ui/src/lib.rs#L1857), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`ui/dr-ui/src/library.rs:378`](../ui/dr-ui/src/library.rs#L378), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:218`](../ui/dr-ui/src/segmentation.rs#L218), [`ui/dr-ui/ui/app.slint:1880`](../ui/dr-ui/ui/app.slint#L1880), [`ui/dr-ui/ui/app.slint:888`](../ui/dr-ui/ui/app.slint#L888) |
|
||||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:323`](../core/dr-pipeline/src/ops/curve.rs#L323), [`ui/dr-ui/src/develop.rs:727`](../ui/dr-ui/src/develop.rs#L727), [`ui/dr-ui/src/lib.rs:526`](../ui/dr-ui/src/lib.rs#L526) |
|
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:18`](../core/dr-pipeline/src/graph.rs#L18), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1008`](../ui/dr-ui/src/develop.rs#L1008), [`ui/dr-ui/src/lib.rs:526`](../ui/dr-ui/src/lib.rs#L526) |
|
||||||
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) |
|
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:53`](../core/dr-pipeline/src/graph.rs#L53), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) |
|
||||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`ui/dr-ui/src/develop.rs:3228`](../ui/dr-ui/src/develop.rs#L3228) |
|
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:184`](../core/dr-pipeline/src/graph.rs#L184), [`core/dr-pipeline/src/graph.rs:40`](../core/dr-pipeline/src/graph.rs#L40), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:3711`](../ui/dr-ui/src/develop.rs#L3711) |
|
||||||
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/capture_sharpen.rs:431`](../core/dr-gpu/tests/capture_sharpen.rs#L431), [`core/dr-gpu/tests/detail_stage.rs:240`](../core/dr-gpu/tests/detail_stage.rs#L240), [`core/dr-gpu/tests/local_contrast.rs:467`](../core/dr-gpu/tests/local_contrast.rs#L467), [`core/dr-gpu/tests/noise_reduction.rs:513`](../core/dr-gpu/tests/noise_reduction.rs#L513), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:420`](../core/dr-pipeline/src/graph.rs#L420), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) |
|
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/capture_sharpen.rs:429`](../core/dr-gpu/tests/capture_sharpen.rs#L429), [`core/dr-gpu/tests/detail_stage.rs:241`](../core/dr-gpu/tests/detail_stage.rs#L241), [`core/dr-gpu/tests/local_contrast.rs:475`](../core/dr-gpu/tests/local_contrast.rs#L475), [`core/dr-gpu/tests/noise_reduction.rs:555`](../core/dr-gpu/tests/noise_reduction.rs#L555), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:426`](../core/dr-pipeline/src/graph.rs#L426), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) |
|
||||||
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:702`](../core/dr-decode/src/lib.rs#L702), [`core/dr-decode/src/lib.rs:742`](../core/dr-decode/src/lib.rs#L742), [`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:690`](../core/dr-gpu/src/adjust.rs#L690), [`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:1344`](../core/dr-pipeline/src/operation.rs#L1344), [`core/dr-pipeline/src/operation.rs:1369`](../core/dr-pipeline/src/operation.rs#L1369), [`core/dr-pipeline/src/operation.rs:1384`](../core/dr-pipeline/src/operation.rs#L1384), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:513`](../core/dr-pipeline/src/operation.rs#L513) |
|
| 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:690`](../core/dr-gpu/src/adjust.rs#L690), [`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:1341`](../core/dr-pipeline/src/operation.rs#L1341), [`core/dr-pipeline/src/operation.rs:1366`](../core/dr-pipeline/src/operation.rs#L1366), [`core/dr-pipeline/src/operation.rs:1381`](../core/dr-pipeline/src/operation.rs#L1381), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:510`](../core/dr-pipeline/src/operation.rs#L510) |
|
||||||
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:401`](../core/dr-decode/src/lib.rs#L401), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:334`](../core/dr-types/src/lib.rs#L334) |
|
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336) |
|
||||||
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
||||||
| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2253`](../ui/dr-ui/src/develop.rs#L2253), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/develop.rs:2263`](../ui/dr-ui/src/develop.rs#L2263), [`ui/dr-ui/src/lib.rs:1168`](../ui/dr-ui/src/lib.rs#L1168) |
|
| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2654`](../ui/dr-ui/src/develop.rs#L2654), [`ui/dr-ui/src/develop.rs:488`](../ui/dr-ui/src/develop.rs#L488), [`ui/dr-ui/src/lib.rs:1197`](../ui/dr-ui/src/lib.rs#L1197) |
|
||||||
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:127`](../core/dr-types/src/settings.rs#L127), [`ui/dr-ui/src/develop.rs:2214`](../ui/dr-ui/src/develop.rs#L2214), [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/lib.rs:1140`](../ui/dr-ui/src/lib.rs#L1140), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2379`](../ui/dr-ui/src/library_ui.rs#L2379), [`ui/dr-ui/src/library_ui.rs:2730`](../ui/dr-ui/src/library_ui.rs#L2730), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1105`](../ui/dr-ui/ui/library.slint#L1105), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:747`](../ui/dr-ui/ui/library.slint#L747), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) |
|
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:128`](../core/dr-types/src/settings.rs#L128), [`ui/dr-ui/src/develop.rs:2594`](../ui/dr-ui/src/develop.rs#L2594), [`ui/dr-ui/src/develop.rs:2615`](../ui/dr-ui/src/develop.rs#L2615), [`ui/dr-ui/src/lib.rs:1169`](../ui/dr-ui/src/lib.rs#L1169), [`ui/dr-ui/src/library.rs:1504`](../ui/dr-ui/src/library.rs#L1504), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2379`](../ui/dr-ui/src/library_ui.rs#L2379), [`ui/dr-ui/src/library_ui.rs:2734`](../ui/dr-ui/src/library_ui.rs#L2734), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1118`](../ui/dr-ui/ui/library.slint#L1118), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:747`](../ui/dr-ui/ui/library.slint#L747), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) |
|
||||||
| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) |
|
| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) |
|
||||||
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1783`](../core/dr-gpu/src/adjust.rs#L1783), [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:1945`](../core/dr-gpu/src/adjust.rs#L1945), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:204`](../core/dr-gpu/tests/capture_sharpen.rs#L204), [`core/dr-gpu/tests/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-gpu/tests/local_contrast.rs:254`](../core/dr-gpu/tests/local_contrast.rs#L254), [`core/dr-gpu/tests/noise_reduction.rs:365`](../core/dr-gpu/tests/noise_reduction.rs#L365), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:655`](../core/dr-pipeline/src/ops/local_contrast.rs#L655), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:1781`](../ui/dr-ui/src/develop.rs#L1781), [`ui/dr-ui/src/develop.rs:2401`](../ui/dr-ui/src/develop.rs#L2401), [`ui/dr-ui/src/develop.rs:2773`](../ui/dr-ui/src/develop.rs#L2773), [`ui/dr-ui/src/develop.rs:2807`](../ui/dr-ui/src/develop.rs#L2807), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:667`](../ui/dr-ui/src/lib.rs#L667) |
|
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1783`](../core/dr-gpu/src/adjust.rs#L1783), [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:1945`](../core/dr-gpu/src/adjust.rs#L1945), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:199`](../core/dr-gpu/tests/capture_sharpen.rs#L199), [`core/dr-gpu/tests/detail_stage.rs:327`](../core/dr-gpu/tests/detail_stage.rs#L327), [`core/dr-gpu/tests/local_contrast.rs:262`](../core/dr-gpu/tests/local_contrast.rs#L262), [`core/dr-gpu/tests/noise_reduction.rs:377`](../core/dr-gpu/tests/noise_reduction.rs#L377), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:367`](../core/dr-pipeline/src/graph.rs#L367), [`core/dr-pipeline/src/graph.rs:397`](../core/dr-pipeline/src/graph.rs#L397), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:658`](../core/dr-pipeline/src/ops/local_contrast.rs#L658), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:2161`](../ui/dr-ui/src/develop.rs#L2161), [`ui/dr-ui/src/develop.rs:2792`](../ui/dr-ui/src/develop.rs#L2792), [`ui/dr-ui/src/develop.rs:3256`](../ui/dr-ui/src/develop.rs#L3256), [`ui/dr-ui/src/develop.rs:3290`](../ui/dr-ui/src/develop.rs#L3290), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:667`](../ui/dr-ui/src/lib.rs#L667) |
|
||||||
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) |
|
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) |
|
||||||
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:1826`](../ui/dr-ui/src/develop.rs#L1826), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/develop.rs:3907`](../ui/dr-ui/src/develop.rs#L3907), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1227`](../ui/dr-ui/src/lib.rs#L1227), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:305`](../ui/dr-ui/ui/app.slint#L305), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
|
| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:2206`](../ui/dr-ui/src/develop.rs#L2206), [`ui/dr-ui/src/develop.rs:4358`](../ui/dr-ui/src/develop.rs#L4358), [`ui/dr-ui/src/develop.rs:4390`](../ui/dr-ui/src/develop.rs#L4390), [`ui/dr-ui/src/develop.rs:499`](../ui/dr-ui/src/develop.rs#L499), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1256`](../ui/dr-ui/src/lib.rs#L1256), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:276`](../ui/dr-ui/ui/app.slint#L276), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
|
||||||
| FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2093`](../core/dr-gpu/src/adjust.rs#L2093), [`core/dr-pipeline/src/graph.rs:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`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:540`](../core/dr-types/src/settings.rs#L540), [`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:2093`](../core/dr-gpu/src/adjust.rs#L2093), [`core/dr-pipeline/src/graph.rs:357`](../core/dr-pipeline/src/graph.rs#L357), [`core/dr-pipeline/src/graph.rs:410`](../core/dr-pipeline/src/graph.rs#L410), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`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:541`](../core/dr-types/src/settings.rs#L541), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||||
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) |
|
| FR-EXP-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:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) |
|
||||||
| FR-EXP-7 | [`ui/dr-ui/src/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:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/lib.rs:1751`](../ui/dr-ui/src/lib.rs#L1751), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:3100`](../ui/dr-ui/src/library_ui.rs#L3100), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:5373`](../ui/dr-ui/src/library_ui.rs#L5373), [`ui/dr-ui/src/library_ui.rs:5450`](../ui/dr-ui/src/library_ui.rs#L5450), [`ui/dr-ui/src/library_ui.rs:5462`](../ui/dr-ui/src/library_ui.rs#L5462), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:603`](../ui/dr-ui/src/library_ui.rs#L603), [`ui/dr-ui/ui/app.slint:1353`](../ui/dr-ui/ui/app.slint#L1353), [`ui/dr-ui/ui/app.slint:992`](../ui/dr-ui/ui/app.slint#L992), [`ui/dr-ui/ui/library.slint:1113`](../ui/dr-ui/ui/library.slint#L1113), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) |
|
| 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:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:1780`](../ui/dr-ui/src/lib.rs#L1780), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:3104`](../ui/dr-ui/src/library_ui.rs#L3104), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:5515`](../ui/dr-ui/src/library_ui.rs#L5515), [`ui/dr-ui/src/library_ui.rs:5592`](../ui/dr-ui/src/library_ui.rs#L5592), [`ui/dr-ui/src/library_ui.rs:5604`](../ui/dr-ui/src/library_ui.rs#L5604), [`ui/dr-ui/src/library_ui.rs:603`](../ui/dr-ui/src/library_ui.rs#L603), [`ui/dr-ui/ui/app.slint:1349`](../ui/dr-ui/ui/app.slint#L1349), [`ui/dr-ui/ui/app.slint:979`](../ui/dr-ui/ui/app.slint#L979), [`ui/dr-ui/ui/library.slint:1126`](../ui/dr-ui/ui/library.slint#L1126), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) |
|
||||||
| 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:1163`](../core/dr-decode/src/locate.rs#L1163), [`core/dr-decode/src/locate.rs:1221`](../core/dr-decode/src/locate.rs#L1221), [`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:119`](../core/dr-export/src/encode.rs#L119), [`core/dr-export/src/encode.rs:163`](../core/dr-export/src/encode.rs#L163), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:208`](../core/dr-export/src/encode.rs#L208), [`core/dr-export/src/encode.rs:237`](../core/dr-export/src/encode.rs#L237), [`core/dr-export/src/encode.rs:310`](../core/dr-export/src/encode.rs#L310), [`core/dr-export/src/encode.rs:324`](../core/dr-export/src/encode.rs#L324), [`core/dr-export/src/encode.rs:404`](../core/dr-export/src/encode.rs#L404), [`core/dr-export/src/encode.rs:452`](../core/dr-export/src/encode.rs#L452), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:791`](../core/dr-export/src/encode.rs#L791), [`core/dr-export/src/encode.rs:805`](../core/dr-export/src/encode.rs#L805), [`core/dr-export/src/encode.rs:846`](../core/dr-export/src/encode.rs#L846), [`core/dr-export/src/encode.rs:894`](../core/dr-export/src/encode.rs#L894), [`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:442`](../core/dr-types/src/lib.rs#L442), [`core/dr-types/src/settings.rs:258`](../core/dr-types/src/settings.rs#L258), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:776`](../ui/dr-ui/src/export.rs#L776), [`ui/dr-ui/src/export.rs:793`](../ui/dr-ui/src/export.rs#L793), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1222`](../core/dr-decode/src/locate.rs#L1222), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/settings.rs:259`](../core/dr-types/src/settings.rs#L259), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:776`](../ui/dr-ui/src/export.rs#L776), [`ui/dr-ui/src/export.rs:793`](../ui/dr-ui/src/export.rs#L793), [`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:503`](../core/dr-decode/src/lib.rs#L503), [`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:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:1904`](../ui/dr-ui/src/develop.rs#L1904), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) |
|
| 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:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:2284`](../ui/dr-ui/src/develop.rs#L2284), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) |
|
||||||
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
|
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
|
||||||
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3100`](../ui/dr-ui/src/library_ui.rs#L3100), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library.rs:1541`](../ui/dr-ui/src/library.rs#L1541), [`ui/dr-ui/src/library.rs:415`](../ui/dr-ui/src/library.rs#L415), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library.rs:906`](../ui/dr-ui/src/library.rs#L906), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3104`](../ui/dr-ui/src/library_ui.rs#L3104), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||||
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
|
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
|
||||||
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) |
|
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) |
|
||||||
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/src/library_ui.rs:4113`](../ui/dr-ui/src/library_ui.rs#L4113), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`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:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2557`](../ui/dr-ui/src/library.rs#L2557), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/src/library_ui.rs:4154`](../ui/dr-ui/src/library_ui.rs#L4154), [`ui/dr-ui/ui/app.slint:504`](../ui/dr-ui/ui/app.slint#L504), [`ui/dr-ui/ui/settings.slint:330`](../ui/dr-ui/ui/settings.slint#L330), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||||
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
|
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
|
||||||
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:398`](../ui/dr-ui/src/import.rs#L398) |
|
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) |
|
||||||
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
||||||
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:738`](../core/dr-catalog/src/schema.rs#L738), [`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:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1511`](../ui/dr-ui/src/lib.rs#L1511), [`ui/dr-ui/src/lib.rs:2251`](../ui/dr-ui/src/lib.rs#L2251), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:1125`](../ui/dr-ui/src/library_ui.rs#L1125), [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180), [`ui/dr-ui/src/library_ui.rs:1298`](../ui/dr-ui/src/library_ui.rs#L1298), [`ui/dr-ui/src/library_ui.rs:1417`](../ui/dr-ui/src/library_ui.rs#L1417), [`ui/dr-ui/src/library_ui.rs:1895`](../ui/dr-ui/src/library_ui.rs#L1895), [`ui/dr-ui/src/library_ui.rs:211`](../ui/dr-ui/src/library_ui.rs#L211), [`ui/dr-ui/src/library_ui.rs:222`](../ui/dr-ui/src/library_ui.rs#L222), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:251`](../ui/dr-ui/src/library_ui.rs#L251), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:327`](../ui/dr-ui/src/library_ui.rs#L327), [`ui/dr-ui/src/library_ui.rs:369`](../ui/dr-ui/src/library_ui.rs#L369), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:4649`](../ui/dr-ui/src/library_ui.rs#L4649), [`ui/dr-ui/src/library_ui.rs:4667`](../ui/dr-ui/src/library_ui.rs#L4667), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`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:2359`](../ui/dr-ui/ui/app.slint#L2359), [`ui/dr-ui/ui/app.slint:589`](../ui/dr-ui/ui/app.slint#L589), [`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:808`](../ui/dr-ui/ui/library.slint#L808) |
|
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:738`](../core/dr-catalog/src/schema.rs#L738), [`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:2965`](../ui/dr-ui/src/collections_ui.rs#L2965), [`ui/dr-ui/src/collections_ui.rs:576`](../ui/dr-ui/src/collections_ui.rs#L576), [`ui/dr-ui/src/collections_ui.rs:638`](../ui/dr-ui/src/collections_ui.rs#L638), [`ui/dr-ui/src/lib.rs:1540`](../ui/dr-ui/src/lib.rs#L1540), [`ui/dr-ui/src/lib.rs:2280`](../ui/dr-ui/src/lib.rs#L2280), [`ui/dr-ui/src/library.rs:1283`](../ui/dr-ui/src/library.rs#L1283), [`ui/dr-ui/src/library.rs:1306`](../ui/dr-ui/src/library.rs#L1306), [`ui/dr-ui/src/library.rs:1464`](../ui/dr-ui/src/library.rs#L1464), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:1125`](../ui/dr-ui/src/library_ui.rs#L1125), [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180), [`ui/dr-ui/src/library_ui.rs:1298`](../ui/dr-ui/src/library_ui.rs#L1298), [`ui/dr-ui/src/library_ui.rs:1417`](../ui/dr-ui/src/library_ui.rs#L1417), [`ui/dr-ui/src/library_ui.rs:1895`](../ui/dr-ui/src/library_ui.rs#L1895), [`ui/dr-ui/src/library_ui.rs:211`](../ui/dr-ui/src/library_ui.rs#L211), [`ui/dr-ui/src/library_ui.rs:222`](../ui/dr-ui/src/library_ui.rs#L222), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:251`](../ui/dr-ui/src/library_ui.rs#L251), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:327`](../ui/dr-ui/src/library_ui.rs#L327), [`ui/dr-ui/src/library_ui.rs:369`](../ui/dr-ui/src/library_ui.rs#L369), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:4751`](../ui/dr-ui/src/library_ui.rs#L4751), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:4769`](../ui/dr-ui/src/library_ui.rs#L4769), [`ui/dr-ui/src/library_ui.rs:4781`](../ui/dr-ui/src/library_ui.rs#L4781), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`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:2378`](../ui/dr-ui/ui/app.slint#L2378), [`ui/dr-ui/ui/app.slint:565`](../ui/dr-ui/ui/app.slint#L565), [`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:808`](../ui/dr-ui/ui/library.slint#L808) |
|
||||||
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180) |
|
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180) |
|
||||||
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:117`](../core/dr-types/src/lib.rs#L117), [`core/dr-types/src/lib.rs:199`](../core/dr-types/src/lib.rs#L199), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
|
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2965`](../ui/dr-ui/src/collections_ui.rs#L2965), [`ui/dr-ui/src/collections_ui.rs:576`](../ui/dr-ui/src/collections_ui.rs#L576), [`ui/dr-ui/src/collections_ui.rs:638`](../ui/dr-ui/src/collections_ui.rs#L638), [`ui/dr-ui/src/library_ui.rs:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`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-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316) |
|
| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:2583`](../ui/dr-ui/src/library.rs#L2583), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/ui/settings.slint:330`](../ui/dr-ui/ui/settings.slint#L330) |
|
||||||
| 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:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`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:944`](../ui/dr-ui/src/lib.rs#L944), [`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:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916) |
|
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:944`](../ui/dr-ui/src/lib.rs#L944) |
|
||||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384) |
|
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1509`](../ui/dr-ui/src/lib.rs#L1509), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384) |
|
||||||
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/src/sidecar.rs:303`](../core/dr-pipeline/src/sidecar.rs#L303), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) |
|
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/src/sidecar.rs:301`](../core/dr-pipeline/src/sidecar.rs#L301), [`ui/dr-ui/src/library.rs:709`](../ui/dr-ui/src/library.rs#L709), [`ui/dr-ui/src/library.rs:839`](../ui/dr-ui/src/library.rs#L839) |
|
||||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:51`](../core/dr-types/src/lib.rs#L51) |
|
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
|
||||||
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||||
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:736`](../ui/dr-ui/src/lib.rs#L736), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||||
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:127`](../core/dr-types/src/lib.rs#L127), [`core/dr-types/src/lib.rs:198`](../core/dr-types/src/lib.rs#L198) |
|
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) |
|
||||||
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:503`](../core/dr-decode/src/lib.rs#L503), [`core/dr-decode/src/locate.rs:1364`](../core/dr-decode/src/locate.rs#L1364) |
|
| 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:1365`](../core/dr-decode/src/locate.rs#L1365) |
|
||||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175) |
|
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175) |
|
||||||
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) |
|
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) |
|
||||||
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2333`](../ui/dr-ui/src/lib.rs#L2333), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/ui/library.slint:866`](../ui/dr-ui/ui/library.slint#L866) |
|
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2362`](../ui/dr-ui/src/lib.rs#L2362), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:866`](../ui/dr-ui/ui/library.slint#L866) |
|
||||||
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/ui/app.slint:619`](../ui/dr-ui/ui/app.slint#L619), [`ui/dr-ui/ui/app.slint:626`](../ui/dr-ui/ui/app.slint#L626), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:1900`](../ui/dr-ui/ui/library.slint#L1900), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707) |
|
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1450`](../ui/dr-ui/src/collections_ui.rs#L1450), [`ui/dr-ui/src/collections_ui.rs:1464`](../ui/dr-ui/src/collections_ui.rs#L1464), [`ui/dr-ui/src/collections_ui.rs:1509`](../ui/dr-ui/src/collections_ui.rs#L1509), [`ui/dr-ui/src/collections_ui.rs:1519`](../ui/dr-ui/src/collections_ui.rs#L1519), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:943`](../ui/dr-ui/src/collections_ui.rs#L943), [`ui/dr-ui/src/collections_ui.rs:953`](../ui/dr-ui/src/collections_ui.rs#L953), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:4781`](../ui/dr-ui/src/library_ui.rs#L4781), [`ui/dr-ui/src/library_ui.rs:5623`](../ui/dr-ui/src/library_ui.rs#L5623), [`ui/dr-ui/ui/app.slint:2107`](../ui/dr-ui/ui/app.slint#L2107), [`ui/dr-ui/ui/app.slint:262`](../ui/dr-ui/ui/app.slint#L262), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/app.slint:602`](../ui/dr-ui/ui/app.slint#L602), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:2003`](../ui/dr-ui/ui/library.slint#L2003), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/settings.slint:93`](../ui/dr-ui/ui/settings.slint#L93) |
|
||||||
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3908`](../ui/dr-ui/src/library_ui.rs#L3908), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:792`](../ui/dr-ui/src/masks_ui.rs#L792), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1884`](../ui/dr-ui/ui/app.slint#L1884), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) |
|
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1831`](../ui/dr-ui/src/develop.rs#L1831), [`ui/dr-ui/src/library_ui.rs:3949`](../ui/dr-ui/src/library_ui.rs#L3949), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/ui/app.slint:1880`](../ui/dr-ui/ui/app.slint#L1880), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) |
|
||||||
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3908`](../ui/dr-ui/src/library_ui.rs#L3908), [`ui/dr-ui/src/library_ui.rs:3953`](../ui/dr-ui/src/library_ui.rs#L3953), [`ui/dr-ui/src/library_ui.rs:4056`](../ui/dr-ui/src/library_ui.rs#L4056), [`ui/dr-ui/src/library_ui.rs:4084`](../ui/dr-ui/src/library_ui.rs#L4084), [`ui/dr-ui/src/library_ui.rs:4667`](../ui/dr-ui/src/library_ui.rs#L4667), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/ui/app.slint:1580`](../ui/dr-ui/ui/app.slint#L1580), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/app.slint:626`](../ui/dr-ui/ui/app.slint#L626), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:1900`](../ui/dr-ui/ui/library.slint#L1900), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
|
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1450`](../ui/dr-ui/src/collections_ui.rs#L1450), [`ui/dr-ui/src/collections_ui.rs:1464`](../ui/dr-ui/src/collections_ui.rs#L1464), [`ui/dr-ui/src/collections_ui.rs:1509`](../ui/dr-ui/src/collections_ui.rs#L1509), [`ui/dr-ui/src/collections_ui.rs:1519`](../ui/dr-ui/src/collections_ui.rs#L1519), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1546`](../ui/dr-ui/src/collections_ui.rs#L1546), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:943`](../ui/dr-ui/src/collections_ui.rs#L943), [`ui/dr-ui/src/collections_ui.rs:953`](../ui/dr-ui/src/collections_ui.rs#L953), [`ui/dr-ui/src/library_ui.rs:3949`](../ui/dr-ui/src/library_ui.rs#L3949), [`ui/dr-ui/src/library_ui.rs:3994`](../ui/dr-ui/src/library_ui.rs#L3994), [`ui/dr-ui/src/library_ui.rs:4097`](../ui/dr-ui/src/library_ui.rs#L4097), [`ui/dr-ui/src/library_ui.rs:4125`](../ui/dr-ui/src/library_ui.rs#L4125), [`ui/dr-ui/src/library_ui.rs:4769`](../ui/dr-ui/src/library_ui.rs#L4769), [`ui/dr-ui/src/library_ui.rs:4781`](../ui/dr-ui/src/library_ui.rs#L4781), [`ui/dr-ui/ui/app.slint:1576`](../ui/dr-ui/ui/app.slint#L1576), [`ui/dr-ui/ui/app.slint:571`](../ui/dr-ui/ui/app.slint#L571), [`ui/dr-ui/ui/app.slint:602`](../ui/dr-ui/ui/app.slint#L602), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:2003`](../ui/dr-ui/ui/library.slint#L2003), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) |
|
||||||
| 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:1978`](../ui/dr-ui/src/lib.rs#L1978), [`ui/dr-ui/src/lib.rs:2367`](../ui/dr-ui/src/lib.rs#L2367), [`ui/dr-ui/src/lib.rs:2552`](../ui/dr-ui/src/lib.rs#L2552), [`ui/dr-ui/src/masks_ui.rs:747`](../ui/dr-ui/src/masks_ui.rs#L747), [`ui/dr-ui/ui/app.slint:326`](../ui/dr-ui/ui/app.slint#L326), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2007`](../ui/dr-ui/src/lib.rs#L2007), [`ui/dr-ui/src/lib.rs:2396`](../ui/dr-ui/src/lib.rs#L2396), [`ui/dr-ui/src/lib.rs:2581`](../ui/dr-ui/src/lib.rs#L2581), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:297`](../ui/dr-ui/ui/app.slint#L297), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
||||||
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) |
|
| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) |
|
||||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||||
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1552`](../ui/dr-ui/src/export.rs#L1552), [`ui/dr-ui/src/export.rs:1578`](../ui/dr-ui/src/export.rs#L1578), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1785`](../ui/dr-ui/src/lib.rs#L1785), [`ui/dr-ui/ui/app.slint:1003`](../ui/dr-ui/ui/app.slint#L1003), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) |
|
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1552`](../ui/dr-ui/src/export.rs#L1552), [`ui/dr-ui/src/export.rs:1578`](../ui/dr-ui/src/export.rs#L1578), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1814`](../ui/dr-ui/src/lib.rs#L1814), [`ui/dr-ui/ui/app.slint:990`](../ui/dr-ui/ui/app.slint#L990), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) |
|
||||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
|
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
|
||||||
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
|
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
|
||||||
| NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
| NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||||
| NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
| NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) |
|
||||||
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
|
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`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:267`](../core/dr-types/src/lib.rs#L267), [`core/dr-types/src/lib.rs:300`](../core/dr-types/src/lib.rs#L300) |
|
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302) |
|
||||||
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) |
|
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:906`](../ui/dr-ui/src/library.rs#L906) |
|
||||||
| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
|
| 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-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
|
||||||
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||||
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||||
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59) |
|
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59) |
|
||||||
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`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:2528`](../ui/dr-ui/src/library.rs#L2528) |
|
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`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:2557`](../ui/dr-ui/src/library.rs#L2557) |
|
||||||
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
||||||
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
|
| R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) |
|
||||||
| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Maintainer: Duncan Tourolle <duncan@tourolle.paris>
|
||||||
|
#
|
||||||
|
# Builds from the local checkout rather than a release tarball, which is what
|
||||||
|
# makes `makepkg -si` in this directory install what you are actually working
|
||||||
|
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||||
|
pkgname=darkroom
|
||||||
|
pkgver=0.5.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Non-destructive RAW photo library and editor"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://gitea.tourolle.paris/dtourolle/DarkRoom"
|
||||||
|
license=('GPL-3.0-or-later')
|
||||||
|
# Runtime: Vulkan for wgpu, and a Secret Service implementation for the
|
||||||
|
# Nextcloud credentials (FR-NC-2) — gnome-keyring or kwallet both provide it.
|
||||||
|
depends=('vulkan-icd-loader' 'fontconfig' 'libxkbcommon')
|
||||||
|
makedepends=('cargo' 'git')
|
||||||
|
optdepends=('gnome-keyring: store Nextcloud credentials'
|
||||||
|
'kwallet: store Nextcloud credentials')
|
||||||
|
options=('!lto') # the workspace sets its own LTO in Cargo.toml
|
||||||
|
|
||||||
|
_repo="$(cd "${startdir}/.." && pwd)"
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cd "${_repo}"
|
||||||
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
|
export CARGO_TARGET_DIR="${_repo}/target"
|
||||||
|
cargo build --release --locked -p darkroom-desktop
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "${_repo}"
|
||||||
|
install -Dm755 "target/release/darkroom-desktop" \
|
||||||
|
"${pkgdir}/usr/bin/darkroom-desktop"
|
||||||
|
|
||||||
|
install -Dm644 "packaging/paris.tourolle.darkroom.desktop" \
|
||||||
|
"${pkgdir}/usr/share/applications/paris.tourolle.darkroom.desktop"
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# actual size — lying about it makes scaled-down icons look wrong.
|
||||||
|
install -Dm644 "ui/dr-ui/ui/app-icon.png" \
|
||||||
|
"${pkgdir}/usr/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png"
|
||||||
|
|
||||||
|
install -Dm644 "README.md" "${pkgdir}/usr/share/doc/${pkgname}/README.md"
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=DarkRoom
|
||||||
|
GenericName=Photo Editor
|
||||||
|
Comment=Non-destructive RAW photo library and editor
|
||||||
|
# The basename of this file is the contract: a Wayland compositor finds it by
|
||||||
|
# matching the surface's app_id, which `dr_ui::run` sets to the same string.
|
||||||
|
# Rename one without the other and the icon silently disappears.
|
||||||
|
Exec=darkroom-desktop %F
|
||||||
|
Icon=paris.tourolle.darkroom
|
||||||
|
Terminal=false
|
||||||
|
Categories=Graphics;Photography;RasterGraphics;2DGraphics;
|
||||||
|
Keywords=RAW;photo;photography;develop;darkroom;library;
|
||||||
|
# X11 falls back to matching the WM_CLASS instead of the app_id; winit sets it
|
||||||
|
# from the same string, so naming it here covers both display servers.
|
||||||
|
StartupWMClass=paris.tourolle.darkroom
|
||||||
|
StartupNotify=true
|
||||||
|
MimeType=image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-sony-arw;image/x-adobe-dng;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/jpeg;image/png;image/tiff;
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
# (see the workspace manifest for why that backend was chosen), and it is what
|
# (see the workspace manifest for why that backend was chosen), and it is what
|
||||||
# makes the tiling option in `semantic.rs` the only route to more resolution.
|
# makes the tiling option in `semantic.rs` the only route to more resolution.
|
||||||
#
|
#
|
||||||
# `imgsz=640` square rather than a rectangle matched to 3:2: one graph has to
|
# `imgsz` square rather than a rectangle matched to 3:2: one graph has to
|
||||||
# serve portrait, landscape, square crops and panoramas. A landscape-shaped
|
# serve portrait, landscape, square crops and panoramas. A landscape-shaped
|
||||||
# graph trades letterbox waste on 3:2 for worse waste on everything else.
|
# graph trades letterbox waste on 3:2 for worse waste on everything else.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -27,21 +27,23 @@ REPO="$(cd "${HERE}/.." && pwd)"
|
|||||||
OUT="${REPO}/core/dr-segment/models"
|
OUT="${REPO}/core/dr-segment/models"
|
||||||
|
|
||||||
MODEL="${1:-yolo26n-seg}"
|
MODEL="${1:-yolo26n-seg}"
|
||||||
|
IMGSZ="${2:-640}"
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
trap 'rm -rf "${WORK}"' EXIT
|
trap 'rm -rf "${WORK}"' EXIT
|
||||||
|
|
||||||
echo "==> exporting ${MODEL} in ${WORK}"
|
echo "==> exporting ${MODEL} at imgsz=${IMGSZ} in ${WORK}"
|
||||||
cd "${WORK}"
|
cd "${WORK}"
|
||||||
uv venv --python 3.12 venv
|
uv venv --python 3.12 venv
|
||||||
VIRTUAL_ENV="${WORK}/venv" uv pip install ultralytics onnx onnxslim
|
VIRTUAL_ENV="${WORK}/venv" uv pip install ultralytics onnx onnxslim
|
||||||
|
|
||||||
VIRTUAL_ENV="${WORK}/venv" "${WORK}/venv/bin/python" - "${MODEL}" <<'PY'
|
VIRTUAL_ENV="${WORK}/venv" "${WORK}/venv/bin/python" - "${MODEL}" "${IMGSZ}" <<'PY'
|
||||||
import sys, json
|
import sys, json
|
||||||
from ultralytics import YOLO
|
from ultralytics import YOLO
|
||||||
|
|
||||||
name = sys.argv[1]
|
name = sys.argv[1]
|
||||||
|
imgsz = int(sys.argv[2])
|
||||||
m = YOLO(f"{name}.pt")
|
m = YOLO(f"{name}.pt")
|
||||||
path = m.export(format="onnx", opset=17, simplify=True, imgsz=640, dynamic=False)
|
path = m.export(format="onnx", opset=17, simplify=True, imgsz=imgsz, dynamic=False)
|
||||||
print("ONNX:", path)
|
print("ONNX:", path)
|
||||||
|
|
||||||
# The class names travel with the model rather than being retyped into Rust —
|
# The class names travel with the model rather than being retyped into Rust —
|
||||||
|
|||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Set the project's version in the few places that must state it, from one
|
||||||
|
# source, and optionally commit the result.
|
||||||
|
#
|
||||||
|
# tools/set-version.sh --from-tag # take it from the latest git tag
|
||||||
|
# tools/set-version.sh 0.5.0 # or state it
|
||||||
|
# tools/set-version.sh --from-tag --commit
|
||||||
|
#
|
||||||
|
# Why this exists: the workspace read 0.1.0 for two releases, so every binary
|
||||||
|
# reported a version two releases stale, and the APK reported none at all. A
|
||||||
|
# version that has to be edited in several files by hand is a version that will
|
||||||
|
# be wrong in at least one of them.
|
||||||
|
#
|
||||||
|
# The number of places is kept deliberately small. Only two files state the
|
||||||
|
# version; everything else derives it:
|
||||||
|
#
|
||||||
|
# Cargo.toml the source of truth for every crate in the workspace
|
||||||
|
# packaging/PKGBUILD pacman needs it before anything is built
|
||||||
|
#
|
||||||
|
# AndroidManifest.xml states NO version on purpose — `package.sh` passes
|
||||||
|
# it to `aapt2 link` from Cargo.toml, so the APK cannot
|
||||||
|
# drift from the binary inside it.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${REPO}"
|
||||||
|
|
||||||
|
COMMIT=0
|
||||||
|
VERSION=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "${arg}" in
|
||||||
|
--commit) COMMIT=1 ;;
|
||||||
|
--from-tag) VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')" ;;
|
||||||
|
-h|--help) sed -n '2,20p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
||||||
|
-*) echo "error: unknown flag ${arg}" >&2; exit 2 ;;
|
||||||
|
*) VERSION="${arg#v}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${VERSION}" ]]; then
|
||||||
|
echo "error: no version given, and no tag to take one from" >&2
|
||||||
|
echo "usage: tools/set-version.sh [--from-tag | <version>] [--commit]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rejected rather than coerced. A version is a contract with whoever reads a
|
||||||
|
# bug report, and "0.4" or "v0.4.0-wip" silently becoming something else is
|
||||||
|
# worse than being told to type it again.
|
||||||
|
if ! [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "error: '${VERSION}' is not MAJOR.MINOR.PATCH" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)"
|
||||||
|
echo "==> ${CURRENT:-unknown} -> ${VERSION}"
|
||||||
|
|
||||||
|
# 1. The workspace. Every member inherits this through `version.workspace`.
|
||||||
|
# Anchored to the [workspace.package] table so a dependency's version
|
||||||
|
# string cannot be hit by accident.
|
||||||
|
python3 - "${VERSION}" <<'PY'
|
||||||
|
import re, sys, pathlib
|
||||||
|
version = sys.argv[1]
|
||||||
|
p = pathlib.Path("Cargo.toml")
|
||||||
|
s = p.read_text()
|
||||||
|
m = re.search(r"(\[workspace\.package\][^\[]*?\nversion = \")([^\"]+)(\")", s, re.S)
|
||||||
|
if not m:
|
||||||
|
sys.exit("error: no version under [workspace.package] in Cargo.toml")
|
||||||
|
p.write_text(s[:m.start(2)] + version + s[m.end(2):])
|
||||||
|
print(f" Cargo.toml {m.group(2)} -> {version}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
# 2. pacman. `pkgrel` returns to 1: it counts packaging revisions *of one
|
||||||
|
# version*, so carrying it across a version bump would claim this is the
|
||||||
|
# second package of a version nobody has packaged yet.
|
||||||
|
python3 - "${VERSION}" <<'PY'
|
||||||
|
import re, sys, pathlib
|
||||||
|
version = sys.argv[1]
|
||||||
|
p = pathlib.Path("packaging/PKGBUILD")
|
||||||
|
s = p.read_text()
|
||||||
|
old = re.search(r"^pkgver=(.*)$", s, re.M)
|
||||||
|
s = re.sub(r"^pkgver=.*$", f"pkgver={version}", s, count=1, flags=re.M)
|
||||||
|
s = re.sub(r"^pkgrel=.*$", "pkgrel=1", s, count=1, flags=re.M)
|
||||||
|
p.write_text(s)
|
||||||
|
print(f" packaging/PKGBUILD {old.group(1) if old else '?'} -> {version}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
# 3. Cargo.lock. The workspace members appear in it by version, so leaving it
|
||||||
|
# alone makes `--locked` builds fail — which is exactly what CI uses.
|
||||||
|
cargo update --workspace --offline >/dev/null 2>&1 \
|
||||||
|
|| cargo update --workspace >/dev/null 2>&1 \
|
||||||
|
|| echo " warning: could not refresh Cargo.lock; run 'cargo update --workspace'" >&2
|
||||||
|
echo " Cargo.lock refreshed"
|
||||||
|
|
||||||
|
echo "==> Android takes its version from Cargo.toml at link time; nothing to edit"
|
||||||
|
|
||||||
|
if [[ "${COMMIT}" == "1" ]]; then
|
||||||
|
git add Cargo.toml Cargo.lock packaging/PKGBUILD
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "==> nothing changed; no commit made"
|
||||||
|
else
|
||||||
|
git commit -q -m "Say which version this is: ${VERSION}
|
||||||
|
|
||||||
|
Set by tools/set-version.sh, which is the only thing that should. The
|
||||||
|
workspace, the pacman package and — through Cargo.toml at link time — the
|
||||||
|
APK all state ${VERSION}, so a bug report naming a version names one commit.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
|
||||||
|
echo "==> committed: $(git log --oneline -1)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "==> not committed (pass --commit to commit)"
|
||||||
|
fi
|
||||||
@@ -548,8 +548,10 @@ pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[
|
|||||||
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
|
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (previous_anchor, cursor) =
|
let (previous_anchor, cursor) = undo.restore(
|
||||||
undo.restore(&mut ctl.selection.borrow_mut(), &mut ctl.anchor.borrow_mut());
|
&mut ctl.selection.borrow_mut(),
|
||||||
|
&mut ctl.anchor.borrow_mut(),
|
||||||
|
);
|
||||||
ctl.previous_anchor.set(previous_anchor);
|
ctl.previous_anchor.set(previous_anchor);
|
||||||
ctl.set_cursor(cursor);
|
ctl.set_cursor(cursor);
|
||||||
sync_selection(window, ctl, ids);
|
sync_selection(window, ctl, ids);
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ pub struct SyncReport {
|
|||||||
pub catalog_uploaded: bool,
|
pub catalog_uploaded: bool,
|
||||||
pub catalog_merged: bool,
|
pub catalog_merged: bool,
|
||||||
pub collections_gained: usize,
|
pub collections_gained: usize,
|
||||||
|
/// Membership rows the merge brought in (FR-CAT-7).
|
||||||
|
///
|
||||||
|
/// Counted apart from `collections_gained`, which counts the collections
|
||||||
|
/// themselves. A sync that files 127 photographs into a collection both
|
||||||
|
/// devices already had gains *no* collection, and reporting only the
|
||||||
|
/// former left the sidebar showing no count beside a full collection.
|
||||||
|
pub members_gained: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncReport {
|
impl SyncReport {
|
||||||
@@ -334,6 +341,7 @@ async fn sync_catalog(
|
|||||||
Ok(merge) => {
|
Ok(merge) => {
|
||||||
report.catalog_merged = true;
|
report.catalog_merged = true;
|
||||||
report.collections_gained = merge.inserted + merge.updated;
|
report.collections_gained = merge.inserted + merge.updated;
|
||||||
|
report.members_gained = merge.members_added;
|
||||||
}
|
}
|
||||||
Err(e) => log::warn!("merging remote catalog: {e}"),
|
Err(e) => log::warn!("merging remote catalog: {e}"),
|
||||||
},
|
},
|
||||||
|
|||||||
+532
-47
@@ -206,12 +206,275 @@ impl SegmentationJob {
|
|||||||
// few places in this codebase where not linearising is correct.
|
// few places in this codebase where not linearising is correct.
|
||||||
let rgb = rgba
|
let rgb = rgba
|
||||||
.chunks_exact(4)
|
.chunks_exact(4)
|
||||||
.flat_map(|p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0])
|
.flat_map(|p| {
|
||||||
|
[
|
||||||
|
p[0] as f32 / 255.0,
|
||||||
|
p[1] as f32 / 255.0,
|
||||||
|
p[2] as f32 / 255.0,
|
||||||
|
]
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Ok((rgb, pw as usize, ph as usize))
|
Ok((rgb, pw as usize, ph as usize))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Longest edge the refine crop is rendered at.
|
||||||
|
///
|
||||||
|
/// Matches [`dr_segment::semantic::INPUT_EDGE`] rather than exceeding it: the
|
||||||
|
/// model's own input is still fixed at 640x640, so rendering the crop larger
|
||||||
|
/// only gets downsampled again inside the model's letterbox. The resolution
|
||||||
|
/// win is entirely from *what fills the window* — a padded crop around one
|
||||||
|
/// subject rather than the whole frame — not from feeding the model more
|
||||||
|
/// pixels than it has ever read.
|
||||||
|
const REFINE_EDGE: u32 = 640;
|
||||||
|
|
||||||
|
/// How much of the box's own size is added on each side before cropping.
|
||||||
|
///
|
||||||
|
/// Context for the model to place the subject's edge against, and slack for
|
||||||
|
/// a box that under-ran the subject slightly on the first pass. Not so much
|
||||||
|
/// that a second subject standing nearby gets pulled into the same window.
|
||||||
|
const REFINE_PADDING: f32 = 0.25;
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// A refine pass lifted out of the session that asked for it.
|
||||||
|
///
|
||||||
|
/// The same split as [`SegmentationJob`], for the same reason — see its
|
||||||
|
/// docs. This one answers for a single already-detected instance rather than
|
||||||
|
/// the whole frame: it re-runs the model over a padded crop of just that
|
||||||
|
/// subject's box, so the subject reaches the model at its own size instead
|
||||||
|
/// of squeezed into the model's fixed window alongside everything else in
|
||||||
|
/// the photograph.
|
||||||
|
pub struct RefineJob {
|
||||||
|
ctx: GpuContext,
|
||||||
|
source: Arc<DemosaicedImage>,
|
||||||
|
session: SessionId,
|
||||||
|
abandon: Abandon,
|
||||||
|
/// Which instance this answers for. A snapshot of what it needs from the
|
||||||
|
/// segmentation, taken when the job was built — the same reason
|
||||||
|
/// `SegmentationJob` carries a proxy render rather than the session.
|
||||||
|
index: usize,
|
||||||
|
class_name: Arc<str>,
|
||||||
|
bbox: (f32, f32, f32, f32),
|
||||||
|
proxy: (usize, usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RefineJob {
|
||||||
|
pub fn session(&self) -> SessionId {
|
||||||
|
self.session
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn abandon(&self) -> Abandon {
|
||||||
|
self.abandon.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Re-detect this one subject at higher effective resolution. **Blocking.**
|
||||||
|
///
|
||||||
|
/// `Ok(None)` covers both "abandoned" and "the model found nothing of the
|
||||||
|
/// same class in the crop" — a photographer pressing refine on a subject
|
||||||
|
/// that the padded window no longer contains is a possible outcome, not
|
||||||
|
/// a bug, and the caller treats it as "kept what was there" either way.
|
||||||
|
pub fn run(&self) -> Result<Option<RefinedInstance>, String> {
|
||||||
|
if self.abandon.asked() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (px0, py0, px1, py1) = self.bbox;
|
||||||
|
let (pw, ph) = (self.proxy.0 as f32, self.proxy.1 as f32);
|
||||||
|
if pw <= 0.0 || ph <= 0.0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (bw, bh) = ((px1 - px0).max(1.0), (py1 - py0).max(1.0));
|
||||||
|
let (padx, pady) = (bw * REFINE_PADDING, bh * REFINE_PADDING);
|
||||||
|
let x0 = (px0 - padx).max(0.0);
|
||||||
|
let y0 = (py0 - pady).max(0.0);
|
||||||
|
let x1 = (px1 + padx).min(pw);
|
||||||
|
let y1 = (py1 + pady).min(ph);
|
||||||
|
if x1 <= x0 || y1 <= y0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (cw_px, ch_px) = (x1 - x0, y1 - y0);
|
||||||
|
|
||||||
|
let view = CropRect {
|
||||||
|
x: x0 / pw,
|
||||||
|
y: y0 / ph,
|
||||||
|
width: cw_px / pw,
|
||||||
|
height: ch_px / ph,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aspect-matched render target, capped against blowing a tiny box up
|
||||||
|
// absurdly far past what the source ever had to offer.
|
||||||
|
let scale = (REFINE_EDGE as f32 / cw_px.max(ch_px)).min(4.0);
|
||||||
|
let (tw, th) = (
|
||||||
|
(cw_px * scale).round().max(1.0) as u32,
|
||||||
|
(ch_px * scale).round().max(1.0) as u32,
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.abandon.asked() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let (rgb, rw, rh) = self.render_view(view, tw, th)?;
|
||||||
|
if self.abandon.asked() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
|
||||||
|
let options = dr_segment::SemanticOptions {
|
||||||
|
tiling: dr_segment::Tiling::Whole,
|
||||||
|
..dr_segment::SemanticOptions::default()
|
||||||
|
};
|
||||||
|
let found = model
|
||||||
|
.detect(&rgb, rw, rh, &options)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// The crop was built around one subject, so the right answer among
|
||||||
|
// whatever the model found in it is the same class closest to the
|
||||||
|
// window's centre — not merely the highest score, which a second,
|
||||||
|
// unrelated instance caught in the padding could win.
|
||||||
|
let (cx, cy) = (rw as f32 * 0.5, rh as f32 * 0.5);
|
||||||
|
let best = found
|
||||||
|
.into_iter()
|
||||||
|
.filter(|i| i.class_name.as_ref() == self.class_name.as_ref())
|
||||||
|
.min_by(|a, b| centre_distance(a, cx, cy).total_cmp(¢re_distance(b, cx, cy)));
|
||||||
|
|
||||||
|
let Some(instance) = best else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Downsampled back onto the shared proxy grid like every other
|
||||||
|
// instance's mask is, but built from a sharper source than the
|
||||||
|
// whole-frame pass ever saw for this subject.
|
||||||
|
let mask = paste_into_proxy(&instance.mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px));
|
||||||
|
let bbox = (
|
||||||
|
x0 + instance.bbox.0 / scale,
|
||||||
|
y0 + instance.bbox.1 / scale,
|
||||||
|
x0 + instance.bbox.2 / scale,
|
||||||
|
y0 + instance.bbox.3 / scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Some(RefinedInstance {
|
||||||
|
index: self.index,
|
||||||
|
summary: segmentation::InstanceSummary {
|
||||||
|
class_name: instance.class_name,
|
||||||
|
score: instance.score,
|
||||||
|
mask,
|
||||||
|
bbox,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the *unedited* image, showing only `view`, at `(width, height)`.
|
||||||
|
///
|
||||||
|
/// The same neutral, as-captured render [`SegmentationJob::neutral_proxy`]
|
||||||
|
/// uses — a refine pass must read the same kind of pixels the first pass
|
||||||
|
/// did, or a subject would gain or lose an edge depending on which pass
|
||||||
|
/// found it. `view` is framing's ephemeral viewport (`Framing::set_view`),
|
||||||
|
/// the mechanism the on-screen zoom already uses to render a region at
|
||||||
|
/// more than proxy resolution — not the crop tool's own persisted
|
||||||
|
/// rectangle, and nothing here touches that.
|
||||||
|
fn render_view(
|
||||||
|
&self,
|
||||||
|
view: CropRect,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<(Vec<f32>, usize, usize), String> {
|
||||||
|
let mut neutral = EditGraph::default_chain();
|
||||||
|
neutral.framing_mut().set_view(view);
|
||||||
|
|
||||||
|
let mut pass = AdjustPass::new(&self.ctx);
|
||||||
|
pass.render(&self.source, &neutral.compose(), width, height)
|
||||||
|
.map_err(|e| format!("could not render the refine crop: {e}"))?;
|
||||||
|
let (rgba, pw, ph) = pass
|
||||||
|
.export_pixels()
|
||||||
|
.map_err(|e| format!("could not read the refine crop: {e}"))?;
|
||||||
|
|
||||||
|
let rgb = rgba
|
||||||
|
.chunks_exact(4)
|
||||||
|
.flat_map(|p| {
|
||||||
|
[
|
||||||
|
p[0] as f32 / 255.0,
|
||||||
|
p[1] as f32 / 255.0,
|
||||||
|
p[2] as f32 / 255.0,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok((rgb, pw as usize, ph as usize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a finished [`RefineJob`] hands back: a replacement for one instance
|
||||||
|
/// in the segmentation it was run against.
|
||||||
|
pub struct RefinedInstance {
|
||||||
|
index: usize,
|
||||||
|
summary: segmentation::InstanceSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn centre_distance(instance: &dr_segment::Instance, cx: f32, cy: f32) -> f32 {
|
||||||
|
let (x0, y0, x1, y1) = instance.bbox;
|
||||||
|
let (ix, iy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
|
||||||
|
((ix - cx).powi(2) + (iy - cy).powi(2)).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample a crop-local mask back onto its footprint in the shared proxy grid.
|
||||||
|
///
|
||||||
|
/// Bilinear, the same as every other resampling in this mask pipeline
|
||||||
|
/// (`dr_segment::semantic`'s own prototype sampling, the letterbox that feeds
|
||||||
|
/// it) — correct whether the crop was rendered denser than the proxy (the
|
||||||
|
/// common case this feature exists for) or coarser than it (a subject large
|
||||||
|
/// enough that refining it buys little, which still renders a sensible if
|
||||||
|
/// unremarkable answer rather than a distorted one).
|
||||||
|
fn paste_into_proxy(
|
||||||
|
crop_mask: &[f32],
|
||||||
|
crop_w: usize,
|
||||||
|
crop_h: usize,
|
||||||
|
proxy: (usize, usize),
|
||||||
|
origin_px: (f32, f32),
|
||||||
|
extent_px: (f32, f32),
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let (pw, ph) = proxy;
|
||||||
|
let mut out = vec![0u8; pw * ph];
|
||||||
|
let (ew, eh) = extent_px;
|
||||||
|
if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (ox, oy) = origin_px;
|
||||||
|
let px0 = ox.floor().max(0.0) as usize;
|
||||||
|
let py0 = oy.floor().max(0.0) as usize;
|
||||||
|
let px1 = ((ox + ew).ceil() as usize).min(pw);
|
||||||
|
let py1 = ((oy + eh).ceil() as usize).min(ph);
|
||||||
|
|
||||||
|
for py in py0..py1 {
|
||||||
|
let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32;
|
||||||
|
if gy < 0.0 || gy >= crop_h as f32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for px in px0..px1 {
|
||||||
|
let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32;
|
||||||
|
if gx < 0.0 || gx >= crop_w as f32 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy);
|
||||||
|
out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
|
||||||
|
let (fx0, fy0) = (x.floor(), y.floor());
|
||||||
|
let (fx, fy) = (x - fx0, y - fy0);
|
||||||
|
let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize;
|
||||||
|
let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize;
|
||||||
|
let x1 = (x0 + 1).min(w - 1);
|
||||||
|
let y1 = (y0 + 1).min(h - 1);
|
||||||
|
let at = |x: usize, y: usize| mask[y * w + x];
|
||||||
|
let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx;
|
||||||
|
let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx;
|
||||||
|
top * (1.0 - fy) + bot * fy
|
||||||
|
}
|
||||||
|
|
||||||
pub struct DevelopSession {
|
pub struct DevelopSession {
|
||||||
/// This session's name, for work that outlives the frame it started on.
|
/// This session's name, for work that outlives the frame it started on.
|
||||||
id: SessionId,
|
id: SessionId,
|
||||||
@@ -259,12 +522,17 @@ pub struct DevelopSession {
|
|||||||
/// has, so this deliberately does *not* include them: dragging those
|
/// has, so this deliberately does *not* include them: dragging those
|
||||||
/// sliders must not rebuild anything.
|
/// sliders must not rebuild anything.
|
||||||
subject_key: u64,
|
subject_key: u64,
|
||||||
/// Which layer the develop panel is editing, if any.
|
/// Which layers the develop panel is editing, if any.
|
||||||
///
|
///
|
||||||
/// This is what lets one panel serve both scopes: with a layer selected,
|
/// This is what lets one panel serve both scopes: with layers selected,
|
||||||
/// the sliders read and write *its* chain, and the photographer is
|
/// the sliders read and write *their* chains, and the photographer is
|
||||||
/// adjusting a region rather than the frame.
|
/// adjusting one or more regions rather than the frame.
|
||||||
active_mask: Option<String>,
|
///
|
||||||
|
/// A plain click replaces this outright; a modifier-click toggles one id
|
||||||
|
/// in or out, so several layers can be shaped by the same slider drag —
|
||||||
|
/// "make these three subjects a stop darker" is one gesture rather than
|
||||||
|
/// three. Order is insertion order and nothing reads it, only membership.
|
||||||
|
active_masks: Vec<String>,
|
||||||
/// Whether to draw the false-coloured region overlay.
|
/// Whether to draw the false-coloured region overlay.
|
||||||
show_overlay: bool,
|
show_overlay: bool,
|
||||||
/// Which attribute the panel is filtered to, or all of them.
|
/// Which attribute the panel is filtered to, or all of them.
|
||||||
@@ -350,7 +618,7 @@ impl DevelopSession {
|
|||||||
masks: None,
|
masks: None,
|
||||||
subjects: None,
|
subjects: None,
|
||||||
subject_key: 0,
|
subject_key: 0,
|
||||||
active_mask: None,
|
active_masks: Vec::new(),
|
||||||
show_overlay: false,
|
show_overlay: false,
|
||||||
active_tab: None,
|
active_tab: None,
|
||||||
curve_channel: 0,
|
curve_channel: 0,
|
||||||
@@ -430,14 +698,27 @@ impl DevelopSession {
|
|||||||
.and_then(|i| self.tabs().get(i).map(|(a, _)| *a));
|
.and_then(|i| self.tabs().get(i).map(|(a, _)| *a));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The selection's representative layer, for anything that can only show
|
||||||
|
/// one answer — which tab is open, what value a slider currently reads.
|
||||||
|
///
|
||||||
|
/// The first id selected, not "the" active layer: with more than one
|
||||||
|
/// selected there is no single truth to show, and the panel has to pick
|
||||||
|
/// something. Whichever layer this is, [`Self::set_param`] and
|
||||||
|
/// [`Self::reset_op`] still write to every selected layer, not just this
|
||||||
|
/// one — a slider shows one number and applies it everywhere selected.
|
||||||
fn active_layer(&self) -> Option<&MaskLayer> {
|
fn active_layer(&self) -> Option<&MaskLayer> {
|
||||||
let id = self.active_mask.as_ref()?;
|
let id = self.active_masks.first()?;
|
||||||
self.graph.masks().get(id)
|
self.graph.masks().get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn active_layer_mut(&mut self) -> Option<&mut MaskLayer> {
|
/// Every selected layer, mutably — what a batched slider or reset walks.
|
||||||
let id = self.active_mask.clone()?;
|
fn active_layers_mut(&mut self) -> impl Iterator<Item = &mut MaskLayer> {
|
||||||
self.graph.masks_mut().get_mut(&id)
|
let selected = self.active_masks.clone();
|
||||||
|
self.graph
|
||||||
|
.masks_mut()
|
||||||
|
.layers_mut()
|
||||||
|
.iter_mut()
|
||||||
|
.filter(move |l| selected.contains(&l.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -962,11 +1243,11 @@ impl DevelopSession {
|
|||||||
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if self.active_mask.is_some() {
|
if !self.active_masks.is_empty() {
|
||||||
let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect();
|
let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect();
|
||||||
let id = cap.id.0;
|
let id = cap.id.0;
|
||||||
if let Some(layer) = self.active_layer_mut() {
|
for layer in self.active_layers_mut() {
|
||||||
for (param, default) in params {
|
for &(param, default) in ¶ms {
|
||||||
layer.set_param(id, param, default);
|
layer.set_param(id, param, default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -999,12 +1280,17 @@ impl DevelopSession {
|
|||||||
log::warn!("control at ({op_index}, {param_index}) has no parameter");
|
log::warn!("control at ({op_index}, {param_index}) has no parameter");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if self.active_mask.is_some() {
|
if !self.active_masks.is_empty() {
|
||||||
if let Some(layer) = self.active_layer_mut() {
|
// Every selected layer is set to the same absolute value the
|
||||||
|
// slider now shows, not offset by however far each one already
|
||||||
|
// was from it — the slider has one position, and "apply this
|
||||||
|
// reading to all of them" is the reading a photographer gets
|
||||||
|
// from watching it move.
|
||||||
|
for layer in self.active_layers_mut() {
|
||||||
layer.set_param(op.0, param, value);
|
layer.set_param(op.0, param, value);
|
||||||
}
|
}
|
||||||
// Coalesced the same way a global drag is: a slider dragged across
|
// Coalesced the same way a global drag is: a slider dragged across
|
||||||
// a masked layer is still one gesture and must undo as one.
|
// masked layers is still one gesture and must undo as one.
|
||||||
let edit = Edit::for_param(&self.graph, op, param);
|
let edit = Edit::for_param(&self.graph, op, param);
|
||||||
self.history.record(&self.graph, edit);
|
self.history.record(&self.graph, edit);
|
||||||
return;
|
return;
|
||||||
@@ -1332,6 +1618,55 @@ impl DevelopSession {
|
|||||||
self.subject_key = 0;
|
self.subject_key = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Everything a refine pass needs for one already-detected instance, so
|
||||||
|
/// it can be run somewhere else — see [`Self::segmentation_job`] for why
|
||||||
|
/// the split exists.
|
||||||
|
///
|
||||||
|
/// `None` where there is nothing to refine: no segmentation yet, or an
|
||||||
|
/// index the panel offered a button for a moment ago but the segmentation
|
||||||
|
/// underneath has since changed.
|
||||||
|
pub fn refine_job(&self, index: usize) -> Option<RefineJob> {
|
||||||
|
let seg = self.segmentation.as_ref()?;
|
||||||
|
let instance = seg.instances().get(index)?;
|
||||||
|
Some(RefineJob {
|
||||||
|
ctx: self.ctx.clone(),
|
||||||
|
source: self.demosaiced.clone(),
|
||||||
|
session: self.id,
|
||||||
|
abandon: Abandon::default(),
|
||||||
|
index,
|
||||||
|
class_name: instance.class_name.clone(),
|
||||||
|
bbox: instance.bbox,
|
||||||
|
proxy: seg.proxy_size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take on a refined instance found elsewhere.
|
||||||
|
///
|
||||||
|
/// Same caution as [`Self::adopt_segmentation`]: the caller checks the
|
||||||
|
/// job's session before handing back its answer, not this. Every mask
|
||||||
|
/// layer pointing at this instance's index reads it fresh next redraw —
|
||||||
|
/// `subject_key` is reset because the pixels changed under an index
|
||||||
|
/// `subject_signature` has no way to know changed, unlike a morphology
|
||||||
|
/// slider it does track.
|
||||||
|
pub fn adopt_refined(&mut self, refined: RefinedInstance) {
|
||||||
|
if let Some(seg) = self.segmentation.as_mut() {
|
||||||
|
seg.replace_instance(refined.index, refined.summary);
|
||||||
|
}
|
||||||
|
self.subjects = None;
|
||||||
|
self.subject_key = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mask layer's original detection index, for the refine button —
|
||||||
|
/// `None` for a layer that is not a subject at all (a gradient has no
|
||||||
|
/// instance to re-detect).
|
||||||
|
pub fn subject_instance_index(&self, id: &str) -> Option<usize> {
|
||||||
|
match &self.graph.masks().get(id)?.source {
|
||||||
|
MaskSource::Subject { index, .. } => Some(*index as usize),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the subjects and take them on, blocking until both are done.
|
/// Find the subjects and take them on, blocking until both are done.
|
||||||
///
|
///
|
||||||
/// Test-only, and deliberately: a session-shaped blocking call is exactly
|
/// Test-only, and deliberately: a session-shaped blocking call is exactly
|
||||||
@@ -1450,7 +1785,7 @@ impl DevelopSession {
|
|||||||
l.id.clone(),
|
l.id.clone(),
|
||||||
l.display_name().to_string(),
|
l.display_name().to_string(),
|
||||||
l.enabled,
|
l.enabled,
|
||||||
Some(&l.id) == self.active_mask.as_ref(),
|
self.active_masks.iter().any(|a| a == &l.id),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -1458,10 +1793,7 @@ impl DevelopSession {
|
|||||||
|
|
||||||
/// What kind of mask a layer is — "regions", "linear", "radial".
|
/// What kind of mask a layer is — "regions", "linear", "radial".
|
||||||
pub fn mask_kind(&self, id: &str) -> &'static str {
|
pub fn mask_kind(&self, id: &str) -> &'static str {
|
||||||
self.graph
|
self.graph.masks().get(id).map_or("", |l| l.source.kind())
|
||||||
.masks()
|
|
||||||
.get(id)
|
|
||||||
.map_or("", |l| l.source.kind())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mask_inverted(&self, id: &str) -> bool {
|
pub fn mask_inverted(&self, id: &str) -> bool {
|
||||||
@@ -1485,15 +1817,25 @@ impl DevelopSession {
|
|||||||
.is_some_and(|l| l.active_ops().next().is_some())
|
.is_some_and(|l| l.active_ops().next().is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The panel's representative selection — see [`Self::active_layer`] for
|
||||||
|
/// what "representative" means once more than one layer is selected.
|
||||||
pub fn active_mask(&self) -> Option<&str> {
|
pub fn active_mask(&self) -> Option<&str> {
|
||||||
self.active_mask.as_deref()
|
self.active_masks.first().map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every selected layer's id, in selection order.
|
||||||
|
pub fn active_masks(&self) -> &[String] {
|
||||||
|
&self.active_masks
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3 | FR-UI-3
|
/// TRACES: FR-DEV-3 | FR-UI-3
|
||||||
/// The selected gradient's handles, in fractions of the shown image.
|
/// The selected gradient's handles, in fractions of the shown image.
|
||||||
///
|
///
|
||||||
/// Empty unless a gradient layer is selected, which is what makes this the
|
/// Empty unless **exactly one** gradient layer is selected. Dragging a
|
||||||
/// panel's whole test for "is there anything to draw on the canvas".
|
/// shared handle for several gradients at once has no single geometry to
|
||||||
|
/// move — each one's centre, angle and extent differ — so multi-select
|
||||||
|
/// simply offers no handles rather than moving one layer's shape while
|
||||||
|
/// silently leaving the others behind.
|
||||||
///
|
///
|
||||||
/// Recomputed on every redraw rather than cached, because the answer
|
/// Recomputed on every redraw rather than cached, because the answer
|
||||||
/// changes with the *view* and not only with the mask: a pan moves every
|
/// changes with the *view* and not only with the mask: a pan moves every
|
||||||
@@ -1501,6 +1843,9 @@ impl DevelopSession {
|
|||||||
/// not work worth caching, and a cache keyed on the wrong thing is how a
|
/// not work worth caching, and a cache keyed on the wrong thing is how a
|
||||||
/// handle comes to sit where the mask used to be.
|
/// handle comes to sit where the mask used to be.
|
||||||
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
|
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
|
||||||
|
if self.active_masks.len() != 1 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
let Some(layer) = self.active_layer() else {
|
let Some(layer) = self.active_layer() else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
@@ -1522,9 +1867,12 @@ impl DevelopSession {
|
|||||||
press: (f32, f32),
|
press: (f32, f32),
|
||||||
now: (f32, f32),
|
now: (f32, f32),
|
||||||
) -> Option<MaskSource> {
|
) -> Option<MaskSource> {
|
||||||
|
if self.active_masks.len() != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let (sw, sh) = self.demosaiced.size();
|
let (sw, sh) = self.demosaiced.size();
|
||||||
let framing = *self.graph.framing();
|
let framing = *self.graph.framing();
|
||||||
let id = self.active_mask.clone()?;
|
let id = self.active_masks.first()?.clone();
|
||||||
let start = match origin {
|
let start = match origin {
|
||||||
Some(s) => s.clone(),
|
Some(s) => s.clone(),
|
||||||
None => self.graph.masks().get(&id)?.source.clone(),
|
None => self.graph.masks().get(&id)?.source.clone(),
|
||||||
@@ -1554,12 +1902,33 @@ impl DevelopSession {
|
|||||||
self.active_layer().map(|l| l.source.clone())
|
self.active_layer().map(|l| l.source.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Select a layer for editing, or `None` to return the panel to the
|
/// Select exactly one layer for editing, or `None` to return the panel to
|
||||||
/// global chain.
|
/// the global chain. Replaces whatever was selected before, including a
|
||||||
|
/// multi-selection — the ordinary, unmodified click.
|
||||||
pub fn set_active_mask(&mut self, id: Option<&str>) {
|
pub fn set_active_mask(&mut self, id: Option<&str>) {
|
||||||
self.active_mask = id
|
self.active_masks = id
|
||||||
.filter(|id| self.graph.masks().get(id).is_some())
|
.filter(|id| self.graph.masks().get(id).is_some())
|
||||||
.map(|id| id.to_string());
|
.map(|id| vec![id.to_string()])
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or remove one layer from the selection, keeping the rest — the
|
||||||
|
/// modifier-click that builds a multi-selection.
|
||||||
|
///
|
||||||
|
/// A layer id the graph no longer has is dropped rather than toggled in:
|
||||||
|
/// the row that offered it is stale by the time the click lands, and
|
||||||
|
/// selecting a ghost would make every subsequent batched edit silently
|
||||||
|
/// skip it (`active_layers_mut` filters by membership, not existence).
|
||||||
|
pub fn toggle_active_mask(&mut self, id: &str) {
|
||||||
|
if self.graph.masks().get(id).is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match self.active_masks.iter().position(|a| a == id) {
|
||||||
|
Some(i) => {
|
||||||
|
self.active_masks.remove(i);
|
||||||
|
}
|
||||||
|
None => self.active_masks.push(id.to_string()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mask the object under a normalised image point.
|
/// Mask the object under a normalised image point.
|
||||||
@@ -1603,7 +1972,7 @@ impl DevelopSession {
|
|||||||
if !self.graph.masks_mut().push(layer) {
|
if !self.graph.masks_mut().push(layer) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.active_mask = Some(id.clone());
|
self.active_masks = vec![id.clone()];
|
||||||
self.history.record(&self.graph, Edit::Discrete);
|
self.history.record(&self.graph, Edit::Discrete);
|
||||||
Some(id)
|
Some(id)
|
||||||
}
|
}
|
||||||
@@ -1625,19 +1994,21 @@ impl DevelopSession {
|
|||||||
width: 0.3,
|
width: 0.3,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !self.graph.masks_mut().push(MaskLayer::new(id.clone(), source)) {
|
if !self
|
||||||
|
.graph
|
||||||
|
.masks_mut()
|
||||||
|
.push(MaskLayer::new(id.clone(), source))
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.active_mask = Some(id.clone());
|
self.active_masks = vec![id.clone()];
|
||||||
self.history.record(&self.graph, Edit::Discrete);
|
self.history.record(&self.graph, Edit::Discrete);
|
||||||
Some(id)
|
Some(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_mask(&mut self, id: &str) {
|
pub fn remove_mask(&mut self, id: &str) {
|
||||||
if self.graph.masks_mut().remove(id).is_some() {
|
if self.graph.masks_mut().remove(id).is_some() {
|
||||||
if self.active_mask.as_deref() == Some(id) {
|
self.active_masks.retain(|a| a != id);
|
||||||
self.active_mask = None;
|
|
||||||
}
|
|
||||||
self.history.record(&self.graph, Edit::Discrete);
|
self.history.record(&self.graph, Edit::Discrete);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1660,7 +2031,8 @@ impl DevelopSession {
|
|||||||
pub fn set_mask_feather(&mut self, id: &str, feather: f32) {
|
pub fn set_mask_feather(&mut self, id: &str, feather: f32) {
|
||||||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||||||
layer.feather = feather.clamp(0.0, 1.0);
|
layer.feather = feather.clamp(0.0, 1.0);
|
||||||
self.history.record(&self.graph, Edit::Op(OpId("mask-feather")));
|
self.history
|
||||||
|
.record(&self.graph, Edit::Op(OpId("mask-feather")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1694,7 +2066,8 @@ impl DevelopSession {
|
|||||||
pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) {
|
pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) {
|
||||||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||||||
layer.morph_radius = radius.clamp(0.0, 1.0);
|
layer.morph_radius = radius.clamp(0.0, 1.0);
|
||||||
self.history.record(&self.graph, Edit::Op(OpId("mask-morph")));
|
self.history
|
||||||
|
.record(&self.graph, Edit::Op(OpId("mask-morph")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1702,7 +2075,10 @@ impl DevelopSession {
|
|||||||
pub fn mask_falloff(&self, id: &str) -> usize {
|
pub fn mask_falloff(&self, id: &str) -> usize {
|
||||||
use dr_pipeline::mask::Falloff;
|
use dr_pipeline::mask::Falloff;
|
||||||
self.graph.masks().get(id).map_or(0, |l| {
|
self.graph.masks().get(id).map_or(0, |l| {
|
||||||
Falloff::ALL.iter().position(|&f| f == l.falloff).unwrap_or(0)
|
Falloff::ALL
|
||||||
|
.iter()
|
||||||
|
.position(|&f| f == l.falloff)
|
||||||
|
.unwrap_or(0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1732,7 +2108,10 @@ impl DevelopSession {
|
|||||||
pub fn mask_is_shapeable(&self, id: &str) -> bool {
|
pub fn mask_is_shapeable(&self, id: &str) -> bool {
|
||||||
use dr_pipeline::mask::MaskSource;
|
use dr_pipeline::mask::MaskSource;
|
||||||
self.graph.masks().get(id).is_some_and(|l| {
|
self.graph.masks().get(id).is_some_and(|l| {
|
||||||
matches!(l.source, MaskSource::Subject { .. } | MaskSource::Regions { .. })
|
matches!(
|
||||||
|
l.source,
|
||||||
|
MaskSource::Subject { .. } | MaskSource::Regions { .. }
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1742,7 +2121,8 @@ impl DevelopSession {
|
|||||||
// `Op` rather than `Discrete`: opacity is dragged, and a drag is
|
// `Op` rather than `Discrete`: opacity is dragged, and a drag is
|
||||||
// one decision however many values it passes through. `Discrete`
|
// one decision however many values it passes through. `Discrete`
|
||||||
// would put every intermediate position on the undo stack.
|
// would put every intermediate position on the undo stack.
|
||||||
self.history.record(&self.graph, Edit::Op(OpId("mask-opacity")));
|
self.history
|
||||||
|
.record(&self.graph, Edit::Op(OpId("mask-opacity")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2217,6 +2597,17 @@ impl DevelopSession {
|
|||||||
/// Captured at full scope — framing included — because the decision about
|
/// Captured at full scope — framing included — because the decision about
|
||||||
/// what travels is made when the preset is *applied*. Copying, then
|
/// what travels is made when the preset is *applied*. Copying, then
|
||||||
/// changing one's mind about the crop, must not mean copying again.
|
/// changing one's mind about the crop, must not mean copying again.
|
||||||
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||||||
|
/// The local adjustment stack, for writing this image's edit back.
|
||||||
|
///
|
||||||
|
/// Beside `copy_settings` rather than part of it: that returns a `Preset`,
|
||||||
|
/// which travels *between* photographs, and a mask must not — it is drawn
|
||||||
|
/// against one frame and describes nothing on another. The save path takes
|
||||||
|
/// both; the paste path takes only the preset.
|
||||||
|
pub fn masks(&self) -> &dr_pipeline::mask::MaskStack {
|
||||||
|
self.graph.masks()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn copy_settings(&self) -> Preset {
|
pub fn copy_settings(&self) -> Preset {
|
||||||
Preset::capture(&self.graph)
|
Preset::capture(&self.graph)
|
||||||
}
|
}
|
||||||
@@ -2510,8 +2901,7 @@ mod tests {
|
|||||||
fn a_job_carries_the_session_it_was_taken_from() {
|
fn a_job_carries_the_session_it_was_taken_from() {
|
||||||
let Some(ctx) = headless() else { return };
|
let Some(ctx) = headless() else { return };
|
||||||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||||
let session =
|
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||||||
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
|
||||||
.expect("session");
|
.expect("session");
|
||||||
assert_eq!(session.segmentation_job().session(), session.id());
|
assert_eq!(session.segmentation_job().session(), session.id());
|
||||||
}
|
}
|
||||||
@@ -2523,8 +2913,7 @@ mod tests {
|
|||||||
fn an_abandoned_job_does_no_work() {
|
fn an_abandoned_job_does_no_work() {
|
||||||
let Some(ctx) = headless() else { return };
|
let Some(ctx) = headless() else { return };
|
||||||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||||
let session =
|
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||||||
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
|
||||||
.expect("session");
|
.expect("session");
|
||||||
|
|
||||||
let job = session.segmentation_job();
|
let job = session.segmentation_job();
|
||||||
@@ -2646,6 +3035,100 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Multi-select: one slider, applied to every selected layer.
|
||||||
|
///
|
||||||
|
/// `toggle_active_mask` builds the selection a control-click makes, and
|
||||||
|
/// `set_param`/`reset_op` are what a drag and a reset call — this pins
|
||||||
|
/// down that both fan out to every layer in it rather than only the
|
||||||
|
/// first, which is the whole point of selecting more than one.
|
||||||
|
#[test]
|
||||||
|
fn a_slider_moved_with_two_layers_selected_moves_both() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||||
|
let mut session =
|
||||||
|
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
|
||||||
|
let a = session.add_gradient_mask(true).expect("first gradient");
|
||||||
|
let b = session.add_gradient_mask(false).expect("second gradient");
|
||||||
|
// Adding `b` selected it alone — build the multi-selection a
|
||||||
|
// control-click would, starting from that single-layer state.
|
||||||
|
session.toggle_active_mask(&a);
|
||||||
|
assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
|
||||||
|
"neither layer has been touched yet"
|
||||||
|
);
|
||||||
|
|
||||||
|
let row = session.rows()[0].clone();
|
||||||
|
session.set_param(row.op_index, row.param_index, row.maximum);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b),
|
||||||
|
"one slider, both layers selected, both layers must show the edit"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a reset walks the same set.
|
||||||
|
session.reset_op(row.op_index);
|
||||||
|
assert!(
|
||||||
|
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
|
||||||
|
"resetting with both selected must clear both, not just the one \
|
||||||
|
the panel happens to read values from"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// A refine crop's mask, pasted back onto the proxy grid it replaces,
|
||||||
|
/// must land exactly where the crop was — not shifted by the origin, not
|
||||||
|
/// scaled onto the wrong footprint.
|
||||||
|
#[test]
|
||||||
|
fn a_refined_mask_pastes_back_at_the_crops_own_position() {
|
||||||
|
// A crop entirely on (1.0), covering proxy pixels 2..6 in x and
|
||||||
|
// 2..6 in y of an 8x8 proxy — everywhere inside that box must read
|
||||||
|
// back as fully covered, everywhere outside as untouched.
|
||||||
|
let crop = vec![1.0f32; 4 * 4];
|
||||||
|
let mask = paste_into_proxy(&crop, 4, 4, (8, 8), (2.0, 2.0), (4.0, 4.0));
|
||||||
|
|
||||||
|
assert_eq!(mask[2 * 8 + 2], 255, "top-left corner of the box");
|
||||||
|
assert_eq!(mask[5 * 8 + 5], 255, "bottom-right corner of the box");
|
||||||
|
assert_eq!(mask[0], 0, "outside the box, untouched");
|
||||||
|
assert_eq!(mask[7 * 8 + 7], 0, "outside the box, untouched");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bilinear_sample_averages_its_four_neighbours() {
|
||||||
|
// Two rows, black then white: the exact midpoint reads as grey.
|
||||||
|
let mask = [0.0f32, 0.0, 1.0, 1.0];
|
||||||
|
let v = bilinear_sample(&mask, 2, 2, 0.5, 0.5);
|
||||||
|
assert!(
|
||||||
|
(v - 0.5).abs() < 1e-6,
|
||||||
|
"expected the midpoint grey, got {v}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A control-click twice — once to add, once to remove — is a no-op on
|
||||||
|
/// the selection, which is the sanity check for `toggle_active_mask`
|
||||||
|
/// itself before trusting anything built on it.
|
||||||
|
#[test]
|
||||||
|
fn toggling_a_layer_twice_returns_to_the_starting_selection() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||||
|
let mut session =
|
||||||
|
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
|
||||||
|
let a = session.add_gradient_mask(true).expect("gradient");
|
||||||
|
assert_eq!(session.active_masks(), [a.clone()].as_slice());
|
||||||
|
|
||||||
|
session.toggle_active_mask(&a);
|
||||||
|
assert!(session.active_masks().is_empty(), "removed by the toggle");
|
||||||
|
|
||||||
|
session.toggle_active_mask(&a);
|
||||||
|
assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back");
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3
|
/// TRACES: FR-DEV-3
|
||||||
/// The mask array and the segmentation proxy are the same size on purpose.
|
/// The mask array and the segmentation proxy are the same size on purpose.
|
||||||
///
|
///
|
||||||
@@ -3993,7 +4476,10 @@ mod tests {
|
|||||||
let narrowed = session.rows().len();
|
let narrowed = session.rows().len();
|
||||||
|
|
||||||
assert!(narrowed > 0, "a tab must show something");
|
assert!(narrowed > 0, "a tab must show something");
|
||||||
assert!(narrowed < all, "and less than everything: {narrowed} of {all}");
|
assert!(
|
||||||
|
narrowed < all,
|
||||||
|
"and less than everything: {narrowed} of {all}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trap: `op_index` counts over *every* capability, so a row that
|
/// The trap: `op_index` counts over *every* capability, so a row that
|
||||||
@@ -4054,7 +4540,6 @@ mod tests {
|
|||||||
session.set_active_tab(99);
|
session.set_active_tab(99);
|
||||||
assert_eq!(session.rows().len(), all);
|
assert_eq!(session.rows().len(), all);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `dr_pipeline`'s morphology, as `dr_segment` names it.
|
/// `dr_pipeline`'s morphology, as `dr_segment` names it.
|
||||||
|
|||||||
+215
-9
@@ -19,6 +19,26 @@
|
|||||||
//! user sees "1,847 photographs, 61.2 GB" and then a progress bar, rather than
|
//! user sees "1,847 photographs, 61.2 GB" and then a progress bar, rather than
|
||||||
//! a frozen window and then a progress bar.
|
//! a frozen window and then a progress bar.
|
||||||
//!
|
//!
|
||||||
|
//! # There is no local library, so there is no destination to choose
|
||||||
|
//!
|
||||||
|
//! DarkRoom's library is a folder on a Nextcloud server (FR-NC-6). An import
|
||||||
|
//! therefore has exactly one place to go, and the interface has nothing to ask
|
||||||
|
//! about it.
|
||||||
|
//!
|
||||||
|
//! What lands on this machine is a **staging copy**, in a directory the app
|
||||||
|
//! owns beside the catalog — the same shape `export` uses for its outbox, and
|
||||||
|
//! for the same reason its module docs give: staging first is the only path,
|
||||||
|
//! not a fallback for the offline case.
|
||||||
|
//!
|
||||||
|
//! The bytes have to touch disk before they touch the network. Streaming a
|
||||||
|
//! card straight to the server would mean a move-import erasing a card against
|
||||||
|
//! an in-flight upload, which is the one operation here with no undo, and it
|
||||||
|
//! would make importing impossible with no connection, which FR-NC-10 forbids.
|
||||||
|
//!
|
||||||
|
//! A staged file is removed once the server confirms it. One that is not
|
||||||
|
//! confirmed stays where it is, and the next import drains it — so an import
|
||||||
|
//! made on a train finishes when the train arrives somewhere.
|
||||||
|
//!
|
||||||
//! # The import does not catalogue what it wrote
|
//! # The import does not catalogue what it wrote
|
||||||
//!
|
//!
|
||||||
//! It writes files into the library and then asks for a scan, rather than
|
//! It writes files into the library and then asks for a scan, rather than
|
||||||
@@ -33,7 +53,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::mpsc::{Receiver, Sender};
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot};
|
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode};
|
||||||
use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage};
|
use dr_plat::{DirRef, LocalStorage, Storage, WritableStorage};
|
||||||
use dr_sync::{RemoteBackend, RemotePath};
|
use dr_sync::{RemoteBackend, RemotePath};
|
||||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||||
@@ -56,8 +76,9 @@ pub type Cancel = Arc<AtomicBool>;
|
|||||||
pub struct Request {
|
pub struct Request {
|
||||||
/// Where the card is mounted.
|
/// Where the card is mounted.
|
||||||
pub card: PathBuf,
|
pub card: PathBuf,
|
||||||
/// The library root the template's folders are created under.
|
/// Where the copies are staged before they go up. Owned by the app, not
|
||||||
pub library: PathBuf,
|
/// chosen by the user — see the module docs.
|
||||||
|
pub staging: PathBuf,
|
||||||
/// The optional second destination (FR-CAT-10).
|
/// The optional second destination (FR-CAT-10).
|
||||||
pub backup: Option<PathBuf>,
|
pub backup: Option<PathBuf>,
|
||||||
/// The catalog, opened by the worker for its duplicate queries.
|
/// The catalog, opened by the worker for its duplicate queries.
|
||||||
@@ -68,8 +89,9 @@ pub struct Request {
|
|||||||
/// Which file types to take off the card.
|
/// Which file types to take off the card.
|
||||||
pub filter: FormatFilter,
|
pub filter: FormatFilter,
|
||||||
pub options: Options,
|
pub options: Options,
|
||||||
/// Where to send the originals afterwards, if anywhere (FR-NC-7b).
|
/// Where the photographs are going. An import without one has nowhere to
|
||||||
pub upload: Option<Upload>,
|
/// put anything, and the page refuses to start.
|
||||||
|
pub upload: Upload,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-NC-7a | FR-NC-7b
|
/// TRACES: FR-NC-7a | FR-NC-7b
|
||||||
@@ -89,6 +111,22 @@ pub struct Upload {
|
|||||||
/// Where the thumbnail shards live, so a thumbnail made during the import
|
/// Where the thumbnail shards live, so a thumbnail made during the import
|
||||||
/// can be filed under the server's `oc:fileid` once the upload assigns one.
|
/// can be filed under the server's `oc:fileid` once the upload assigns one.
|
||||||
pub thumbs: PathBuf,
|
pub thumbs: PathBuf,
|
||||||
|
/// Where copies wait between disk and the server.
|
||||||
|
///
|
||||||
|
/// Per account, beside the catalog, like the export outbox: a file queued
|
||||||
|
/// for one server is meaningless to another, and a staging directory
|
||||||
|
/// shared between accounts would upload a card to whichever was signed in
|
||||||
|
/// when the connection came back.
|
||||||
|
pub staging: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-7b
|
||||||
|
/// Where an account's imports wait between disk and the server.
|
||||||
|
pub fn staging_dir(server: &str, user_id: &str) -> PathBuf {
|
||||||
|
crate::library::catalog_path(server, user_id)
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.join("staging"))
|
||||||
|
.unwrap_or_else(|| std::env::temp_dir().join("darkroom-import-staging"))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for Upload {
|
impl std::fmt::Debug for Upload {
|
||||||
@@ -98,6 +136,7 @@ impl std::fmt::Debug for Upload {
|
|||||||
.field("user_id", &self.user_id)
|
.field("user_id", &self.user_id)
|
||||||
.field("library", &self.library)
|
.field("library", &self.library)
|
||||||
.field("thumbs", &self.thumbs)
|
.field("thumbs", &self.thumbs)
|
||||||
|
.field("staging", &self.staging)
|
||||||
.finish_non_exhaustive()
|
.finish_non_exhaustive()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +205,18 @@ pub struct Outcome {
|
|||||||
/// and the card has not been touched. Reported so the user knows the
|
/// and the card has not been touched. Reported so the user knows the
|
||||||
/// server does not have them yet.
|
/// server does not have them yet.
|
||||||
pub upload_failed: usize,
|
pub upload_failed: usize,
|
||||||
|
/// Card files actually deleted, for a move-import.
|
||||||
|
///
|
||||||
|
/// Never more than `retirable`, and zero for a copy-import. Reported
|
||||||
|
/// because "the card is now empty" is a claim the user needs to be able to
|
||||||
|
/// check against what they expected.
|
||||||
|
pub retired: usize,
|
||||||
|
/// Staged copies still waiting to go up.
|
||||||
|
///
|
||||||
|
/// The same files as `upload_failed`, named for what happens next rather
|
||||||
|
/// than for what went wrong: they are queued, and the next import sends
|
||||||
|
/// them. An import made with no connection is this and nothing else.
|
||||||
|
pub staged: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start an import.
|
/// Start an import.
|
||||||
@@ -188,7 +239,16 @@ pub fn spawn(request: Request, cancel: Cancel) -> Receiver<Message> {
|
|||||||
|
|
||||||
fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), String> {
|
fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), String> {
|
||||||
let card = LocalStorage::with_root(CARD, request.card.clone());
|
let card = LocalStorage::with_root(CARD, request.card.clone());
|
||||||
let library = LocalStorage::with_root(LIBRARY, request.library.clone());
|
// Created rather than assumed: the first import on a machine finds no
|
||||||
|
// staging directory, and a failure here is worth reporting as itself
|
||||||
|
// rather than as every file failing to be written.
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&request.staging) {
|
||||||
|
return Err(format!(
|
||||||
|
"could not prepare {}: {e}",
|
||||||
|
request.staging.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let library = LocalStorage::with_root(LIBRARY, request.staging.clone());
|
||||||
let backup = request
|
let backup = request
|
||||||
.backup
|
.backup
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -242,7 +302,8 @@ fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), St
|
|||||||
// FR-NC-7b. Everything above has already finished: every file is on disk
|
// FR-NC-7b. Everything above has already finished: every file is on disk
|
||||||
// and its digest checked, so nothing from here on can cost the user a
|
// and its digest checked, so nothing from here on can cost the user a
|
||||||
// photograph — only a transfer.
|
// photograph — only a transfer.
|
||||||
if let Some(upload) = &request.upload {
|
{
|
||||||
|
let upload = &request.upload;
|
||||||
let (sent, thumbnails) = upload_all(upload, &library, &report.imported, cancel, tx);
|
let (sent, thumbnails) = upload_all(upload, &library, &report.imported, cancel, tx);
|
||||||
outcome.thumbnails = thumbnails;
|
outcome.thumbnails = thumbnails;
|
||||||
outcome.uploaded = sent.iter().filter(|s| s.transferred).count();
|
outcome.uploaded = sent.iter().filter(|s| s.transferred).count();
|
||||||
@@ -264,6 +325,36 @@ fn run(request: Request, cancel: &Cancel, tx: &Sender<Message>) -> Result<(), St
|
|||||||
// now. The metadata tier covers the interval, which is why this is not
|
// now. The metadata tier covers the interval, which is why this is not
|
||||||
// worth waiting for a sync to do properly.
|
// worth waiting for a sync to do properly.
|
||||||
record_digests(catalog.connection(), &upload.library, &sent);
|
record_digests(catalog.connection(), &upload.library, &sent);
|
||||||
|
|
||||||
|
// The staging copy has done its job the moment the server confirms it:
|
||||||
|
// the photograph is in the library, and keeping a second copy in a
|
||||||
|
// directory no view ever shows would be a slow disk leak. What is *not*
|
||||||
|
// confirmed stays exactly where it is — that is the queue FR-NC-7b
|
||||||
|
// describes, and the next import drains it.
|
||||||
|
outcome.staged = report.imported.len() - sent.len();
|
||||||
|
clear_staged(&library, &report.imported, &sent);
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-10 | FR-NC-7b
|
||||||
|
// The card, last of all, and only for what the server has.
|
||||||
|
//
|
||||||
|
// The rule this enforces: a copy-import never touches the card, and a
|
||||||
|
// move-import empties it only of photographs that are confirmed on the
|
||||||
|
// server — not merely written to disk here, because the staging copy
|
||||||
|
// above has just been removed and this machine is no longer holding
|
||||||
|
// them either. Anything the upload did not take keeps its card copy,
|
||||||
|
// which is then the only copy that exists.
|
||||||
|
//
|
||||||
|
// Done here rather than handed to the caller: `dr_ingest::retire` is
|
||||||
|
// deliberately awkward to reach, and a caller that forgot to call it
|
||||||
|
// was exactly the bug this replaces — Move behaved as Copy, silently.
|
||||||
|
if request.options.mode == TransferMode::Move {
|
||||||
|
let confirmed = confirmed_sources(&report.imported, &sent);
|
||||||
|
outcome.retirable = confirmed.len();
|
||||||
|
let failures = dr_ingest::retire(&card, &confirmed);
|
||||||
|
outcome.retired = confirmed.len() - failures.len();
|
||||||
|
} else {
|
||||||
|
outcome.retirable = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = tx.send(Message::Finished(outcome));
|
let _ = tx.send(Message::Finished(outcome));
|
||||||
@@ -478,6 +569,43 @@ async fn file_thumbnails(
|
|||||||
filed
|
filed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The card sources whose photographs the server now holds.
|
||||||
|
///
|
||||||
|
/// Paired positionally with `imported`, which is how `upload_all` builds
|
||||||
|
/// `sent`: one entry per original it got an answer about, in order. A file the
|
||||||
|
/// upload skipped or failed on has no entry, and so is never returned here.
|
||||||
|
fn confirmed_sources(imported: &[Imported], sent: &[Sent]) -> Vec<dr_types::SourceRef> {
|
||||||
|
imported
|
||||||
|
.iter()
|
||||||
|
.zip(sent.iter())
|
||||||
|
.map(|(image, _)| image.source.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-NC-7b
|
||||||
|
/// Remove the staging copies the server has confirmed.
|
||||||
|
///
|
||||||
|
/// Keyed on what came back from the upload rather than on what was imported:
|
||||||
|
/// a file the server did not take must survive, because the staging directory
|
||||||
|
/// is the only place it exists once the card is put away.
|
||||||
|
///
|
||||||
|
/// Failures are logged, not propagated. A staged file that cannot be deleted
|
||||||
|
/// costs disk; refusing to finish an import over it would cost the user the
|
||||||
|
/// report telling them their photographs are safe.
|
||||||
|
fn clear_staged(library: &LocalStorage, imported: &[Imported], sent: &[Sent]) {
|
||||||
|
let confirmed: std::collections::HashSet<&str> =
|
||||||
|
sent.iter().map(|s| s.remote_path.as_str()).collect();
|
||||||
|
|
||||||
|
for (image, item) in imported.iter().zip(sent.iter()) {
|
||||||
|
if !confirmed.contains(item.remote_path.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Err(e) = library.remove_file(&image.written) {
|
||||||
|
log::warn!("could not clear the staged copy of {}: {e}", image.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read an imported file back out of the library.
|
/// Read an imported file back out of the library.
|
||||||
fn read_all(library: &LocalStorage, image: &Imported) -> Result<Vec<u8>, String> {
|
fn read_all(library: &LocalStorage, image: &Imported) -> Result<Vec<u8>, String> {
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
@@ -640,6 +768,8 @@ pub fn summarise(report: &Report) -> Outcome {
|
|||||||
uploaded: 0,
|
uploaded: 0,
|
||||||
already_on_server: 0,
|
already_on_server: 0,
|
||||||
upload_failed: 0,
|
upload_failed: 0,
|
||||||
|
staged: 0,
|
||||||
|
retired: 0,
|
||||||
thumbnails: 0,
|
thumbnails: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -698,12 +828,18 @@ pub fn describe(outcome: &Outcome) -> String {
|
|||||||
}
|
}
|
||||||
if outcome.upload_failed > 0 {
|
if outcome.upload_failed > 0 {
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
", {} still only on this computer",
|
", {} waiting to go up — the next import sends them",
|
||||||
outcome.upload_failed
|
outcome.upload_failed
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The card. Said plainly, because emptying one is irreversible and the
|
||||||
|
// user should not have to infer it from a count of uploads.
|
||||||
|
if outcome.retired > 0 {
|
||||||
|
out.push_str(&format!(". {} removed from the card", outcome.retired));
|
||||||
|
}
|
||||||
|
|
||||||
// Worth saying: it is why the grid fills in immediately after an import
|
// Worth saying: it is why the grid fills in immediately after an import
|
||||||
// rather than fetching a preview out of every original that just went up.
|
// rather than fetching a preview out of every original that just went up.
|
||||||
if outcome.thumbnails > 0 {
|
if outcome.thumbnails > 0 {
|
||||||
@@ -938,7 +1074,77 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let text = describe(&o);
|
let text = describe(&o);
|
||||||
assert!(text.contains("2 uploaded"), "{text}");
|
assert!(text.contains("2 uploaded"), "{text}");
|
||||||
assert!(text.contains("1 still only on this computer"), "{text}");
|
// Named for what happens next rather than for what went wrong: the
|
||||||
|
// file is queued, not lost, and the next import sends it.
|
||||||
|
assert!(text.contains("1 waiting to go up"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn emptying_the_card_is_stated_plainly() {
|
||||||
|
// Irreversible, so the user should not have to infer it from a count
|
||||||
|
// of uploads.
|
||||||
|
let o = Outcome {
|
||||||
|
uploaded: 3,
|
||||||
|
retirable: 3,
|
||||||
|
retired: 3,
|
||||||
|
..outcome()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
describe(&o).contains("3 removed from the card"),
|
||||||
|
"{}",
|
||||||
|
describe(&o)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_copy_import_never_mentions_the_card() {
|
||||||
|
let o = Outcome {
|
||||||
|
uploaded: 3,
|
||||||
|
..outcome()
|
||||||
|
};
|
||||||
|
assert!(!describe(&o).contains("card"), "{}", describe(&o));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_confirmed_photographs_are_taken_off_the_card() {
|
||||||
|
// The rule: a card is emptied of what the *server* has, never of what
|
||||||
|
// merely reached this disk — the staging copy is removed straight
|
||||||
|
// after, so anything not uploaded would otherwise exist nowhere.
|
||||||
|
let imported: Vec<Imported> = (0..3)
|
||||||
|
.map(|i| Imported {
|
||||||
|
source: dr_types::SourceRef::Local {
|
||||||
|
root: CARD,
|
||||||
|
relative: format!("IMG_{i}.CR3"),
|
||||||
|
},
|
||||||
|
written: dr_types::SourceRef::Local {
|
||||||
|
root: LIBRARY,
|
||||||
|
relative: format!("2026/2026-08-22/IMG_{i}.CR3"),
|
||||||
|
},
|
||||||
|
folders: vec!["2026".into(), "2026-08-22".into()],
|
||||||
|
name: format!("IMG_{i}.CR3"),
|
||||||
|
digest: "x".into(),
|
||||||
|
size: 10,
|
||||||
|
dated_from: DateSource::Capture,
|
||||||
|
backup: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// The server took the first two; the third never went up.
|
||||||
|
let sent: Vec<Sent> = (0..2)
|
||||||
|
.map(|i| Sent {
|
||||||
|
remote_path: format!("PhotosRaw/2026/2026-08-22/IMG_{i}.CR3"),
|
||||||
|
digest: "x".into(),
|
||||||
|
transferred: true,
|
||||||
|
thumbnail: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let confirmed = confirmed_sources(&imported, &sent);
|
||||||
|
assert_eq!(confirmed.len(), 2);
|
||||||
|
assert_eq!(confirmed[0].key(), "IMG_0.CR3");
|
||||||
|
assert_eq!(confirmed[1].key(), "IMG_1.CR3");
|
||||||
|
// IMG_2 keeps its card copy, which is now the only copy of it.
|
||||||
|
assert!(!confirmed.iter().any(|s| s.key() == "IMG_2.CR3"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+44
-31
@@ -188,7 +188,9 @@ impl ImportController {
|
|||||||
fn can_start(&self) -> bool {
|
fn can_start(&self) -> bool {
|
||||||
!self.running.get()
|
!self.running.get()
|
||||||
&& !self.card.borrow().is_empty()
|
&& !self.card.borrow().is_empty()
|
||||||
&& !self.options().destination.is_empty()
|
// An account, because the library is on the server and an import
|
||||||
|
// with none has nowhere to go at all.
|
||||||
|
&& !self.upload_target.borrow().is_empty()
|
||||||
&& self.survey.borrow().map(|(n, _)| n > 0).unwrap_or(false)
|
&& self.survey.borrow().map(|(n, _)| n > 0).unwrap_or(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,17 +254,9 @@ pub fn render(window: &AppWindow, ctl: &Rc<ImportController>) {
|
|||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
window.set_import_destination(stored.destination.as_str().into());
|
// The only destination there is. Empty means no account, which is what
|
||||||
// Empty where no account is signed in, or where the user turned the upload
|
// keeps Import disabled — see `can_start`.
|
||||||
// off — the page then shows one destination because there is one.
|
window.set_import_upload_target(ctl.upload_target.borrow().as_str().into());
|
||||||
window.set_import_upload_target(
|
|
||||||
if stored.upload {
|
|
||||||
ctl.upload_target.borrow().clone()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
window.set_import_folder_template(stored.folder_template.as_str().into());
|
window.set_import_folder_template(stored.folder_template.as_str().into());
|
||||||
window.set_import_template_preview(preview(&stored, crate::library::now_secs()).into());
|
window.set_import_template_preview(preview(&stored, crate::library::now_secs()).into());
|
||||||
|
|
||||||
@@ -333,13 +327,6 @@ where
|
|||||||
// does: another instance may have written it since.
|
// does: another instance may have written it since.
|
||||||
*ctl.settings.settings.borrow_mut() = ctl.settings.store.load();
|
*ctl.settings.settings.borrow_mut() = ctl.settings.store.load();
|
||||||
|
|
||||||
// A destination the user has never chosen defaults to somewhere
|
|
||||||
// that exists, rather than to an empty field they must decode.
|
|
||||||
if ctl.options().destination.is_empty() {
|
|
||||||
if let Some(home) = default_destination() {
|
|
||||||
ctl.edit(|s| s.destination = home.display().to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*ctl.upload_target.borrow_mut() =
|
*ctl.upload_target.borrow_mut() =
|
||||||
context().map(|c| c.library_label).unwrap_or_default();
|
context().map(|c| c.library_label).unwrap_or_default();
|
||||||
ctl.refresh_volumes();
|
ctl.refresh_volumes();
|
||||||
@@ -505,16 +492,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Somewhere sensible for a first import to go.
|
|
||||||
fn default_destination() -> Option<PathBuf> {
|
|
||||||
let home = std::env::var_os("HOME").map(PathBuf::from)?;
|
|
||||||
let pictures = home.join("Pictures");
|
|
||||||
// Only if it exists: inventing a folder the user has not asked for, in a
|
|
||||||
// field they may not read, is how photographs end up somewhere nobody
|
|
||||||
// looks. An empty field keeps Import disabled until they choose.
|
|
||||||
pictures.exists().then_some(pictures)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count what is on the card, without copying anything.
|
/// Count what is on the card, without copying anything.
|
||||||
///
|
///
|
||||||
/// Runs on the UI thread, which is defensible only because it is a directory
|
/// Runs on the UI thread, which is defensible only because it is a directory
|
||||||
@@ -570,14 +547,21 @@ fn start(
|
|||||||
Some(PathBuf::from(stored.backup.trim()))
|
Some(PathBuf::from(stored.backup.trim()))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let Some(upload) = context.upload else {
|
||||||
|
*ctl.error.borrow_mut() =
|
||||||
|
"Sign in first — the library this imports into is on the server.".into();
|
||||||
|
render(window, ctl);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let request = Request {
|
let request = Request {
|
||||||
card: PathBuf::from(ctl.card.borrow().clone()),
|
card: PathBuf::from(ctl.card.borrow().clone()),
|
||||||
library: PathBuf::from(&stored.destination),
|
staging: upload.staging.clone(),
|
||||||
backup,
|
backup,
|
||||||
catalog: context.catalog.clone(),
|
catalog: context.catalog.clone(),
|
||||||
filter: context.filter.clone(),
|
filter: context.filter.clone(),
|
||||||
options: ctl.ingest_options(),
|
options: ctl.ingest_options(),
|
||||||
upload: stored.upload.then_some(context.upload).flatten(),
|
upload,
|
||||||
};
|
};
|
||||||
|
|
||||||
let cancel: import::Cancel = Default::default();
|
let cancel: import::Cancel = Default::default();
|
||||||
@@ -745,6 +729,35 @@ mod tests {
|
|||||||
assert_eq!(preview(&s, AUG_22), "2026/EOS R5");
|
assert_eq!(preview(&s, AUG_22), "2026/EOS R5");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_import_needs_an_account_before_it_can_start() {
|
||||||
|
// The library is on the server, so "where does this go" has no local
|
||||||
|
// answer to fall back on. Import stays disabled rather than quietly
|
||||||
|
// filling a folder nothing ever shows.
|
||||||
|
let ctl = ImportController::new(
|
||||||
|
crate::settings_ui::SettingsController::new(),
|
||||||
|
crate::activity::ActivityLog::new(),
|
||||||
|
);
|
||||||
|
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
|
||||||
|
*ctl.survey.borrow_mut() = Some((12, 1234));
|
||||||
|
assert!(!ctl.can_start(), "no account, yet Import was offered");
|
||||||
|
|
||||||
|
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
|
||||||
|
assert!(ctl.can_start());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_card_cannot_be_imported_either() {
|
||||||
|
let ctl = ImportController::new(
|
||||||
|
crate::settings_ui::SettingsController::new(),
|
||||||
|
crate::activity::ActivityLog::new(),
|
||||||
|
);
|
||||||
|
*ctl.card.borrow_mut() = "/run/media/duncan/EOS DIGITAL".into();
|
||||||
|
*ctl.upload_target.borrow_mut() = "PhotosRaw".into();
|
||||||
|
*ctl.survey.borrow_mut() = Some((0, 0));
|
||||||
|
assert!(!ctl.can_start());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_volume_says_why_it_is_being_offered() {
|
fn a_volume_says_why_it_is_being_offered() {
|
||||||
// "EOS DIGITAL" and "archive" look equally plausible in a list, and
|
// "EOS DIGITAL" and "archive" look equally plausible in a list, and
|
||||||
|
|||||||
@@ -733,6 +733,30 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
let entries = Rc::new(RefCell::new(collect(&paths)));
|
let entries = Rc::new(RefCell::new(collect(&paths)));
|
||||||
log::info!("{} image(s) to browse", entries.borrow().len());
|
log::info!("{} image(s) to browse", entries.borrow().len());
|
||||||
|
|
||||||
|
// TRACES: FR-PLAT-LIN-1
|
||||||
|
// The name the compositor knows this window by, and the reason the
|
||||||
|
// launcher shows a real icon rather than a grey square.
|
||||||
|
//
|
||||||
|
// `app.slint` sets `icon:`, and that is genuinely embedded (see
|
||||||
|
// `build.rs`) — but a Wayland compositor ignores a client-set icon
|
||||||
|
// entirely. It takes the icon from the `.desktop` file whose basename
|
||||||
|
// matches the surface's `app_id`, and nothing else. So the embedded icon
|
||||||
|
// is what X11 and the window itself use, and *this* is what GNOME's
|
||||||
|
// overview, dash and alt-tab use. Both are needed and neither substitutes
|
||||||
|
// for the other.
|
||||||
|
//
|
||||||
|
// The string must equal the installed `.desktop` file's basename exactly:
|
||||||
|
// `packaging/paris.tourolle.darkroom.desktop`. It matches the Android
|
||||||
|
// package id (`AndroidManifest.xml`) on purpose — one application, one
|
||||||
|
// reverse-DNS name on both platforms.
|
||||||
|
//
|
||||||
|
// Before the window, necessarily: it is read when the surface is created.
|
||||||
|
if let Err(e) = slint::set_xdg_app_id("paris.tourolle.darkroom") {
|
||||||
|
// Not fatal. On X11 and on Android this does nothing useful, and a
|
||||||
|
// missing app id costs an icon rather than a working application.
|
||||||
|
log::debug!("could not set the xdg app id: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
// Before the window, and it has to be: this selects the Slint backend, and
|
// Before the window, and it has to be: this selects the Slint backend, and
|
||||||
// creating a window selects one for us. See `shared_gpu`. The device is
|
// creating a window selects one for us. See `shared_gpu`. The device is
|
||||||
// shared by demosaic, the adjust pass and the compositor; without one the
|
// shared by demosaic, the adjust pass and the compositor; without one the
|
||||||
@@ -740,6 +764,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
let gpu = shared_gpu();
|
let gpu = shared_gpu();
|
||||||
|
|
||||||
let window = AppWindow::new()?;
|
let window = AppWindow::new()?;
|
||||||
|
// The version the About page shows. Taken from the crate rather than
|
||||||
|
// passed in, so it is the version of the code that is running and cannot
|
||||||
|
// be set to something else by a caller.
|
||||||
|
window.set_app_version(env!("CARGO_PKG_VERSION").into());
|
||||||
match &gpu {
|
match &gpu {
|
||||||
Some(ctx) => {
|
Some(ctx) => {
|
||||||
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
|
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||||||
@@ -953,6 +981,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// pushes, so a thumbnail made during an import is the
|
// pushes, so a thumbnail made during an import is the
|
||||||
// one every other client gets.
|
// one every other client gets.
|
||||||
thumbs: library::thumbs_dir(&session.server, &session.user_id),
|
thumbs: library::thumbs_dir(&session.server, &session.user_id),
|
||||||
|
staging: import::staging_dir(&session.server, &session.user_id),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
+30
-1
@@ -375,6 +375,23 @@ pub enum Amendment {
|
|||||||
Settings {
|
Settings {
|
||||||
preset: dr_pipeline::Preset,
|
preset: dr_pipeline::Preset,
|
||||||
scope: dr_pipeline::Scope,
|
scope: dr_pipeline::Scope,
|
||||||
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||||||
|
/// The local adjustments, when this is an image's own edit being
|
||||||
|
/// written back rather than a paste onto someone else's.
|
||||||
|
///
|
||||||
|
/// A `Preset` is a parameter map, and a mask is not a parameter — it
|
||||||
|
/// is a rule about *where*, with a chain of its own. So a save that
|
||||||
|
/// carried only the preset wrote the sliders and silently dropped
|
||||||
|
/// every local adjustment: the sidecar format has stored masks since
|
||||||
|
/// they were added and `Version::apply` restores them, but nothing
|
||||||
|
/// ever put any there. The mask survived until the session ended and
|
||||||
|
/// then did not exist.
|
||||||
|
///
|
||||||
|
/// `None` for a paste, which must not carry the source image's masks
|
||||||
|
/// onto the target: a mask is drawn against one photograph and means
|
||||||
|
/// nothing on another, and `Scope` cannot express that because it
|
||||||
|
/// filters parameters.
|
||||||
|
masks: Option<dr_pipeline::mask::MaskStack>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,8 +608,20 @@ fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
|
|||||||
version.rating = *rating;
|
version.rating = *rating;
|
||||||
version.flag = *flag;
|
version.flag = *flag;
|
||||||
}
|
}
|
||||||
Amendment::Settings { preset, scope } => {
|
Amendment::Settings {
|
||||||
|
preset,
|
||||||
|
scope,
|
||||||
|
masks,
|
||||||
|
} => {
|
||||||
preset.amend(&mut version.params, *scope);
|
preset.amend(&mut version.params, *scope);
|
||||||
|
// Replaced wholesale rather than merged: this is the whole of the
|
||||||
|
// image's local adjustment stack as it stands, so a layer the user
|
||||||
|
// deleted has to leave the sidecar too. Cross-device merging of
|
||||||
|
// two stacks is `Sidecar::merge`'s job and happens on sync, not
|
||||||
|
// here (FR-NC-9).
|
||||||
|
if let Some(masks) = masks {
|
||||||
|
version.masks = masks.clone();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+224
-14
@@ -2008,7 +2008,7 @@ fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
period_heading: heading.into(),
|
period_heading: heading.into(),
|
||||||
// A freshly loaded window has no drag in flight.
|
// A freshly loaded window has no drag in flight.
|
||||||
lifted: false,
|
lifted: false,
|
||||||
name: c.name.as_str().into(),
|
name: without_extension(&c.name).into(),
|
||||||
thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(),
|
thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(),
|
||||||
has_thumb: carried.is_some_and(|h| h.has_thumb),
|
has_thumb: carried.is_some_and(|h| h.has_thumb),
|
||||||
unavailable: carried.is_some_and(|h| h.unavailable),
|
unavailable: carried.is_some_and(|h| h.unavailable),
|
||||||
@@ -2475,6 +2475,10 @@ fn collect_settings_writes(
|
|||||||
amendment: library::Amendment::Settings {
|
amendment: library::Amendment::Settings {
|
||||||
preset: preset.clone(),
|
preset: preset.clone(),
|
||||||
scope,
|
scope,
|
||||||
|
// A paste carries no masks, and must not: a mask is drawn
|
||||||
|
// against one photograph and describes nothing on another.
|
||||||
|
// The target keeps whatever local adjustments it already had.
|
||||||
|
masks: None,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -3211,9 +3215,32 @@ fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
|||||||
}
|
}
|
||||||
// Adopted thumbnails and merged collections both change
|
// Adopted thumbnails and merged collections both change
|
||||||
// what the grid should show.
|
// what the grid should show.
|
||||||
if report.thumbnails_adopted > 0 || report.collections_gained > 0 {
|
if report.thumbnails_adopted > 0
|
||||||
|
|| report.collections_gained > 0
|
||||||
|
|| report.members_gained > 0
|
||||||
|
{
|
||||||
load_window(&w, &ctl_cb);
|
load_window(&w, &ctl_cb);
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-CAT-7
|
||||||
|
// And the sidebar, which the grid reload does not
|
||||||
|
// touch. Membership counts as a change: a sync that
|
||||||
|
// files 127 photographs into a collection both devices
|
||||||
|
// already had gains no *collection*, so keying this on
|
||||||
|
// `collections_gained` alone left the tree showing no
|
||||||
|
// number beside a collection that had just been filled.
|
||||||
|
//
|
||||||
|
// The scan already does this after it finishes; the
|
||||||
|
// sync merges the same tables and did not.
|
||||||
|
if report.collections_gained > 0 || report.members_gained > 0 {
|
||||||
|
let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade());
|
||||||
|
if let Some(coll) = coll {
|
||||||
|
let borrow = ctl_cb.catalog();
|
||||||
|
let borrow = borrow.borrow();
|
||||||
|
if let Some(cat) = borrow.as_ref() {
|
||||||
|
crate::collections_ui::refresh_tree(&w, &coll, cat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
stop(&ctl_cb.sync_timer);
|
stop(&ctl_cb.sync_timer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3515,6 +3542,20 @@ fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc<LibraryContr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A chosen date range is what the axis draws, when there is one.
|
||||||
|
//
|
||||||
|
// It used to keep drawing the full extent while a range was on, because
|
||||||
|
// the axis was the only way back out: narrow it to the range and there
|
||||||
|
// would be nothing left to widen from. The range now has typed ends, and
|
||||||
|
// those are the way back out — so the axis is free to show the span the
|
||||||
|
// user actually asked about, at the granularity that span deserves. A
|
||||||
|
// fortnight drawn across fifteen years is one column; drawn across itself
|
||||||
|
// it is fourteen days.
|
||||||
|
let span = match (filter.captured_from, filter.captured_to) {
|
||||||
|
(Some(from), Some(to)) if to > from => (from, to),
|
||||||
|
_ => span,
|
||||||
|
};
|
||||||
|
|
||||||
// Zoom narrows the span around wherever the view sits rather than around
|
// Zoom narrows the span around wherever the view sits rather than around
|
||||||
// the library's midpoint, so zooming in keeps what you were looking at.
|
// the library's midpoint, so zooming in keeps what you were looking at.
|
||||||
let zoom = *ctl.timeline_zoom.borrow();
|
let zoom = *ctl.timeline_zoom.borrow();
|
||||||
@@ -4599,32 +4640,93 @@ pub fn wire<F>(
|
|||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
window.on_library_toggle_date_range(move || {
|
window.on_library_toggle_date_range(move || {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
let on = !ctl.filter.borrow().has_date_range();
|
// Whether the *controls* are showing, which is not the same as
|
||||||
|
// whether a range is set — and reading it from the filter was why
|
||||||
|
// pressing this did nothing. The fields are how a range gets set,
|
||||||
|
// so requiring one before they appear is a door locked from the
|
||||||
|
// inside.
|
||||||
|
let on = !w.get_library_range_active();
|
||||||
if on {
|
if on {
|
||||||
// The span the axis is currently drawn over, which is what the
|
// Seeded from the span the axis is drawn over, which is what
|
||||||
// user is looking at when they ask for "this range".
|
// the user is looking at when they ask for "this range".
|
||||||
|
//
|
||||||
|
// Only a seed. If there is no catalog yet, or nothing in it
|
||||||
|
// carries a capture date — dates are read from EXIF as
|
||||||
|
// thumbnails load, so a freshly opened library has none — the
|
||||||
|
// fields simply open empty and wait to be typed into. This
|
||||||
|
// used to `return` in both cases, which meant the button
|
||||||
|
// silently did nothing on exactly the libraries where a person
|
||||||
|
// is most likely to be looking for a date.
|
||||||
|
let seeded = {
|
||||||
let borrow = ctl.catalog.borrow();
|
let borrow = ctl.catalog.borrow();
|
||||||
let Some(catalog) = borrow.as_ref() else {
|
borrow
|
||||||
return;
|
.as_ref()
|
||||||
};
|
.and_then(|catalog| catalog_span(catalog, &ctl))
|
||||||
let Some(span) = catalog_span(catalog, &ctl) else {
|
.map(|span| {
|
||||||
return;
|
zoomed_span(
|
||||||
};
|
|
||||||
let (from, to) = zoomed_span(
|
|
||||||
span,
|
span,
|
||||||
*ctl.timeline_zoom.borrow(),
|
*ctl.timeline_zoom.borrow(),
|
||||||
*ctl.timeline_centre.borrow(),
|
*ctl.timeline_centre.borrow(),
|
||||||
);
|
)
|
||||||
drop(borrow);
|
})
|
||||||
|
};
|
||||||
|
if let Some((from, to)) = seeded {
|
||||||
let mut f = ctl.filter.borrow_mut();
|
let mut f = ctl.filter.borrow_mut();
|
||||||
f.captured_from = Some(from);
|
f.captured_from = Some(from);
|
||||||
f.captured_to = Some(to);
|
f.captured_to = Some(to);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let mut f = ctl.filter.borrow_mut();
|
let mut f = ctl.filter.borrow_mut();
|
||||||
f.captured_from = None;
|
f.captured_from = None;
|
||||||
f.captured_to = None;
|
f.captured_to = None;
|
||||||
}
|
}
|
||||||
w.set_library_range_active(on);
|
w.set_library_range_active(on);
|
||||||
|
show_range(&w, &ctl);
|
||||||
|
refilter(&w, &ctl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-CAT-6
|
||||||
|
// The ends of the range, typed.
|
||||||
|
//
|
||||||
|
// The chip alone used to be the whole control and it read its span from
|
||||||
|
// the timeline's zoom, which is zero until someone zooms — so "limit to
|
||||||
|
// range" set the range to the entire library and appeared to do nothing.
|
||||||
|
// The span is shown now, and these two fields are how it is corrected.
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let ctl = ctl.clone();
|
||||||
|
window.on_library_range_edited(move |from, to| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
|
||||||
|
// Both ends, or neither. A range with one end parsed and the other
|
||||||
|
// silently dropped is a filter nobody asked for, and the grid
|
||||||
|
// going empty is a poor way to find out a date was mistyped.
|
||||||
|
let (Some(from), Some(to)) = (
|
||||||
|
dr_types::parse_date(from.as_str()),
|
||||||
|
dr_types::parse_date(to.as_str()),
|
||||||
|
) else {
|
||||||
|
w.set_library_range_invalid(true);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Typed the other way round is a slip, not an error: the user has
|
||||||
|
// said which two days they mean and there is exactly one range
|
||||||
|
// between them.
|
||||||
|
let (from, to) = if from <= to { (from, to) } else { (to, from) };
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut f = ctl.filter.borrow_mut();
|
||||||
|
f.captured_from = Some(from);
|
||||||
|
// Inclusive of the last day. "To the 5th" means the whole of
|
||||||
|
// the 5th — `parse_date` returns its midnight, and a range
|
||||||
|
// ending there would exclude every photograph taken on the day
|
||||||
|
// the user named.
|
||||||
|
f.captured_to = Some(to + 86_400 - 1);
|
||||||
|
}
|
||||||
|
w.set_library_range_invalid(false);
|
||||||
|
w.set_library_range_active(true);
|
||||||
|
show_range(&w, &ctl);
|
||||||
refilter(&w, &ctl);
|
refilter(&w, &ctl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5087,6 +5189,46 @@ mod tests {
|
|||||||
assert_eq!(y, 1969);
|
assert_eq!(y, 1969);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The axis follows a chosen range, so its granularity does too.
|
||||||
|
///
|
||||||
|
/// The complaint this fixes: a fortnight selected out of a fifteen-year
|
||||||
|
/// library was still drawn as one column of a year-per-bar axis, which is
|
||||||
|
/// the least useful picture of a fortnight available.
|
||||||
|
#[test]
|
||||||
|
fn a_chosen_range_decides_the_granularity() {
|
||||||
|
use dr_catalog::Granularity;
|
||||||
|
const DAY: i64 = 86_400;
|
||||||
|
|
||||||
|
// Fifteen years unfiltered: a bar per year, fifteen of them.
|
||||||
|
assert_eq!(Granularity::for_span(15 * 365 * DAY), Granularity::Year);
|
||||||
|
|
||||||
|
// Narrowed to a fortnight, the axis is handed the range rather than
|
||||||
|
// the extent, and a fortnight is drawn as fourteen days.
|
||||||
|
assert_eq!(Granularity::for_span(14 * DAY), Granularity::Day);
|
||||||
|
|
||||||
|
// A single day's shoot is drawn by the hour.
|
||||||
|
assert_eq!(Granularity::for_span(DAY), Granularity::Hour);
|
||||||
|
|
||||||
|
// The property rather than the thresholds, because the thresholds are
|
||||||
|
// no longer what decides it: a narrower range never produces a coarser
|
||||||
|
// bucket than a wider one.
|
||||||
|
let mut previous = i64::MAX;
|
||||||
|
for days in [3650, 730, 180, 60, 14, 3, 1] {
|
||||||
|
let g = Granularity::for_span(days * DAY);
|
||||||
|
let bucket = match g {
|
||||||
|
Granularity::Year => 365 * DAY,
|
||||||
|
Granularity::Month => 30 * DAY,
|
||||||
|
Granularity::Day => DAY,
|
||||||
|
Granularity::Hour => 3600,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
bucket <= previous,
|
||||||
|
"{days} days chose a coarser bucket than the range above it"
|
||||||
|
);
|
||||||
|
previous = bucket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bucket_labels_match_their_granularity() {
|
fn bucket_labels_match_their_granularity() {
|
||||||
use dr_catalog::Granularity;
|
use dr_catalog::Granularity;
|
||||||
@@ -5477,3 +5619,71 @@ mod tests {
|
|||||||
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
|
assert_eq!(paths, vec!["a.CR2", "b.CR2"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-2
|
||||||
|
/// A filename as a caption: without the part that says how it is stored.
|
||||||
|
///
|
||||||
|
/// A grid cell is about four words wide, and `.CR2` spends one of them saying
|
||||||
|
/// something the photographer already knows — every frame in a RAW library
|
||||||
|
/// ends the same way, so the extension distinguishes nothing while taking
|
||||||
|
/// room from the part that does. The name is elided under pressure, and it is
|
||||||
|
/// the *end* that goes, so an extension can push the digits that identify a
|
||||||
|
/// frame off the visible part of its own label.
|
||||||
|
///
|
||||||
|
/// Display only. `LibraryCell::name` keeps the true filename and
|
||||||
|
/// `remote_path` the full path, because both are used to find the file again
|
||||||
|
/// and a stem is not a filename.
|
||||||
|
///
|
||||||
|
/// **The case this is wrong for**, worth knowing before it is reported: a
|
||||||
|
/// library holding `IMG_1234.CR2` beside `IMG_1234.JPG` shows two cells
|
||||||
|
/// captioned `IMG_1234`. They are still two rows with two thumbnails and two
|
||||||
|
/// entries in the info panel, and RAW+JPEG pairs are usually shot to be one
|
||||||
|
/// photograph anyway — but the caption alone no longer separates them.
|
||||||
|
fn without_extension(name: &str) -> &str {
|
||||||
|
match name.rsplit_once('.') {
|
||||||
|
// The guard is for a leading dot: `.hidden` splits to an empty stem,
|
||||||
|
// and a dotfile's name starts with that dot rather than ending with an
|
||||||
|
// extension. A *trailing* dot needs no guard — `odd.` splits to `odd`,
|
||||||
|
// which is the better caption anyway.
|
||||||
|
Some((stem, _)) if !stem.is_empty() => stem,
|
||||||
|
_ => name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod display_name_tests {
|
||||||
|
use super::without_extension;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_extension_is_dropped_and_nothing_else_is() {
|
||||||
|
assert_eq!(without_extension("IMG_1234.CR2"), "IMG_1234");
|
||||||
|
assert_eq!(without_extension("IMG_1234.jpeg"), "IMG_1234");
|
||||||
|
// Only the last one: a name can contain dots and they are part of it.
|
||||||
|
assert_eq!(without_extension("2026.08.23-a.dng"), "2026.08.23-a");
|
||||||
|
// Nothing to drop.
|
||||||
|
assert_eq!(without_extension("IMG_1234"), "IMG_1234");
|
||||||
|
// A dotfile is not an extensionless name with an extension.
|
||||||
|
assert_eq!(without_extension(".hidden"), ".hidden");
|
||||||
|
// A trailing dot is an empty extension, and dropping it is right.
|
||||||
|
assert_eq!(without_extension("odd."), "odd");
|
||||||
|
assert_eq!(without_extension(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-6
|
||||||
|
/// Show the range the filter is actually using.
|
||||||
|
///
|
||||||
|
/// Pushed back rather than left as the user typed it, because the filter may
|
||||||
|
/// have adjusted it — ends given backwards are swapped, and the closing day is
|
||||||
|
/// extended to include itself. A field showing something other than what is
|
||||||
|
/// being filtered on is worse than one showing nothing.
|
||||||
|
fn show_range(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||||
|
let f = ctl.filter.borrow();
|
||||||
|
let text = |t: Option<i64>| -> slint::SharedString {
|
||||||
|
t.map(dr_types::format_date).unwrap_or_default().into()
|
||||||
|
};
|
||||||
|
window.set_library_range_from(text(f.captured_from));
|
||||||
|
// The stored end is the last second of the closing day; naming that day is
|
||||||
|
// what the user typed and what they should read back.
|
||||||
|
window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1)));
|
||||||
|
}
|
||||||
|
|||||||
+129
-14
@@ -28,7 +28,7 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
|
use slint::{ComponentHandle as _, Model as _, ModelRc, VecModel};
|
||||||
|
|
||||||
use crate::develop::{Abandon, DevelopSession, Segmented, SessionId};
|
use crate::develop::{Abandon, DevelopSession, RefinedInstance, Segmented, SessionId};
|
||||||
use crate::segmentation;
|
use crate::segmentation;
|
||||||
use crate::{sync_rows, AppWindow, GradientHandle, MaskRow, ParamRow, SubjectRow};
|
use crate::{sync_rows, AppWindow, GradientHandle, MaskRow, ParamRow, SubjectRow};
|
||||||
|
|
||||||
@@ -48,17 +48,18 @@ pub(crate) const GLOBAL_SCOPE: &str = "ADJUST";
|
|||||||
/// string the row in the stack above shows — one name for one thing, so the
|
/// string the row in the stack above shows — one name for one thing, so the
|
||||||
/// selected row and the panel it scopes cannot appear to disagree.
|
/// selected row and the panel it scopes cannot appear to disagree.
|
||||||
pub(crate) fn scope_label(session: &DevelopSession) -> String {
|
pub(crate) fn scope_label(session: &DevelopSession) -> String {
|
||||||
let Some(id) = session.active_mask() else {
|
match session.active_masks() {
|
||||||
return GLOBAL_SCOPE.to_string();
|
[] => GLOBAL_SCOPE.to_string(),
|
||||||
};
|
[id] => session
|
||||||
session
|
|
||||||
.mask_layers()
|
.mask_layers()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|(layer_id, ..)| layer_id == id)
|
.find(|(layer_id, ..)| layer_id == id)
|
||||||
.map_or_else(
|
.map_or_else(
|
||||||
|| GLOBAL_SCOPE.to_string(),
|
|| GLOBAL_SCOPE.to_string(),
|
||||||
|(_, label, ..)| label.to_uppercase(),
|
|(_, label, ..)| label.to_uppercase(),
|
||||||
)
|
),
|
||||||
|
many => format!("{} LAYERS", many.len()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How often the window looks to see whether the model has finished.
|
/// How often the window looks to see whether the model has finished.
|
||||||
@@ -309,7 +310,7 @@ pub(crate) fn wire(
|
|||||||
let redraw = redraw.clone();
|
let redraw = redraw.clone();
|
||||||
let rows = rows.clone();
|
let rows = rows.clone();
|
||||||
let running = running.clone();
|
let running = running.clone();
|
||||||
window.on_segment_image(move || {
|
window.on_segment_image(move |fine| {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
// Both the photograph and the device come out of the session, so
|
// Both the photograph and the device come out of the session, so
|
||||||
// no session is nothing to look at and nothing to look with.
|
// no session is nothing to look at and nothing to look with.
|
||||||
@@ -329,7 +330,13 @@ pub(crate) fn wire(
|
|||||||
w.set_segmenting(true);
|
w.set_segmenting(true);
|
||||||
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
let options = segmentation::Options::default();
|
// The only thing the button decides. Everything else about the
|
||||||
|
// run is the same, which is what makes the second pass a genuine
|
||||||
|
// re-run of the first rather than a different feature.
|
||||||
|
let options = segmentation::Options {
|
||||||
|
fine,
|
||||||
|
..segmentation::Options::default()
|
||||||
|
};
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
// A failed send means the window stopped waiting — the user
|
// A failed send means the window stopped waiting — the user
|
||||||
// moved on, or the app is closing. Neither is worth reporting:
|
// moved on, or the app is closing. Neither is worth reporting:
|
||||||
@@ -341,6 +348,49 @@ pub(crate) fn wire(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- refining one subject's mask ---------------------------------------
|
||||||
|
//
|
||||||
|
// Its own `Running` slot rather than sharing the segmentation one: the
|
||||||
|
// two answer different questions (the whole frame's subjects versus one
|
||||||
|
// already-found instance) and there is no reason a refine in flight
|
||||||
|
// should block a fresh "Find subjects", or the reverse.
|
||||||
|
let refining: Rc<RefCell<Running>> = Rc::default();
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
let running = refining.clone();
|
||||||
|
window.on_mask_refined(move |id| {
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
let Some(index) = session
|
||||||
|
.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.subject_instance_index(&id))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(job) = session.borrow().as_ref().and_then(|s| s.refine_job(index)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let session_id = job.session();
|
||||||
|
|
||||||
|
let claimed = running.borrow_mut().start(session_id, job.abandon());
|
||||||
|
if !claimed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
w.set_refining(true);
|
||||||
|
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = tx.send(job.run());
|
||||||
|
});
|
||||||
|
|
||||||
|
watch_refine(&w, session_id, rx, &session, &rows, &redraw, &running);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- dragging a gradient on the photograph ----------------------------
|
// --- dragging a gradient on the photograph ----------------------------
|
||||||
//
|
//
|
||||||
// The geometry the gesture started from, held for its duration.
|
// The geometry the gesture started from, held for its duration.
|
||||||
@@ -429,15 +479,23 @@ pub(crate) fn wire(
|
|||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let rows = rows.clone();
|
let rows = rows.clone();
|
||||||
window.on_mask_selected(move |id| {
|
window.on_mask_selected(move |id, extend| {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
{
|
{
|
||||||
let mut slot = session.borrow_mut();
|
let mut slot = session.borrow_mut();
|
||||||
let Some(s) = slot.as_mut() else { return };
|
let Some(s) = slot.as_mut() else { return };
|
||||||
// Clicking the selected layer again deselects it, which is how
|
if extend {
|
||||||
// the panel gets back to the whole photograph without a
|
// Control/command-click: add or remove this one layer,
|
||||||
// separate "edit globally" control.
|
// keeping whatever else was already selected.
|
||||||
if s.active_mask() == Some(id.as_str()) {
|
s.toggle_active_mask(&id);
|
||||||
|
} else if s.active_masks() == [id.to_string()] {
|
||||||
|
// Clicking the sole selected layer again deselects it,
|
||||||
|
// which is how the panel gets back to the whole
|
||||||
|
// photograph without a separate "edit globally" control.
|
||||||
|
// Only when it is the *only* one selected — a plain click
|
||||||
|
// on one row of a multi-selection collapses to just that
|
||||||
|
// row rather than clearing everything, which is the more
|
||||||
|
// useful reading of "I clicked a specific layer".
|
||||||
s.set_active_mask(None);
|
s.set_active_mask(None);
|
||||||
} else {
|
} else {
|
||||||
s.set_active_mask(Some(&id));
|
s.set_active_mask(Some(&id));
|
||||||
@@ -666,6 +724,63 @@ fn watch(
|
|||||||
running.borrow_mut().poll = Some(timer);
|
running.borrow_mut().poll = Some(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`watch`]'s counterpart for a refine pass — same shape, same reason: the
|
||||||
|
/// window polls rather than being called back from the worker, and a
|
||||||
|
/// photograph the user has left discards its answer instead of applying it.
|
||||||
|
fn watch_refine(
|
||||||
|
window: &AppWindow,
|
||||||
|
id: SessionId,
|
||||||
|
rx: std::sync::mpsc::Receiver<Result<Option<RefinedInstance>, String>>,
|
||||||
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
|
rows: &Rc<VecModel<ParamRow>>,
|
||||||
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||||||
|
running: &Rc<RefCell<Running>>,
|
||||||
|
) {
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = session.clone();
|
||||||
|
let rows = rows.clone();
|
||||||
|
let redraw = redraw.clone();
|
||||||
|
let slot = running.clone();
|
||||||
|
|
||||||
|
let timer = Rc::new(slint::Timer::default());
|
||||||
|
let stop = Rc::downgrade(&timer);
|
||||||
|
timer.start(slint::TimerMode::Repeated, POLL, move || {
|
||||||
|
let done = || {
|
||||||
|
if let Some(t) = stop.upgrade() {
|
||||||
|
t.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if delivery(id, open_session(&session)) == Delivery::Discard {
|
||||||
|
slot.borrow_mut().finish(id);
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(answer) = rx.try_recv() else { return };
|
||||||
|
slot.borrow_mut().finish(id);
|
||||||
|
done();
|
||||||
|
|
||||||
|
let Some(w) = weak.upgrade() else { return };
|
||||||
|
match answer {
|
||||||
|
Ok(Some(refined)) => {
|
||||||
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
|
s.adopt_refined(refined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Abandoned, or the model found nothing of the same class in the
|
||||||
|
// padded crop — both leave the instance exactly as it was.
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => log::warn!("refine failed: {e}"),
|
||||||
|
}
|
||||||
|
w.set_refining(false);
|
||||||
|
sync(&w, &session);
|
||||||
|
sync_rows(&w, &rows, &session);
|
||||||
|
redraw(&w);
|
||||||
|
});
|
||||||
|
running.borrow_mut().poll = Some(timer);
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear the panel when the open image changes.
|
/// Clear the panel when the open image changes.
|
||||||
///
|
///
|
||||||
/// Its own function rather than a call to [`sync`] with an empty session,
|
/// Its own function rather than a call to [`sync`] with an empty session,
|
||||||
@@ -677,6 +792,7 @@ fn watch(
|
|||||||
pub(crate) fn reset(window: &AppWindow) {
|
pub(crate) fn reset(window: &AppWindow) {
|
||||||
window.set_overlay_on(false);
|
window.set_overlay_on(false);
|
||||||
window.set_segmenting(false);
|
window.set_segmenting(false);
|
||||||
|
window.set_refining(false);
|
||||||
window.set_segmented(false);
|
window.set_segmented(false);
|
||||||
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
window.set_mask_rows(ModelRc::new(VecModel::<MaskRow>::default()));
|
||||||
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
window.set_subject_rows(ModelRc::new(VecModel::<SubjectRow>::default()));
|
||||||
@@ -844,7 +960,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// The ordinary case: the answer comes back to the photograph that asked.
|
/// The ordinary case: the answer comes back to the photograph that asked.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_result_for_the_open_photograph_is_applied() {
|
fn a_result_for_the_open_photograph_is_applied() {
|
||||||
|
|||||||
+98
-12
@@ -177,7 +177,16 @@ pub fn load_local(image: &Path) -> Option<Sidecar> {
|
|||||||
/// Read first for the same reason the remote writer does: the file may already
|
/// Read first for the same reason the remote writer does: the file may already
|
||||||
/// hold a rating, or an operation this build does not know about, and writing
|
/// hold a rating, or an operation this build does not know about, and writing
|
||||||
/// a fresh document containing only the current edit would delete both.
|
/// a fresh document containing only the current edit would delete both.
|
||||||
pub fn save_local(image: &Path, preset: &Preset, scope: Scope) -> Result<(), String> {
|
/// `masks` carries the local adjustments when this is the image's own edit,
|
||||||
|
/// and is `None` for a paste, which must leave the target's masks alone — a
|
||||||
|
/// mask is drawn against one photograph and describes nothing on another. See
|
||||||
|
/// `library::Amendment::Settings` for the same distinction on the remote path.
|
||||||
|
pub fn save_local(
|
||||||
|
image: &Path,
|
||||||
|
preset: &Preset,
|
||||||
|
scope: Scope,
|
||||||
|
masks: Option<&dr_pipeline::mask::MaskStack>,
|
||||||
|
) -> Result<(), String> {
|
||||||
let path = local_sidecar_path(image);
|
let path = local_sidecar_path(image);
|
||||||
|
|
||||||
// Distinguish "no sidecar yet" from "a sidecar this build cannot read".
|
// Distinguish "no sidecar yet" from "a sidecar this build cannot read".
|
||||||
@@ -217,6 +226,11 @@ pub fn save_local(image: &Path, preset: &Preset, scope: Scope) -> Result<(), Str
|
|||||||
});
|
});
|
||||||
|
|
||||||
preset.amend(&mut version.params, scope);
|
preset.amend(&mut version.params, scope);
|
||||||
|
// Wholesale, not merged: this is the stack as it stands, so a layer the
|
||||||
|
// user deleted must leave the file too.
|
||||||
|
if let Some(masks) = masks {
|
||||||
|
version.masks = masks.clone();
|
||||||
|
}
|
||||||
version.revision = version.revision.saturating_add(1);
|
version.revision = version.revision.saturating_add(1);
|
||||||
version.modified = now_secs();
|
version.modified = now_secs();
|
||||||
sidecar.put(version);
|
sidecar.put(version);
|
||||||
@@ -252,7 +266,13 @@ pub fn save_open_edit(
|
|||||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
library: &Rc<library_ui::LibraryController>,
|
library: &Rc<library_ui::LibraryController>,
|
||||||
) {
|
) {
|
||||||
let Some(preset) = session.borrow().as_ref().map(|s| s.copy_settings()) else {
|
// Both taken in one borrow: they are one edit, and a mask stack captured
|
||||||
|
// from a session that had already moved on would be a different image's.
|
||||||
|
let Some((preset, masks)) = session
|
||||||
|
.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| (s.copy_settings(), s.masks().clone()))
|
||||||
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,7 +285,7 @@ pub fn save_open_edit(
|
|||||||
// `Everything`: this is the image's *own* edit being written back,
|
// `Everything`: this is the image's *own* edit being written back,
|
||||||
// not a paste onto someone else's. Excluding framing here would
|
// not a paste onto someone else's. Excluding framing here would
|
||||||
// make a crop the one adjustment that never survived a restart.
|
// make a crop the one adjustment that never survived a restart.
|
||||||
if let Err(e) = save_local(path, &preset, Scope::Everything) {
|
if let Err(e) = save_local(path, &preset, Scope::Everything, Some(&masks)) {
|
||||||
log::warn!("saving {}: {e}", path.display());
|
log::warn!("saving {}: {e}", path.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,6 +297,11 @@ pub fn save_open_edit(
|
|||||||
amendment: library::Amendment::Settings {
|
amendment: library::Amendment::Settings {
|
||||||
preset,
|
preset,
|
||||||
scope: Scope::Everything,
|
scope: Scope::Everything,
|
||||||
|
// The image's own edit, so the whole of it: `Everything`
|
||||||
|
// already says framing travels, and the local adjustments
|
||||||
|
// have to travel for the same reason. Without this the
|
||||||
|
// sliders were written and every mask was dropped.
|
||||||
|
masks: Some(masks),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
library_ui::start_sidecar_writes(window, library, vec![write]);
|
library_ui::start_sidecar_writes(window, library, vec![write]);
|
||||||
@@ -318,8 +343,8 @@ fn refresh_thumbnail_for(
|
|||||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
library: &Rc<library_ui::LibraryController>,
|
library: &Rc<library_ui::LibraryController>,
|
||||||
) {
|
) {
|
||||||
let worth_it = window.get_can_undo()
|
let worth_it =
|
||||||
|| session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
|
window.get_can_undo() || session.borrow().as_ref().is_some_and(|s| !s.is_neutral());
|
||||||
if !worth_it {
|
if !worth_it {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -521,6 +546,66 @@ mod tests {
|
|||||||
g
|
g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bug this exists to prevent: a local adjustment that survived until
|
||||||
|
/// the session ended and then did not exist.
|
||||||
|
///
|
||||||
|
/// The sidecar format has stored masks since they were added, and
|
||||||
|
/// `Version::apply` restores them — but the save path wrote a `Preset`,
|
||||||
|
/// which is a parameter map, and a mask is not a parameter. So the sliders
|
||||||
|
/// came back and every mask was silently gone. Nothing reported it,
|
||||||
|
/// because nothing had failed.
|
||||||
|
#[test]
|
||||||
|
fn a_mask_survives_being_written_and_read_back() {
|
||||||
|
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||||||
|
|
||||||
|
let dir = tempdir("mask-roundtrip");
|
||||||
|
let image = dir.join("a.CR2");
|
||||||
|
|
||||||
|
let mut graph = edited();
|
||||||
|
let mut layer = MaskLayer::new(
|
||||||
|
"m1",
|
||||||
|
MaskSource::Radial {
|
||||||
|
centre: (0.4, 0.6),
|
||||||
|
radii: (0.2, 0.3),
|
||||||
|
angle: 0.0,
|
||||||
|
feather: 0.5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
layer.opacity = 0.75;
|
||||||
|
graph.masks_mut().push(layer);
|
||||||
|
assert_eq!(graph.masks().len(), 1, "the premise: the edit has a mask");
|
||||||
|
|
||||||
|
save_local(
|
||||||
|
&image,
|
||||||
|
&Preset::capture(&graph),
|
||||||
|
Scope::Everything,
|
||||||
|
Some(graph.masks()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Read it back the way opening the photograph again does.
|
||||||
|
let text = std::fs::read_to_string(local_sidecar_path(&image)).unwrap();
|
||||||
|
let sidecar = Sidecar::parse(&text).unwrap();
|
||||||
|
let version = sidecar.default_version().expect("a default version");
|
||||||
|
|
||||||
|
let mut reopened = EditGraph::default_chain();
|
||||||
|
version.apply(&mut reopened);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reopened.masks().len(),
|
||||||
|
1,
|
||||||
|
"the mask must come back with the photograph"
|
||||||
|
);
|
||||||
|
let back = &reopened.masks().layers()[0];
|
||||||
|
assert_eq!(back.id, "m1");
|
||||||
|
assert!((back.opacity - 0.75).abs() < 1e-6, "and with its settings");
|
||||||
|
assert!(
|
||||||
|
matches!(back.source, MaskSource::Radial { .. }),
|
||||||
|
"and its kind: {:?}",
|
||||||
|
back.source.kind()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_sidecar_sits_beside_its_image_with_the_extension_replaced() {
|
fn a_sidecar_sits_beside_its_image_with_the_extension_replaced() {
|
||||||
// Replaced, not appended, so a RAW and the JPEG beside it share one
|
// Replaced, not appended, so a RAW and the JPEG beside it share one
|
||||||
@@ -537,7 +622,7 @@ mod tests {
|
|||||||
let dir = tempdir("round-trip");
|
let dir = tempdir("round-trip");
|
||||||
let image = dir.join("a.CR2");
|
let image = dir.join("a.CR2");
|
||||||
|
|
||||||
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
|
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
|
||||||
|
|
||||||
let sidecar = load_local(&image).expect("a sidecar was written");
|
let sidecar = load_local(&image).expect("a sidecar was written");
|
||||||
let mut restored = EditGraph::default_chain();
|
let mut restored = EditGraph::default_chain();
|
||||||
@@ -563,8 +648,8 @@ mod tests {
|
|||||||
let dir = tempdir("one-version");
|
let dir = tempdir("one-version");
|
||||||
let image = dir.join("a.CR2");
|
let image = dir.join("a.CR2");
|
||||||
|
|
||||||
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
|
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
|
||||||
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
|
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
|
||||||
|
|
||||||
assert_eq!(load_local(&image).expect("a sidecar").versions.len(), 1);
|
assert_eq!(load_local(&image).expect("a sidecar").versions.len(), 1);
|
||||||
}
|
}
|
||||||
@@ -576,13 +661,13 @@ mod tests {
|
|||||||
let dir = tempdir("revision");
|
let dir = tempdir("revision");
|
||||||
let image = dir.join("a.CR2");
|
let image = dir.join("a.CR2");
|
||||||
|
|
||||||
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
|
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
|
||||||
let first = load_local(&image)
|
let first = load_local(&image)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.default_version()
|
.default_version()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.revision;
|
.revision;
|
||||||
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
|
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
|
||||||
let second = load_local(&image)
|
let second = load_local(&image)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.default_version()
|
.default_version()
|
||||||
@@ -610,7 +695,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
std::fs::write(local_sidecar_path(&image), sidecar.to_text()).unwrap();
|
std::fs::write(local_sidecar_path(&image), sidecar.to_text()).unwrap();
|
||||||
|
|
||||||
save_local(&image, &Preset::capture(&edited()), Scope::Everything).unwrap();
|
save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).unwrap();
|
||||||
|
|
||||||
let back = load_local(&image).unwrap();
|
let back = load_local(&image).unwrap();
|
||||||
let v = back.default_version().unwrap();
|
let v = back.default_version().unwrap();
|
||||||
@@ -627,7 +712,7 @@ mod tests {
|
|||||||
let image = dir.join("a.CR2");
|
let image = dir.join("a.CR2");
|
||||||
std::fs::write(local_sidecar_path(&image), "drsc 99\n").unwrap();
|
std::fs::write(local_sidecar_path(&image), "drsc 99\n").unwrap();
|
||||||
|
|
||||||
assert!(save_local(&image, &Preset::capture(&edited()), Scope::Everything).is_err());
|
assert!(save_local(&image, &Preset::capture(&edited()), Scope::Everything, None).is_err());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::read_to_string(local_sidecar_path(&image)).unwrap(),
|
std::fs::read_to_string(local_sidecar_path(&image)).unwrap(),
|
||||||
"drsc 99\n",
|
"drsc 99\n",
|
||||||
@@ -642,6 +727,7 @@ mod tests {
|
|||||||
&dir.join("a.CR2"),
|
&dir.join("a.CR2"),
|
||||||
&Preset::capture(&edited()),
|
&Preset::capture(&edited()),
|
||||||
Scope::Everything,
|
Scope::Everything,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ pub struct InstanceSummary {
|
|||||||
/// This is the *source* a distance field is built from, not the mask
|
/// This is the *source* a distance field is built from, not the mask
|
||||||
/// itself — `dr_segment::Shaped` turns it into one.
|
/// itself — `dr_segment::Shaped` turns it into one.
|
||||||
pub mask: Vec<u8>,
|
pub mask: Vec<u8>,
|
||||||
|
/// `(x0, y0, x1, y1)` in [`Segmentation::proxy_size`] pixels.
|
||||||
|
///
|
||||||
|
/// Carried through from `dr_segment::Instance` rather than re-derived
|
||||||
|
/// from the mask, so a refine pass knows what region to crop without
|
||||||
|
/// scanning a proxy-sized buffer for its own extent.
|
||||||
|
pub bbox: (f32, f32, f32, f32),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One image's recognised objects, ready to mask.
|
/// One image's recognised objects, ready to mask.
|
||||||
@@ -83,6 +89,18 @@ impl Segmentation {
|
|||||||
self.instances.get(index).map(|i| i.mask.as_slice())
|
self.instances.get(index).map(|i| i.mask.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace one instance in place, keeping every other index and the
|
||||||
|
/// signature unchanged.
|
||||||
|
///
|
||||||
|
/// What a refine pass calls once it has a sharper mask for the subject at
|
||||||
|
/// `index`: the layers pointing at this run by index still mean what they
|
||||||
|
/// meant, they just read better pixels now.
|
||||||
|
pub fn replace_instance(&mut self, index: usize, instance: InstanceSummary) {
|
||||||
|
if let Some(slot) = self.instances.get_mut(index) {
|
||||||
|
*slot = instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The strongest instance covering a point in normalised image
|
/// The strongest instance covering a point in normalised image
|
||||||
/// coordinates.
|
/// coordinates.
|
||||||
///
|
///
|
||||||
@@ -197,11 +215,29 @@ pub struct Options {
|
|||||||
/// costs a subject that cannot be selected at all, which is the worse
|
/// costs a subject that cannot be selected at all, which is the worse
|
||||||
/// failure for a selection tool.
|
/// failure for a selection tool.
|
||||||
pub confidence: f32,
|
pub confidence: f32,
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Run the model over overlapping tiles instead of the whole frame once.
|
||||||
|
///
|
||||||
|
/// Off by default and deliberately so. The graph's input is fixed at
|
||||||
|
/// 640x640 (docs/segmentation.md F6), so every image is letterboxed into
|
||||||
|
/// it and a subject 200px across in a 1600px proxy reaches the model at
|
||||||
|
/// 80px — which is where a coarse outline comes from. Tiling is the only
|
||||||
|
/// route to more resolution with a fixed window, and it costs one
|
||||||
|
/// inference per tile: about 2.8s for a 3x2 grid against 470ms whole-frame.
|
||||||
|
///
|
||||||
|
/// Six times the wait is the wrong default for the common case, where the
|
||||||
|
/// subject is large in frame and whole-frame inference is already the best
|
||||||
|
/// answer. It is the right answer for a bird against sky, so it is offered
|
||||||
|
/// per-image rather than chosen once for all of them.
|
||||||
|
pub fine: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Options {
|
impl Default for Options {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { confidence: 0.30 }
|
Self {
|
||||||
|
confidence: 0.30,
|
||||||
|
fine: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +254,7 @@ pub fn compute(
|
|||||||
height: usize,
|
height: usize,
|
||||||
options: &Options,
|
options: &Options,
|
||||||
) -> Result<Segmentation, String> {
|
) -> Result<Segmentation, String> {
|
||||||
let found = detect(rgb, width, height)?;
|
let found = detect(rgb, width, height, options.fine)?;
|
||||||
|
|
||||||
let instances: Vec<InstanceSummary> = found
|
let instances: Vec<InstanceSummary> = found
|
||||||
.iter()
|
.iter()
|
||||||
@@ -227,6 +263,7 @@ pub fn compute(
|
|||||||
class_name: i.class_name.clone(),
|
class_name: i.class_name.clone(),
|
||||||
score: i.score,
|
score: i.score,
|
||||||
mask: quantise(&i.mask),
|
mask: quantise(&i.mask),
|
||||||
|
bbox: i.bbox,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -234,7 +271,19 @@ pub fn compute(
|
|||||||
width as u32,
|
width as u32,
|
||||||
height as u32,
|
height as u32,
|
||||||
instances.len() as u32,
|
instances.len() as u32,
|
||||||
options.confidence.to_bits() as u64,
|
// The tiling choice belongs in the signature as much as the
|
||||||
|
// confidence does. A mask stores the signature of the segmentation its
|
||||||
|
// region ids index into (`MaskSource::Regions`), and a tiled run finds
|
||||||
|
// different instances in a different order — so if the two runs shared
|
||||||
|
// a signature, a layer built against the coarse pass would be silently
|
||||||
|
// reinterpreted against the fine one. That is a *wrong* mask, which is
|
||||||
|
// far worse than a stale one, because nothing announces it.
|
||||||
|
options.confidence.to_bits() as u64
|
||||||
|
^ if options.fine {
|
||||||
|
0x9E37_79B9_7F4A_7C15
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Segmentation {
|
Ok(Segmentation {
|
||||||
@@ -249,10 +298,27 @@ pub fn compute(
|
|||||||
/// Loading is ~24 ms against the ~470 ms of inference that follows, and this
|
/// Loading is ~24 ms against the ~470 ms of inference that follows, and this
|
||||||
/// runs once per image — so caching the session would keep 11 MB of weights
|
/// runs once per image — so caching the session would keep 11 MB of weights
|
||||||
/// resident for the life of the app to save five percent of a background task.
|
/// resident for the life of the app to save five percent of a background task.
|
||||||
fn detect(rgb: &[f32], width: usize, height: usize) -> Result<Vec<dr_segment::Instance>, String> {
|
fn detect(
|
||||||
|
rgb: &[f32],
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
fine: bool,
|
||||||
|
) -> Result<Vec<dr_segment::Instance>, String> {
|
||||||
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
|
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
|
||||||
|
let options = dr_segment::SemanticOptions {
|
||||||
|
// A quarter shared with each neighbour. It has to exceed zero at all,
|
||||||
|
// or a subject sitting on a seam is cut in half by both tiles and
|
||||||
|
// recognised by neither; a quarter is enough to carry a whole subject
|
||||||
|
// inside one tile at the sizes tiling is reached for.
|
||||||
|
tiling: if fine {
|
||||||
|
dr_segment::Tiling::Grid { overlap: 0.25 }
|
||||||
|
} else {
|
||||||
|
dr_segment::Tiling::Whole
|
||||||
|
},
|
||||||
|
..dr_segment::SemanticOptions::default()
|
||||||
|
};
|
||||||
model
|
model
|
||||||
.detect(rgb, width, height, &dr_segment::SemanticOptions::default())
|
.detect(rgb, width, height, &options)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,11 +357,13 @@ mod tests {
|
|||||||
class_name: "bus".into(),
|
class_name: "bus".into(),
|
||||||
score: 0.5,
|
score: 0.5,
|
||||||
mask: big,
|
mask: big,
|
||||||
|
bbox: (0.0, 0.0, 6.0, h as f32),
|
||||||
},
|
},
|
||||||
InstanceSummary {
|
InstanceSummary {
|
||||||
class_name: "person".into(),
|
class_name: "person".into(),
|
||||||
score: 0.9,
|
score: 0.9,
|
||||||
mask: small,
|
mask: small,
|
||||||
|
bbox: (4.0, 0.0, 8.0, h as f32),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
signature: 1,
|
signature: 1,
|
||||||
|
|||||||
+77
-58
@@ -104,64 +104,31 @@ component StatusBar inherits Rectangle {
|
|||||||
|
|
||||||
Rectangle { horizontal-stretch: 1; }
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
|
||||||
// --- instrumentation, and only where there is room for it ---------
|
// --- instrumentation lives in Settings now ------------------------
|
||||||
//
|
//
|
||||||
// The render backend, the layout class and the frame rate are all
|
// The render backend, the layout class and the frame rate are
|
||||||
// developer readouts. They cost about 200 logical pixels of a strip
|
// developer readouts. They cost about 200 logical pixels of a strip
|
||||||
// that also carries the only way out of develop, the undo pair, the
|
// that also carries the only way out of develop, the undo pair, the
|
||||||
// panel toggle and the export button — and a `HorizontalLayout` given
|
// panel toggle and the export button — and a `HorizontalLayout` given
|
||||||
// less width than its children's minimums does not shrink them, it
|
// less width than its children's minimums does not shrink them, it
|
||||||
// runs off the end.
|
// runs off the end.
|
||||||
//
|
//
|
||||||
// A tablet in portrait is 768 logical pixels, so that is exactly what
|
// A tablet in portrait is 768 logical pixels and was already below the
|
||||||
// happened: everything from the panel toggle rightwards was pushed off
|
// breakpoint that hid them, so it kept its header. Landscape is 1200
|
||||||
// the right-hand edge. The develop column is *also* closed by default
|
// and is not, so the readouts came back and the header ran off the
|
||||||
// below the breakpoint, so the toggle being unreachable meant the
|
// right-hand edge there instead — a breakpoint that hides a problem at
|
||||||
// column could not be opened at all — and with it went copy and paste,
|
// one size and not another is a workaround, not a fix.
|
||||||
// which live in it. Reported as "I have no idea how to copy a setting
|
|
||||||
// on Android and apply it to other images", and the answer was that
|
|
||||||
// there was no way to.
|
|
||||||
//
|
//
|
||||||
// Collapsed to zero width rather than wrapped in an `if`: this strip
|
// The backend, the frame rate and the layout class used to live here.
|
||||||
// is inside the layout that `expanded` feeds, and a conditional child
|
// They are diagnostics — read once when something looks wrong, and
|
||||||
// here is the shape that has already caused binding loops in this file
|
// never again — and as permanent furniture in a 44px strip they cost
|
||||||
// (see the panel below). A `visible: false` child still takes its slot
|
// three slots that the controls beside them actually needed. On a
|
||||||
// in a layout, so the width has to go as well.
|
// tablet in landscape the header ran off the right-hand edge, and this
|
||||||
Rectangle {
|
// was a third of the reason.
|
||||||
width: root.expanded ? diagnostics.preferred-width : 0px;
|
//
|
||||||
visible: root.expanded;
|
// They are in Settings under ABOUT now, next to the version, which is
|
||||||
clip: true;
|
// where someone goes when they have a bug to report rather than a
|
||||||
|
// photograph to edit.
|
||||||
diagnostics := HorizontalLayout {
|
|
||||||
spacing: Theme.gap;
|
|
||||||
|
|
||||||
// The backend is an identifying label, not a state — it says
|
|
||||||
// which path is in use, and it says the same thing whether
|
|
||||||
// that path is fast or slow. The accent it used to carry made
|
|
||||||
// every frame look like an alert; `fps` below is the thing
|
|
||||||
// here that can go wrong.
|
|
||||||
Label {
|
|
||||||
text: root.backend;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
Caption {
|
|
||||||
text: root.layout-class;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Degraded performance, so this is a caution rather than an
|
|
||||||
// active state: the frame rate has fallen below what the
|
|
||||||
// compositing path is supposed to sustain, which is exactly
|
|
||||||
// the assumption A1 exists to test. `warn` is the one hue left
|
|
||||||
// in the palette and this earns it.
|
|
||||||
Caption {
|
|
||||||
text: root.fps + " fps";
|
|
||||||
warn: root.fps < 55;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo and redo, in the strip rather than in the develop column: the
|
// Undo and redo, in the strip rather than in the develop column: the
|
||||||
// panel can be put away, and the one control that takes back a
|
// panel can be put away, and the one control that takes back a
|
||||||
@@ -292,6 +259,10 @@ export component AppWindow inherits Window {
|
|||||||
in property <string> adapter: "detecting…";
|
in property <string> adapter: "detecting…";
|
||||||
in property <string> backend: "—";
|
in property <string> backend: "—";
|
||||||
in property <int> fps: 0;
|
in property <int> fps: 0;
|
||||||
|
/// TRACES: FR-UI-2
|
||||||
|
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
|
||||||
|
/// the binary it is part of.
|
||||||
|
in property <string> app-version: "unknown";
|
||||||
|
|
||||||
// Current image, for the status strip and empty state.
|
// Current image, for the status strip and empty state.
|
||||||
in property <string> filename: "";
|
in property <string> filename: "";
|
||||||
@@ -484,6 +455,11 @@ export component AppWindow inherits Window {
|
|||||||
/// Whether the grid is narrowed to the timeline's visible span.
|
/// Whether the grid is narrowed to the timeline's visible span.
|
||||||
in-out property <bool> library-range-active: false;
|
in-out property <bool> library-range-active: false;
|
||||||
callback library-toggle-date-range();
|
callback library-toggle-date-range();
|
||||||
|
/// TRACES: FR-CAT-6
|
||||||
|
in property <string> library-range-from;
|
||||||
|
in property <string> library-range-to;
|
||||||
|
in property <bool> library-range-invalid: false;
|
||||||
|
callback library-range-edited(string, string);
|
||||||
callback library-retry-connection();
|
callback library-retry-connection();
|
||||||
|
|
||||||
// --- pinning a collection offline (FR-NC-6a) ---
|
// --- pinning a collection offline (FR-NC-6a) ---
|
||||||
@@ -781,7 +757,6 @@ export component AppWindow inherits Window {
|
|||||||
in property <bool> import-card-looks-right: false;
|
in property <bool> import-card-looks-right: false;
|
||||||
in property <bool> import-surveying: false;
|
in property <bool> import-surveying: false;
|
||||||
in property <string> import-survey-summary: "";
|
in property <string> import-survey-summary: "";
|
||||||
in property <string> import-destination: "";
|
|
||||||
in property <string> import-upload-target: "";
|
in property <string> import-upload-target: "";
|
||||||
in property <string> import-folder-template: "";
|
in property <string> import-folder-template: "";
|
||||||
in property <string> import-template-preview: "";
|
in property <string> import-template-preview: "";
|
||||||
@@ -910,6 +885,16 @@ export component AppWindow inherits Window {
|
|||||||
/// opaque map hides the thing being judged.
|
/// opaque map hides the thing being judged.
|
||||||
in property <float> overlay-strength: 0.55;
|
in property <float> overlay-strength: 0.55;
|
||||||
in property <bool> overlay-on: false;
|
in property <bool> overlay-on: false;
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// The photographer's switch for the same overlay, kept separate from
|
||||||
|
/// `overlay-on` because the two answer different questions: `overlay-on`
|
||||||
|
/// is "is there a mask to draw", which Rust decides, and this is "do I
|
||||||
|
/// want to look at it right now", which only they can.
|
||||||
|
///
|
||||||
|
/// Folding them into one property would mean the next recompute — any
|
||||||
|
/// mask edit — silently switched the overlay back on under someone who had
|
||||||
|
/// just turned it off to check their work against the photograph.
|
||||||
|
in-out property <bool> overlay-hidden: false;
|
||||||
/// Which part of the source-space overlay the view is showing, in overlay
|
/// Which part of the source-space overlay the view is showing, in overlay
|
||||||
/// pixels. Without it the overlay stays frame-sized while the photograph
|
/// pixels. Without it the overlay stays frame-sized while the photograph
|
||||||
/// moves under it.
|
/// moves under it.
|
||||||
@@ -939,6 +924,7 @@ export component AppWindow inherits Window {
|
|||||||
in property <[SubjectRow]> subject-rows;
|
in property <[SubjectRow]> subject-rows;
|
||||||
in property <bool> segmented: false;
|
in property <bool> segmented: false;
|
||||||
in property <bool> segmenting: false;
|
in property <bool> segmenting: false;
|
||||||
|
in property <bool> refining: false;
|
||||||
/// The selected layer's name, already resolved, or "ADJUST" when the
|
/// The selected layer's name, already resolved, or "ADJUST" when the
|
||||||
/// controls are global. Names the adjust panel's heading.
|
/// controls are global. Names the adjust panel's heading.
|
||||||
in property <string> adjust-scope: "ADJUST";
|
in property <string> adjust-scope: "ADJUST";
|
||||||
@@ -958,12 +944,13 @@ export component AppWindow inherits Window {
|
|||||||
/// rather than as one per frame of the gesture.
|
/// rather than as one per frame of the gesture.
|
||||||
callback gradient-handle-released();
|
callback gradient-handle-released();
|
||||||
|
|
||||||
callback segment-image();
|
callback segment-image(bool);
|
||||||
/// A click on the photograph, in fractions of the shown image, plus
|
/// A click on the photograph, in fractions of the shown image, plus
|
||||||
/// whether it should extend the selection rather than replace it.
|
/// whether it should extend the selection rather than replace it.
|
||||||
callback region-picked(float, float, bool);
|
callback region-picked(float, float, bool);
|
||||||
callback mask-selected(string);
|
callback mask-selected(string, bool);
|
||||||
callback mask-removed(string);
|
callback mask-removed(string);
|
||||||
|
callback mask-refined(string);
|
||||||
callback mask-toggled(string, bool);
|
callback mask-toggled(string, bool);
|
||||||
callback mask-invert-toggled(string, bool);
|
callback mask-invert-toggled(string, bool);
|
||||||
callback mask-opacity-changed(string, float);
|
callback mask-opacity-changed(string, float);
|
||||||
@@ -1099,7 +1086,6 @@ in property <bool> panel-visible: true;
|
|||||||
card-looks-right: root.import-card-looks-right;
|
card-looks-right: root.import-card-looks-right;
|
||||||
surveying: root.import-surveying;
|
surveying: root.import-surveying;
|
||||||
survey-summary: root.import-survey-summary;
|
survey-summary: root.import-survey-summary;
|
||||||
destination: root.import-destination;
|
|
||||||
upload-target: root.import-upload-target;
|
upload-target: root.import-upload-target;
|
||||||
folder-template: root.import-folder-template;
|
folder-template: root.import-folder-template;
|
||||||
template-preview: root.import-template-preview;
|
template-preview: root.import-template-preview;
|
||||||
@@ -1135,6 +1121,12 @@ in property <bool> panel-visible: true;
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
adapter: root.adapter;
|
||||||
|
backend: root.backend;
|
||||||
|
fps: root.fps;
|
||||||
|
layout-class: root.layout-class;
|
||||||
|
app-version: root.app-version;
|
||||||
|
|
||||||
original-budget: root.settings-original-budget;
|
original-budget: root.settings-original-budget;
|
||||||
original-unlimited: root.settings-original-unlimited;
|
original-unlimited: root.settings-original-unlimited;
|
||||||
thumbnail-budget: root.settings-thumbnail-budget;
|
thumbnail-budget: root.settings-thumbnail-budget;
|
||||||
@@ -1329,6 +1321,10 @@ in property <bool> panel-visible: true;
|
|||||||
toggle-local-only() => { root.library-toggle-local-only(); }
|
toggle-local-only() => { root.library-toggle-local-only(); }
|
||||||
range-active: root.library-range-active;
|
range-active: root.library-range-active;
|
||||||
toggle-date-range() => { root.library-toggle-date-range(); }
|
toggle-date-range() => { root.library-toggle-date-range(); }
|
||||||
|
range-from: root.library-range-from;
|
||||||
|
range-to: root.library-range-to;
|
||||||
|
range-invalid: root.library-range-invalid;
|
||||||
|
range-edited(a, b) => { root.library-range-edited(a, b); }
|
||||||
|
|
||||||
collections-visible: root.collections-visible;
|
collections-visible: root.collections-visible;
|
||||||
toggle-collections() => { root.toggle-collections(); }
|
toggle-collections() => { root.toggle-collections(); }
|
||||||
@@ -1521,7 +1517,7 @@ in property <bool> panel-visible: true;
|
|||||||
// it is a diagnostic and not an edit: it must not reach
|
// it is a diagnostic and not an edit: it must not reach
|
||||||
// the histogram, an export, or the texture the develop
|
// the histogram, an export, or the texture the develop
|
||||||
// pass hands the compositor.
|
// pass hands the compositor.
|
||||||
if root.overlay-on && root.total > 0: Image {
|
if root.overlay-on && !root.overlay-hidden && root.total > 0: Image {
|
||||||
x: parent.shown-x;
|
x: parent.shown-x;
|
||||||
y: parent.shown-y;
|
y: parent.shown-y;
|
||||||
width: parent.shown-w;
|
width: parent.shown-w;
|
||||||
@@ -2108,7 +2104,24 @@ in property <bool> panel-visible: true;
|
|||||||
// width, which the layout then influences. Slint flags it, and
|
// width, which the layout then influences. Slint flags it, and
|
||||||
// it can panic at runtime.
|
// it can panic at runtime.
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: root.panel-visible ? 280px : 0px;
|
// TRACES: FR-UI-2
|
||||||
|
// 280px was chosen for a tablet, where the column is a
|
||||||
|
// large fraction of the screen and every pixel of it is
|
||||||
|
// taken from the photograph. On a desktop window it is
|
||||||
|
// simply too narrow: the mode strip alone is two modes,
|
||||||
|
// a separator, "All", and one chip per attribute the
|
||||||
|
// operation set declares, which is more words than 280px
|
||||||
|
// holds — so it scrolled sideways, and a control you have
|
||||||
|
// to pan to reach is one you do not know is there.
|
||||||
|
//
|
||||||
|
// Driven off `layout-class` rather than `root.width`
|
||||||
|
// because reading the window width inside the layout that
|
||||||
|
// sets it is the binding loop the comment on `expanded`
|
||||||
|
// above describes. Rust already classifies the window; this
|
||||||
|
// just spends the extra width where there is some.
|
||||||
|
width: root.panel-visible
|
||||||
|
? (root.layout-class == "expanded" ? 380px : 280px)
|
||||||
|
: 0px;
|
||||||
visible: root.panel-visible;
|
visible: root.panel-visible;
|
||||||
background: Theme.surface;
|
background: Theme.surface;
|
||||||
clip: true;
|
clip: true;
|
||||||
@@ -2257,10 +2270,16 @@ in property <bool> panel-visible: true;
|
|||||||
subjects: root.subject-rows;
|
subjects: root.subject-rows;
|
||||||
segmented: root.segmented;
|
segmented: root.segmented;
|
||||||
segmenting: root.segmenting;
|
segmenting: root.segmenting;
|
||||||
|
refining: root.refining;
|
||||||
|
// Two-way: the panel is where it is toggled and
|
||||||
|
// the canvas is what obeys it, so neither owns
|
||||||
|
// the value alone.
|
||||||
|
overlay-hidden <=> root.overlay-hidden;
|
||||||
|
|
||||||
segment => { root.segment-image(); }
|
segment(fine) => { root.segment-image(fine); }
|
||||||
mask-selected(id) => { root.mask-selected(id); }
|
mask-selected(id, extend) => { root.mask-selected(id, extend); }
|
||||||
mask-removed(id) => { root.mask-removed(id); }
|
mask-removed(id) => { root.mask-removed(id); }
|
||||||
|
mask-refined(id) => { root.mask-refined(id); }
|
||||||
mask-toggled(id, on) => { root.mask-toggled(id, on); }
|
mask-toggled(id, on) => { root.mask-toggled(id, on); }
|
||||||
mask-invert-toggled(id, on) => {
|
mask-invert-toggled(id, on) => {
|
||||||
root.mask-invert-toggled(id, on);
|
root.mask-invert-toggled(id, on);
|
||||||
|
|||||||
+18
-21
@@ -85,11 +85,11 @@ export component ImportPage inherits Rectangle {
|
|||||||
in property <string> survey-summary;
|
in property <string> survey-summary;
|
||||||
|
|
||||||
// --- where they are going --------------------------------------------
|
// --- where they are going --------------------------------------------
|
||||||
/// The library root. Shown, not editable — see the header comment.
|
/// The library folder on the server — the only destination there is.
|
||||||
in property <string> destination;
|
///
|
||||||
/// The folder on the server the originals go on to, once they are safely
|
/// Empty means no account is signed in, and Import stays disabled: the
|
||||||
/// on disk (FR-NC-7b). Empty when no account is signed in, which hides the
|
/// library lives on the server, so an import without one has nowhere to
|
||||||
/// line rather than showing an empty one.
|
/// go at all.
|
||||||
in property <string> upload-target;
|
in property <string> upload-target;
|
||||||
in-out property <string> folder-template;
|
in-out property <string> folder-template;
|
||||||
/// The template expanded against a real date, so the answer to "what will
|
/// The template expanded against a real date, so the answer to "what will
|
||||||
@@ -272,27 +272,24 @@ export component ImportPage inherits Rectangle {
|
|||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
Label { text: "Library"; }
|
Label { text: "Library"; }
|
||||||
Value {
|
Value {
|
||||||
text: root.destination;
|
text: root.upload-target != "" ? root.upload-target
|
||||||
|
: "not signed in";
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if root.upload-target != "": HorizontalLayout {
|
// The order is the promise, and a user watching a slow
|
||||||
spacing: Theme.gap;
|
// upload needs to already know the photographs are safe.
|
||||||
Label { text: "Server"; }
|
// Said here, beside the destination, rather than beside the
|
||||||
Value {
|
// Import button where it would read as a warning.
|
||||||
text: root.upload-target;
|
|
||||||
overflow: elide;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Said once, here, rather than beside the Import button:
|
|
||||||
// the order is the promise, and a user watching a slow
|
|
||||||
// upload needs to already know the photographs are on disk.
|
|
||||||
if root.upload-target != "": Caption {
|
if root.upload-target != "": Caption {
|
||||||
text: "Copied here and verified first, then uploaded.";
|
text: "Copied to this computer and verified first, then uploaded. Anything that does not go up stays queued for the next import.";
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.upload-target == "": Caption {
|
||||||
|
text: "Sign in first — the library this imports into is on the server.";
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +330,7 @@ export component ImportPage inherits Rectangle {
|
|||||||
// The one operation in this application with no undo,
|
// The one operation in this application with no undo,
|
||||||
// and the only honest place to say so is beside the
|
// and the only honest place to say so is beside the
|
||||||
// control that arms it (FR-NC-7b).
|
// control that arms it (FR-NC-7b).
|
||||||
text: "The card is emptied only after every file is verified in the library — and, on a library that syncs, after it has reached the server.";
|
text: "The card is emptied only of photographs the server has confirmed. Anything that does not upload keeps its card copy — which is then the only copy there is.";
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+105
-2
@@ -1098,6 +1098,19 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
/// Whether the grid is narrowed to the timeline's visible span.
|
/// Whether the grid is narrowed to the timeline's visible span.
|
||||||
in property <bool> range-active: false;
|
in property <bool> range-active: false;
|
||||||
callback toggle-date-range();
|
callback toggle-date-range();
|
||||||
|
/// TRACES: FR-CAT-6
|
||||||
|
/// The ends of the range, as `YYYY-MM-DD`, when one is set.
|
||||||
|
///
|
||||||
|
/// Strings rather than instants because this is what the user types and
|
||||||
|
/// what they read back. Rust parses them and refuses what is not a date;
|
||||||
|
/// `range-invalid` is how that refusal reaches the field, since a filter
|
||||||
|
/// that silently ignored a typo would show an empty grid and no reason.
|
||||||
|
in property <string> range-from;
|
||||||
|
in property <string> range-to;
|
||||||
|
in property <bool> range-invalid: false;
|
||||||
|
/// Both ends at once: they are one range, and applying half of an edit
|
||||||
|
/// would filter to a span the user never asked for.
|
||||||
|
callback range-edited(string, string);
|
||||||
|
|
||||||
/// How many images are selected, for the header's count.
|
/// How many images are selected, for the header's count.
|
||||||
in property <int> selected-count: 0;
|
in property <int> selected-count: 0;
|
||||||
@@ -1349,11 +1362,25 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
// thought it could draw. Seven action buttons and four readouts do
|
// thought it could draw. Seven action buttons and four readouts do
|
||||||
// not fit across a tablet even in landscape, which is why the last
|
// not fit across a tablet even in landscape, which is why the last
|
||||||
// one was clipped by the screen edge.
|
// one was clipped by the screen edge.
|
||||||
|
//
|
||||||
|
// Sized to `min-width`, not `preferred-width`: the latter is the
|
||||||
|
// row's *untruncated* footprint — every readout at its natural
|
||||||
|
// text width — so the Flickable was always at least that wide and
|
||||||
|
// the row never actually shrank, it just scrolled, with the
|
||||||
|
// sidebar toggle and the "More" disclosure off past the right
|
||||||
|
// edge on anything narrower than a tablet. `min-width` is what
|
||||||
|
// the row needs once its `elide` + `horizontal-stretch: 1`
|
||||||
|
// readouts (image count, selection count, scan status, the
|
||||||
|
// window label below) are collapsed to an ellipsis, so a window
|
||||||
|
// with room for that shrunk row now gets the row shrunk to fit
|
||||||
|
// instead of scrolled — and the toggle and disclosure, which
|
||||||
|
// don't shrink, stay on screen. Only a window too narrow even for
|
||||||
|
// the shrunk row still scrolls.
|
||||||
Flickable {
|
Flickable {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
viewport-height: self.height;
|
viewport-height: self.height;
|
||||||
viewport-width: max(self.width, header-row.preferred-width);
|
viewport-width: max(self.width, header-row.min-width);
|
||||||
|
|
||||||
header-row := HorizontalLayout {
|
header-row := HorizontalLayout {
|
||||||
width: parent.viewport-width;
|
width: parent.viewport-width;
|
||||||
@@ -1381,7 +1408,18 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
|
|
||||||
Caption { text: root.total > 0 ? root.total + " images" : ""; }
|
// `elide` + `horizontal-stretch: 1`, like `window-label` below:
|
||||||
|
// stretch is what actually lets a `Text` shrink under pressure
|
||||||
|
// in Slint, not `elide` alone (see the comment on the header
|
||||||
|
// row's Flickable) — without it these three readouts kept
|
||||||
|
// their full natural width and were what pushed the sidebar
|
||||||
|
// toggle and the "More" button past the right edge on a
|
||||||
|
// narrow window, reachable only by knowing to flick-scroll.
|
||||||
|
Caption {
|
||||||
|
text: root.total > 0 ? root.total + " images" : "";
|
||||||
|
overflow: elide;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
|
||||||
// The selection count, and how to act on it. Both appear only
|
// The selection count, and how to act on it. Both appear only
|
||||||
// when something is selected — an empty selection has nothing
|
// when something is selected — an empty selection has nothing
|
||||||
@@ -1395,6 +1433,8 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
modified: true;
|
modified: true;
|
||||||
compact: true;
|
compact: true;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
overflow: elide;
|
||||||
|
horizontal-stretch: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
@@ -1410,6 +1450,8 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
: (root.sweeping
|
: (root.sweeping
|
||||||
? "indexing " + root.sweep-done + " / " + root.sweep-total
|
? "indexing " + root.sweep-done + " / " + root.sweep-total
|
||||||
: root.scan-status);
|
: root.scan-status);
|
||||||
|
overflow: elide;
|
||||||
|
horizontal-stretch: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where in the library the visible window sits. A scrubbable
|
// Where in the library the visible window sits. A scrubbable
|
||||||
@@ -1725,6 +1767,67 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the date range's ends ---------------------------------------
|
||||||
|
//
|
||||||
|
// Its own strip, a sibling of the chips rather than a child of them.
|
||||||
|
//
|
||||||
|
// Twice now this control has looked broken. First it narrowed to the
|
||||||
|
// whole library, because it took its span from a timeline zoom that
|
||||||
|
// is zero until someone zooms. Then the fields that fixed that went
|
||||||
|
// into the chip row — which is 34px tall and scrolls sideways, so
|
||||||
|
// they were both clipped and off past the right-hand edge.
|
||||||
|
//
|
||||||
|
// A `Rectangle` stacks its children at the origin rather than laying
|
||||||
|
// them out, which is why putting a second row inside the chips' one
|
||||||
|
// drew it over them instead of under them. This is a row of the
|
||||||
|
// header's `VerticalLayout`, so it gets a line of its own and the
|
||||||
|
// width of the window.
|
||||||
|
if root.range-active: Rectangle {
|
||||||
|
height: 40px;
|
||||||
|
background: Theme.surface;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
// The same leading inset the chips above use, so the two rows
|
||||||
|
// start on one vertical line rather than a few pixels apart.
|
||||||
|
padding-left: Theme.gap;
|
||||||
|
padding-right: Theme.gap;
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
// Fixed-width fields and a two-letter word: left-aligned, so
|
||||||
|
// they sit under the chip that turned them on instead of
|
||||||
|
// spreading across the window.
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
|
Field {
|
||||||
|
width: 108px;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
text: root.range-from;
|
||||||
|
placeholder: "YYYY-MM-DD";
|
||||||
|
accepted(t) => { root.range-edited(t, root.range-to); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Caption {
|
||||||
|
text: "to";
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
Field {
|
||||||
|
width: 108px;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
text: root.range-to;
|
||||||
|
placeholder: "YYYY-MM-DD";
|
||||||
|
accepted(t) => { root.range-edited(root.range-from, t); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.range-invalid: Caption {
|
||||||
|
text: "not a date";
|
||||||
|
warn: true;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- progress -----------------------------------------------------
|
// --- progress -----------------------------------------------------
|
||||||
//
|
//
|
||||||
// The bar that used to sit here is now the shell's, drawn across the
|
// The bar that used to sit here is now the shell's, drawn across the
|
||||||
|
|||||||
+78
-6
@@ -73,8 +73,13 @@ export struct SubjectRow {
|
|||||||
component MaskEntry inherits Rectangle {
|
component MaskEntry inherits Rectangle {
|
||||||
in property <MaskRow> data;
|
in property <MaskRow> data;
|
||||||
in property <bool> enabled: true;
|
in property <bool> enabled: true;
|
||||||
|
/// Whether a refine pass is running (for any row — only one runs at a
|
||||||
|
/// time), so this row's button can say so and refuse a second press.
|
||||||
|
in property <bool> refining: false;
|
||||||
|
|
||||||
callback selected();
|
/// `true` when the click should add this row to the selection rather
|
||||||
|
/// than replace it with just this row.
|
||||||
|
callback selected(bool);
|
||||||
callback removed();
|
callback removed();
|
||||||
callback toggled(bool);
|
callback toggled(bool);
|
||||||
callback invert-toggled(bool);
|
callback invert-toggled(bool);
|
||||||
@@ -83,6 +88,7 @@ component MaskEntry inherits Rectangle {
|
|||||||
callback falloff-picked(int);
|
callback falloff-picked(int);
|
||||||
callback morphology-picked(int);
|
callback morphology-picked(int);
|
||||||
callback morph-radius-changed(float);
|
callback morph-radius-changed(float);
|
||||||
|
callback refined();
|
||||||
|
|
||||||
height: layout.preferred-height;
|
height: layout.preferred-height;
|
||||||
background: root.data.selected ? Theme.surface-raised : transparent;
|
background: root.data.selected ? Theme.surface-raised : transparent;
|
||||||
@@ -91,7 +97,15 @@ component MaskEntry inherits Rectangle {
|
|||||||
touch := TouchArea {
|
touch := TouchArea {
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: pointer;
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
clicked => { root.selected(); }
|
// `clicked` carries no modifiers, so a plain click and a
|
||||||
|
// multi-select click need the pointer event itself: released, over
|
||||||
|
// the row, with control or command held is "add to the selection"
|
||||||
|
// rather than "replace it".
|
||||||
|
pointer-event(event) => {
|
||||||
|
if event.kind == PointerEventKind.up && event.button == PointerEventButton.left {
|
||||||
|
root.selected(event.modifiers.control || event.modifiers.meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
layout := VerticalLayout {
|
layout := VerticalLayout {
|
||||||
@@ -190,6 +204,15 @@ component MaskEntry inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only a subject has a detection behind it worth re-running. A
|
||||||
|
// gradient's edge is geometry the photographer placed, and there
|
||||||
|
// is nothing for the model to find twice.
|
||||||
|
if root.data.kind == "subject": Button {
|
||||||
|
text: root.refining ? "Refining…" : "Refine mask";
|
||||||
|
enabled: !root.refining;
|
||||||
|
clicked => { root.refined(); }
|
||||||
|
}
|
||||||
|
|
||||||
// The edge. All of it reads one signed distance field, so these
|
// The edge. All of it reads one signed distance field, so these
|
||||||
// are free to move — none of them recomputes anything except a
|
// are free to move — none of them recomputes anything except a
|
||||||
// compound morphology.
|
// compound morphology.
|
||||||
@@ -242,12 +265,23 @@ export component MaskPanel inherits Rectangle {
|
|||||||
in property <bool> segmented: false;
|
in property <bool> segmented: false;
|
||||||
/// One is being computed now.
|
/// One is being computed now.
|
||||||
in property <bool> segmenting: false;
|
in property <bool> segmenting: false;
|
||||||
|
/// A refine pass is running for one subject layer.
|
||||||
|
in property <bool> refining: false;
|
||||||
|
|
||||||
|
/// Whether the photographer has hidden the overlay to see the photograph.
|
||||||
|
/// Two-way bound to the window's own, so the canvas and this control never
|
||||||
|
/// disagree about it.
|
||||||
|
in-out property <bool> overlay-hidden: false;
|
||||||
|
|
||||||
|
|
||||||
callback segment();
|
/// `true` asks for the slower, tiled pass.
|
||||||
|
callback segment(bool);
|
||||||
|
|
||||||
callback mask-selected(string);
|
/// `true` when the click should add the layer to the selection rather
|
||||||
|
/// than replace it.
|
||||||
|
callback mask-selected(string, bool);
|
||||||
callback mask-removed(string);
|
callback mask-removed(string);
|
||||||
|
callback mask-refined(string);
|
||||||
callback mask-toggled(string, bool);
|
callback mask-toggled(string, bool);
|
||||||
callback mask-invert-toggled(string, bool);
|
callback mask-invert-toggled(string, bool);
|
||||||
callback mask-opacity-changed(string, float);
|
callback mask-opacity-changed(string, float);
|
||||||
@@ -299,7 +333,7 @@ export component MaskPanel inherits Rectangle {
|
|||||||
text: root.segmenting ? "Looking…" : "Find subjects";
|
text: root.segmenting ? "Looking…" : "Find subjects";
|
||||||
enabled: !root.segmenting;
|
enabled: !root.segmenting;
|
||||||
primary: true;
|
primary: true;
|
||||||
clicked => { root.segment(); }
|
clicked => { root.segment(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// No "Overlay" button and no "Select" button. Both switched on things
|
// No "Overlay" button and no "Select" button. Both switched on things
|
||||||
@@ -312,6 +346,42 @@ export component MaskPanel inherits Rectangle {
|
|||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hiding the overlay is not the "Overlay" button this panel used to
|
||||||
|
// have, and the distinction is the reason it is back. That one *armed*
|
||||||
|
// the overlay: local mode could be entered and still show nothing,
|
||||||
|
// which is the fault the comment below records. This one only takes an
|
||||||
|
// overlay that is already there out of the way for a moment, which is
|
||||||
|
// the one thing a photographer needs constantly and had no way to do —
|
||||||
|
// a mask is judged against the photograph under it, and you cannot see
|
||||||
|
// that photograph through the thing describing it.
|
||||||
|
//
|
||||||
|
// Reads as its own action rather than its state: "Show the mask" is
|
||||||
|
// what pressing it will do, not what is currently true.
|
||||||
|
if root.enabled && root.segmented: Button {
|
||||||
|
text: root.overlay-hidden ? "Show the mask" : "Hide the mask";
|
||||||
|
clicked => { root.overlay-hidden = !root.overlay-hidden; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second pass, offered rather than taken automatically.
|
||||||
|
//
|
||||||
|
// The model's input is a fixed 640x640 square and every frame is
|
||||||
|
// letterboxed into it, so a subject that is small in the frame reaches
|
||||||
|
// the model small — a bird at 200px in a 1600px proxy arrives at 80px,
|
||||||
|
// and an outline traced at 80px is what a coarse mask is. Tiling runs
|
||||||
|
// the model over overlapping windows instead, so that bird arrives at
|
||||||
|
// its own size.
|
||||||
|
//
|
||||||
|
// It costs one inference per tile: about 2.8s for a 3x2 grid against
|
||||||
|
// 470ms for the whole frame. That is the wrong trade for the usual
|
||||||
|
// photograph, where the subject fills much of the frame and the first
|
||||||
|
// pass is already the best answer available — so it is a button and
|
||||||
|
// not a default, and it says what it costs.
|
||||||
|
if root.enabled && root.segmented: Button {
|
||||||
|
text: root.segmenting ? "Looking closer…" : "Look closer (slower)";
|
||||||
|
enabled: !root.segmenting;
|
||||||
|
clicked => { root.segment(true); }
|
||||||
|
}
|
||||||
|
|
||||||
// Granularity, labelled by what it does rather than by its number:
|
// Granularity, labelled by what it does rather than by its number:
|
||||||
// "detail" is what a photographer is choosing between, where "300
|
// "detail" is what a photographer is choosing between, where "300
|
||||||
// regions" is an implementation detail they would have to learn.
|
// regions" is an implementation detail they would have to learn.
|
||||||
@@ -386,8 +456,10 @@ export component MaskPanel inherits Rectangle {
|
|||||||
for mask in root.masks: MaskEntry {
|
for mask in root.masks: MaskEntry {
|
||||||
data: mask;
|
data: mask;
|
||||||
enabled: root.enabled;
|
enabled: root.enabled;
|
||||||
selected => { root.mask-selected(mask.id); }
|
refining: root.refining;
|
||||||
|
selected(extend) => { root.mask-selected(mask.id, extend); }
|
||||||
removed => { root.mask-removed(mask.id); }
|
removed => { root.mask-removed(mask.id); }
|
||||||
|
refined => { root.mask-refined(mask.id); }
|
||||||
toggled(on) => { root.mask-toggled(mask.id, on); }
|
toggled(on) => { root.mask-toggled(mask.id, on); }
|
||||||
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
|
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
|
||||||
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
|
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ export component SettingsPage inherits Rectangle {
|
|||||||
/// What the cache currently holds. Empty hides the line.
|
/// What the cache currently holds. Empty hides the line.
|
||||||
in property <string> cache-usage;
|
in property <string> cache-usage;
|
||||||
|
|
||||||
|
/// TRACES: FR-UI-2
|
||||||
|
/// What is drawing, and how fast. These used to sit in the window's top
|
||||||
|
/// strip, where they were three items of permanent furniture answering a
|
||||||
|
/// question almost nobody asks twice — and on a tablet in landscape they
|
||||||
|
/// were part of why the header ran off the screen.
|
||||||
|
///
|
||||||
|
/// They are diagnostics, so they belong where a person goes to look
|
||||||
|
/// something up, not where they are looked at all day.
|
||||||
|
in property <string> adapter;
|
||||||
|
in property <string> backend;
|
||||||
|
in property <int> fps;
|
||||||
|
in property <string> layout-class;
|
||||||
|
in property <string> app-version;
|
||||||
|
|
||||||
callback original-budget-changed(string);
|
callback original-budget-changed(string);
|
||||||
callback original-unlimited-toggled(bool);
|
callback original-unlimited-toggled(bool);
|
||||||
callback thumbnail-budget-changed(string);
|
callback thumbnail-budget-changed(string);
|
||||||
@@ -551,6 +565,65 @@ export component SettingsPage inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- about and diagnostics -------------------------------
|
||||||
|
Rectangle {
|
||||||
|
width: content.column;
|
||||||
|
height: about-panel.preferred-height;
|
||||||
|
|
||||||
|
about-panel := Panel {
|
||||||
|
width: 100%;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
PanelHeading { text: "ABOUT"; }
|
||||||
|
|
||||||
|
// The version first, because it is the one line anyone
|
||||||
|
// is ever asked to quote. A bug report that names a
|
||||||
|
// version names a commit.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: Theme.gap;
|
||||||
|
Label { text: "Version"; }
|
||||||
|
Value { text: root.app-version; horizontal-stretch: 1; overflow: elide; }
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: Theme.gap;
|
||||||
|
Label { text: "Graphics"; }
|
||||||
|
Value {
|
||||||
|
text: root.adapter + " (" + root.backend + ")";
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: Theme.gap;
|
||||||
|
Label { text: "Frame rate"; }
|
||||||
|
// Still earns the warning hue here. It is the
|
||||||
|
// number that says whether the zero-copy path is
|
||||||
|
// holding up, which is the assumption the whole
|
||||||
|
// display design rests on.
|
||||||
|
// `Caption` rather than `Value` here alone: it is
|
||||||
|
// the one that carries `warn`, and the frame rate
|
||||||
|
// is the one reading on this page that can be bad
|
||||||
|
// news rather than merely a fact.
|
||||||
|
Caption { text: root.fps + " fps"; warn: root.fps < 55; horizontal-stretch: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: Theme.gap;
|
||||||
|
Label { text: "Layout"; }
|
||||||
|
Value { text: root.layout-class; horizontal-stretch: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Caption {
|
||||||
|
text: "Graphics and frame rate describe this session, "
|
||||||
|
+ "not a setting — they are here so a bug report "
|
||||||
|
+ "can quote them.";
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- naming and destination ------------------------------
|
// --- naming and destination ------------------------------
|
||||||
//
|
//
|
||||||
// Its own panel rather than more of the export one: format and
|
// Its own panel rather than more of the export one: format and
|
||||||
|
|||||||
Reference in New Issue
Block a user