Merge branch 'master' into android-bundled-face-models
This commit is contained in:
+163
@@ -0,0 +1,163 @@
|
|||||||
|
# Contributing to DarkRoom
|
||||||
|
|
||||||
|
There is a lot of documentation here — 14 documents and 177 numbered
|
||||||
|
requirements — and almost all of it is written for someone who has already
|
||||||
|
decided to work on this. This file is the other thing: how to get a first
|
||||||
|
change landed without reading any of it.
|
||||||
|
|
||||||
|
## The shortest useful contribution
|
||||||
|
|
||||||
|
**A develop operation is one file.** Not one file plus a registration, plus a
|
||||||
|
shader edit, plus a control in the UI — one file:
|
||||||
|
|
||||||
|
```
|
||||||
|
core/dr-pipeline/ops/split_toning.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
`build.rs` finds it with `read_dir`, compiles it into Rust implementing
|
||||||
|
`Operation`, and from there it is indistinguishable from a hand-written node.
|
||||||
|
It arrives with controls built from its declared parameter kinds, a place in
|
||||||
|
the chain from `order:`, a place in the panel from `attributes:`, sidecar
|
||||||
|
persistence, and its own tests — which are declared in the same file and run
|
||||||
|
under `cargo test`.
|
||||||
|
|
||||||
|
Read [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) and
|
||||||
|
copy [`exposure.yaml`](core/dr-pipeline/ops/exposure.yaml). Split toning,
|
||||||
|
colour zones, selective colour and channel-mixer variants are all pure point
|
||||||
|
operations, which means all of them are declarations rather than code.
|
||||||
|
|
||||||
|
If you want to understand one thing about the architecture before starting,
|
||||||
|
make it this: **the core describes its capabilities and the interface composes
|
||||||
|
them.** No code in `ui/` names an operation, and a test enforces that
|
||||||
|
(`ui/dr-ui/tests/ui_names_no_operation.rs`). It is why your node needs no UI
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Getting it to build
|
||||||
|
|
||||||
|
**Git LFS is required.** Model weights are stored in LFS, and a clone made
|
||||||
|
without it leaves a ~130-byte text pointer where an 11 MB model should be:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git lfs install && git lfs pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Forget this and `dr-segment`'s build script stops with an instruction rather
|
||||||
|
than embedding the pointer and failing at inference time — but it is easier to
|
||||||
|
run the two commands now.
|
||||||
|
|
||||||
|
**The toolchain pins itself.** `rust-toolchain.toml` selects 1.92.0 and rustup
|
||||||
|
fetches it on first use. Do not override it; `cargo fmt` and `clippy` are both
|
||||||
|
version-sensitive and CI runs exactly this version.
|
||||||
|
|
||||||
|
**System packages.** Slint and winit need these at build time. On Debian or
|
||||||
|
Ubuntu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Then:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p darkroom-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
The first build resolves 826 crates and takes a while — on a laptop, long
|
||||||
|
enough to look like a hang. It is not one.
|
||||||
|
|
||||||
|
Android is a containerised toolchain and is not needed for most work; see
|
||||||
|
[`docker/android/README.md`](docker/android/README.md) if you get there.
|
||||||
|
|
||||||
|
## What CI will check
|
||||||
|
|
||||||
|
All four of these run on every push, so run them before you send anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all -- --check
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace
|
||||||
|
cargo build --workspace --release
|
||||||
|
```
|
||||||
|
|
||||||
|
GPU tests skip themselves where there is no adapter rather than failing — a
|
||||||
|
test that cannot run is not evidence either way — so a green run on a machine
|
||||||
|
without a GPU is expected, and does not mean the GPU paths were exercised.
|
||||||
|
|
||||||
|
## Requirements and traceability
|
||||||
|
|
||||||
|
[`requirements.md`](docs/requirements.md) is the register of record.
|
||||||
|
[`traceability.md`](docs/traceability.md) is generated from `TRACES:` tags in
|
||||||
|
the source and must never be hand-edited:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// TRACES: FR-DEV-3a | FR-DEV-3c
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags are read from `.rs`, `.slint`, `.wgsl` and `.yaml` — the last so a
|
||||||
|
declared operation can record the requirement it satisfies, since the Rust it
|
||||||
|
generates lands in `OUT_DIR` and is not scanned.
|
||||||
|
|
||||||
|
A pre-commit hook regenerates the matrix and stages it whenever you touch
|
||||||
|
something that can carry a tag, so you should not have to think about it. If
|
||||||
|
you do need to run it by hand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p traceability -- report
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that it tracks line numbers, so a change that only moves code still moves
|
||||||
|
the matrix. Never regenerate it with a stale prebuilt binary.
|
||||||
|
|
||||||
|
**One convention that the tooling cannot enforce.** A tag proves that a tag
|
||||||
|
exists, not that the code under it does the thing — `docs/code-health.md`
|
||||||
|
CH-4 has the details, and two requirements currently read as covered on the
|
||||||
|
strength of plumbing a future feature would use. So: **close a requirement
|
||||||
|
with a test that would fail if the behaviour were removed.** Coverage that
|
||||||
|
moves slowly and means something beats coverage that moves quickly.
|
||||||
|
|
||||||
|
## Two invariants the build defends
|
||||||
|
|
||||||
|
Worth knowing before you trip one, because both failures name a requirement
|
||||||
|
rather than a line:
|
||||||
|
|
||||||
|
- **No operation may be named in `ui/`** (FR-DEV-3a). Special-casing one
|
||||||
|
operation in the panel to fix a layout problem is how a generated interface
|
||||||
|
stops being generated. If a node needs presentation the panel cannot give it,
|
||||||
|
the answer is a `presentation:` hint in the declaration and a `WidgetKind`,
|
||||||
|
not a branch in `develop.rs`.
|
||||||
|
- **The operation schema rejects ambiguity at build time**: a duplicate
|
||||||
|
`order:`, a filename disagreeing with its `id:`, a default outside its own
|
||||||
|
range, an expression naming something that is not a parameter. Each error
|
||||||
|
names the key you got wrong and exits rather than panicking.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Imperative subject describing the change from the reader's side — "Offer the
|
||||||
|
merge when two people turn out to share a name", not "fix: merge dialog". No
|
||||||
|
conventional-commits prefixes.
|
||||||
|
|
||||||
|
The body is where the reasoning goes, and it is expected to be substantial when
|
||||||
|
the change is. This codebase records *why* far more than most, in commits and
|
||||||
|
in comments alike, and that is the single habit most worth adopting: the
|
||||||
|
constraint you worked around is invisible to whoever reads the diff next.
|
||||||
|
|
||||||
|
One commit per change. If you fixed two things, that is two commits.
|
||||||
|
|
||||||
|
## Where to read next, in order
|
||||||
|
|
||||||
|
| Document | Read it when |
|
||||||
|
|---|---|
|
||||||
|
| [`core/dr-pipeline/ops/README.md`](core/dr-pipeline/ops/README.md) | Adding or changing a develop operation — start here regardless |
|
||||||
|
| [`docs/architecture.md`](docs/architecture.md) | Anything touching the render path, catalog or sync |
|
||||||
|
| [`docs/code-health.md`](docs/code-health.md) | Deciding what to work on; grades each seam by what it costs |
|
||||||
|
| [`docs/technical-debt.md`](docs/technical-debt.md) | Something looks wrong — check it was not chosen |
|
||||||
|
| [`docs/requirements.md`](docs/requirements.md) | Reference, not reading |
|
||||||
|
|
||||||
|
`technical-debt.md` is the one to check before "fixing" anything surprising.
|
||||||
|
It records compromises that were deliberate, each with the reasoning and a
|
||||||
|
falsifiable condition for when it stops being one — the point being that you
|
||||||
|
can tell a constraint from an accident without asking.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
GPL-3.0-or-later. By contributing you agree your work is licensed the same way.
|
||||||
Generated
+3
@@ -1528,6 +1528,9 @@ dependencies = [
|
|||||||
"keyring-core",
|
"keyring-core",
|
||||||
"log",
|
"log",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+19
@@ -115,6 +115,25 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
base64 = "0.23"
|
base64 = "0.23"
|
||||||
|
|
||||||
|
# Display-server clients, for FR-DSP-8's per-display profile acquisition.
|
||||||
|
#
|
||||||
|
# Neither is a new cost: winit already builds both, so the versions are the
|
||||||
|
# ones Slint's backend has resolved to and pinning anything else here would
|
||||||
|
# compile a second copy. Both are pure Rust — x11rb speaks the X11 wire
|
||||||
|
# protocol itself rather than binding libxcb, and wayland-client binds
|
||||||
|
# libwayland only under a feature that is off — which keeps the Android
|
||||||
|
# cross-compile a plain Rust dependency graph, the same criterion as the TLS
|
||||||
|
# and SQLite choices above. They are declared under a target predicate that
|
||||||
|
# excludes Android, where neither display server exists.
|
||||||
|
#
|
||||||
|
# `staging` on wayland-protocols is what carries `wp_color_manager_v1`: the
|
||||||
|
# colour-management extension is still staging upstream, which is the
|
||||||
|
# protocol-level statement of the thing FR-DSP-8 anticipates when it says
|
||||||
|
# Wayland's colour management "is not universally available".
|
||||||
|
x11rb = { version = "0.13", features = ["randr"] }
|
||||||
|
wayland-client = "0.31"
|
||||||
|
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||||
|
|
||||||
# Platform secure storage: Secret Service on Linux, Keystore on Android
|
# Platform secure storage: Secret Service on Linux, Keystore on Android
|
||||||
# (FR-NC-2). Credentials never touch the catalog or a plain file.
|
# (FR-NC-2). Credentials never touch the catalog or a plain file.
|
||||||
# keyring 4 restructured its features: `v1` is the default set and brings
|
# keyring 4 restructured its features: `v1` is the default set and brings
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
//! What a frame actually costs — the measurement FR-DSP-2 is waiting on.
|
||||||
|
//!
|
||||||
|
//! `docs/display-and-extension.md` §2 argues that tiled computation predates
|
||||||
|
//! the fused-shader design and may not need to exist: the composer folds every
|
||||||
|
//! active operation into **one dispatch over a viewport-sized target**, so the
|
||||||
|
//! problem tiles were invented to solve may already be solved. That argument
|
||||||
|
//! is only worth as much as the numbers behind it, and the decision rule was
|
||||||
|
//! fixed in advance — if the 99th percentile sits inside 16 ms, FR-DSP-2 is
|
||||||
|
//! rewritten as a scheduling concern for export rather than implemented on the
|
||||||
|
//! interactive path.
|
||||||
|
//!
|
||||||
|
//! This is the instrument that decides it. Three measurements:
|
||||||
|
//!
|
||||||
|
//! - **M1** — the fused pass at proxy resolution, at three viewport sizes and
|
||||||
|
//! three chain lengths.
|
||||||
|
//! - **M2** — the same with the view zoomed to 1:1 on a 60 MP source, which is
|
||||||
|
//! the case FR-DSP-5 names.
|
||||||
|
//! - **M3** — the neighbourhood stage on its own. It is the only part of the
|
||||||
|
//! chain that is not one read and one write, so it is the plausible
|
||||||
|
//! budget-breaker and deserves to be measured apart from the fused pass
|
||||||
|
//! rather than hidden inside its total.
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! cargo run --release -p dr-gpu --example frame_budget
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! **Release, always.** A debug build measures rustc's shadow, not the GPU's:
|
||||||
|
//! the per-frame CPU half is dominated by shader-source assembly, which is
|
||||||
|
//! string formatting and is several times slower unoptimised.
|
||||||
|
//!
|
||||||
|
//! The committed numbers live in `docs/frame-budget.md`. Rerun this and diff
|
||||||
|
//! that file; a regression should be a diff rather than somebody's memory.
|
||||||
|
//!
|
||||||
|
//! # Why the 99th percentile and not the mean
|
||||||
|
//!
|
||||||
|
//! A slider drag is judged by its worst frame. A chain averaging 4 ms with one
|
||||||
|
//! frame in fifty at 30 ms reads as a stutter, and the mean says nothing about
|
||||||
|
//! it. Percentiles are nearest-rank over the samples with the warm-up already
|
||||||
|
//! discarded — see [`Percentiles`].
|
||||||
|
//!
|
||||||
|
//! # What is being timed
|
||||||
|
//!
|
||||||
|
//! Each frame is `compose` (CPU: assemble the WGSL and its uniforms) followed
|
||||||
|
//! by `render_detailed` and a `poll` that waits for the device to go idle.
|
||||||
|
//! Waiting serialises the GPU work into the frame it belongs to, which is
|
||||||
|
//! pessimistic — a real presentation pipeline overlaps a frame's tail with the
|
||||||
|
//! next frame's head — and pessimistic is the right direction for a budget.
|
||||||
|
//!
|
||||||
|
//! The compose half is reported separately because it is *not* GPU work and
|
||||||
|
//! would otherwise be invisible: it happens on the UI thread on every slider
|
||||||
|
//! event, and if it were the expensive half then tiling could not help at all.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use dr_film::bake::{bake, Recipe};
|
||||||
|
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||||
|
use dr_pipeline::descriptor::ParamKind;
|
||||||
|
use dr_pipeline::ops::{
|
||||||
|
blacks_whites, contrast, exposure, highlights_shadows, local_contrast, noise_reduction,
|
||||||
|
vibrance, FilmTables,
|
||||||
|
};
|
||||||
|
use dr_pipeline::{Attribute, CropRect, EditGraph, OpId, ParamId};
|
||||||
|
|
||||||
|
/// The synthetic source: 9504 × 6336 is 60.2 MP, which is a Sony A7R V or a
|
||||||
|
/// Fujifilm GFX 100 with a little taken off. §2's M2 names 60 MP, so this is
|
||||||
|
/// that number rather than a round one.
|
||||||
|
const SOURCE: (u32, u32) = (9504, 6336);
|
||||||
|
|
||||||
|
/// Frames measured per row, after the warm-up. Enough that the 99th percentile
|
||||||
|
/// means something (nearest-rank picks the second-worst of 100) without the
|
||||||
|
/// whole matrix taking minutes.
|
||||||
|
const FRAMES: usize = 100;
|
||||||
|
|
||||||
|
/// Frames discarded before measurement begins.
|
||||||
|
///
|
||||||
|
/// The first frame at a new size allocates a render target, the first frame
|
||||||
|
/// with a new chain compiles a pipeline, and the first frame of a detail stage
|
||||||
|
/// allocates its ping-pong pair. None of those recur during a drag, so
|
||||||
|
/// including them would measure the wrong thing — but they are worth knowing
|
||||||
|
/// about, so [`Run::first_frame_ms`] reports the very first one separately.
|
||||||
|
const WARMUP: usize = 12;
|
||||||
|
|
||||||
|
/// The viewport sizes from §2's M1: a laptop panel, a 16:10 desktop, and 4K.
|
||||||
|
const SIZES: [(u32, u32); 3] = [(1920, 1200), (2560, 1600), (3840, 2160)];
|
||||||
|
|
||||||
|
/// The frame budget FR-DSP-3 is asserting against, in milliseconds. 60 Hz.
|
||||||
|
const BUDGET_MS: f64 = 16.0;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let ctx = match pollster::block_on(GpuContext::new_headless()) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("no GPU adapter: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!("adapter {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||||||
|
|
||||||
|
let limit = DemosaicedImage::max_dimension(&ctx);
|
||||||
|
if SOURCE.0 > limit {
|
||||||
|
eprintln!(
|
||||||
|
"this adapter caps textures at {limit} px; {} × {} will not fit",
|
||||||
|
SOURCE.0, SOURCE.1
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = Instant::now();
|
||||||
|
let source = synthetic_source(&ctx);
|
||||||
|
println!(
|
||||||
|
"source {} × {} ({:.1} MP, {:.0} MB as rgba16f), built in {:.1} s",
|
||||||
|
SOURCE.0,
|
||||||
|
SOURCE.1,
|
||||||
|
(SOURCE.0 as f64 * SOURCE.1 as f64) / 1e6,
|
||||||
|
(SOURCE.0 as f64 * SOURCE.1 as f64 * 8.0) / 1e6,
|
||||||
|
t.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
println!("budget {BUDGET_MS:.0} ms (FR-DSP-3)\n");
|
||||||
|
|
||||||
|
let mut adjust = AdjustPass::new(&ctx);
|
||||||
|
|
||||||
|
m1_and_m2(&ctx, &mut adjust, &source);
|
||||||
|
m3(&ctx, &mut adjust, &source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// M1 and M2 — the fused pass, fit and at 1:1
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The chain lengths §2 asks for.
|
||||||
|
///
|
||||||
|
/// "Every operation" means every operation in [`EditGraph::default_chain`],
|
||||||
|
/// which is what `ops/*.yaml` generates. The lens corrections are not in it —
|
||||||
|
/// they are constructed from a matched profile rather than being part of the
|
||||||
|
/// default chain — and neither are masks or spot repairs, which are stacks of
|
||||||
|
/// their own. So this is an upper bound on the *declared* chain and a lower
|
||||||
|
/// bound on the worst edit a photograph can carry; §7 of the spec asks for
|
||||||
|
/// honesty about exactly this sort of thing.
|
||||||
|
///
|
||||||
|
/// [`Chain::AllPoint`] is not in §2's list and is the row that decides the
|
||||||
|
/// question. §2 asks whether *one dispatch over a viewport-sized target* is
|
||||||
|
/// fast enough; "every operation" mixes that dispatch together with the
|
||||||
|
/// neighbourhood stage, which is not one dispatch and is measured separately in
|
||||||
|
/// M3. Splitting the two apart is what lets M1 answer the question it was
|
||||||
|
/// written to answer instead of a different, harder one.
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum Chain {
|
||||||
|
One,
|
||||||
|
Five,
|
||||||
|
/// Every operation that contributes a fragment to the fused shader — the
|
||||||
|
/// full point-operation chain, and nothing with a kernel.
|
||||||
|
AllPoint,
|
||||||
|
Everything,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Chain {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Chain::One => "one",
|
||||||
|
Chain::Five => "five",
|
||||||
|
Chain::AllPoint => "point",
|
||||||
|
Chain::Everything => "all",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn m1_and_m2(ctx: &GpuContext, adjust: &mut AdjustPass, source: &DemosaicedImage) {
|
||||||
|
for (title, zoomed) in [
|
||||||
|
(
|
||||||
|
"M1 — fused frame at proxy resolution (fit; the develop view)",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"M2 — the same view zoomed to 1:1 on the 60 MP source (FR-DSP-5)",
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
println!("{title}");
|
||||||
|
header();
|
||||||
|
for size in SIZES {
|
||||||
|
for chain in [Chain::One, Chain::Five, Chain::AllPoint, Chain::Everything] {
|
||||||
|
let mut graph = build_chain(chain);
|
||||||
|
if zoomed {
|
||||||
|
graph.framing_mut().set_view(one_to_one(size));
|
||||||
|
}
|
||||||
|
if matches!(chain, Chain::AllPoint | Chain::Everything) {
|
||||||
|
adjust.set_film(Some(&film_tables()));
|
||||||
|
}
|
||||||
|
// The drag: exposure, moved by a hundredth of a stop per frame.
|
||||||
|
// A real slider does exactly this, and it is what stops the
|
||||||
|
// fused dispatch being skipped by `render_detailed`'s colour
|
||||||
|
// reuse — which would turn M1 into a measurement of the detail
|
||||||
|
// stage by accident.
|
||||||
|
let run = measure(ctx, adjust, source, &mut graph, size, |g, i| {
|
||||||
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.30 + i as f32 * 0.01);
|
||||||
|
});
|
||||||
|
adjust.set_film(None);
|
||||||
|
row(size, chain.label(), &run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
println!(" `shader` is `EditGraph::compose` alone; `cpu` adds the detail chain and the");
|
||||||
|
println!(" invalidation hash, which is everything the develop view does per frame before");
|
||||||
|
println!(" it dispatches. `gpu` is submit plus wait-for-idle. `TOTAL` ranks cpu + gpu");
|
||||||
|
println!(" summed within each frame — the column the {BUDGET_MS:.0} ms budget is judged on.");
|
||||||
|
println!();
|
||||||
|
println!(" `point` is every operation that contributes a fragment to the fused shader;");
|
||||||
|
println!(" `all` is that plus the four neighbourhood operations, which is why the two");
|
||||||
|
println!(" differ by roughly what M3 charges for the detail stage on its own.");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// M3 — the detail stage alone
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Neighbourhood operations, timed with the fused dispatch deliberately reused.
|
||||||
|
///
|
||||||
|
/// `render_detailed` skips the fused pass when the colour key, the shader, its
|
||||||
|
/// uniforms and the size are all unchanged — that is FR-DEV-3d, and it is also
|
||||||
|
/// the lever that isolates M3: move only a detail operation's amount and the
|
||||||
|
/// timing covers the convolutions and nothing else. The `colour` column proves
|
||||||
|
/// the isolation held rather than asserting it: it counts fused dispatches over
|
||||||
|
/// the measured frames, and a zero there is what makes the number mean "detail
|
||||||
|
/// alone".
|
||||||
|
fn m3(ctx: &GpuContext, adjust: &mut AdjustPass, source: &DemosaicedImage) {
|
||||||
|
println!("M3 — the neighbourhood stage alone (fused dispatch reused; FR-DEV-3d)");
|
||||||
|
println!(
|
||||||
|
" {:>11} {:>8} {:>5} {:>4} {:>6} {:>6} {:>8} {:>7} {:>7} {:>7}",
|
||||||
|
"size", "stage", "view", "pass", "radius", "colour", "cpu p99", "p50", "p99", "max"
|
||||||
|
);
|
||||||
|
|
||||||
|
for size in SIZES {
|
||||||
|
for zoomed in [false, true] {
|
||||||
|
for (label, build) in DETAIL_CHAINS {
|
||||||
|
let mut graph = build();
|
||||||
|
if zoomed {
|
||||||
|
graph.framing_mut().set_view(one_to_one(size));
|
||||||
|
}
|
||||||
|
let composed = graph.compose_detail(source.size(), size);
|
||||||
|
let (passes, radius) = (composed.len(), composed.radius());
|
||||||
|
// Only the detail parameter moves, so the colour half of the
|
||||||
|
// chain is bit-identical frame to frame and gets reused.
|
||||||
|
let run = measure(ctx, adjust, source, &mut graph, size, |g, i| {
|
||||||
|
let drift = (i % 20) as f32;
|
||||||
|
g.set_param(
|
||||||
|
local_contrast::CLARITY,
|
||||||
|
local_contrast::AMOUNT,
|
||||||
|
60.0 + drift,
|
||||||
|
);
|
||||||
|
g.set_param(
|
||||||
|
local_contrast::TEXTURE,
|
||||||
|
local_contrast::AMOUNT,
|
||||||
|
60.0 + drift,
|
||||||
|
);
|
||||||
|
g.set_param(
|
||||||
|
noise_reduction::ID,
|
||||||
|
noise_reduction::LUMINANCE,
|
||||||
|
60.0 + drift,
|
||||||
|
);
|
||||||
|
g.set_param(noise_reduction::ID, noise_reduction::CHROMA, 60.0 + drift);
|
||||||
|
});
|
||||||
|
println!(
|
||||||
|
" {:>5}x{:<5} {:>8} {:>5} {:>4} {:>6} {:>6} {:>6.2}ms {:>5.2}ms {:>5.2}ms {:>5.2}ms",
|
||||||
|
size.0,
|
||||||
|
size.1,
|
||||||
|
label,
|
||||||
|
if zoomed { "1:1" } else { "fit" },
|
||||||
|
passes,
|
||||||
|
radius,
|
||||||
|
run.colour_dispatches,
|
||||||
|
run.cpu.p99,
|
||||||
|
run.frame.p50,
|
||||||
|
run.frame.p99,
|
||||||
|
run.frame.max
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!(" `pass` is dispatches in the detail chain and `radius` the widest halo any of");
|
||||||
|
println!(" them reads, in render pixels. `colour` is fused dispatches over the {FRAMES}");
|
||||||
|
println!(" measured frames, and must be 0 for the row to mean what it says.");
|
||||||
|
println!();
|
||||||
|
println!(" Clarity's kernel is a fraction of the *frame*, so it grows with the viewport");
|
||||||
|
println!(" and not with the zoom. Noise reduction's is a fraction of the *sensor*, so it");
|
||||||
|
println!(" grows with the zoom and not with the viewport. That is why both are here.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The detail chains M3 walks: the widest kernel alone, then all four
|
||||||
|
/// neighbourhood operations at once.
|
||||||
|
///
|
||||||
|
/// Clarity is first because §2 names it — "a separable blur at a large radius
|
||||||
|
/// is the plausible budget-breaker" — and its σ is 1.2% of the shorter edge,
|
||||||
|
/// which at 4K is a 52-pixel radius and the widest kernel anywhere in the
|
||||||
|
/// pipeline.
|
||||||
|
/// A named detail chain: a label for the table, and the graph it builds.
|
||||||
|
type DetailChain = (&'static str, fn() -> EditGraph);
|
||||||
|
|
||||||
|
const DETAIL_CHAINS: [DetailChain; 2] = [
|
||||||
|
("clarity", || {
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
g.set_param(local_contrast::CLARITY, local_contrast::AMOUNT, 60.0);
|
||||||
|
g
|
||||||
|
}),
|
||||||
|
("all four", || {
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
g.set_param(local_contrast::CLARITY, local_contrast::AMOUNT, 60.0);
|
||||||
|
g.set_param(local_contrast::TEXTURE, local_contrast::AMOUNT, 60.0);
|
||||||
|
g.set_param(noise_reduction::ID, noise_reduction::LUMINANCE, 60.0);
|
||||||
|
g.set_param(noise_reduction::ID, noise_reduction::CHROMA, 60.0);
|
||||||
|
g.set_param(
|
||||||
|
dr_pipeline::ops::capture_sharpen::ID,
|
||||||
|
dr_pipeline::ops::capture_sharpen::AMOUNT,
|
||||||
|
60.0,
|
||||||
|
);
|
||||||
|
g
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The measurement itself
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Nearest-rank percentiles over a set of frame times, in milliseconds.
|
||||||
|
///
|
||||||
|
/// Nearest-rank rather than an interpolating definition because the samples
|
||||||
|
/// *are* the population — there is no distribution being estimated, only a
|
||||||
|
/// hundred frames that either happened inside the budget or did not. At
|
||||||
|
/// `FRAMES = 100` the 99th percentile is the second-worst frame, which is the
|
||||||
|
/// honest reading of "one stutter in a hundred is one too many" without
|
||||||
|
/// letting a single scheduler hiccup on an unrelated process decide the
|
||||||
|
/// verdict.
|
||||||
|
struct Percentiles {
|
||||||
|
p50: f64,
|
||||||
|
p99: f64,
|
||||||
|
max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Percentiles {
|
||||||
|
fn of(mut samples: Vec<f64>) -> Self {
|
||||||
|
samples.sort_by(f64::total_cmp);
|
||||||
|
let rank = |p: f64| {
|
||||||
|
let n = samples.len();
|
||||||
|
let i = ((p * n as f64).ceil() as usize).clamp(1, n) - 1;
|
||||||
|
samples[i]
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
p50: rank(0.50),
|
||||||
|
p99: rank(0.99),
|
||||||
|
max: samples[samples.len() - 1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Run {
|
||||||
|
/// CPU: assembling the fused shader alone.
|
||||||
|
shader: Percentiles,
|
||||||
|
/// CPU: everything `DevelopSession::render` does before it dispatches —
|
||||||
|
/// the fused shader, the detail chain, and the invalidation hash the
|
||||||
|
/// colour key comes from. Split out from [`Self::shader`] because if the
|
||||||
|
/// expensive half of a frame turns out to be string formatting on the UI
|
||||||
|
/// thread, no amount of tiling helps and the conclusion is a different
|
||||||
|
/// one.
|
||||||
|
cpu: Percentiles,
|
||||||
|
/// Submit plus wait for the device to go idle.
|
||||||
|
frame: Percentiles,
|
||||||
|
/// CPU and GPU summed **per frame**, then ranked.
|
||||||
|
///
|
||||||
|
/// Not the sum of the two percentiles above, which would be a number no
|
||||||
|
/// frame ever took: the CPU's worst frame and the GPU's worst frame are
|
||||||
|
/// not generally the same frame, and adding them invents a stutter that
|
||||||
|
/// did not happen. This is the column the budget is judged on.
|
||||||
|
total: Percentiles,
|
||||||
|
/// The very first frame of all, warm-up included — pipeline compilation
|
||||||
|
/// and target allocation. Reported because it is real (it is what a
|
||||||
|
/// photograph opening costs) and because it must not be inside the
|
||||||
|
/// percentiles.
|
||||||
|
first_frame_ms: f64,
|
||||||
|
/// Fused dispatches over the measured frames. `FRAMES` when the colour
|
||||||
|
/// chain is moving, 0 when only a detail parameter is.
|
||||||
|
colour_dispatches: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render `FRAMES` frames, moving a parameter between each, and time them.
|
||||||
|
///
|
||||||
|
/// `drag` receives the frame index and is expected to move whatever this row
|
||||||
|
/// is measuring the drag of. It is called for the warm-up frames too, so that
|
||||||
|
/// nothing measured is the first of its kind.
|
||||||
|
fn measure(
|
||||||
|
ctx: &GpuContext,
|
||||||
|
adjust: &mut AdjustPass,
|
||||||
|
source: &DemosaicedImage,
|
||||||
|
graph: &mut EditGraph,
|
||||||
|
size: (u32, u32),
|
||||||
|
drag: impl Fn(&mut EditGraph, usize),
|
||||||
|
) -> Run {
|
||||||
|
// A constant, because every row here renders the same photograph. In the
|
||||||
|
// app this is the `VersionId` mixed with the colour invalidation — see
|
||||||
|
// `render_detailed`'s note on why the caller owns it.
|
||||||
|
const COLOUR_KEY: u64 = 0x0dd_ba11;
|
||||||
|
|
||||||
|
let src = source.size();
|
||||||
|
let mut first_frame_ms = f64::NAN;
|
||||||
|
|
||||||
|
for i in 0..WARMUP {
|
||||||
|
drag(graph, i);
|
||||||
|
let t = Instant::now();
|
||||||
|
frame(ctx, adjust, source, graph, src, size, COLOUR_KEY);
|
||||||
|
if i == 0 {
|
||||||
|
first_frame_ms = t.elapsed().as_secs_f64() * 1e3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let dispatches_before = adjust.colour_dispatches();
|
||||||
|
let mut shader_ms = Vec::with_capacity(FRAMES);
|
||||||
|
let mut cpu_ms = Vec::with_capacity(FRAMES);
|
||||||
|
let mut gpu_ms = Vec::with_capacity(FRAMES);
|
||||||
|
let mut total_ms = Vec::with_capacity(FRAMES);
|
||||||
|
|
||||||
|
for i in 0..FRAMES {
|
||||||
|
drag(graph, WARMUP + i);
|
||||||
|
|
||||||
|
// The same three calls `DevelopSession::render` makes, in the same
|
||||||
|
// order, so that this is the develop view's frame and not an
|
||||||
|
// idealisation of it.
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let shader = graph.compose();
|
||||||
|
let after_shader = t0.elapsed();
|
||||||
|
let detail = graph.compose_detail(src, size);
|
||||||
|
let colour_key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||||
|
let cpu = t0.elapsed();
|
||||||
|
|
||||||
|
let t1 = Instant::now();
|
||||||
|
adjust
|
||||||
|
.render_detailed(
|
||||||
|
source,
|
||||||
|
&shader,
|
||||||
|
size.0,
|
||||||
|
size.1,
|
||||||
|
None,
|
||||||
|
&detail,
|
||||||
|
colour_key ^ COLOUR_KEY,
|
||||||
|
)
|
||||||
|
.expect("render");
|
||||||
|
ctx.device
|
||||||
|
.poll(wgpu::PollType::wait_indefinitely())
|
||||||
|
.expect("poll");
|
||||||
|
let gpu = t1.elapsed();
|
||||||
|
|
||||||
|
shader_ms.push(after_shader.as_secs_f64() * 1e3);
|
||||||
|
cpu_ms.push(cpu.as_secs_f64() * 1e3);
|
||||||
|
gpu_ms.push(gpu.as_secs_f64() * 1e3);
|
||||||
|
total_ms.push((cpu + gpu).as_secs_f64() * 1e3);
|
||||||
|
}
|
||||||
|
|
||||||
|
Run {
|
||||||
|
shader: Percentiles::of(shader_ms),
|
||||||
|
cpu: Percentiles::of(cpu_ms),
|
||||||
|
frame: Percentiles::of(gpu_ms),
|
||||||
|
total: Percentiles::of(total_ms),
|
||||||
|
first_frame_ms,
|
||||||
|
colour_dispatches: adjust.colour_dispatches() - dispatches_before,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame, composition included, with nothing timed. The warm-up path.
|
||||||
|
fn frame(
|
||||||
|
ctx: &GpuContext,
|
||||||
|
adjust: &mut AdjustPass,
|
||||||
|
source: &DemosaicedImage,
|
||||||
|
graph: &EditGraph,
|
||||||
|
src: (u32, u32),
|
||||||
|
size: (u32, u32),
|
||||||
|
colour_key: u64,
|
||||||
|
) {
|
||||||
|
let shader = graph.compose();
|
||||||
|
let detail = graph.compose_detail(src, size);
|
||||||
|
let key = graph.invalidation().through(dr_pipeline::Affects::Colour) ^ colour_key;
|
||||||
|
adjust
|
||||||
|
.render_detailed(source, &shader, size.0, size.1, None, &detail, key)
|
||||||
|
.expect("render");
|
||||||
|
ctx.device
|
||||||
|
.poll(wgpu::PollType::wait_indefinitely())
|
||||||
|
.expect("poll");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header() {
|
||||||
|
println!(
|
||||||
|
" {:>11} {:>5} {:>8} {:>8} {:>8} {:>8} {:>8} {:>7}",
|
||||||
|
"size", "chain", "shader", "cpu p99", "gpu p50", "gpu p99", "TOTAL", "first"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(size: (u32, u32), chain: &str, run: &Run) {
|
||||||
|
println!(
|
||||||
|
" {:>5}x{:<5} {:>5} {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>6.2}ms {:>5.0}ms{}",
|
||||||
|
size.0,
|
||||||
|
size.1,
|
||||||
|
chain,
|
||||||
|
run.shader.p99,
|
||||||
|
run.cpu.p99,
|
||||||
|
run.frame.p50,
|
||||||
|
run.frame.p99,
|
||||||
|
run.total.p99,
|
||||||
|
run.first_frame_ms,
|
||||||
|
if run.total.p99 > BUDGET_MS {
|
||||||
|
" OVER"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The view rect that puts one render pixel on one source pixel.
|
||||||
|
///
|
||||||
|
/// This is FR-DSP-5's mechanism stated as arithmetic: the render target keeps
|
||||||
|
/// its size while the sampled region shrinks, so a view covering exactly
|
||||||
|
/// `render / source` of the frame samples one-for-one. Nothing anywhere
|
||||||
|
/// switches to a "full resolution path"; the zoom *is* the full-resolution
|
||||||
|
/// path.
|
||||||
|
fn one_to_one(render: (u32, u32)) -> CropRect {
|
||||||
|
let w = render.0 as f32 / SOURCE.0 as f32;
|
||||||
|
let h = render.1 as f32 / SOURCE.1 as f32;
|
||||||
|
CropRect {
|
||||||
|
// Centred, so the sampled region is somewhere a person would actually
|
||||||
|
// look and not a corner the caches treat differently.
|
||||||
|
x: (1.0 - w) * 0.5,
|
||||||
|
y: (1.0 - h) * 0.5,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 60 MP source with detail at every scale.
|
||||||
|
///
|
||||||
|
/// Not flat and not noise. A flat frame lets the memory system serve every
|
||||||
|
/// sample from one cache line, which flatters the wide kernels of M3 by an
|
||||||
|
/// amount that has nothing to do with photographs; pure noise does the
|
||||||
|
/// opposite. This is a coarse gradient with a fine dither on top, which is
|
||||||
|
/// closer to a real frame's spectrum than either and costs one multiply per
|
||||||
|
/// pixel to generate.
|
||||||
|
fn synthetic_source(ctx: &GpuContext) -> DemosaicedImage {
|
||||||
|
let (w, h) = SOURCE;
|
||||||
|
let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||||
|
for y in 0..h as usize {
|
||||||
|
let row = y * (w as usize) * 4;
|
||||||
|
for x in 0..w as usize {
|
||||||
|
// A cheap integer hash for the fine structure, so neighbouring
|
||||||
|
// pixels differ and a bilateral filter has something to reject.
|
||||||
|
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13;
|
||||||
|
let dither = (n & 0x1f) as u32;
|
||||||
|
let gx = (x * 200 / w as usize) as u32;
|
||||||
|
let gy = (y * 55 / h as usize) as u32;
|
||||||
|
let px = &mut rgba[row + x * 4..row + x * 4 + 4];
|
||||||
|
px[0] = (30 + gx + dither).min(255) as u8;
|
||||||
|
px[1] = (40 + gy + dither).min(255) as u8;
|
||||||
|
px[2] = (60 + gx / 2 + gy + dither).min(255) as u8;
|
||||||
|
px[3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DemosaicedImage::from_rgba8(ctx, &rgba, w, h).expect("upload source")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_chain(chain: Chain) -> EditGraph {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
match chain {
|
||||||
|
Chain::One => {
|
||||||
|
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
|
||||||
|
}
|
||||||
|
Chain::Five => {
|
||||||
|
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.3);
|
||||||
|
graph.set_param(contrast::ID, contrast::CONTRAST, 25.0);
|
||||||
|
graph.set_param(
|
||||||
|
highlights_shadows::ID,
|
||||||
|
highlights_shadows::HIGHLIGHTS,
|
||||||
|
-40.0,
|
||||||
|
);
|
||||||
|
graph.set_param(blacks_whites::ID, blacks_whites::BLACKS, -20.0);
|
||||||
|
graph.set_param(vibrance::ID, vibrance::VIBRANCE, 35.0);
|
||||||
|
}
|
||||||
|
Chain::AllPoint => {
|
||||||
|
activate_everything(&mut graph, false);
|
||||||
|
}
|
||||||
|
Chain::Everything => {
|
||||||
|
activate_everything(&mut graph, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graph
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move every parameter of every operation off its default.
|
||||||
|
///
|
||||||
|
/// Written against [`EditGraph::capabilities`] rather than as a list of
|
||||||
|
/// operations, for the same reason the panel is: this bench must not need
|
||||||
|
/// editing when an operation is added, or it will quietly stop measuring "all
|
||||||
|
/// of them" on the first day somebody declares a new node.
|
||||||
|
///
|
||||||
|
/// A quarter of the way from the default towards the maximum, which is a
|
||||||
|
/// setting a photographer might plausibly reach and — far more importantly —
|
||||||
|
/// is *active*, since an operation sitting at its neutral contributes no
|
||||||
|
/// fragment at all and would make this row a shorter chain wearing a longer
|
||||||
|
/// chain's label.
|
||||||
|
///
|
||||||
|
/// `neighbourhood` selects whether the operations declaring
|
||||||
|
/// [`Attribute::Detail`] are moved as well. Left off, what remains is exactly
|
||||||
|
/// the set that contributes a fragment to the fused shader — see [`Chain`] for
|
||||||
|
/// why that distinction is the point of this bench.
|
||||||
|
fn activate_everything(graph: &mut EditGraph, neighbourhood: bool) {
|
||||||
|
let moves: Vec<(OpId, ParamId, f32)> = graph
|
||||||
|
.capabilities()
|
||||||
|
.iter()
|
||||||
|
.filter(|op| neighbourhood || !op.attributes.contains(&Attribute::Detail))
|
||||||
|
.flat_map(|op| {
|
||||||
|
op.params.iter().map(move |p| {
|
||||||
|
let value = match p.kind {
|
||||||
|
ParamKind::Scalar { min, max, .. } => {
|
||||||
|
// Towards whichever end is further away, so a
|
||||||
|
// parameter defaulting to its maximum still moves.
|
||||||
|
let far = if (max - p.default).abs() >= (p.default - min).abs() {
|
||||||
|
max
|
||||||
|
} else {
|
||||||
|
min
|
||||||
|
};
|
||||||
|
p.default + (far - p.default) * 0.25
|
||||||
|
}
|
||||||
|
ParamKind::Bool => 1.0,
|
||||||
|
// The second variant when there is one. The first is the
|
||||||
|
// default by construction — see `ParamDescriptor::choice`.
|
||||||
|
// Bound by reference: a descriptor's variants became an
|
||||||
|
// owned `Vec` when descriptors stopped being `&'static`,
|
||||||
|
// and this arm only ever reads the length.
|
||||||
|
ParamKind::Enum { ref variants } => {
|
||||||
|
if variants.len() > 1 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(op.id, p.id, value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (op, param, value) in moves {
|
||||||
|
graph.set_param(op, param, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The film stock is not a slider and so is not reachable through the loop
|
||||||
|
// above: `FilmSim::is_active` is true when it has tables and false
|
||||||
|
// otherwise (see `graph::Film`). Without this the "all" row would be the
|
||||||
|
// whole chain minus its single most expensive node, which is a 3D lookup
|
||||||
|
// and a pair of curve textures.
|
||||||
|
graph.set_film(Some(dr_pipeline::graph::Film {
|
||||||
|
stock: STOCK.into(),
|
||||||
|
print: None,
|
||||||
|
tables: film_tables(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stock the "every operation" chain renders through. A colour negative
|
||||||
|
/// viewed directly, which is the more expensive of the two paths: the LUT is
|
||||||
|
/// consulted either way, and skipping the print step is one fewer thing that
|
||||||
|
/// could be mistaken for the measurement.
|
||||||
|
const STOCK: &str = "kodak_portra_400";
|
||||||
|
|
||||||
|
/// The stock the "all operations" row renders through, in the layout the pass
|
||||||
|
/// binds. Baked once per row; baking is milliseconds and happens off the frame
|
||||||
|
/// path in the app too.
|
||||||
|
fn film_tables() -> FilmTables {
|
||||||
|
let film = dr_film::find(STOCK).expect("stock");
|
||||||
|
let baked = bake(&Recipe::new(film, None));
|
||||||
|
FilmTables {
|
||||||
|
exposure_matrix: baked.exposure_matrix,
|
||||||
|
curves: baked.curves.clone(),
|
||||||
|
curve_log_min: baked.curve_log_min,
|
||||||
|
curve_log_max: baked.curve_log_max,
|
||||||
|
lut: baked.lut.clone(),
|
||||||
|
density_max: baked.density_max,
|
||||||
|
lut_size: baked.lut_size,
|
||||||
|
// Grain off. It is a per-pixel hash and would be measured; it is also
|
||||||
|
// not part of every edit, and the chain being measured here is "every
|
||||||
|
// operation active", not "every option of every operation".
|
||||||
|
grain_particles: [0.0; 3],
|
||||||
|
grain_density_max: [baked.density_max; 3],
|
||||||
|
grain_uniformity: 0.97,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
//! The frame budget, asserted rather than hoped for.
|
||||||
|
//!
|
||||||
|
//! FR-DSP-3 says a slider updates the visible region within one frame budget at
|
||||||
|
//! proxy resolution. Until this file existed nothing checked it, which made it
|
||||||
|
//! a wish — `docs/display-and-extension.md` §3 is blunt about that, and §7 is
|
||||||
|
//! blunt about what tagging an unchecked requirement does to the coverage
|
||||||
|
//! figure.
|
||||||
|
//!
|
||||||
|
//! The measurements this guards are in [`docs/frame-budget.md`], produced by
|
||||||
|
//! `examples/frame_budget.rs`. This file is the part of them that has to keep
|
||||||
|
//! being true: it renders the **whole point-operation chain** through the real
|
||||||
|
//! `render_detailed` for a hundred frames, moving a slider between each, and
|
||||||
|
//! fails if the 99th percentile leaves the budget.
|
||||||
|
//!
|
||||||
|
//! # What it does not cover, said out loud
|
||||||
|
//!
|
||||||
|
//! **The neighbourhood stage is deliberately not in the asserted chain.** It is
|
||||||
|
//! over the budget today — clarity alone is 34 ms at 4K, because its kernel is
|
||||||
|
//! a fraction of the frame and reaches a 52-pixel radius there — and
|
||||||
|
//! `docs/frame-budget.md` records that, names the fix (a base computed at
|
||||||
|
//! reduced resolution) and does not pretend otherwise. Asserting a budget the
|
||||||
|
//! code does not meet would produce a red suite that everyone learns to ignore;
|
||||||
|
//! asserting it on a chain that quietly excluded the expensive stage *without
|
||||||
|
//! saying so* would be the coverage overstatement §7 warns about. So it is
|
||||||
|
//! excluded, loudly, here.
|
||||||
|
//!
|
||||||
|
//! What is asserted is exactly the claim the FR-DSP-2 recommendation rests on:
|
||||||
|
//! that **one fused dispatch over a viewport-sized target is comfortably inside
|
||||||
|
//! the budget**, at a full chain, fit and at 1:1. If that stops being true, the
|
||||||
|
//! recommendation to strike tiled computation from the interactive path stops
|
||||||
|
//! being supported, and this test is what says so.
|
||||||
|
//!
|
||||||
|
//! # Why the percentile and not the mean
|
||||||
|
//!
|
||||||
|
//! A drag is judged by its worst frame. Nearest-rank over 100 frames puts the
|
||||||
|
//! 99th percentile at the second-worst, which is strict enough to catch a
|
||||||
|
//! stutter and forgiving enough that one scheduler hiccup from an unrelated
|
||||||
|
//! process does not decide the verdict.
|
||||||
|
//!
|
||||||
|
//! # Why the CPU half is asserted only in an optimised build
|
||||||
|
//!
|
||||||
|
//! Composing the shader is per-frame work on the UI thread and belongs in the
|
||||||
|
//! budget — `DevelopSession::render` calls `compose` on every frame, and on a
|
||||||
|
//! full chain it is milliseconds of string formatting. But the workspace builds
|
||||||
|
//! its own crates at `opt-level = 0` in dev (see the root `Cargo.toml`), and
|
||||||
|
//! `cargo test` is a dev build, so that formatting runs unoptimised here and
|
||||||
|
//! measures rustc rather than the pipeline. The GPU half is unaffected: a
|
||||||
|
//! shader is compiled by the driver either way.
|
||||||
|
//!
|
||||||
|
//! So the GPU half is always asserted, and the composition is folded in only
|
||||||
|
//! when `debug_assertions` is off. Running `cargo test --release -p dr-gpu`
|
||||||
|
//! therefore checks strictly more than the default run does, and the numbers
|
||||||
|
//! printed on failure say which of the two halves was over.
|
||||||
|
|
||||||
|
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||||
|
use dr_pipeline::descriptor::ParamKind;
|
||||||
|
use dr_pipeline::ops::exposure;
|
||||||
|
use dr_pipeline::{Affects, Attribute, CropRect, EditGraph, OpId, ParamId};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// 60 Hz. FR-DSP-3 does not name a number; this is the one every interactive
|
||||||
|
/// application means by "one frame".
|
||||||
|
const BUDGET_MS: f64 = 16.0;
|
||||||
|
|
||||||
|
/// Measured frames per case. Nearest-rank p99 of 100 is the second-worst.
|
||||||
|
const FRAMES: usize = 100;
|
||||||
|
|
||||||
|
/// Discarded before measurement: the first frame at a size allocates a render
|
||||||
|
/// target and the first frame of a chain compiles a pipeline. Neither recurs
|
||||||
|
/// during a drag, so neither belongs in a drag's percentile.
|
||||||
|
const WARMUP: usize = 12;
|
||||||
|
|
||||||
|
/// A 24 MP source — a full-frame camera, and large enough that a 1:1 view of it
|
||||||
|
/// is a genuine zoom rather than a rounding error.
|
||||||
|
///
|
||||||
|
/// Smaller than the bench's 60 MP on purpose. The fused pass costs what the
|
||||||
|
/// *output* costs, so the source size barely moves these numbers, and 24 MP
|
||||||
|
/// keeps the fixture inside a second even at `opt-level = 0`.
|
||||||
|
const SOURCE: (u32, u32) = (6000, 4000);
|
||||||
|
|
||||||
|
/// The viewport the budget is asserted at: a 16:10 desktop display.
|
||||||
|
///
|
||||||
|
/// Not 4K, and the reason is worth stating. At 4K the fused chain still passes
|
||||||
|
/// with room to spare (4.5 ms of GPU; see `docs/frame-budget.md`), but a test
|
||||||
|
/// that renders 8.3 M pixels a hundred times twice over is four seconds of
|
||||||
|
/// suite time to re-establish a conclusion 4.1 M pixels already establishes.
|
||||||
|
const VIEWPORT: (u32, u32) = (2560, 1600);
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||||
|
// than fail, exactly as the rest of this crate's device tests do.
|
||||||
|
match pollster::block_on(GpuContext::new_headless()) {
|
||||||
|
Ok(c) => Some(c),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("skipping: no GPU adapter ({e})");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-3 | FR-DSP-5
|
||||||
|
/// A slider drag on the full point-operation chain stays inside one frame —
|
||||||
|
/// fit, and at 1:1.
|
||||||
|
///
|
||||||
|
/// The develop view's ordinary case, at a full chain rather than a flattering
|
||||||
|
/// one: every operation that contributes a fragment to the fused shader is
|
||||||
|
/// active, and exposure moves between frames exactly as a drag moves it.
|
||||||
|
///
|
||||||
|
/// The 1:1 case is the one FR-DSP-5 names and the one FR-DSP-3's asynchronous
|
||||||
|
/// clause was written for. It is asserted here because the measurement found
|
||||||
|
/// that clause unnecessary rather than merely unimplemented: a 1:1 view is
|
||||||
|
/// *cheaper* than a fit view of the same file, since the dispatch is the same
|
||||||
|
/// size and the reads are contiguous rather than strided. If that ever inverts,
|
||||||
|
/// the argument for striking the clause weakens, and this is what would notice.
|
||||||
|
///
|
||||||
|
/// # One test and not two, deliberately
|
||||||
|
///
|
||||||
|
/// The two cases were two `#[test]` functions until the numbers said otherwise.
|
||||||
|
/// Cargo runs a binary's tests on a thread each, both of these want the same
|
||||||
|
/// GPU, and contending for it took the 1:1 case from 2.5 ms to 14.9 ms — a
|
||||||
|
/// measurement of the test harness that would have flickered either side of the
|
||||||
|
/// budget forever. A timing assertion has to own the device while it runs, and
|
||||||
|
/// the only way to say that in a test binary is to be the only test in it.
|
||||||
|
#[test]
|
||||||
|
fn a_slider_drag_stays_inside_the_frame_budget() {
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let source = synthetic_source(&ctx);
|
||||||
|
|
||||||
|
let mut fit = full_point_chain();
|
||||||
|
drag(&ctx, &source, &mut fit, VIEWPORT).assert_inside_budget("proxy resolution, fit", VIEWPORT);
|
||||||
|
|
||||||
|
let mut zoomed = full_point_chain();
|
||||||
|
zoomed.framing_mut().set_view(one_to_one(VIEWPORT));
|
||||||
|
drag(&ctx, &source, &mut zoomed, VIEWPORT)
|
||||||
|
.assert_inside_budget("1:1 on a 24 MP source", VIEWPORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The measurement
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Run {
|
||||||
|
/// Per frame: compose, compose the detail chain, hash the invalidation.
|
||||||
|
cpu_p99: f64,
|
||||||
|
/// Per frame: submit and wait for the device to go idle.
|
||||||
|
gpu_p99: f64,
|
||||||
|
/// `cpu + gpu` summed within each frame, then ranked. Not the sum of the
|
||||||
|
/// two percentiles above, which would be a frame that never happened.
|
||||||
|
total_p99: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Run {
|
||||||
|
/// Fail if the budget was missed, saying which half missed it.
|
||||||
|
///
|
||||||
|
/// In a dev build only the GPU half is judged — see the module
|
||||||
|
/// documentation for why — and the CPU figure is still printed, because a
|
||||||
|
/// reader looking at a failure wants both numbers even when only one of
|
||||||
|
/// them is the verdict.
|
||||||
|
fn assert_inside_budget(&self, case: &str, viewport: (u32, u32)) {
|
||||||
|
let judged = if cfg!(debug_assertions) {
|
||||||
|
self.gpu_p99
|
||||||
|
} else {
|
||||||
|
self.total_p99
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
judged <= BUDGET_MS,
|
||||||
|
"{case} at {}x{}: p99 of {FRAMES} frames was {judged:.2} ms, over the \
|
||||||
|
{BUDGET_MS:.0} ms budget (cpu {:.2} ms, gpu {:.2} ms, total {:.2} ms). \
|
||||||
|
FR-DSP-3 is what this violates; docs/frame-budget.md holds the \
|
||||||
|
numbers it used to be.",
|
||||||
|
viewport.0,
|
||||||
|
viewport.1,
|
||||||
|
self.cpu_p99,
|
||||||
|
self.gpu_p99,
|
||||||
|
self.total_p99,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render `FRAMES` frames with the exposure slider moving between each.
|
||||||
|
///
|
||||||
|
/// The three calls before the dispatch are the three `DevelopSession::render`
|
||||||
|
/// makes, in the same order, so this is the develop view's frame rather than an
|
||||||
|
/// idealisation of it.
|
||||||
|
fn drag(
|
||||||
|
ctx: &GpuContext,
|
||||||
|
source: &DemosaicedImage,
|
||||||
|
graph: &mut EditGraph,
|
||||||
|
viewport: (u32, u32),
|
||||||
|
) -> Run {
|
||||||
|
// Stands for the `VersionId` the app mixes in. Constant because every frame
|
||||||
|
// here is the same photograph.
|
||||||
|
const PHOTOGRAPH: u64 = 0x0dd_ba11;
|
||||||
|
|
||||||
|
let mut adjust = AdjustPass::new(ctx);
|
||||||
|
let src = source.size();
|
||||||
|
let (w, h) = viewport;
|
||||||
|
|
||||||
|
let mut cpu = Vec::with_capacity(FRAMES);
|
||||||
|
let mut gpu = Vec::with_capacity(FRAMES);
|
||||||
|
let mut total = Vec::with_capacity(FRAMES);
|
||||||
|
|
||||||
|
for i in 0..WARMUP + FRAMES {
|
||||||
|
// A hundredth of a stop per frame: what a drag does, and what stops
|
||||||
|
// `render_detailed` reusing the previous frame's colour result and
|
||||||
|
// turning this into a measurement of nothing.
|
||||||
|
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.30 + i as f32 * 0.01);
|
||||||
|
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let shader = graph.compose();
|
||||||
|
let detail = graph.compose_detail(src, viewport);
|
||||||
|
let colour_key = graph.invalidation().through(Affects::Colour) ^ PHOTOGRAPH;
|
||||||
|
let cpu_elapsed = t0.elapsed();
|
||||||
|
|
||||||
|
let t1 = Instant::now();
|
||||||
|
adjust
|
||||||
|
.render_detailed(source, &shader, w, h, None, &detail, colour_key)
|
||||||
|
.expect("render");
|
||||||
|
ctx.device
|
||||||
|
.poll(wgpu::PollType::wait_indefinitely())
|
||||||
|
.expect("poll");
|
||||||
|
let gpu_elapsed = t1.elapsed();
|
||||||
|
|
||||||
|
if i >= WARMUP {
|
||||||
|
cpu.push(cpu_elapsed.as_secs_f64() * 1e3);
|
||||||
|
gpu.push(gpu_elapsed.as_secs_f64() * 1e3);
|
||||||
|
total.push((cpu_elapsed + gpu_elapsed).as_secs_f64() * 1e3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Run {
|
||||||
|
cpu_p99: p99(cpu),
|
||||||
|
gpu_p99: p99(gpu),
|
||||||
|
total_p99: p99(total),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nearest-rank 99th percentile.
|
||||||
|
///
|
||||||
|
/// Nearest-rank because the samples *are* the population: there is no
|
||||||
|
/// distribution being estimated, only a hundred frames that either fitted in
|
||||||
|
/// the budget or did not.
|
||||||
|
fn p99(mut samples: Vec<f64>) -> f64 {
|
||||||
|
samples.sort_by(f64::total_cmp);
|
||||||
|
let n = samples.len();
|
||||||
|
samples[((0.99 * n as f64).ceil() as usize).clamp(1, n) - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Every operation that contributes a fragment to the fused shader, active.
|
||||||
|
///
|
||||||
|
/// Built from [`EditGraph::capabilities`] rather than from a list of operation
|
||||||
|
/// names, for the same reason the develop panel is: declaring a new node must
|
||||||
|
/// not silently shrink what this test calls "the full chain". A quarter of the
|
||||||
|
/// way from each parameter's default towards whichever end is further from it,
|
||||||
|
/// which is a plausible setting and — the part that matters — is never the
|
||||||
|
/// neutral, since a neutral operation contributes nothing at all.
|
||||||
|
///
|
||||||
|
/// The film stock is not reachable this way (it is a choice of material, not a
|
||||||
|
/// slider) and is left off. It is one texture lookup and two curve reads; the
|
||||||
|
/// bench includes it and it is worth about a millisecond at this size.
|
||||||
|
fn full_point_chain() -> EditGraph {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
let moves: Vec<(OpId, ParamId, f32)> = graph
|
||||||
|
.capabilities()
|
||||||
|
.iter()
|
||||||
|
// The neighbourhood operations. See the module documentation for why
|
||||||
|
// they are not here.
|
||||||
|
.filter(|op| !op.attributes.contains(&Attribute::Detail))
|
||||||
|
.flat_map(|op| {
|
||||||
|
op.params.iter().map(move |p| {
|
||||||
|
let value = match p.kind {
|
||||||
|
ParamKind::Scalar { min, max, .. } => {
|
||||||
|
let far = if (max - p.default).abs() >= (p.default - min).abs() {
|
||||||
|
max
|
||||||
|
} else {
|
||||||
|
min
|
||||||
|
};
|
||||||
|
p.default + (far - p.default) * 0.25
|
||||||
|
}
|
||||||
|
ParamKind::Bool => 1.0,
|
||||||
|
// Bound by reference: a descriptor's variants became an
|
||||||
|
// owned `Vec` when descriptors stopped being `&'static`,
|
||||||
|
// and this arm only ever reads the length.
|
||||||
|
ParamKind::Enum { ref variants } => {
|
||||||
|
if variants.len() > 1 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(op.id, p.id, value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (op, param, value) in moves {
|
||||||
|
graph.set_param(op, param, value);
|
||||||
|
}
|
||||||
|
graph
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The view rect that puts one render pixel on one source pixel, centred.
|
||||||
|
fn one_to_one(render: (u32, u32)) -> CropRect {
|
||||||
|
let w = render.0 as f32 / SOURCE.0 as f32;
|
||||||
|
let h = render.1 as f32 / SOURCE.1 as f32;
|
||||||
|
CropRect {
|
||||||
|
x: (1.0 - w) * 0.5,
|
||||||
|
y: (1.0 - h) * 0.5,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source with structure at every scale.
|
||||||
|
///
|
||||||
|
/// Not flat: a flat frame lets the memory system serve every sample of every
|
||||||
|
/// pixel from one cache line, which flatters a bandwidth-bound pass by an amount
|
||||||
|
/// that has nothing to do with photographs.
|
||||||
|
fn synthetic_source(ctx: &GpuContext) -> DemosaicedImage {
|
||||||
|
let (w, h) = SOURCE;
|
||||||
|
let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||||
|
for y in 0..h as usize {
|
||||||
|
let row = y * (w as usize) * 4;
|
||||||
|
for x in 0..w as usize {
|
||||||
|
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 13;
|
||||||
|
let dither = (n & 0x1f) as u32;
|
||||||
|
let gx = (x * 200 / w as usize) as u32;
|
||||||
|
let gy = (y * 55 / h as usize) as u32;
|
||||||
|
let px = &mut rgba[row + x * 4..row + x * 4 + 4];
|
||||||
|
px[0] = (30 + gx + dither).min(255) as u8;
|
||||||
|
px[1] = (40 + gy + dither).min(255) as u8;
|
||||||
|
px[2] = (60 + gx / 2 + gy + dither).min(255) as u8;
|
||||||
|
px[3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DemosaicedImage::from_rgba8(ctx, &rgba, w, h).expect("upload")
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
//! TRACES: FR-DSP-5
|
||||||
|
//! Zooming to 1:1 samples the source, pixel for pixel.
|
||||||
|
//!
|
||||||
|
//! FR-DSP-5: *"Fit, 1:1, and arbitrary zoom levels. At 1:1 and above, the
|
||||||
|
//! pipeline operates on the visible crop at full source resolution."*
|
||||||
|
//!
|
||||||
|
//! `Framing::view` shrinks the sampled region while the render target keeps its
|
||||||
|
//! size, so zooming *raises* the resolution the pipeline works at rather than
|
||||||
|
//! magnifying pixels it has already drawn. There is no second full-resolution
|
||||||
|
//! code path — the zoom is the full-resolution path — which is why the
|
||||||
|
//! requirement has been satisfied for some time without anyone tagging it.
|
||||||
|
//!
|
||||||
|
//! `docs/display-and-extension.md` §7 is the reason this file exists rather
|
||||||
|
//! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES`
|
||||||
|
//! comment names it, and nothing checks that the code under the tag does the
|
||||||
|
//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use.
|
||||||
|
//! So the rule that document sets is that a requirement is closed by **a test
|
||||||
|
//! that would fail if the behaviour were removed**, and these are written to
|
||||||
|
//! fail in exactly that case: delete the view from `Framing::visible_rect` and
|
||||||
|
//! the 1:1 render collapses into the fit render, which
|
||||||
|
//! [`a_proxy_cannot_resolve_the_finest_detail_in_the_source`] establishes
|
||||||
|
//! carries none of the information the 1:1 render reproduces.
|
||||||
|
//!
|
||||||
|
//! # Why the fixture is alternating columns
|
||||||
|
//!
|
||||||
|
//! Because it makes "sampled at source resolution" a *pixel* assertion rather
|
||||||
|
//! than a "something got sharper" one.
|
||||||
|
//!
|
||||||
|
//! The source is one pixel black, one pixel white, all the way across. That is
|
||||||
|
//! the highest spatial frequency the image can hold, and it is exactly what a
|
||||||
|
//! proxy render throws away: a 1024-wide source in a 128-wide viewport maps
|
||||||
|
//! output column `x` to source column `8x + 4`, every one of which has the same
|
||||||
|
//! parity, so the whole proxy comes out flat. No amount of resampling that flat
|
||||||
|
//! image recovers the stripes. If the 1:1 render shows them — and shows them in
|
||||||
|
//! the right phase, from the right place in the source — then it read the
|
||||||
|
//! source and did not magnify the proxy. There is no third explanation.
|
||||||
|
//!
|
||||||
|
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
|
||||||
|
//! non-linear, so the fused shader decodes sRGB before the chain and re-encodes
|
||||||
|
//! after it. Bytes 0 and 255 are fixed points of that round trip, which is why
|
||||||
|
//! the pattern is black and white and why the comparison can be for equality
|
||||||
|
//! rather than within a tolerance.
|
||||||
|
|
||||||
|
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||||
|
use dr_pipeline::{CropRect, EditGraph};
|
||||||
|
|
||||||
|
/// A power of two, so every view rect below is exact in binary32 and the
|
||||||
|
/// mapping from output column to source column is exact arithmetic rather than
|
||||||
|
/// something that happens to round the right way.
|
||||||
|
const SOURCE: u32 = 1024;
|
||||||
|
|
||||||
|
/// The viewport. `SOURCE / RENDER` is 8, so a fit render steps eight source
|
||||||
|
/// columns per output column — four full periods of the pattern.
|
||||||
|
const RENDER: u32 = 128;
|
||||||
|
|
||||||
|
/// Where the 1:1 window sits in the source. **Odd on both axes on purpose**: a
|
||||||
|
/// view that honoured `width` but dropped `x` would land on the opposite phase
|
||||||
|
/// of the stripes and produce an exactly inverted image, which is the most
|
||||||
|
/// likely way for this to be subtly wrong and the one an assertion about
|
||||||
|
/// "contrast" or "variance" would sail straight past.
|
||||||
|
const WINDOW: (u32, u32) = (301, 157);
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
// CI runners and headless machines may have no usable adapter. Skip rather
|
||||||
|
// than fail, exactly as the rest of this crate's device tests do.
|
||||||
|
match pollster::block_on(GpuContext::new_headless()) {
|
||||||
|
Ok(c) => Some(c),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("skipping: no GPU adapter ({e})");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Black and white alternating every column: the finest detail an image can
|
||||||
|
/// carry.
|
||||||
|
fn stripes(ctx: &GpuContext) -> DemosaicedImage {
|
||||||
|
let data: Vec<u8> = (0..SOURCE * SOURCE)
|
||||||
|
.flat_map(|i| {
|
||||||
|
let v = if (i % SOURCE).is_multiple_of(2) {
|
||||||
|
0u8
|
||||||
|
} else {
|
||||||
|
255
|
||||||
|
};
|
||||||
|
[v, v, v, 255]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
DemosaicedImage::from_rgba8(ctx, &data, SOURCE, SOURCE).expect("upload")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the source holds at `(x, y)` — the same rule [`stripes`] wrote.
|
||||||
|
fn source_byte(x: u32, _y: u32) -> u8 {
|
||||||
|
if x.is_multiple_of(2) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a neutral edit through `view` and hand back the bytes and the size.
|
||||||
|
///
|
||||||
|
/// Neutral because this is a test about *which pixel* is read, and any active
|
||||||
|
/// operation would put a colour transform between the source byte and the
|
||||||
|
/// rendered one for no gain.
|
||||||
|
fn render(ctx: &GpuContext, source: &DemosaicedImage, view: CropRect) -> (Vec<u8>, u32, u32) {
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.framing_mut().set_view(view);
|
||||||
|
let shader = graph.compose();
|
||||||
|
|
||||||
|
let mut adjust = AdjustPass::new(ctx);
|
||||||
|
adjust
|
||||||
|
.render(source, &shader, RENDER, RENDER)
|
||||||
|
.expect("render");
|
||||||
|
adjust.export_pixels().expect("readback")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The view rect that puts one render pixel on one source pixel, with its
|
||||||
|
/// top-left corner at `WINDOW`.
|
||||||
|
fn one_to_one() -> CropRect {
|
||||||
|
CropRect {
|
||||||
|
x: WINDOW.0 as f32 / SOURCE as f32,
|
||||||
|
y: WINDOW.1 as f32 / SOURCE as f32,
|
||||||
|
width: RENDER as f32 / SOURCE as f32,
|
||||||
|
height: RENDER as f32 / SOURCE as f32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The red channel of one row of a rendered frame.
|
||||||
|
fn row(pixels: &[u8], width: u32, y: u32) -> Vec<u8> {
|
||||||
|
(0..width)
|
||||||
|
.map(|x| pixels[((y * width + x) * 4) as usize])
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-5
|
||||||
|
/// The proxy render carries none of the source's finest detail.
|
||||||
|
///
|
||||||
|
/// Half of the argument, and the half that makes the other half mean something:
|
||||||
|
/// if the fit render already showed the stripes, a 1:1 render showing them would
|
||||||
|
/// prove nothing at all. It comes out uniform, so whatever the 1:1 render
|
||||||
|
/// contains cannot have come from resampling it.
|
||||||
|
#[test]
|
||||||
|
fn a_proxy_cannot_resolve_the_finest_detail_in_the_source() {
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let source = stripes(&ctx);
|
||||||
|
|
||||||
|
let (pixels, w, h) = render(&ctx, &source, CropRect::default());
|
||||||
|
assert_eq!((w, h), (RENDER, RENDER));
|
||||||
|
|
||||||
|
let first = pixels[0];
|
||||||
|
let uniform = pixels
|
||||||
|
.chunks_exact(4)
|
||||||
|
.all(|px| px[0] == first && px[1] == first && px[2] == first);
|
||||||
|
assert!(
|
||||||
|
uniform,
|
||||||
|
"the fit render of a one-pixel stripe pattern should be flat — a 1024 px \
|
||||||
|
source in a {RENDER} px viewport steps 8 source columns per output \
|
||||||
|
column, so every sample lands on the same phase. It was not: row 0 is \
|
||||||
|
{:?}. Either the sampling changed or the fixture no longer says what it \
|
||||||
|
is meant to, and the 1:1 test below is worthless until this is true \
|
||||||
|
again.",
|
||||||
|
&row(&pixels, w, 0)[..16.min(w as usize)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-5
|
||||||
|
/// At 1:1 the render *is* the source region, byte for byte.
|
||||||
|
///
|
||||||
|
/// The requirement's actual content — "at 1:1 and above, the pipeline operates
|
||||||
|
/// on the visible crop at full source resolution" — stated as the strongest
|
||||||
|
/// thing that could be true of it: not that the result is sharper, but that
|
||||||
|
/// output pixel `(x, y)` is source pixel `(WINDOW.0 + x, WINDOW.1 + y)` and
|
||||||
|
/// nothing has been interpolated, averaged or magnified on the way.
|
||||||
|
#[test]
|
||||||
|
fn a_one_to_one_view_reproduces_the_source_pixel_for_pixel() {
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let source = stripes(&ctx);
|
||||||
|
|
||||||
|
let (pixels, w, h) = render(&ctx, &source, one_to_one());
|
||||||
|
|
||||||
|
// The render target keeps its size while the sampled region shrinks. That
|
||||||
|
// is the whole mechanism, and a zoom that resized the target would be
|
||||||
|
// magnification rather than resolution.
|
||||||
|
assert_eq!(
|
||||||
|
(w, h),
|
||||||
|
(RENDER, RENDER),
|
||||||
|
"zooming must not change the size of the render target"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut mismatches = Vec::new();
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let got = pixels[((y * w + x) * 4) as usize];
|
||||||
|
let want = source_byte(WINDOW.0 + x, WINDOW.1 + y);
|
||||||
|
if got != want && mismatches.len() < 8 {
|
||||||
|
mismatches.push((x, y, got, want));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
mismatches.is_empty(),
|
||||||
|
"a 1:1 view starting at {WINDOW:?} must reproduce the source exactly. \
|
||||||
|
First mismatches (x, y, got, want): {mismatches:?}\n\
|
||||||
|
rendered row 0: {:?}\n\
|
||||||
|
source row 0: {:?}\n\
|
||||||
|
An exactly inverted row means the view's *offset* was dropped while its \
|
||||||
|
width was honoured; a flat row means the view was ignored altogether \
|
||||||
|
and the pipeline is still rendering the proxy.",
|
||||||
|
&row(&pixels, w, 0)[..12],
|
||||||
|
(0..12)
|
||||||
|
.map(|x| source_byte(WINDOW.0 + x, WINDOW.1))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-5
|
||||||
|
/// An arbitrary zoom between fit and 1:1 samples at the ratio it asks for.
|
||||||
|
///
|
||||||
|
/// FR-DSP-5 says "fit, 1:1, **and arbitrary zoom levels**", and the two tests
|
||||||
|
/// above only pin the ends. This one takes the middle: a view a quarter of the
|
||||||
|
/// frame wide, which puts four source pixels behind each output pixel, and
|
||||||
|
/// checks that the pipeline reports and samples at that ratio rather than
|
||||||
|
/// snapping to one of the two cases anybody would have special-cased.
|
||||||
|
///
|
||||||
|
/// `render_scale` is asserted alongside the pixels because it is what the
|
||||||
|
/// neighbourhood stage converts kernel radii through: a zoom that moved the
|
||||||
|
/// pixels but not the scale would silently sharpen at the wrong radius, which
|
||||||
|
/// is invisible until somebody compares a preview against an export.
|
||||||
|
#[test]
|
||||||
|
fn an_arbitrary_zoom_samples_at_the_ratio_it_asks_for() {
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let source = stripes(&ctx);
|
||||||
|
|
||||||
|
// A quarter of the frame: 256 source columns across 128 output columns.
|
||||||
|
let view = CropRect {
|
||||||
|
x: 0.25,
|
||||||
|
y: 0.25,
|
||||||
|
width: 0.25,
|
||||||
|
height: 0.25,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.framing_mut().set_view(view);
|
||||||
|
|
||||||
|
// The region on screen is 256 source pixels wide, rendered into 128, so the
|
||||||
|
// ratio is one render pixel per two source pixels.
|
||||||
|
let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER));
|
||||||
|
assert_eq!(scale.full_size(), (256, 256));
|
||||||
|
assert!(
|
||||||
|
(scale.ratio() - 0.5).abs() < 1e-6,
|
||||||
|
"ratio {}",
|
||||||
|
scale.ratio()
|
||||||
|
);
|
||||||
|
|
||||||
|
let (pixels, w, _) = render(&ctx, &source, view);
|
||||||
|
|
||||||
|
// Where output column `x` reads from, worked through rather than asserted
|
||||||
|
// from a previous run — a test that recomputed this with the shader's own
|
||||||
|
// expression would agree with a bug in it.
|
||||||
|
//
|
||||||
|
// uv = 0.25 + (x + 0.5) / 128 * 0.25 = (128 + x + 0.5) / 512
|
||||||
|
// col = floor(uv * 1024) = floor(256 + 2x + 1.0) = 257 + 2x
|
||||||
|
//
|
||||||
|
// Odd for every `x`, so this zoom lands flat — as the fit render does, and
|
||||||
|
// for the same reason. **The 1.0 is the interesting part.** At a two-to-one
|
||||||
|
// downsample an output pixel's centre falls exactly on the boundary between
|
||||||
|
// the two source pixels it covers, and truncation takes the right-hand one.
|
||||||
|
// That is nearest-neighbour behaving correctly and not an off-by-one; a
|
||||||
|
// reader checking this file by hand will get 256 on the first attempt, so
|
||||||
|
// it is written out.
|
||||||
|
const SAMPLED: u32 = 257;
|
||||||
|
|
||||||
|
let first = pixels[0];
|
||||||
|
assert!(
|
||||||
|
pixels.chunks_exact(4).all(|px| px[0] == first),
|
||||||
|
"a two-to-one zoom steps two source columns per output column, so every \
|
||||||
|
sample has the same parity and the frame should be flat. row 0: {:?}",
|
||||||
|
&row(&pixels, w, 0)[..12]
|
||||||
|
);
|
||||||
|
// And it is flat on the *right* phase. A pipeline that had ignored the view
|
||||||
|
// entirely would also be flat — but its `ratio` would not be 0.5, which the
|
||||||
|
// assertion above already rules out — and one that had snapped to 1:1 would
|
||||||
|
// show the stripes instead. Between them, only sampling the window the view
|
||||||
|
// actually asked for produces this.
|
||||||
|
assert_eq!(
|
||||||
|
first,
|
||||||
|
source_byte(SAMPLED, SAMPLED),
|
||||||
|
"a view starting a quarter of the way across a {SOURCE} px source should \
|
||||||
|
sample from source column {SAMPLED}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,12 @@ license.workspace = true
|
|||||||
dr-types.workspace = true
|
dr-types.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|
||||||
|
# A dependency of the library, not only of the build script, since FR-PLG-2:
|
||||||
|
# `src/declared/` reads the same declaration format at *load* time, so that an
|
||||||
|
# operation found in a file at startup is the same kind of thing as one found
|
||||||
|
# at compile time. The reader itself is one file shared by both.
|
||||||
|
serde_norway.workspace = true
|
||||||
|
|
||||||
# Nodes are declared in `ops/*.yaml` and compiled to Rust by `build.rs`
|
# Nodes are declared in `ops/*.yaml` and compiled to Rust by `build.rs`
|
||||||
# (ARCH §5.7). The same reasoning as `ui/dr-ui`'s style.yaml: the declaration
|
# (ARCH §5.7). The same reasoning as `ui/dr-ui`'s style.yaml: the declaration
|
||||||
# is the source of truth, the Rust is generated into OUT_DIR where it cannot
|
# is the source of truth, the Rust is generated into OUT_DIR where it cannot
|
||||||
|
|||||||
+216
-1295
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,23 @@ directory — there is no list to extend, no shader to edit, and no UI change.
|
|||||||
`../build.rs` compiles each declaration into Rust implementing
|
`../build.rs` compiles each declaration into Rust implementing
|
||||||
[`Operation`](../src/operation.rs), generated into `OUT_DIR`. The result is
|
[`Operation`](../src/operation.rs), generated into `OUT_DIR`. The result is
|
||||||
indistinguishable downstream from a hand-written operation: the same
|
indistinguishable downstream from a hand-written operation: the same
|
||||||
`&'static OpDescriptor`, the same fused-shader composition, the same sidecar
|
`Arc<OpDescriptor>`, the same fused-shader composition, the same sidecar
|
||||||
round-trip.
|
round-trip.
|
||||||
|
|
||||||
|
**The same declaration also runs without being compiled** (FR-PLG-2).
|
||||||
|
[`DeclaredOp`](../src/declared/mod.rs) reads this format at *load* time and
|
||||||
|
implements `Operation` from it directly — one interpreter over many
|
||||||
|
declarations, where `build.rs` emits generated code per node. Both paths exist
|
||||||
|
on purpose: the built-ins stay compiled, because a generated `match` is faster
|
||||||
|
than an interpreted one and because the `tests:` blocks below have to run under
|
||||||
|
`cargo test`.
|
||||||
|
|
||||||
|
The reading half is one file, [`src/declared/decl.rs`](../src/declared/decl.rs),
|
||||||
|
shared by both — so everything documented here means exactly one thing, and
|
||||||
|
`tests/declared_parity.rs` asserts the two backends compose byte-identical WGSL
|
||||||
|
for every node in this directory. Nothing in this document is specific to the
|
||||||
|
build-time path.
|
||||||
|
|
||||||
## `attributes:` — what the operation is about
|
## `attributes:` — what the operation is about
|
||||||
|
|
||||||
Required, one or more of `tone`, `colour`, `detail`, `optics`, `geometry`,
|
Required, one or more of `tone`, `colour`, `detail`, `optics`, `geometry`,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
|||||||
|
//! TRACES: FR-PLG-2
|
||||||
|
//! The little expression language a node declaration derives its uniforms in.
|
||||||
|
//!
|
||||||
|
//! Uniforms are functions of parameters — `exp2(exposure)`, `blacks / 100 *
|
||||||
|
//! 0.02` — and that derivation is the one piece of a node that is genuinely
|
||||||
|
//! computation rather than description. The language is arithmetic over the
|
||||||
|
//! node's own parameters plus a fixed set of maths functions: enough for every
|
||||||
|
//! operation in the chain, and small enough that a reader of the YAML can see
|
||||||
|
//! exactly what will happen.
|
||||||
|
//!
|
||||||
|
//! # Why this file is compiled twice
|
||||||
|
//!
|
||||||
|
//! It is `#[path]`-included by `build.rs` as well as being a module of the
|
||||||
|
//! crate, and it deliberately depends on nothing but `std` so that it can be.
|
||||||
|
//!
|
||||||
|
//! There are two backends over one grammar. `build.rs` renders an [`Expr`] to
|
||||||
|
//! Rust source, so a built-in node's arithmetic costs nothing at run time and
|
||||||
|
//! an unknown name is a build error naming the file. [`Expr::eval`] evaluates
|
||||||
|
//! the same tree directly, which is what lets a declaration loaded at run time
|
||||||
|
//! produce uniforms without a compiler.
|
||||||
|
//!
|
||||||
|
//! **The two must agree bit for bit**, or a plugin is not the same kind of
|
||||||
|
//! thing as a built-in and `tests/declared_parity.rs` says so. Sharing the
|
||||||
|
//! tokeniser and the parser removes the larger half of the ways they could
|
||||||
|
//! drift; the remaining half is the pair of backends, and each entry in
|
||||||
|
//! [`FUNCTIONS`] below is written twice on purpose, once here and once in
|
||||||
|
//! `build.rs`, with the parity test standing between them.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// A number in a declaration, as the `f32` the arithmetic will actually use.
|
||||||
|
///
|
||||||
|
/// **The route through the decimal string is load-bearing, not clumsiness.**
|
||||||
|
/// `build.rs` renders a number as a Rust literal — `0.02f32` — and `rustc`
|
||||||
|
/// rounds that decimal text to the nearest `f32` exactly once. Writing
|
||||||
|
/// `n as f32` here would instead round the `f64` YAML parsed to the nearest
|
||||||
|
/// `f32`, which is a *second* rounding on top of the one that produced the
|
||||||
|
/// `f64`, and double rounding does not always land where single rounding does.
|
||||||
|
///
|
||||||
|
/// So this reproduces what the compiler sees: `{:?}` is the shortest decimal
|
||||||
|
/// that round-trips the `f64`, which is exactly the literal `build.rs` emits,
|
||||||
|
/// and parsing it as `f32` is exactly what `rustc` does with it.
|
||||||
|
pub fn as_f32(n: f64) -> f32 {
|
||||||
|
// Infallible in practice: `{:?}` on a finite `f64` is always a parseable
|
||||||
|
// decimal, and the infinities and NaN it can also print all parse back.
|
||||||
|
// The fallback is the direct cast rather than a panic, because a bad
|
||||||
|
// number in a declaration is the reader's error to report, not this
|
||||||
|
// function's to crash on.
|
||||||
|
format!("{n:?}").parse::<f32>().unwrap_or(n as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The maths functions a declaration may call, and how many arguments each
|
||||||
|
/// takes.
|
||||||
|
///
|
||||||
|
/// A closed list rather than a passthrough to `f32`: a node is a description,
|
||||||
|
/// and letting it name arbitrary Rust would make the YAML a second, worse
|
||||||
|
/// place to write code. Closed for the same reason `WidgetKind` and
|
||||||
|
/// `attributes` are closed (FR-PLG-2d) — a typo must be an error rather than a
|
||||||
|
/// silent new category of one.
|
||||||
|
///
|
||||||
|
/// Shared by both backends so that the *set* of callable functions cannot
|
||||||
|
/// drift even though the two translations of each must be written separately.
|
||||||
|
pub const FUNCTIONS: &[(&str, usize)] = &[
|
||||||
|
("exp2", 1),
|
||||||
|
("log2", 1),
|
||||||
|
("exp", 1),
|
||||||
|
("sqrt", 1),
|
||||||
|
("abs", 1),
|
||||||
|
("floor", 1),
|
||||||
|
("ceil", 1),
|
||||||
|
("round", 1),
|
||||||
|
("pow", 2),
|
||||||
|
("min", 2),
|
||||||
|
("max", 2),
|
||||||
|
("clamp", 3),
|
||||||
|
("mix", 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A parsed expression over a node's parameters.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Expr {
|
||||||
|
Num(f64),
|
||||||
|
Param(String),
|
||||||
|
Neg(Box<Expr>),
|
||||||
|
Bin(char, Box<Expr>, Box<Expr>),
|
||||||
|
Call(String, Vec<Expr>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse and validate an expression against the parameters a node declares.
|
||||||
|
///
|
||||||
|
/// Validation happens here rather than in either backend, so that "this names
|
||||||
|
/// a parameter that does not exist" is one error message in one place and
|
||||||
|
/// cannot be reported at build time but missed at load time.
|
||||||
|
pub fn parse(src: &str, params: &BTreeSet<&str>) -> Result<Expr, String> {
|
||||||
|
let tokens = tokenise(src)?;
|
||||||
|
let mut parser = Parser { tokens, at: 0 };
|
||||||
|
let expr = parser.expr()?;
|
||||||
|
if parser.at < parser.tokens.len() {
|
||||||
|
return Err(format!(
|
||||||
|
"unexpected `{}` after the end of the expression",
|
||||||
|
parser.tokens[parser.at]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
check(&expr, params)?;
|
||||||
|
Ok(expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every name and arity in the tree resolves.
|
||||||
|
fn check(expr: &Expr, params: &BTreeSet<&str>) -> Result<(), String> {
|
||||||
|
match expr {
|
||||||
|
Expr::Num(_) => Ok(()),
|
||||||
|
Expr::Param(name) => {
|
||||||
|
if params.contains(name.as_str()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let known: Vec<&str> = params.iter().copied().collect();
|
||||||
|
Err(format!(
|
||||||
|
"`{name}` is not a parameter of this node. Its parameters \
|
||||||
|
are: {}",
|
||||||
|
known.join(", ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Expr::Neg(inner) => check(inner, params),
|
||||||
|
Expr::Bin(_, l, r) => {
|
||||||
|
check(l, params)?;
|
||||||
|
check(r, params)
|
||||||
|
}
|
||||||
|
Expr::Call(name, args) => {
|
||||||
|
let Some((_, arity)) = FUNCTIONS.iter().find(|(f, _)| *f == name) else {
|
||||||
|
let known: Vec<&str> = FUNCTIONS.iter().map(|(f, _)| *f).collect();
|
||||||
|
return Err(format!(
|
||||||
|
"`{name}` is not one of the maths functions a node may \
|
||||||
|
call. Available: {}",
|
||||||
|
known.join(", ")
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if args.len() != *arity {
|
||||||
|
return Err(format!(
|
||||||
|
"`{name}` takes {arity} argument(s), given {}",
|
||||||
|
args.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for a in args {
|
||||||
|
check(a, params)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Expr {
|
||||||
|
/// TRACES: FR-PLG-2
|
||||||
|
/// Evaluate this expression for a set of parameter values.
|
||||||
|
///
|
||||||
|
/// **Every step is an `f32` operation in the same order `build.rs` renders
|
||||||
|
/// it**, which is what makes the interpreted result bit-identical to the
|
||||||
|
/// compiled one rather than merely close. Rust's `f32` arithmetic is IEEE
|
||||||
|
/// 754 with no excess precision, so `(a * b) + c` here and `(a * b) + c`
|
||||||
|
/// in generated source are the same number down to the last bit — and the
|
||||||
|
/// parity test asserts exactly that rather than an epsilon, because a
|
||||||
|
/// tolerance is how a real divergence gets to hide.
|
||||||
|
///
|
||||||
|
/// `param` is asked for a parameter's current value. It is a closure
|
||||||
|
/// rather than a map so the caller can serve the values out of whatever it
|
||||||
|
/// already has, which for [`super::DeclaredOp`] is a plain `Vec<f32>`
|
||||||
|
/// indexed in declaration order.
|
||||||
|
///
|
||||||
|
/// Infallible: [`parse`] has already established that every name resolves
|
||||||
|
/// and every call has the right arity. An unknown parameter reaching here
|
||||||
|
/// would be a reader that let one through, so `param` decides what to do
|
||||||
|
/// about it rather than this returning a `Result` every caller would
|
||||||
|
/// unwrap.
|
||||||
|
pub fn eval(&self, param: &dyn Fn(&str) -> f32) -> f32 {
|
||||||
|
match self {
|
||||||
|
Expr::Num(n) => as_f32(*n),
|
||||||
|
Expr::Param(name) => param(name),
|
||||||
|
Expr::Neg(inner) => -inner.eval(param),
|
||||||
|
Expr::Bin(op, l, r) => {
|
||||||
|
let (l, r) = (l.eval(param), r.eval(param));
|
||||||
|
match op {
|
||||||
|
'+' => l + r,
|
||||||
|
'-' => l - r,
|
||||||
|
'*' => l * r,
|
||||||
|
'/' => l / r,
|
||||||
|
// `tokenise` only ever produces these four as binary
|
||||||
|
// operators, and `Parser` only ever builds `Bin` from what
|
||||||
|
// `tokenise` produced.
|
||||||
|
_ => unreachable!("`{op}` is not a binary operator"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expr::Call(name, args) => {
|
||||||
|
let a = |i: usize| args[i].eval(param);
|
||||||
|
match name.as_str() {
|
||||||
|
"exp2" => f32::exp2(a(0)),
|
||||||
|
"log2" => f32::log2(a(0)),
|
||||||
|
"exp" => f32::exp(a(0)),
|
||||||
|
"sqrt" => f32::sqrt(a(0)),
|
||||||
|
"abs" => f32::abs(a(0)),
|
||||||
|
"floor" => f32::floor(a(0)),
|
||||||
|
"ceil" => f32::ceil(a(0)),
|
||||||
|
"round" => f32::round(a(0)),
|
||||||
|
"pow" => f32::powf(a(0), a(1)),
|
||||||
|
"min" => f32::min(a(0), a(1)),
|
||||||
|
"max" => f32::max(a(0), a(1)),
|
||||||
|
"clamp" => f32::clamp(a(0), a(1), a(2)),
|
||||||
|
// Spelled out rather than called, matching what `build.rs`
|
||||||
|
// renders: Rust has no `mix`, and this linear form is what
|
||||||
|
// WGSL's `mix` means. The association matters — `a + (b -
|
||||||
|
// a) * t` and `a * (1 - t) + b * t` are the same value in
|
||||||
|
// real arithmetic and different ones in `f32`.
|
||||||
|
"mix" => {
|
||||||
|
let (x, y, t) = (a(0), a(1), a(2));
|
||||||
|
x + (y - x) * t
|
||||||
|
}
|
||||||
|
// `parse` rejects anything not in `FUNCTIONS`.
|
||||||
|
_ => unreachable!("`{name}` is not a declared maths function"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Tok {
|
||||||
|
Num(f64),
|
||||||
|
Ident(String),
|
||||||
|
Sym(char),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Tok {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Tok::Num(n) => write!(f, "{n}"),
|
||||||
|
Tok::Ident(s) => write!(f, "{s}"),
|
||||||
|
Tok::Sym(c) => write!(f, "{c}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokenise(src: &str) -> Result<Vec<Tok>, String> {
|
||||||
|
let bytes: Vec<char> = src.chars().collect();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let c = bytes[i];
|
||||||
|
if c.is_whitespace() {
|
||||||
|
i += 1;
|
||||||
|
} else if c.is_ascii_digit()
|
||||||
|
|| (c == '.' && bytes.get(i + 1).is_some_and(char::is_ascii_digit))
|
||||||
|
{
|
||||||
|
let start = i;
|
||||||
|
while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let text: String = bytes[start..i].iter().collect();
|
||||||
|
let n = text
|
||||||
|
.parse::<f64>()
|
||||||
|
.map_err(|_| format!("`{text}` is not a number"))?;
|
||||||
|
out.push(Tok::Num(n));
|
||||||
|
} else if c.is_ascii_alphabetic() || c == '_' {
|
||||||
|
let start = i;
|
||||||
|
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out.push(Tok::Ident(bytes[start..i].iter().collect()));
|
||||||
|
} else if "+-*/(),".contains(c) {
|
||||||
|
out.push(Tok::Sym(c));
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
return Err(format!(
|
||||||
|
"`{c}` is not valid in an expression; the language is \
|
||||||
|
arithmetic (+ - * /), parentheses, numbers, this node's \
|
||||||
|
parameters, and the maths functions"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
return Err("the expression is empty".into());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Parser {
|
||||||
|
tokens: Vec<Tok>,
|
||||||
|
at: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parser {
|
||||||
|
fn peek(&self) -> Option<&Tok> {
|
||||||
|
self.tokens.get(self.at)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eat(&mut self, sym: char) -> bool {
|
||||||
|
if self.peek() == Some(&Tok::Sym(sym)) {
|
||||||
|
self.at += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expr(&mut self) -> Result<Expr, String> {
|
||||||
|
let mut left = self.term()?;
|
||||||
|
loop {
|
||||||
|
if self.eat('+') {
|
||||||
|
left = Expr::Bin('+', Box::new(left), Box::new(self.term()?));
|
||||||
|
} else if self.eat('-') {
|
||||||
|
left = Expr::Bin('-', Box::new(left), Box::new(self.term()?));
|
||||||
|
} else {
|
||||||
|
return Ok(left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn term(&mut self) -> Result<Expr, String> {
|
||||||
|
let mut left = self.unary()?;
|
||||||
|
loop {
|
||||||
|
if self.eat('*') {
|
||||||
|
left = Expr::Bin('*', Box::new(left), Box::new(self.unary()?));
|
||||||
|
} else if self.eat('/') {
|
||||||
|
left = Expr::Bin('/', Box::new(left), Box::new(self.unary()?));
|
||||||
|
} else {
|
||||||
|
return Ok(left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unary(&mut self) -> Result<Expr, String> {
|
||||||
|
if self.eat('-') {
|
||||||
|
return Ok(Expr::Neg(Box::new(self.unary()?)));
|
||||||
|
}
|
||||||
|
self.primary()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn primary(&mut self) -> Result<Expr, String> {
|
||||||
|
match self.peek().cloned() {
|
||||||
|
Some(Tok::Num(n)) => {
|
||||||
|
self.at += 1;
|
||||||
|
Ok(Expr::Num(n))
|
||||||
|
}
|
||||||
|
Some(Tok::Ident(name)) => {
|
||||||
|
self.at += 1;
|
||||||
|
if !self.eat('(') {
|
||||||
|
return Ok(Expr::Param(name));
|
||||||
|
}
|
||||||
|
let mut args = Vec::new();
|
||||||
|
if !self.eat(')') {
|
||||||
|
loop {
|
||||||
|
args.push(self.expr()?);
|
||||||
|
if self.eat(',') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if self.eat(')') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return Err(format!("expected `,` or `)` in the call to `{name}`"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Expr::Call(name, args))
|
||||||
|
}
|
||||||
|
Some(Tok::Sym('(')) => {
|
||||||
|
self.at += 1;
|
||||||
|
let inner = self.expr()?;
|
||||||
|
if !self.eat(')') {
|
||||||
|
return Err("unclosed `(`".into());
|
||||||
|
}
|
||||||
|
Ok(inner)
|
||||||
|
}
|
||||||
|
Some(t) => Err(format!("unexpected `{t}`")),
|
||||||
|
None => Err("the expression ends early".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn params() -> BTreeSet<&'static str> {
|
||||||
|
["a", "b"].into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval(src: &str, a: f32, b: f32) -> f32 {
|
||||||
|
parse(src, ¶ms())
|
||||||
|
.expect("parses")
|
||||||
|
.eval(&|name| match name {
|
||||||
|
"a" => a,
|
||||||
|
"b" => b,
|
||||||
|
other => panic!("no parameter {other}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arithmetic_follows_the_usual_precedence() {
|
||||||
|
assert_eq!(eval("a + b * 2", 1.0, 3.0), 7.0);
|
||||||
|
assert_eq!(eval("(a + b) * 2", 1.0, 3.0), 8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unary_minus_binds_tighter_than_addition() {
|
||||||
|
// `-(a + b)` and `-a + b` are different numbers, and the parser has to
|
||||||
|
// agree with the renderer about which one `-a + b` is.
|
||||||
|
assert_eq!(eval("-a + b", 1.0, 3.0), 2.0);
|
||||||
|
assert_eq!(eval("-(a + b)", 1.0, 3.0), -4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_number_is_rounded_once_the_way_the_compiler_rounds_a_literal() {
|
||||||
|
// The whole reason `as_f32` goes through the decimal string. If this
|
||||||
|
// ever regresses to `n as f32`, the values a declared node produces
|
||||||
|
// drift from the generated one in the last bit, and every parity
|
||||||
|
// assertion has to become a tolerance to keep passing — which is
|
||||||
|
// exactly the silent disagreement the test exists to catch.
|
||||||
|
assert_eq!(as_f32(0.02), 0.02f32);
|
||||||
|
assert_eq!(as_f32(0.1), 0.1f32);
|
||||||
|
// A third: eight significant digits, which is past what an `f32`
|
||||||
|
// resolves, so this is the case where a second rounding could land
|
||||||
|
// somewhere the compiler's single one does not.
|
||||||
|
assert_eq!(as_f32(1.0 / 3.0), 0.333_333_34_f32);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mix_is_the_linear_form_wgsl_means() {
|
||||||
|
assert_eq!(eval("mix(a, b, 0.25)", 0.0, 4.0), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_parameter_names_the_ones_that_exist() {
|
||||||
|
// The error is the whole value of validating in the parser: whoever
|
||||||
|
// wrote the typo needs the list, and needs it identically whether the
|
||||||
|
// declaration was read by the build script or at load time.
|
||||||
|
let err = parse("c * 2", ¶ms()).unwrap_err();
|
||||||
|
assert!(err.contains("`c` is not a parameter"), "{err}");
|
||||||
|
assert!(err.contains("a, b"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_function_is_rejected_rather_than_passed_through() {
|
||||||
|
let err = parse("tan(a)", ¶ms()).unwrap_err();
|
||||||
|
assert!(err.contains("not one of the maths functions"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wrong_arity_is_caught_where_it_is_written() {
|
||||||
|
let err = parse("pow(a)", ¶ms()).unwrap_err();
|
||||||
|
assert!(err.contains("takes 2 argument(s), given 1"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
//! TRACES: FR-PLG-2 | FR-PLG-2d
|
||||||
|
//! Running a node declaration without compiling it.
|
||||||
|
//!
|
||||||
|
//! # The format already existed
|
||||||
|
//!
|
||||||
|
//! `ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the
|
||||||
|
//! declarative nodes landed — it was simply resolved at build time:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! ops/exposure.yaml ──build.rs──▶ generated impl Operation ──▶ fused shader
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Nothing about that requires the declaration to be present when the compiler
|
||||||
|
//! runs. Everything a declaration produces is *data plus a WGSL string*, and
|
||||||
|
//! the composer already assembles WGSL at run time from whichever operations
|
||||||
|
//! are active. So this module is not a new mechanism; it is the existing one,
|
||||||
|
//! loaded later.
|
||||||
|
//!
|
||||||
|
//! [`DeclaredOp`] is **one interpreter over many declarations**, where
|
||||||
|
//! `build.rs` emits generated code per node. It implements [`Operation`] from
|
||||||
|
//! an owned [`Declaration`], which is only possible because descriptors became
|
||||||
|
//! owned — see [`crate::descriptor::OpDescriptor`] for why a `&'static`
|
||||||
|
//! descriptor made a run-time node impossible.
|
||||||
|
//!
|
||||||
|
//! # Both paths stay
|
||||||
|
//!
|
||||||
|
//! The generated path is not removed and should not be. FR-PLG-2 says so, and
|
||||||
|
//! the reasons are good ones: a generated `match` is faster than an
|
||||||
|
//! interpreted one, the built-ins' declared tests have to run under `cargo
|
||||||
|
//! test`, and generated source is *inspectable* in a way an interpreter's
|
||||||
|
//! internal state is not.
|
||||||
|
//!
|
||||||
|
//! What matters is that the two are **indistinguishable downstream**, and that
|
||||||
|
//! is a test rather than an intention: `tests/declared_parity.rs` parses every
|
||||||
|
//! built-in `ops/*.yaml` at run time and asserts the composed WGSL is
|
||||||
|
//! byte-for-byte identical to what the generated implementation produces, for
|
||||||
|
//! the same parameter values. If the two ever disagree, a plugin is not the
|
||||||
|
//! same kind of thing as a built-in and the premise of the whole plugin plan
|
||||||
|
//! has failed quietly.
|
||||||
|
//!
|
||||||
|
//! # What this is not, yet
|
||||||
|
//!
|
||||||
|
//! Not load-time WGSL validation (FR-PLG-11), not id namespacing (FR-PLG-2's
|
||||||
|
//! `author.name`), and not a plugin directory read at startup. Those are
|
||||||
|
//! separate work and are deliberately absent — a declaration reaching
|
||||||
|
//! [`DeclaredOp`] here is one that ships in this repository, so its WGSL has
|
||||||
|
//! already been compiled by the build and its id has already been checked for
|
||||||
|
//! collisions.
|
||||||
|
|
||||||
|
pub mod decl;
|
||||||
|
pub mod expr;
|
||||||
|
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
pub use decl::{Declaration, Node, SharedHelpers};
|
||||||
|
|
||||||
|
use crate::descriptor::{
|
||||||
|
intern, Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
|
||||||
|
Scale, Unit, WidgetDemand, WidgetKind,
|
||||||
|
};
|
||||||
|
use crate::operation::{Helper, Operation, Uniform};
|
||||||
|
use expr::{as_f32, Expr};
|
||||||
|
|
||||||
|
/// The shared WGSL helper library, as the built-in nodes see it.
|
||||||
|
///
|
||||||
|
/// The very same `_helpers.yaml` `build.rs` reads, embedded rather than read
|
||||||
|
/// from disk: there is no file path an installed application could look it up
|
||||||
|
/// at, and embedding is what makes it impossible for the compiled helpers and
|
||||||
|
/// the interpreted ones to be two different files.
|
||||||
|
///
|
||||||
|
/// Panics if the bundled file does not parse, which is a build-time fact about
|
||||||
|
/// this repository rather than anything a user can cause — `build.rs` reads the
|
||||||
|
/// same text on the same build and fails first.
|
||||||
|
pub fn builtin_helpers() -> &'static SharedHelpers {
|
||||||
|
static LIBRARY: LazyLock<SharedHelpers> = LazyLock::new(|| {
|
||||||
|
decl::read_helpers(include_str!("../../ops/_helpers.yaml"))
|
||||||
|
.unwrap_or_else(|e| panic!("the bundled {}: {e}", decl::HELPERS_FILE))
|
||||||
|
});
|
||||||
|
&LIBRARY
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-2
|
||||||
|
/// An operation built from a declaration at run time.
|
||||||
|
///
|
||||||
|
/// Holds everything the trait has to answer with, resolved once when the
|
||||||
|
/// declaration is read: the descriptor, the uniform expressions, the helper
|
||||||
|
/// list and the fragment text. Nothing is re-parsed per call, so the per-frame
|
||||||
|
/// cost of an interpreted node is the arithmetic in [`Expr::eval`] and nothing
|
||||||
|
/// else — the same arithmetic the generated node does, simply walked rather
|
||||||
|
/// than inlined.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DeclaredOp {
|
||||||
|
descriptor: Arc<OpDescriptor>,
|
||||||
|
/// Parameter ids in declaration order, parallel to `defaults` and
|
||||||
|
/// `values`.
|
||||||
|
///
|
||||||
|
/// Three parallel `Vec`s rather than one of triples because the hot read
|
||||||
|
/// is `values` alone, and because `set_param` writes exactly one of them.
|
||||||
|
/// They are built together and never resized.
|
||||||
|
params: Vec<ParamId>,
|
||||||
|
defaults: Vec<f32>,
|
||||||
|
values: Vec<f32>,
|
||||||
|
uniforms: Vec<DeclaredUniform>,
|
||||||
|
/// The declared `active:` rule, or `None` for the default one.
|
||||||
|
active: Option<Expr>,
|
||||||
|
wgsl: String,
|
||||||
|
helpers: Vec<Helper>,
|
||||||
|
presentation: Option<Presentation>,
|
||||||
|
order: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One uniform: the name the fragment reads it by, and how to compute it.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct DeclaredUniform {
|
||||||
|
/// Interned when the declaration was read, not on every `uniforms()` call.
|
||||||
|
/// See [`crate::operation::Uniform::name`].
|
||||||
|
name: &'static str,
|
||||||
|
expr: Expr,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeclaredOp {
|
||||||
|
/// Read one `ops/<id>.yaml` and build the operation it declares.
|
||||||
|
///
|
||||||
|
/// `ctx` names the source in error messages — a path, usually.
|
||||||
|
///
|
||||||
|
/// A `rust:` node is an error here rather than a silent `None`: it names a
|
||||||
|
/// hand-written type in this crate, which is by definition not something a
|
||||||
|
/// declaration can produce, and a caller that got one back as "nothing to
|
||||||
|
/// do" would drop an operation out of the chain without saying so.
|
||||||
|
pub fn from_yaml(text: &str, ctx: &str, library: &SharedHelpers) -> Result<Self, String> {
|
||||||
|
let names = library.names();
|
||||||
|
match decl::read_node(text, ctx, &names)? {
|
||||||
|
Node::Declared(d) => Self::new(&d, library),
|
||||||
|
Node::Rust { id, ty, .. } => Err(format!(
|
||||||
|
"`{id}` declares `rust: {ty}`, which names a hand-written type \
|
||||||
|
rather than describing an operation. Only a full declaration \
|
||||||
|
can be read at run time."
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the operation a parsed declaration describes.
|
||||||
|
pub fn new(declaration: &Declaration, library: &SharedHelpers) -> Result<Self, String> {
|
||||||
|
let params: Vec<ParamId> = declaration
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.map(|p| ParamId::interned(&p.id))
|
||||||
|
.collect();
|
||||||
|
let defaults: Vec<f32> = declaration
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.map(|p| as_f32(p.default))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Helpers in the order the composer will see them: the shared ones the
|
||||||
|
// node asked for, in the order it asked, then its own definitions.
|
||||||
|
// Order decides emission order in the generated shader, so it is part
|
||||||
|
// of the output rather than an implementation detail.
|
||||||
|
let mut helpers =
|
||||||
|
Vec::with_capacity(declaration.shared_helpers.len() + declaration.local_helpers.len());
|
||||||
|
for name in &declaration.shared_helpers {
|
||||||
|
// `decl::read_node` has already rejected a name the library does
|
||||||
|
// not define, so this is a library that changed underneath a
|
||||||
|
// declaration rather than a declaration with a typo in it.
|
||||||
|
let helper = library.get(name).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"`{}` asks for the shared helper `{name}`, which this \
|
||||||
|
helper library does not define",
|
||||||
|
declaration.id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
helpers.push(Helper {
|
||||||
|
name: intern(&helper.name),
|
||||||
|
source: intern(&helper.source()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for helper in &declaration.local_helpers {
|
||||||
|
helpers.push(Helper {
|
||||||
|
name: intern(&helper.name),
|
||||||
|
source: intern(&helper.source()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
descriptor: Arc::new(OpDescriptor {
|
||||||
|
id: OpId::interned(&declaration.id),
|
||||||
|
label: LocalizedKey::interned(&declaration.label),
|
||||||
|
params: declaration.params.iter().map(param_descriptor).collect(),
|
||||||
|
attributes: declaration
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(attribute)
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
values: defaults.clone(),
|
||||||
|
params,
|
||||||
|
defaults,
|
||||||
|
uniforms: declaration
|
||||||
|
.uniforms
|
||||||
|
.iter()
|
||||||
|
.map(|u| DeclaredUniform {
|
||||||
|
name: intern(&u.name),
|
||||||
|
expr: u.expr.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
active: declaration.active.clone(),
|
||||||
|
wgsl: declaration.wgsl_body(),
|
||||||
|
helpers,
|
||||||
|
presentation: declaration.presentation.as_deref().map(presentation),
|
||||||
|
order: declaration.order,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where this node sits in the chain, from its `order:`.
|
||||||
|
///
|
||||||
|
/// Not part of [`Operation`] — the graph holds operations in a `Vec` and
|
||||||
|
/// order *is* that position (ARCH §3.4). Exposed so whoever assembles a
|
||||||
|
/// chain out of declarations can sort them, which is what `build.rs` does
|
||||||
|
/// at the other end.
|
||||||
|
pub fn order(&self) -> i64 {
|
||||||
|
self.order
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_of(&self, id: ParamId) -> Option<usize> {
|
||||||
|
self.params.iter().position(|p| *p == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parameter's current value, by the name an expression calls it.
|
||||||
|
///
|
||||||
|
/// Returns 0.0 for a name that is not a parameter, matching what the
|
||||||
|
/// generated `param()` does with an unknown id. It cannot happen —
|
||||||
|
/// [`expr::parse`] rejects a name that is not declared — but a silent zero
|
||||||
|
/// is a better failure here than a panic inside a render.
|
||||||
|
fn value_named(&self, name: &str) -> f32 {
|
||||||
|
self.params
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.0 == name)
|
||||||
|
.map_or(0.0, |i| self.values[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Operation for DeclaredOp {
|
||||||
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
|
self.descriptor.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
|
match self.index_of(id) {
|
||||||
|
Some(i) => self.values[i] = value,
|
||||||
|
// The same complaint the generated `set_param` makes, for the same
|
||||||
|
// reason: a parameter that does not exist is a sidecar or a UI
|
||||||
|
// naming something this build does not have, and dropping it
|
||||||
|
// silently is how an edit comes to be half-applied.
|
||||||
|
None => log::warn!("{}: unknown parameter {id}", self.descriptor.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param(&self, id: ParamId) -> f32 {
|
||||||
|
self.index_of(id).map_or(0.0, |i| self.values[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
match &self.active {
|
||||||
|
Some(expr) => expr.eval(&|name| self.value_named(name)) != 0.0,
|
||||||
|
// The default rule, and the honest one: the operation is doing
|
||||||
|
// something exactly when a parameter has moved off its default.
|
||||||
|
// Short-circuiting in declaration order, which is what the
|
||||||
|
// generated `a != d || b != d` does.
|
||||||
|
None => self.values.iter().zip(&self.defaults).any(|(v, d)| v != d),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wgsl_body(&self) -> String {
|
||||||
|
self.wgsl.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uniforms(&self) -> Vec<Uniform> {
|
||||||
|
self.uniforms
|
||||||
|
.iter()
|
||||||
|
.map(|u| Uniform {
|
||||||
|
name: u.name,
|
||||||
|
value: u.expr.eval(&|name| self.value_named(name)),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn helpers(&self) -> &[Helper] {
|
||||||
|
&self.helpers
|
||||||
|
}
|
||||||
|
|
||||||
|
fn presentation(&self) -> Option<Presentation> {
|
||||||
|
self.presentation.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A declared parameter as the descriptor the panel reads.
|
||||||
|
///
|
||||||
|
/// Every arm calls the constructor `build.rs` renders a call to, so the two
|
||||||
|
/// produce the same `ParamDescriptor` by construction rather than by
|
||||||
|
/// coincidence.
|
||||||
|
fn param_descriptor(p: &decl::ParamDef) -> ParamDescriptor {
|
||||||
|
let id = intern(&p.id);
|
||||||
|
let label = intern(&p.label);
|
||||||
|
match &p.kind {
|
||||||
|
decl::Kind::Stops { min, max } => {
|
||||||
|
ParamDescriptor::stops(id, label, as_f32(*min), as_f32(*max))
|
||||||
|
}
|
||||||
|
decl::Kind::Amount => ParamDescriptor::amount(id, label),
|
||||||
|
decl::Kind::Switch => ParamDescriptor::switch(id, label),
|
||||||
|
decl::Kind::Fraction { default } => ParamDescriptor::fraction(id, label, as_f32(*default)),
|
||||||
|
decl::Kind::Scalar {
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
default,
|
||||||
|
unit,
|
||||||
|
scale,
|
||||||
|
precision,
|
||||||
|
} => ParamDescriptor::scalar(
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
as_f32(*min),
|
||||||
|
as_f32(*max),
|
||||||
|
as_f32(*default),
|
||||||
|
match unit {
|
||||||
|
decl::Unit::None => Unit::None,
|
||||||
|
decl::Unit::Stops => Unit::Stops,
|
||||||
|
decl::Unit::Kelvin => Unit::Kelvin,
|
||||||
|
decl::Unit::Percent => Unit::Percent,
|
||||||
|
},
|
||||||
|
match scale {
|
||||||
|
decl::Scale::Linear => Scale::Linear,
|
||||||
|
decl::Scale::Perceptual => Scale::Perceptual,
|
||||||
|
},
|
||||||
|
// `decl::read_kind` refuses a precision that does not fit, so this
|
||||||
|
// cast cannot lose anything.
|
||||||
|
*precision as u8,
|
||||||
|
),
|
||||||
|
decl::Kind::Enum { variants } => ParamDescriptor::choice(
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
variants.iter().map(|v| LocalizedKey::interned(v)).collect(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attribute(a: decl::Attr) -> Attribute {
|
||||||
|
match a {
|
||||||
|
decl::Attr::Tone => Attribute::Tone,
|
||||||
|
decl::Attr::Colour => Attribute::Colour,
|
||||||
|
decl::Attr::Detail => Attribute::Detail,
|
||||||
|
decl::Attr::Optics => Attribute::Optics,
|
||||||
|
decl::Attr::Geometry => Attribute::Geometry,
|
||||||
|
decl::Attr::Effect => Attribute::Effect,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn widget(w: decl::Widget) -> WidgetKind {
|
||||||
|
match w {
|
||||||
|
decl::Widget::ToneCurve => WidgetKind::ToneCurve,
|
||||||
|
decl::Widget::ColourWheel => WidgetKind::ColourWheel,
|
||||||
|
decl::Widget::CropOverlay => WidgetKind::CropOverlay,
|
||||||
|
decl::Widget::GradientHandle => WidgetKind::GradientHandle,
|
||||||
|
decl::Widget::BrushMask => WidgetKind::BrushMask,
|
||||||
|
decl::Widget::WhitePoint => WidgetKind::WhitePoint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn presentation(p: &decl::PresentationDef) -> Presentation {
|
||||||
|
Presentation {
|
||||||
|
widgets: p.widgets.iter().copied().map(widget).collect(),
|
||||||
|
demand: WidgetDemand {
|
||||||
|
two_dimensional: p.two_dimensional,
|
||||||
|
precise_pointing: p.precise_pointing,
|
||||||
|
},
|
||||||
|
params: p.params.iter().map(|n| ParamId::interned(n)).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-2d
|
||||||
|
/// The two spellings of the attribute vocabulary are one vocabulary.
|
||||||
|
///
|
||||||
|
/// `decl::Attr` exists because the reader is compiled by `build.rs`, which
|
||||||
|
/// cannot see `crate::descriptor`. That is a duplicated closed list, and a
|
||||||
|
/// duplicated closed list is exactly the thing FR-PLG-2d warns about: an
|
||||||
|
/// attribute added to one and not the other would put an operation in a
|
||||||
|
/// category the panel does not know it has.
|
||||||
|
#[test]
|
||||||
|
fn the_attribute_vocabulary_is_the_same_on_both_sides() {
|
||||||
|
assert_eq!(decl::Attr::ALL.len(), Attribute::ALL.len());
|
||||||
|
for (a, b) in decl::Attr::ALL.iter().zip(Attribute::ALL) {
|
||||||
|
// Same order, so an index into one indexes the other.
|
||||||
|
assert_eq!(attribute(*a), b);
|
||||||
|
// And the name a declaration writes resolves to the same variant.
|
||||||
|
assert_eq!(Attribute::from_name(a.name()), Some(b), "{}", a.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-2d
|
||||||
|
/// Every `WidgetKind` is nameable from a declaration.
|
||||||
|
///
|
||||||
|
/// A widget the core can ask for but a declaration cannot name is a widget
|
||||||
|
/// only a hand-written operation may have, which would make the two kinds
|
||||||
|
/// of node unequal in exactly the way FR-PLG-2 forbids.
|
||||||
|
#[test]
|
||||||
|
fn every_widget_kind_can_be_declared() {
|
||||||
|
assert_eq!(decl::Widget::ALL.len(), 6);
|
||||||
|
let named: Vec<WidgetKind> = decl::Widget::ALL.iter().copied().map(widget).collect();
|
||||||
|
for kind in [
|
||||||
|
WidgetKind::ToneCurve,
|
||||||
|
WidgetKind::ColourWheel,
|
||||||
|
WidgetKind::CropOverlay,
|
||||||
|
WidgetKind::GradientHandle,
|
||||||
|
WidgetKind::BrushMask,
|
||||||
|
WidgetKind::WhitePoint,
|
||||||
|
] {
|
||||||
|
assert!(named.contains(&kind), "{kind:?} cannot be declared");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_bundled_helper_library_parses() {
|
||||||
|
// It is `include_str!`'d, so a syntax error in it is a panic at first
|
||||||
|
// use rather than a build failure. This is the first use.
|
||||||
|
assert!(!builtin_helpers().helpers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exposure() -> DeclaredOp {
|
||||||
|
DeclaredOp::from_yaml(
|
||||||
|
include_str!("../../ops/exposure.yaml"),
|
||||||
|
"ops/exposure.yaml",
|
||||||
|
builtin_helpers(),
|
||||||
|
)
|
||||||
|
.expect("exposure declares an operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_declaration_becomes_an_operation_with_its_declared_descriptor() {
|
||||||
|
let op = exposure();
|
||||||
|
let d = op.descriptor();
|
||||||
|
assert_eq!(d.id, OpId("exposure"));
|
||||||
|
assert_eq!(d.label, LocalizedKey("op.exposure"));
|
||||||
|
assert_eq!(d.params.len(), 1);
|
||||||
|
assert_eq!(d.params[0].id, ParamId("exposure"));
|
||||||
|
assert_eq!(d.attributes, vec![Attribute::Tone]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_declared_operation_is_neutral_until_a_parameter_moves() {
|
||||||
|
let mut op = exposure();
|
||||||
|
assert!(!op.is_active());
|
||||||
|
assert_eq!(op.uniforms()[0].value, 1.0);
|
||||||
|
|
||||||
|
op.set_param(ParamId("exposure"), 1.0);
|
||||||
|
assert!(op.is_active());
|
||||||
|
// A stop is a doubling — the same assertion `exposure.yaml`'s own
|
||||||
|
// declared test makes against the generated implementation.
|
||||||
|
assert_eq!(op.uniforms()[0].value, 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_interned_id_matches_a_literal_one() {
|
||||||
|
// The property that lets a declared operation be addressed by the same
|
||||||
|
// `ParamId` constants the generated code matches on. If interning ever
|
||||||
|
// stopped deduplicating, this would still pass — `ParamId` compares
|
||||||
|
// string contents — but the point is that the two are interchangeable
|
||||||
|
// at every call site.
|
||||||
|
let mut op = exposure();
|
||||||
|
op.set_param(ParamId::interned("exposure"), 2.0);
|
||||||
|
assert_eq!(op.param(ParamId("exposure")), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rust_node_is_refused_rather_than_silently_dropped() {
|
||||||
|
let err = DeclaredOp::from_yaml(
|
||||||
|
include_str!("../../ops/tone_curve.yaml"),
|
||||||
|
"ops/tone_curve.yaml",
|
||||||
|
builtin_helpers(),
|
||||||
|
)
|
||||||
|
.expect_err("a `rust:` node is not a declaration");
|
||||||
|
assert!(err.contains("hand-written type"), "{err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,75 @@
|
|||||||
//! Labels are keys, not strings: resolving them needs a localiser, and
|
//! Labels are keys, not strings: resolving them needs a localiser, and
|
||||||
//! `core/` must not depend on one (NFR-A11Y-1).
|
//! `core/` must not depend on one (NFR-A11Y-1).
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-2
|
||||||
|
/// Give a string read at run time the `'static` lifetime the identifier types
|
||||||
|
/// carry.
|
||||||
|
///
|
||||||
|
/// # Why the identifiers stayed `&'static str` when the descriptors did not
|
||||||
|
///
|
||||||
|
/// [`OpDescriptor`] became owned so a declaration read at *load* time can
|
||||||
|
/// produce one (FR-PLG-2). The three identifier newtypes below deliberately
|
||||||
|
/// did not follow it.
|
||||||
|
///
|
||||||
|
/// An id is not content; it is a key. [`ParamId`] is `Copy`, is compared in
|
||||||
|
/// `match` arms against the constants `build.rs` generates, is a map key in
|
||||||
|
/// the sidecar and in history, and is threaded through `dr-ui` into Slint
|
||||||
|
/// model rows. An `Arc<str>` there would put a refcount on every one of those
|
||||||
|
/// and would take `match id { EXPOSURE => .. }` away from the generated code —
|
||||||
|
/// which is precisely the inspectability of the built-in chain that keeping
|
||||||
|
/// the generated path was for.
|
||||||
|
///
|
||||||
|
/// So ids are interned instead, and interning is honest about its lifetime
|
||||||
|
/// rather than pretending to one. The set of interned ids is:
|
||||||
|
///
|
||||||
|
/// - **Bounded.** One entry per *distinct* string, deduplicated on the way in.
|
||||||
|
/// Parsing the same declaration a thousand times adds nothing after the
|
||||||
|
/// first.
|
||||||
|
/// - **Process-lifetime by construction.** A loaded declaration's vocabulary
|
||||||
|
/// is never withdrawn. Nothing unloads a plugin, and nothing could: the
|
||||||
|
/// sidecar on disk stores parameters by `(op_id, param_id)`, so an id has to
|
||||||
|
/// stay resolvable for as long as any edit naming it can be opened.
|
||||||
|
///
|
||||||
|
/// A leak whose bound is "the distinct ids this process has ever seen" is a
|
||||||
|
/// different thing from one that grows with use, and this is the first.
|
||||||
|
pub fn intern(s: &str) -> &'static str {
|
||||||
|
static POOL: LazyLock<Mutex<HashSet<&'static str>>> =
|
||||||
|
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||||
|
|
||||||
|
// A poisoned pool is still a correct pool: every entry in it is a
|
||||||
|
// `&'static str` that was interned successfully, and a panic elsewhere
|
||||||
|
// while the lock was held cannot have made one invalid. Refusing to
|
||||||
|
// intern here would turn an unrelated panic into an application that can
|
||||||
|
// no longer read a declaration.
|
||||||
|
let mut pool = POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if let Some(found) = pool.get(s) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
let leaked: &'static str = Box::leak(s.to_owned().into_boxed_str());
|
||||||
|
pool.insert(leaked);
|
||||||
|
leaked
|
||||||
|
}
|
||||||
|
|
||||||
/// Identifies a parameter within an operation.
|
/// Identifies a parameter within an operation.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
pub struct ParamId(pub &'static str);
|
pub struct ParamId(pub &'static str);
|
||||||
|
|
||||||
|
impl ParamId {
|
||||||
|
/// The same id, from a name read out of a declaration at load time.
|
||||||
|
///
|
||||||
|
/// Equal to `ParamId("exposure")` when the name is `"exposure"`: the
|
||||||
|
/// derived `PartialEq` compares the `str` contents, not the pointer, which
|
||||||
|
/// is what lets an interned id match a generated `match` arm. See
|
||||||
|
/// [`intern`].
|
||||||
|
pub fn interned(name: &str) -> Self {
|
||||||
|
Self(intern(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl fmt::Display for ParamId {
|
impl fmt::Display for ParamId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.write_str(self.0)
|
f.write_str(self.0)
|
||||||
@@ -24,6 +87,13 @@ impl fmt::Display for ParamId {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
pub struct OpId(pub &'static str);
|
pub struct OpId(pub &'static str);
|
||||||
|
|
||||||
|
impl OpId {
|
||||||
|
/// The same id, from a declaration read at load time. See [`intern`].
|
||||||
|
pub fn interned(name: &str) -> Self {
|
||||||
|
Self(intern(name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl fmt::Display for OpId {
|
impl fmt::Display for OpId {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
f.write_str(self.0)
|
f.write_str(self.0)
|
||||||
@@ -34,6 +104,13 @@ impl fmt::Display for OpId {
|
|||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct LocalizedKey(pub &'static str);
|
pub struct LocalizedKey(pub &'static str);
|
||||||
|
|
||||||
|
impl LocalizedKey {
|
||||||
|
/// The same key, from a declaration read at load time. See [`intern`].
|
||||||
|
pub fn interned(key: &str) -> Self {
|
||||||
|
Self(intern(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a slider's travel means.
|
/// What a slider's travel means.
|
||||||
///
|
///
|
||||||
/// Photographic controls are rarely linear in their underlying quantity:
|
/// Photographic controls are rarely linear in their underlying quantity:
|
||||||
@@ -170,7 +247,11 @@ pub enum ParamKind {
|
|||||||
Enum {
|
Enum {
|
||||||
/// In index order. The label is a localisation key, resolved by the
|
/// In index order. The label is a localisation key, resolved by the
|
||||||
/// frontend — `core/` must not depend on a localiser (NFR-A11Y-1).
|
/// frontend — `core/` must not depend on a localiser (NFR-A11Y-1).
|
||||||
variants: &'static [LocalizedKey],
|
///
|
||||||
|
/// Owned rather than `&'static`, for the reason [`OpDescriptor`]
|
||||||
|
/// gives: a declaration parsed at load time has nowhere to put a
|
||||||
|
/// `'static` slice.
|
||||||
|
variants: Vec<LocalizedKey>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,7 +285,7 @@ pub struct Presentation {
|
|||||||
/// pair of sliders, and an operation that can say so gets a good control
|
/// pair of sliders, and an operation that can say so gets a good control
|
||||||
/// on a workstation and a usable one on a phone without the core knowing
|
/// on a workstation and a usable one on a phone without the core knowing
|
||||||
/// which it is talking to.
|
/// which it is talking to.
|
||||||
pub widgets: &'static [WidgetKind],
|
pub widgets: Vec<WidgetKind>,
|
||||||
/// What the preferred widget needs in order to be worth drawing.
|
/// What the preferred widget needs in order to be worth drawing.
|
||||||
///
|
///
|
||||||
/// Applies to the list as a whole rather than per entry: a frontend that
|
/// Applies to the list as a whole rather than per entry: a frontend that
|
||||||
@@ -215,7 +296,7 @@ pub struct Presentation {
|
|||||||
///
|
///
|
||||||
/// Parameters absent from this list are presented normally, so an
|
/// Parameters absent from this list are presented normally, so an
|
||||||
/// operation can pair a curve with an ordinary strength slider.
|
/// operation can pair a curve with an ordinary strength slider.
|
||||||
pub params: &'static [ParamId],
|
pub params: Vec<ParamId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Presentation {
|
impl Presentation {
|
||||||
@@ -328,11 +409,13 @@ impl ParamDescriptor {
|
|||||||
/// holds the way it does for every other kind: index 0 is the neutral
|
/// holds the way it does for every other kind: index 0 is the neutral
|
||||||
/// choice, and an operation whose default is not its first variant has
|
/// choice, and an operation whose default is not its first variant has
|
||||||
/// listed them in the wrong order.
|
/// listed them in the wrong order.
|
||||||
pub const fn choice(
|
//
|
||||||
id: &'static str,
|
// Not `const`, unlike its four siblings, and the reason is the `Vec` in
|
||||||
label: &'static str,
|
// [`ParamKind::Enum`]: a heap allocation cannot happen in a const context.
|
||||||
variants: &'static [LocalizedKey],
|
// Nothing is lost — every descriptor now lives inside a `LazyLock`
|
||||||
) -> Self {
|
// initialiser rather than a `static`, because `descriptor()` hands out an
|
||||||
|
// `Arc` and an `Arc` is not const-constructible either.
|
||||||
|
pub fn choice(id: &'static str, label: &'static str, variants: Vec<LocalizedKey>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: ParamId(id),
|
id: ParamId(id),
|
||||||
label: LocalizedKey(label),
|
label: LocalizedKey(label),
|
||||||
@@ -366,13 +449,15 @@ impl ParamDescriptor {
|
|||||||
|
|
||||||
/// A general scalar with an explicit range and default.
|
/// A general scalar with an explicit range and default.
|
||||||
//
|
//
|
||||||
// Eight arguments, and a builder would be the usual answer — but this has
|
// Eight arguments, and a builder would be the usual answer — but eight
|
||||||
// to be `const` so descriptors can be `static`, which rules out the
|
// `&mut self` steps would be eight methods to say what one call already
|
||||||
// `&mut self` builder the pattern usually takes. (A `self`-by-value step
|
// says, and each one would be a place for a caller to forget a field. The
|
||||||
// *is* const-callable — `faceted` below is one — but eight of them would
|
// two common shapes have their own constructors above; this is the escape
|
||||||
// be eight methods to say what one call already says.) The two common
|
// hatch for the rest.
|
||||||
// shapes have their own constructors above; this is the escape hatch for
|
//
|
||||||
// the rest.
|
// Still `const` although no descriptor is a `static` any more: it costs
|
||||||
|
// nothing, and it keeps the five constructors uniform where only `choice`
|
||||||
|
// genuinely cannot be.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub const fn scalar(
|
pub const fn scalar(
|
||||||
id: &'static str,
|
id: &'static str,
|
||||||
@@ -404,10 +489,13 @@ impl ParamDescriptor {
|
|||||||
/// A method rather than a sixth constructor, because a facet is orthogonal
|
/// A method rather than a sixth constructor, because a facet is orthogonal
|
||||||
/// to the shape of the value: a faceted parameter is still an amount, or
|
/// to the shape of the value: a faceted parameter is still an amount, or
|
||||||
/// still a scalar in stops, and pairing every constructor with a faceted
|
/// still a scalar in stops, and pairing every constructor with a faceted
|
||||||
/// twin would double the list above to say one thing. Taking `self` by
|
/// twin would double the list above to say one thing.
|
||||||
/// value is what keeps it usable in the `static` descriptors — a `&mut
|
//
|
||||||
/// self` builder is what cannot be `const`.
|
// No longer `const`: a `ParamDescriptor` can now carry a `Vec` (an enum's
|
||||||
pub const fn faceted(mut self, facet: Facet) -> Self {
|
// variants), which gives the type drop glue, and assigning over a field of
|
||||||
|
// such a type is not something a const function may do. Nothing is lost —
|
||||||
|
// every descriptor is built inside a `LazyLock` initialiser now.
|
||||||
|
pub fn faceted(mut self, facet: Facet) -> Self {
|
||||||
self.facet = Some(facet);
|
self.facet = Some(facet);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -418,10 +506,10 @@ impl ParamDescriptor {
|
|||||||
/// bounds, or a sidecar written by a newer version with a wider range,
|
/// bounds, or a sidecar written by a newer version with a wider range,
|
||||||
/// must not produce out-of-range uniforms.
|
/// must not produce out-of-range uniforms.
|
||||||
pub fn clamp(&self, value: f32) -> f32 {
|
pub fn clamp(&self, value: f32) -> f32 {
|
||||||
match self.kind {
|
match &self.kind {
|
||||||
ParamKind::Scalar { min, max, .. } => {
|
ParamKind::Scalar { min, max, .. } => {
|
||||||
if value.is_finite() {
|
if value.is_finite() {
|
||||||
value.clamp(min, max)
|
value.clamp(*min, *max)
|
||||||
} else {
|
} else {
|
||||||
// A NaN from a corrupt sidecar would otherwise poison the
|
// A NaN from a corrupt sidecar would otherwise poison the
|
||||||
// uniform block and blank the image.
|
// uniform block and blank the image.
|
||||||
@@ -544,20 +632,45 @@ impl Attribute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The static description of an operation.
|
/// TRACES: FR-PLG-2
|
||||||
|
/// The description of an operation.
|
||||||
|
///
|
||||||
|
/// # Owned, not `&'static`
|
||||||
|
///
|
||||||
|
/// This used to be a `static` with `&'static [ParamDescriptor]` inside it, and
|
||||||
|
/// [`crate::Operation::descriptor`] used to hand out a reference to it. That
|
||||||
|
/// shape made a build-time node free and a run-time node **impossible**: a
|
||||||
|
/// declaration parsed at startup has nothing to borrow from, so no amount of
|
||||||
|
/// interpreting `ops/*.yaml` at load time could ever produce a descriptor the
|
||||||
|
/// rest of the application would accept. FR-PLG-2 says a bundled operation and
|
||||||
|
/// a third-party plugin are the same kind of thing, differing only in where
|
||||||
|
/// the file was found — and a lifetime that only a compile-time literal can
|
||||||
|
/// satisfy is exactly a second, weaker format for outsiders.
|
||||||
|
///
|
||||||
|
/// So the descriptor owns its contents and is handed out as an
|
||||||
|
/// `Arc<OpDescriptor>`. The `Arc` rather than a `&self`-borrowed reference
|
||||||
|
/// because the callers want to *keep* it: the develop panel collects
|
||||||
|
/// descriptors and then mutates the graph, and a borrow would tie the
|
||||||
|
/// descriptor's lifetime to a borrow of the operation it came from — which is
|
||||||
|
/// the one thing `&'static` was doing right.
|
||||||
|
///
|
||||||
|
/// The cost is a refcount per read, on a path that reads descriptors when a
|
||||||
|
/// panel is built rather than per pixel. See `Operation::descriptor` for the
|
||||||
|
/// one place that is read per composition and why it does not matter.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct OpDescriptor {
|
pub struct OpDescriptor {
|
||||||
pub id: OpId,
|
pub id: OpId,
|
||||||
pub label: LocalizedKey,
|
pub label: LocalizedKey,
|
||||||
pub params: &'static [ParamDescriptor],
|
pub params: Vec<ParamDescriptor>,
|
||||||
/// What this operation is about (ARCH §4.3a).
|
/// What this operation is about (ARCH §4.3a).
|
||||||
///
|
///
|
||||||
/// **Never empty**, and `build.rs` refuses to generate an operation that
|
/// **Never empty**, and both the build-time and the load-time reader
|
||||||
/// declares none. An operation with no attribute would be invisible to a
|
/// refuse an operation that declares none. An operation with no attribute
|
||||||
/// frontend that filters by them, and a control that silently does not
|
/// would be invisible to a frontend that filters by them, and a control
|
||||||
/// exist is a worse failure than a build that stops — particularly when
|
/// that silently does not exist is a worse failure than a build that stops
|
||||||
/// the cause would be a missing line in a YAML file nobody looked at.
|
/// — particularly when the cause would be a missing line in a YAML file
|
||||||
pub attributes: &'static [Attribute],
|
/// nobody looked at.
|
||||||
|
pub attributes: Vec<Attribute>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpDescriptor {
|
impl OpDescriptor {
|
||||||
|
|||||||
@@ -519,7 +519,13 @@ pub fn compose_detail_with(
|
|||||||
) -> ComposedDetail {
|
) -> ComposedDetail {
|
||||||
// Every pass of every active detail operation, flattened, carrying the
|
// Every pass of every active detail operation, flattened, carrying the
|
||||||
// operation it came from for the uniform prefix and the helper set.
|
// operation it came from for the uniform prefix and the helper set.
|
||||||
let mut planned: Vec<(&'static str, &'static [Helper], DetailPass, usize)> = Vec::new();
|
//
|
||||||
|
// The helper slice borrows from the operation rather than being `'static`:
|
||||||
|
// `Operation::helpers` hands out a slice owned by the operation now, so
|
||||||
|
// that a node built from a declaration at load time can own its list
|
||||||
|
// (FR-PLG-2). The borrow lasts as long as `ops`, which outlives this
|
||||||
|
// function's body.
|
||||||
|
let mut planned: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::new();
|
||||||
for (index, pass) in spots.iter().enumerate() {
|
for (index, pass) in spots.iter().enumerate() {
|
||||||
planned.push((
|
planned.push((
|
||||||
crate::spot::SPOT_ID,
|
crate::spot::SPOT_ID,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
//! was built for: a horizontal pass then a vertical one is mathematically a 2D
|
//! was built for: a horizontal pass then a vertical one is mathematically a 2D
|
||||||
//! box average, so if the ping-pong is wired backwards or a pass reads its own
|
//! box average, so if the ping-pong is wired backwards or a pass reads its own
|
||||||
//! output the result is visibly not a box blur rather than subtly wrong.
|
//! output the result is visibly not a box blur rather than subtly wrong.
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{
|
use crate::descriptor::{
|
||||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
|
||||||
@@ -36,28 +37,30 @@ use crate::descriptor::{
|
|||||||
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
||||||
use crate::operation::{Affects, Operation, Uniform};
|
use crate::operation::{Affects, Operation, Uniform};
|
||||||
|
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
id: OpId("detail_probe"),
|
Arc::new(OpDescriptor {
|
||||||
label: LocalizedKey("op.detail_probe"),
|
id: OpId("detail_probe"),
|
||||||
params: &[ParamDescriptor {
|
label: LocalizedKey("op.detail_probe"),
|
||||||
id: ParamId("radius"),
|
params: vec![ParamDescriptor {
|
||||||
label: LocalizedKey("param.detail_probe.radius"),
|
id: ParamId("radius"),
|
||||||
// A fraction of the frame's shorter edge, which is the unit
|
label: LocalizedKey("param.detail_probe.radius"),
|
||||||
// `RenderScale::frame_fraction` converts and the unit a mask feather
|
// A fraction of the frame's shorter edge, which is the unit
|
||||||
// is already stored in. Stating it in pixels is the mistake this
|
// `RenderScale::frame_fraction` converts and the unit a mask feather
|
||||||
// whole stage is arranged to make impossible.
|
// is already stored in. Stating it in pixels is the mistake this
|
||||||
kind: ParamKind::Scalar {
|
// whole stage is arranged to make impossible.
|
||||||
min: 0.0,
|
kind: ParamKind::Scalar {
|
||||||
max: 0.25,
|
min: 0.0,
|
||||||
scale: Scale::Linear,
|
max: 0.25,
|
||||||
unit: Unit::None,
|
scale: Scale::Linear,
|
||||||
precision: 4,
|
unit: Unit::None,
|
||||||
},
|
precision: 4,
|
||||||
default: 0.0,
|
},
|
||||||
facet: None,
|
default: 0.0,
|
||||||
}],
|
facet: None,
|
||||||
attributes: &[Attribute::Detail],
|
}],
|
||||||
};
|
attributes: vec![Attribute::Detail],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
/// A separable box blur whose radius is a fraction of the frame's shorter edge.
|
/// A separable box blur whose radius is a fraction of the frame's shorter edge.
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
@@ -85,8 +88,8 @@ impl BoxBlur {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Operation for BoxBlur {
|
impl Operation for BoxBlur {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
|
|
||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{
|
use crate::descriptor::{
|
||||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale,
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale,
|
||||||
@@ -73,50 +74,52 @@ static FRAMING_PARAMS: [ParamId; 8] = [
|
|||||||
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
|
CROP_X, CROP_Y, CROP_W, CROP_H, ANGLE, ROTATION, FLIP_H, FLIP_V,
|
||||||
];
|
];
|
||||||
|
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
// The shape of the frame, and the only operation that changes the
|
Arc::new(OpDescriptor {
|
||||||
// output's dimensions.
|
// The shape of the frame, and the only operation that changes the
|
||||||
attributes: &[Attribute::Geometry],
|
// output's dimensions.
|
||||||
id: ID,
|
attributes: vec![Attribute::Geometry],
|
||||||
label: LocalizedKey("op.framing"),
|
id: ID,
|
||||||
params: &[
|
label: LocalizedKey("op.framing"),
|
||||||
// Straightening. Degrees rather than a normalised amount because a
|
params: vec![
|
||||||
// photographer reading "-1.4°" off a horizon knows what it means.
|
// Straightening. Degrees rather than a normalised amount because a
|
||||||
ParamDescriptor::scalar(
|
// photographer reading "-1.4°" off a horizon knows what it means.
|
||||||
"angle",
|
ParamDescriptor::scalar(
|
||||||
"param.angle",
|
"angle",
|
||||||
-MAX_STRAIGHTEN,
|
"param.angle",
|
||||||
MAX_STRAIGHTEN,
|
-MAX_STRAIGHTEN,
|
||||||
0.0,
|
MAX_STRAIGHTEN,
|
||||||
Unit::None,
|
0.0,
|
||||||
Scale::Linear,
|
Unit::None,
|
||||||
2,
|
Scale::Linear,
|
||||||
),
|
2,
|
||||||
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
),
|
||||||
// and lossless, and because reorienting a frame is a different
|
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
||||||
// gesture from nudging a horizon.
|
// and lossless, and because reorienting a frame is a different
|
||||||
ParamDescriptor::scalar(
|
// gesture from nudging a horizon.
|
||||||
"rotation",
|
ParamDescriptor::scalar(
|
||||||
"param.rotation",
|
"rotation",
|
||||||
0.0,
|
"param.rotation",
|
||||||
3.0,
|
0.0,
|
||||||
0.0,
|
3.0,
|
||||||
Unit::None,
|
0.0,
|
||||||
Scale::Linear,
|
Unit::None,
|
||||||
0,
|
Scale::Linear,
|
||||||
),
|
0,
|
||||||
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
),
|
||||||
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
||||||
// The crop rect, in fractions of the source. Normalised rather than
|
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
||||||
// in pixels so a crop survives being applied to a proxy, a full
|
// The crop rect, in fractions of the source. Normalised rather than
|
||||||
// resolution render, or an export at another size — the same reason
|
// in pixels so a crop survives being applied to a proxy, a full
|
||||||
// the viewport renders at display resolution (FR-DSP-1).
|
// resolution render, or an export at another size — the same reason
|
||||||
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
// the viewport renders at display resolution (FR-DSP-1).
|
||||||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
||||||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
||||||
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||||||
],
|
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
||||||
};
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
/// A normalised crop rectangle, in fractions of the source image.
|
/// A normalised crop rectangle, in fractions of the source image.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -252,8 +255,8 @@ impl Framing {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn descriptor(&self) -> &'static OpDescriptor {
|
pub fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3a | FR-DEV-3b | FR-UI-7
|
/// TRACES: FR-DEV-3a | FR-DEV-3b | FR-UI-7
|
||||||
@@ -280,7 +283,7 @@ impl Framing {
|
|||||||
/// into the generated panel underneath a crop control that already exists.
|
/// into the generated panel underneath a crop control that already exists.
|
||||||
pub fn presentation(&self) -> Option<Presentation> {
|
pub fn presentation(&self) -> Option<Presentation> {
|
||||||
Some(Presentation {
|
Some(Presentation {
|
||||||
widgets: &[WidgetKind::CropOverlay],
|
widgets: vec![WidgetKind::CropOverlay],
|
||||||
demand: WidgetDemand {
|
demand: WidgetDemand {
|
||||||
// A crop rect is dragged by its corners; nothing about that
|
// A crop rect is dragged by its corners; nothing about that
|
||||||
// reduces to one axis at a time.
|
// reduces to one axis at a time.
|
||||||
@@ -290,7 +293,7 @@ impl Framing {
|
|||||||
// modality, so a thumb is as workable as a mouse.
|
// modality, so a thumb is as workable as a mouse.
|
||||||
precise_pointing: false,
|
precise_pointing: false,
|
||||||
},
|
},
|
||||||
params: &FRAMING_PARAMS,
|
params: FRAMING_PARAMS.to_vec(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1426,7 +1429,7 @@ mod tests {
|
|||||||
fn every_parameter_at_its_extremes_is_survivable() {
|
fn every_parameter_at_its_extremes_is_survivable() {
|
||||||
// The whole descriptor driven to both ends, which is what a codegen
|
// The whole descriptor driven to both ends, which is what a codegen
|
||||||
// test does and what a corrupt sidecar can do.
|
// test does and what a corrupt sidecar can do.
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] {
|
for value in [-1e9, -1.0, 0.0, 1.0, 1e9, f32::NAN] {
|
||||||
let mut f = Framing::new();
|
let mut f = Framing::new();
|
||||||
f.set_param(p.id, p.clamp(value));
|
f.set_param(p.id, p.clamp(value));
|
||||||
@@ -1610,7 +1613,7 @@ mod tests {
|
|||||||
// The same contract the operations honour, checked against the
|
// The same contract the operations honour, checked against the
|
||||||
// descriptor rather than a literal.
|
// descriptor rather than a literal.
|
||||||
let mut f = Framing::new();
|
let mut f = Framing::new();
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
f.set_param(p.id, p.default);
|
f.set_param(p.id, p.default);
|
||||||
}
|
}
|
||||||
assert!(!f.is_active(), "descriptor defaults must be neutral");
|
assert!(!f.is_active(), "descriptor defaults must be neutral");
|
||||||
@@ -1618,7 +1621,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_default_is_within_its_declared_range() {
|
fn every_default_is_within_its_declared_range() {
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id);
|
assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ pub struct OpCapability {
|
|||||||
/// other, so a new operation joins the right group by declaring what it
|
/// other, so a new operation joins the right group by declaring what it
|
||||||
/// is — which is the only thing its author is well placed to say.
|
/// is — which is the only thing its author is well placed to say.
|
||||||
///
|
///
|
||||||
/// Never empty; `build.rs` refuses an operation that declares none.
|
/// Never empty; both readers refuse an operation that declares none.
|
||||||
pub attributes: &'static [Attribute],
|
pub attributes: Vec<Attribute>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
||||||
@@ -243,7 +243,7 @@ impl EditGraph {
|
|||||||
/// because this is what the codegen tests count `---- ` shader blocks
|
/// because this is what the codegen tests count `---- ` shader blocks
|
||||||
/// against, and framing generates a prologue rather than a colour block.
|
/// against, and framing generates a prologue rather than a colour block.
|
||||||
/// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a).
|
/// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a).
|
||||||
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
|
pub fn descriptors(&self) -> Vec<Arc<OpDescriptor>> {
|
||||||
self.ops.iter().map(|o| o.descriptor()).collect()
|
self.ops.iter().map(|o| o.descriptor()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ impl EditGraph {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
presentation: op.presentation(),
|
presentation: op.presentation(),
|
||||||
attributes: desc.attributes,
|
attributes: desc.attributes.clone(),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,7 +310,7 @@ impl EditGraph {
|
|||||||
// sliders for framing *without naming framing* — see
|
// sliders for framing *without naming framing* — see
|
||||||
// `Framing::presentation`.
|
// `Framing::presentation`.
|
||||||
presentation: self.framing.presentation(),
|
presentation: self.framing.presentation(),
|
||||||
attributes: desc.attributes,
|
attributes: desc.attributes.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
ops.chain(std::iter::once(framing)).collect()
|
ops.chain(std::iter::once(framing)).collect()
|
||||||
@@ -427,7 +427,11 @@ impl EditGraph {
|
|||||||
|
|
||||||
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
||||||
if op == crate::framing::ID {
|
if op == crate::framing::ID {
|
||||||
let Some(desc) = self.framing.descriptor().param(param) else {
|
// Bound rather than chained: `descriptor()` hands back an owned
|
||||||
|
// `Arc` now, so a `param()` borrowed straight out of the call
|
||||||
|
// would outlive the temporary it came from.
|
||||||
|
let descriptor = self.framing.descriptor();
|
||||||
|
let Some(desc) = descriptor.param(param) else {
|
||||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -469,7 +473,7 @@ impl EditGraph {
|
|||||||
/// Reset every parameter of every operation, and the framing, to default.
|
/// Reset every parameter of every operation, and the framing, to default.
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
for op in &mut self.ops {
|
for op in &mut self.ops {
|
||||||
for p in op.descriptor().params {
|
for p in &op.descriptor().params {
|
||||||
op.set_param(p.id, p.default);
|
op.set_param(p.id, p.default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,7 +629,7 @@ impl EditGraph {
|
|||||||
// structure key deliberately omits because they do not recompile a
|
// structure key deliberately omits because they do not recompile a
|
||||||
// shader. Both matter to a cached *result*, so both are here.
|
// shader. Both matter to a cached *result*, so both are here.
|
||||||
let mut geometry = mix(FNV_OFFSET, self.framing.structure_key());
|
let mut geometry = mix(FNV_OFFSET, self.framing.structure_key());
|
||||||
for p in self.framing.descriptor().params {
|
for p in &self.framing.descriptor().params {
|
||||||
geometry = hash_bytes(geometry, p.id.0.as_bytes());
|
geometry = hash_bytes(geometry, p.id.0.as_bytes());
|
||||||
geometry = mix(
|
geometry = mix(
|
||||||
geometry,
|
geometry,
|
||||||
@@ -944,7 +948,7 @@ mod tests {
|
|||||||
.capabilities()
|
.capabilities()
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|c| {
|
.flat_map(|c| {
|
||||||
c.params.iter().map(move |p| match p.kind {
|
c.params.iter().map(move |p| match &p.kind {
|
||||||
ParamKind::Scalar {
|
ParamKind::Scalar {
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
//! wearing the same lens, which defeats the point of a lens profile.
|
//! wearing the same lens, which defeats the point of a lens profile.
|
||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::descriptor::{OpDescriptor, ParamId};
|
use crate::descriptor::{OpDescriptor, ParamId};
|
||||||
use crate::operation::{Helper, Uniform};
|
use crate::operation::{Helper, Uniform};
|
||||||
@@ -62,8 +63,10 @@ use crate::operation::{Helper, Uniform};
|
|||||||
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
|
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
|
||||||
/// graph holds `Box<dyn Warp>` in order, so the geometry chain is data.
|
/// graph holds `Box<dyn Warp>` in order, so the geometry chain is data.
|
||||||
pub trait Warp: Send + Sync {
|
pub trait Warp: Send + Sync {
|
||||||
/// Static description, driving UI generation exactly as for an operation.
|
/// This warp's description, driving UI generation exactly as for an
|
||||||
fn descriptor(&self) -> &'static OpDescriptor;
|
/// operation — including being owned rather than `&'static`, for the
|
||||||
|
/// reason [`crate::descriptor::OpDescriptor`] gives.
|
||||||
|
fn descriptor(&self) -> Arc<OpDescriptor>;
|
||||||
|
|
||||||
/// Set a parameter. Values arrive already clamped to the descriptor.
|
/// Set a parameter. Values arrive already clamped to the descriptor.
|
||||||
fn set_param(&mut self, id: ParamId, value: f32);
|
fn set_param(&mut self, id: ParamId, value: f32);
|
||||||
@@ -204,32 +207,38 @@ fn sanitise(id: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::descriptor::Attribute;
|
use crate::descriptor::Attribute;
|
||||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor};
|
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor};
|
||||||
|
|
||||||
static DESC_A: OpDescriptor = OpDescriptor {
|
static DESC_A: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
id: OpId("warp_a"),
|
Arc::new(OpDescriptor {
|
||||||
label: LocalizedKey("a"),
|
id: OpId("warp_a"),
|
||||||
params: &[ParamDescriptor::amount("amount", "a.amount")],
|
label: LocalizedKey("a"),
|
||||||
attributes: &[Attribute::Tone],
|
params: vec![ParamDescriptor::amount("amount", "a.amount")],
|
||||||
};
|
attributes: vec![Attribute::Tone],
|
||||||
static DESC_B: OpDescriptor = OpDescriptor {
|
})
|
||||||
id: OpId("warp_b"),
|
});
|
||||||
label: LocalizedKey("b"),
|
static DESC_B: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
params: &[ParamDescriptor::amount("amount", "b.amount")],
|
Arc::new(OpDescriptor {
|
||||||
attributes: &[Attribute::Tone],
|
id: OpId("warp_b"),
|
||||||
};
|
label: LocalizedKey("b"),
|
||||||
|
params: vec![ParamDescriptor::amount("amount", "b.amount")],
|
||||||
|
attributes: vec![Attribute::Tone],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
struct Fake {
|
struct Fake {
|
||||||
desc: &'static OpDescriptor,
|
desc: Arc<OpDescriptor>,
|
||||||
amount: f32,
|
amount: f32,
|
||||||
splits: bool,
|
splits: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Warp for Fake {
|
impl Warp for Fake {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
self.desc
|
self.desc.clone()
|
||||||
}
|
}
|
||||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||||
self.amount = value;
|
self.amount = value;
|
||||||
@@ -254,7 +263,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fake(desc: &'static OpDescriptor, amount: f32, splits: bool) -> Box<dyn Warp> {
|
fn fake(desc: Arc<OpDescriptor>, amount: f32, splits: bool) -> Box<dyn Warp> {
|
||||||
Box::new(Fake {
|
Box::new(Fake {
|
||||||
desc,
|
desc,
|
||||||
amount,
|
amount,
|
||||||
@@ -266,7 +275,7 @@ mod tests {
|
|||||||
fn no_active_warp_composes_to_nothing() {
|
fn no_active_warp_composes_to_nothing() {
|
||||||
// The property that keeps the common case free: an image with no lens
|
// The property that keeps the common case free: an image with no lens
|
||||||
// correction must not pay for a bilinear sample.
|
// correction must not pay for a bilinear sample.
|
||||||
let composed = compose_warps(&[fake(&DESC_A, 0.0, false)]);
|
let composed = compose_warps(&[fake(DESC_A.clone(), 0.0, false)]);
|
||||||
assert!(!composed.is_active());
|
assert!(!composed.is_active());
|
||||||
assert!(composed.uniforms.is_empty());
|
assert!(composed.uniforms.is_empty());
|
||||||
assert!(!composed.splits_channels);
|
assert!(!composed.splits_channels);
|
||||||
@@ -274,7 +283,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_active_warp_appears_once() {
|
fn an_active_warp_appears_once() {
|
||||||
let composed = compose_warps(&[fake(&DESC_A, 2.0, false)]);
|
let composed = compose_warps(&[fake(DESC_A.clone(), 2.0, false)]);
|
||||||
assert!(composed.is_active());
|
assert!(composed.is_active());
|
||||||
assert!(composed.body.contains("---- warp: warp_a ----"));
|
assert!(composed.body.contains("---- warp: warp_a ----"));
|
||||||
assert!(composed.body.contains("u.warp_a_amount"));
|
assert!(composed.body.contains("u.warp_a_amount"));
|
||||||
@@ -284,7 +293,10 @@ mod tests {
|
|||||||
fn uniforms_are_prefixed_so_warps_cannot_collide() {
|
fn uniforms_are_prefixed_so_warps_cannot_collide() {
|
||||||
// Both fakes declare `amount`; without prefixing the generated struct
|
// Both fakes declare `amount`; without prefixing the generated struct
|
||||||
// would carry a duplicate field and fail to compile.
|
// would carry a duplicate field and fail to compile.
|
||||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)]);
|
let composed = compose_warps(&[
|
||||||
|
fake(DESC_A.clone(), 1.0, false),
|
||||||
|
fake(DESC_B.clone(), 2.0, false),
|
||||||
|
]);
|
||||||
assert!(composed.uniform_fields.contains("warp_a_amount: f32"));
|
assert!(composed.uniform_fields.contains("warp_a_amount: f32"));
|
||||||
assert!(composed.uniform_fields.contains("warp_b_amount: f32"));
|
assert!(composed.uniform_fields.contains("warp_b_amount: f32"));
|
||||||
assert_eq!(composed.uniforms, vec![1.0, 2.0]);
|
assert_eq!(composed.uniforms, vec![1.0, 2.0]);
|
||||||
@@ -294,7 +306,10 @@ mod tests {
|
|||||||
fn channel_splitting_is_requested_by_any_active_warp() {
|
fn channel_splitting_is_requested_by_any_active_warp() {
|
||||||
// One CA warp among several must switch the whole stage to the
|
// One CA warp among several must switch the whole stage to the
|
||||||
// three-sample path.
|
// three-sample path.
|
||||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, true)]);
|
let composed = compose_warps(&[
|
||||||
|
fake(DESC_A.clone(), 1.0, false),
|
||||||
|
fake(DESC_B.clone(), 1.0, true),
|
||||||
|
]);
|
||||||
assert!(composed.splits_channels);
|
assert!(composed.splits_channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,14 +317,20 @@ mod tests {
|
|||||||
fn an_inactive_splitting_warp_does_not_force_three_samples() {
|
fn an_inactive_splitting_warp_does_not_force_three_samples() {
|
||||||
// CA present but at neutral must cost nothing — otherwise every image
|
// CA present but at neutral must cost nothing — otherwise every image
|
||||||
// with the panel visible pays triple bandwidth.
|
// with the panel visible pays triple bandwidth.
|
||||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 0.0, true)]);
|
let composed = compose_warps(&[
|
||||||
|
fake(DESC_A.clone(), 1.0, false),
|
||||||
|
fake(DESC_B.clone(), 0.0, true),
|
||||||
|
]);
|
||||||
assert!(composed.is_active());
|
assert!(composed.is_active());
|
||||||
assert!(!composed.splits_channels);
|
assert!(!composed.splits_channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn warps_compose_in_order() {
|
fn warps_compose_in_order() {
|
||||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
let composed = compose_warps(&[
|
||||||
|
fake(DESC_A.clone(), 1.0, false),
|
||||||
|
fake(DESC_B.clone(), 1.0, false),
|
||||||
|
]);
|
||||||
let a = composed.body.find("warp_a").expect("a present");
|
let a = composed.body.find("warp_a").expect("a present");
|
||||||
let b = composed.body.find("warp_b").expect("b present");
|
let b = composed.body.find("warp_b").expect("b present");
|
||||||
assert!(a < b, "warps must chain in graph order");
|
assert!(a < b, "warps must chain in graph order");
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
//! single multiply and white balance a per-channel scale; on gamma-encoded
|
//! single multiply and white balance a per-channel scale; on gamma-encoded
|
||||||
//! data neither would be physically meaningful (ARCH §5.2).
|
//! data neither would be physically meaningful (ARCH §5.2).
|
||||||
|
|
||||||
|
pub mod declared;
|
||||||
pub mod descriptor;
|
pub mod descriptor;
|
||||||
pub mod detail;
|
pub mod detail;
|
||||||
pub mod framing;
|
pub mod framing;
|
||||||
@@ -45,6 +46,7 @@ pub mod sidecar;
|
|||||||
pub mod spot;
|
pub mod spot;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
||||||
|
pub use declared::{Declaration, DeclaredOp};
|
||||||
pub use descriptor::{
|
pub use descriptor::{
|
||||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
|
||||||
Presentation, Scale, Unit, WidgetDemand, WidgetKind,
|
Presentation, Scale, Unit, WidgetDemand, WidgetKind,
|
||||||
@@ -81,13 +83,13 @@ mod tests {
|
|||||||
let mut g = EditGraph::default_chain();
|
let mut g = EditGraph::default_chain();
|
||||||
for desc in g.descriptors() {
|
for desc in g.descriptors() {
|
||||||
for (i, p) in desc.params.iter().enumerate() {
|
for (i, p) in desc.params.iter().enumerate() {
|
||||||
let v = match p.kind {
|
let v = match &p.kind {
|
||||||
ParamKind::Scalar { min, max, .. } => {
|
ParamKind::Scalar { min, max, .. } => {
|
||||||
// A fraction that differs per parameter, so no two
|
// A fraction that differs per parameter, so no two
|
||||||
// move in lockstep.
|
// move in lockstep.
|
||||||
let fraction = 0.15 + 0.05 * (i % 4) as f32;
|
let fraction = 0.15 + 0.05 * (i % 4) as f32;
|
||||||
let step = (max - min) * fraction;
|
let step = (max - min) * fraction;
|
||||||
if p.default + step <= max {
|
if p.default + step <= *max {
|
||||||
p.default + step
|
p.default + step
|
||||||
} else {
|
} else {
|
||||||
p.default - step
|
p.default - step
|
||||||
@@ -217,7 +219,7 @@ mod tests {
|
|||||||
// A default outside its own range would mean a fresh image opens
|
// A default outside its own range would mean a fresh image opens
|
||||||
// with a value the UI cannot represent.
|
// with a value the UI cannot represent.
|
||||||
for desc in EditGraph::default_chain().descriptors() {
|
for desc in EditGraph::default_chain().descriptors() {
|
||||||
for p in desc.params {
|
for p in &desc.params {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
p.clamp(p.default),
|
p.clamp(p.default),
|
||||||
p.default,
|
p.default,
|
||||||
@@ -236,7 +238,7 @@ mod tests {
|
|||||||
// operation must read its own default as neutral.
|
// operation must read its own default as neutral.
|
||||||
let g = EditGraph::default_chain();
|
let g = EditGraph::default_chain();
|
||||||
for desc in g.descriptors() {
|
for desc in g.descriptors() {
|
||||||
for p in desc.params {
|
for p in &desc.params {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
g.param(desc.id, p.id),
|
g.param(desc.id, p.id),
|
||||||
Some(p.default),
|
Some(p.default),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
//! see there for what happens when it does not match.
|
//! see there for what happens when it does not match.
|
||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::descriptor::{OpDescriptor, ParamId};
|
use crate::descriptor::{OpDescriptor, ParamId};
|
||||||
use crate::operation::Operation;
|
use crate::operation::Operation;
|
||||||
@@ -692,7 +693,7 @@ impl Clone for MaskLayer {
|
|||||||
fn clone(&self) -> Self {
|
fn clone(&self) -> Self {
|
||||||
let mut ops = layer_chain();
|
let mut ops = layer_chain();
|
||||||
for (dst, src) in ops.iter_mut().zip(&self.ops) {
|
for (dst, src) in ops.iter_mut().zip(&self.ops) {
|
||||||
for p in src.descriptor().params {
|
for p in &src.descriptor().params {
|
||||||
dst.set_param(p.id, src.param(p.id));
|
dst.set_param(p.id, src.param(p.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -947,7 +948,7 @@ impl MaskLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
|
pub fn descriptors(&self) -> Vec<Arc<OpDescriptor>> {
|
||||||
self.ops.iter().map(|o| o.descriptor()).collect()
|
self.ops.iter().map(|o| o.descriptor()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,7 +996,7 @@ impl MaskLayer {
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
presentation: op.presentation(),
|
presentation: op.presentation(),
|
||||||
attributes: desc.attributes,
|
attributes: desc.attributes.clone(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -1007,7 +1008,7 @@ impl MaskLayer {
|
|||||||
/// arrive at — so "start this layer's edit again" must not throw it away.
|
/// arrive at — so "start this layer's edit again" must not throw it away.
|
||||||
pub fn reset_adjustments(&mut self) {
|
pub fn reset_adjustments(&mut self) {
|
||||||
for op in &mut self.ops {
|
for op in &mut self.ops {
|
||||||
for p in op.descriptor().params {
|
for p in &op.descriptor().params {
|
||||||
op.set_param(p.id, p.default);
|
op.set_param(p.id, p.default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1026,10 +1027,18 @@ impl MaskLayer {
|
|||||||
/// Every non-default parameter, for the sidecar.
|
/// Every non-default parameter, for the sidecar.
|
||||||
pub fn params(&self) -> impl Iterator<Item = (&'static str, &'static str, f32)> + '_ {
|
pub fn params(&self) -> impl Iterator<Item = (&'static str, &'static str, f32)> + '_ {
|
||||||
self.ops.iter().flat_map(|o| {
|
self.ops.iter().flat_map(|o| {
|
||||||
let id = o.descriptor().id.0;
|
let desc = o.descriptor();
|
||||||
o.descriptor().params.iter().filter_map(move |p| {
|
let id = desc.id.0;
|
||||||
let v = o.param(p.id);
|
// Collected rather than borrowed from `desc`: a descriptor is an
|
||||||
(v != p.default).then_some((id, p.id.0, v))
|
// `Arc` handed over by value now (FR-PLG-2), so it would be
|
||||||
|
// dropped at the end of this closure and the lazy iterator would
|
||||||
|
// outlive it. The ids and defaults are all this needs, and there
|
||||||
|
// are a handful of them.
|
||||||
|
let params: Vec<(ParamId, f32)> =
|
||||||
|
desc.params.iter().map(|p| (p.id, p.default)).collect();
|
||||||
|
params.into_iter().filter_map(move |(param, default)| {
|
||||||
|
let v = o.param(param);
|
||||||
|
(v != default).then_some((id, param.0, v))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
//! generator emits readable, commented output — see [`compose`].
|
//! generator emits readable, commented output — see [`compose`].
|
||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use dr_types::{ColourSpace, Transfer};
|
use dr_types::{ColourSpace, Transfer};
|
||||||
|
|
||||||
@@ -171,7 +172,7 @@ impl Invalidation {
|
|||||||
pub(crate) fn hash_op(h: u64, op: &dyn Operation) -> u64 {
|
pub(crate) fn hash_op(h: u64, op: &dyn Operation) -> u64 {
|
||||||
let desc = op.descriptor();
|
let desc = op.descriptor();
|
||||||
let mut h = hash_bytes(h, desc.id.0.as_bytes());
|
let mut h = hash_bytes(h, desc.id.0.as_bytes());
|
||||||
for p in desc.params {
|
for p in &desc.params {
|
||||||
h = hash_bytes(h, p.id.0.as_bytes());
|
h = hash_bytes(h, p.id.0.as_bytes());
|
||||||
h = mix(h, u64::from(canonical_bits(op.param(p.id))));
|
h = mix(h, u64::from(canonical_bits(op.param(p.id))));
|
||||||
}
|
}
|
||||||
@@ -213,6 +214,12 @@ pub(crate) const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
|||||||
pub struct Uniform {
|
pub struct Uniform {
|
||||||
/// Field name as it appears in WGSL. Prefixed with the op id by the
|
/// Field name as it appears in WGSL. Prefixed with the op id by the
|
||||||
/// composer, so two operations may both declare `amount`.
|
/// composer, so two operations may both declare `amount`.
|
||||||
|
///
|
||||||
|
/// `&'static str` for the reason [`crate::descriptor::intern`] gives about
|
||||||
|
/// ids: a uniform name is a small, deduplicated, process-lifetime piece of
|
||||||
|
/// vocabulary, and a declared operation interns its names once when it is
|
||||||
|
/// parsed rather than allocating them on every `uniforms()` call — which
|
||||||
|
/// happens per composition, and composition happens per frame.
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
pub value: f32,
|
pub value: f32,
|
||||||
}
|
}
|
||||||
@@ -222,8 +229,32 @@ pub struct Uniform {
|
|||||||
/// Object-safe: the pipeline holds `Box<dyn Operation>` in graph order, so
|
/// Object-safe: the pipeline holds `Box<dyn Operation>` in graph order, so
|
||||||
/// order is data rather than code (ARCH §3.4).
|
/// order is data rather than code (ARCH §3.4).
|
||||||
pub trait Operation: Send + Sync {
|
pub trait Operation: Send + Sync {
|
||||||
/// Static description, driving UI generation (FR-DEV-3a).
|
/// TRACES: FR-DEV-3a | FR-PLG-2
|
||||||
fn descriptor(&self) -> &'static OpDescriptor;
|
/// This operation's description, driving UI generation (FR-DEV-3a).
|
||||||
|
///
|
||||||
|
/// **Shared and owned rather than `&'static`.** See [`OpDescriptor`] for
|
||||||
|
/// why — in short, a `&'static` descriptor is one a compile-time literal
|
||||||
|
/// can produce and a load-time declaration cannot, which would make a
|
||||||
|
/// plugin a second-class kind of operation for a reason that is purely an
|
||||||
|
/// artefact of how the built-ins happen to be written.
|
||||||
|
///
|
||||||
|
/// # What this costs, and where
|
||||||
|
///
|
||||||
|
/// One `Arc` clone and drop per call. Descriptors are read when a panel is
|
||||||
|
/// built (`EditGraph::capabilities`), when a sidecar is written or read,
|
||||||
|
/// and when the history names what changed — none of which is a per-frame
|
||||||
|
/// path.
|
||||||
|
///
|
||||||
|
/// There is **one** exception, and it is worth stating plainly rather than
|
||||||
|
/// letting somebody discover it with a profiler: [`compose_full`] reads
|
||||||
|
/// `descriptor().id` once per *active* operation to prefix its uniforms,
|
||||||
|
/// and `dr-ui` composes on every frame it draws. That is a handful of
|
||||||
|
/// atomic increments — a dozen or so, against a composition that is
|
||||||
|
/// already building several kilobytes of WGSL text from scratch on the
|
||||||
|
/// same call. If composition ever stops being a per-frame operation, this
|
||||||
|
/// stops being a question at all; while it is one, the refcount is not
|
||||||
|
/// what makes it expensive.
|
||||||
|
fn descriptor(&self) -> Arc<OpDescriptor>;
|
||||||
|
|
||||||
/// Set a parameter. Values arrive already clamped to the descriptor.
|
/// Set a parameter. Values arrive already clamped to the descriptor.
|
||||||
fn set_param(&mut self, id: ParamId, value: f32);
|
fn set_param(&mut self, id: ParamId, value: f32);
|
||||||
@@ -321,7 +352,13 @@ pub trait Operation: Send + Sync {
|
|||||||
/// Emitted once per *distinct* function name even if several operations
|
/// Emitted once per *distinct* function name even if several operations
|
||||||
/// request it, so shared helpers (luminance, soft clipping) are declared
|
/// request it, so shared helpers (luminance, soft clipping) are declared
|
||||||
/// exactly once.
|
/// exactly once.
|
||||||
fn helpers(&self) -> &'static [Helper] {
|
///
|
||||||
|
/// Borrowed from `self` rather than `'static`, for the reason
|
||||||
|
/// [`Self::descriptor`] is owned: a generated operation returns a
|
||||||
|
/// `&'static [Helper]` and coerces, while an operation built from a
|
||||||
|
/// declaration at load time owns its list. The [`Helper`] *strings*
|
||||||
|
/// themselves stay `&'static` — they are interned, like the ids.
|
||||||
|
fn helpers(&self) -> &[Helper] {
|
||||||
&[]
|
&[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1184,31 +1221,37 @@ pub(crate) fn sanitise(id: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::descriptor::Attribute;
|
use crate::descriptor::Attribute;
|
||||||
use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor};
|
use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor};
|
||||||
|
|
||||||
static DESC_A: OpDescriptor = OpDescriptor {
|
static DESC_A: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
id: OpId("op_a"),
|
Arc::new(OpDescriptor {
|
||||||
label: LocalizedKey("a"),
|
id: OpId("op_a"),
|
||||||
params: &[ParamDescriptor::amount("amount", "a.amount")],
|
label: LocalizedKey("a"),
|
||||||
attributes: &[Attribute::Tone],
|
params: vec![ParamDescriptor::amount("amount", "a.amount")],
|
||||||
};
|
attributes: vec![Attribute::Tone],
|
||||||
static DESC_B: OpDescriptor = OpDescriptor {
|
})
|
||||||
id: OpId("op_b"),
|
});
|
||||||
label: LocalizedKey("b"),
|
static DESC_B: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
params: &[ParamDescriptor::amount("amount", "b.amount")],
|
Arc::new(OpDescriptor {
|
||||||
attributes: &[Attribute::Tone],
|
id: OpId("op_b"),
|
||||||
};
|
label: LocalizedKey("b"),
|
||||||
|
params: vec![ParamDescriptor::amount("amount", "b.amount")],
|
||||||
|
attributes: vec![Attribute::Tone],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
struct Fake {
|
struct Fake {
|
||||||
desc: &'static OpDescriptor,
|
desc: Arc<OpDescriptor>,
|
||||||
amount: f32,
|
amount: f32,
|
||||||
helper: Option<Helper>,
|
helper: Option<Helper>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Operation for Fake {
|
impl Operation for Fake {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
self.desc
|
self.desc.clone()
|
||||||
}
|
}
|
||||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||||
self.amount = value;
|
self.amount = value;
|
||||||
@@ -1241,7 +1284,7 @@ mod tests {
|
|||||||
source: "fn luma(c: vec3<f32>) -> f32 { return c.g; }",
|
source: "fn luma(c: vec3<f32>) -> f32 { return c.g; }",
|
||||||
}];
|
}];
|
||||||
|
|
||||||
fn fake(desc: &'static OpDescriptor, amount: f32, helper: bool) -> Box<dyn Operation> {
|
fn fake(desc: Arc<OpDescriptor>, amount: f32, helper: bool) -> Box<dyn Operation> {
|
||||||
Box::new(Fake {
|
Box::new(Fake {
|
||||||
desc,
|
desc,
|
||||||
amount,
|
amount,
|
||||||
@@ -1253,7 +1296,7 @@ mod tests {
|
|||||||
fn an_inactive_operation_contributes_nothing() {
|
fn an_inactive_operation_contributes_nothing() {
|
||||||
// The point of composing rather than branching: an op at neutral
|
// The point of composing rather than branching: an op at neutral
|
||||||
// must not appear in the source at all.
|
// must not appear in the source at all.
|
||||||
let ops = vec![fake(&DESC_A, 0.0, false)];
|
let ops = vec![fake(DESC_A.clone(), 0.0, false)];
|
||||||
let shader = compose(&ops);
|
let shader = compose(&ops);
|
||||||
assert!(
|
assert!(
|
||||||
!shader.source.contains("op_a"),
|
!shader.source.contains("op_a"),
|
||||||
@@ -1274,7 +1317,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_active_operation_appears_once() {
|
fn an_active_operation_appears_once() {
|
||||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
||||||
let shader = compose(&ops);
|
let shader = compose(&ops);
|
||||||
assert!(shader.source.contains("---- op_a ----"));
|
assert!(shader.source.contains("---- op_a ----"));
|
||||||
assert!(shader.source.contains("u.op_a_amount"));
|
assert!(shader.source.contains("u.op_a_amount"));
|
||||||
@@ -1285,7 +1328,10 @@ mod tests {
|
|||||||
// Both fakes declare a uniform called `amount`. Without prefixing,
|
// Both fakes declare a uniform called `amount`. Without prefixing,
|
||||||
// the generated struct would have a duplicate field and fail to
|
// the generated struct would have a duplicate field and fail to
|
||||||
// compile — the failure mode that makes naive concatenation fragile.
|
// compile — the failure mode that makes naive concatenation fragile.
|
||||||
let ops = vec![fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)];
|
let ops = vec![
|
||||||
|
fake(DESC_A.clone(), 1.0, false),
|
||||||
|
fake(DESC_B.clone(), 2.0, false),
|
||||||
|
];
|
||||||
let shader = compose(&ops);
|
let shader = compose(&ops);
|
||||||
assert!(shader.source.contains("op_a_amount: f32"));
|
assert!(shader.source.contains("op_a_amount: f32"));
|
||||||
assert!(shader.source.contains("op_b_amount: f32"));
|
assert!(shader.source.contains("op_b_amount: f32"));
|
||||||
@@ -1295,7 +1341,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn uniform_values_follow_declaration_order() {
|
fn uniform_values_follow_declaration_order() {
|
||||||
let ops = vec![fake(&DESC_A, 1.5, false), fake(&DESC_B, 2.5, false)];
|
let ops = vec![
|
||||||
|
fake(DESC_A.clone(), 1.5, false),
|
||||||
|
fake(DESC_B.clone(), 2.5, false),
|
||||||
|
];
|
||||||
let shader = compose(&ops);
|
let shader = compose(&ops);
|
||||||
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
|
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
|
||||||
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
|
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
|
||||||
@@ -1305,7 +1354,10 @@ mod tests {
|
|||||||
fn a_shared_helper_is_emitted_once() {
|
fn a_shared_helper_is_emitted_once() {
|
||||||
// Two operations wanting the same helper must not produce a
|
// Two operations wanting the same helper must not produce a
|
||||||
// duplicate function definition.
|
// duplicate function definition.
|
||||||
let ops = vec![fake(&DESC_A, 1.0, true), fake(&DESC_B, 1.0, true)];
|
let ops = vec![
|
||||||
|
fake(DESC_A.clone(), 1.0, true),
|
||||||
|
fake(DESC_B.clone(), 1.0, true),
|
||||||
|
];
|
||||||
let shader = compose(&ops);
|
let shader = compose(&ops);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shader.source.matches("fn luma(").count(),
|
shader.source.matches("fn luma(").count(),
|
||||||
@@ -1319,7 +1371,17 @@ mod tests {
|
|||||||
// WGSL rejects a uniform struct whose size is not a multiple of 16.
|
// WGSL rejects a uniform struct whose size is not a multiple of 16.
|
||||||
for n in 0..6 {
|
for n in 0..6 {
|
||||||
let ops: Vec<Box<dyn Operation>> = (0..n)
|
let ops: Vec<Box<dyn Operation>> = (0..n)
|
||||||
.map(|i| fake(if i % 2 == 0 { &DESC_A } else { &DESC_B }, 1.0, false))
|
.map(|i| {
|
||||||
|
fake(
|
||||||
|
if i % 2 == 0 {
|
||||||
|
DESC_A.clone()
|
||||||
|
} else {
|
||||||
|
DESC_B.clone()
|
||||||
|
},
|
||||||
|
1.0,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let shader = compose(&ops);
|
let shader = compose(&ops);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1335,11 +1397,14 @@ mod tests {
|
|||||||
fn structure_hash_ignores_values_but_tracks_the_op_set() {
|
fn structure_hash_ignores_values_but_tracks_the_op_set() {
|
||||||
// The property the shader cache depends on: moving a slider must not
|
// The property the shader cache depends on: moving a slider must not
|
||||||
// trigger a recompile, but enabling an operation must.
|
// trigger a recompile, but enabling an operation must.
|
||||||
let a1 = compose(&[fake(&DESC_A, 1.0, false)]).structure_hash;
|
let a1 = compose(&[fake(DESC_A.clone(), 1.0, false)]).structure_hash;
|
||||||
let a2 = compose(&[fake(&DESC_A, 9.0, false)]).structure_hash;
|
let a2 = compose(&[fake(DESC_A.clone(), 9.0, false)]).structure_hash;
|
||||||
assert_eq!(a1, a2, "a value change must reuse the compiled pipeline");
|
assert_eq!(a1, a2, "a value change must reuse the compiled pipeline");
|
||||||
|
|
||||||
let both = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
let both = compose(&[
|
||||||
|
fake(DESC_A.clone(), 1.0, false),
|
||||||
|
fake(DESC_B.clone(), 1.0, false),
|
||||||
|
]);
|
||||||
assert_ne!(a1, both.structure_hash, "a different op-set must recompile");
|
assert_ne!(a1, both.structure_hash, "a different op-set must recompile");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,8 +1412,14 @@ mod tests {
|
|||||||
fn structure_hash_is_order_sensitive() {
|
fn structure_hash_is_order_sensitive() {
|
||||||
// Operation order is data (ARCH §3.4); two orders are different
|
// Operation order is data (ARCH §3.4); two orders are different
|
||||||
// shaders and must not share a cache entry.
|
// shaders and must not share a cache entry.
|
||||||
let ab = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
let ab = compose(&[
|
||||||
let ba = compose(&[fake(&DESC_B, 1.0, false), fake(&DESC_A, 1.0, false)]);
|
fake(DESC_A.clone(), 1.0, false),
|
||||||
|
fake(DESC_B.clone(), 1.0, false),
|
||||||
|
]);
|
||||||
|
let ba = compose(&[
|
||||||
|
fake(DESC_B.clone(), 1.0, false),
|
||||||
|
fake(DESC_A.clone(), 1.0, false),
|
||||||
|
]);
|
||||||
assert_ne!(ab.structure_hash, ba.structure_hash);
|
assert_ne!(ab.structure_hash, ba.structure_hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1412,7 +1483,7 @@ mod tests {
|
|||||||
// Exposure and the tonal controls act on white-balanced values; if
|
// Exposure and the tonal controls act on white-balanced values; if
|
||||||
// the multiply came afterwards, every operation would be reasoning
|
// the multiply came afterwards, every operation would be reasoning
|
||||||
// about a green-cast image.
|
// about a green-cast image.
|
||||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
||||||
let source = compose(&ops).source;
|
let source = compose(&ops).source;
|
||||||
let wb = source.find("u.as_shot_wb").expect("wb applied");
|
let wb = source.find("u.as_shot_wb").expect("wb applied");
|
||||||
let op = source.find("---- op_a ----").expect("op present");
|
let op = source.find("---- op_a ----").expect("op present");
|
||||||
@@ -1472,7 +1543,7 @@ mod tests {
|
|||||||
// The other half, and the one that would fail silently: a bug that
|
// The other half, and the one that would fail silently: a bug that
|
||||||
// suppressed the tail unconditionally renders every ordinary edit
|
// suppressed the tail unconditionally renders every ordinary edit
|
||||||
// flat and uncorrected, which reads as a broken camera profile.
|
// flat and uncorrected, which reads as a broken camera profile.
|
||||||
let source = compose(&[fake(&DESC_A, 2.0, false)]).source;
|
let source = compose(&[fake(DESC_A.clone(), 2.0, false)]).source;
|
||||||
assert!(source.contains("base_curve_last.z > 0.5"));
|
assert!(source.contains("base_curve_last.z > 0.5"));
|
||||||
assert!(source.contains("Camera space -> linear sRGB"));
|
assert!(source.contains("Camera space -> linear sRGB"));
|
||||||
}
|
}
|
||||||
@@ -1484,7 +1555,7 @@ mod tests {
|
|||||||
// stock loaded is the default state of every photograph in the
|
// stock loaded is the default state of every photograph in the
|
||||||
// catalogue, and it must not disturb the camera's own rendering.
|
// catalogue, and it must not disturb the camera's own rendering.
|
||||||
let film: Box<dyn Operation> = Box::new(crate::ops::FilmSim::new());
|
let film: Box<dyn Operation> = Box::new(crate::ops::FilmSim::new());
|
||||||
let source = compose(&[film, fake(&DESC_A, 2.0, false)]).source;
|
let source = compose(&[film, fake(DESC_A.clone(), 2.0, false)]).source;
|
||||||
assert!(source.contains("base_curve_last.z > 0.5"));
|
assert!(source.contains("base_curve_last.z > 0.5"));
|
||||||
assert!(source.contains("Camera space -> linear sRGB"));
|
assert!(source.contains("Camera space -> linear sRGB"));
|
||||||
}
|
}
|
||||||
@@ -1493,7 +1564,7 @@ mod tests {
|
|||||||
fn the_camera_matrix_is_applied_after_the_operations() {
|
fn the_camera_matrix_is_applied_after_the_operations() {
|
||||||
// Adjustments are meaningful in sensor-native space, where highlight
|
// Adjustments are meaningful in sensor-native space, where highlight
|
||||||
// headroom still exists; converting first would clip it away.
|
// headroom still exists; converting first would clip it away.
|
||||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
||||||
let source = compose(&ops).source;
|
let source = compose(&ops).source;
|
||||||
let op = source.find("---- op_a ----").expect("op present");
|
let op = source.find("---- op_a ----").expect("op present");
|
||||||
let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied");
|
let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied");
|
||||||
@@ -1514,7 +1585,7 @@ mod tests {
|
|||||||
// Before the matrix: the curve was tuned against this body's own
|
// Before the matrix: the curve was tuned against this body's own
|
||||||
// primaries. Applied after the conversion it would be a Canon
|
// primaries. Applied after the conversion it would be a Canon
|
||||||
// rendering acting on sRGB values, which is a different curve.
|
// rendering acting on sRGB values, which is a different curve.
|
||||||
let ops = vec![fake(&DESC_A, 2.0, false)];
|
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
||||||
let source = compose(&ops).source;
|
let source = compose(&ops).source;
|
||||||
let op = source.find("---- op_a ----").expect("op present");
|
let op = source.find("---- op_a ----").expect("op present");
|
||||||
let curve = source
|
let curve = source
|
||||||
@@ -1592,7 +1663,7 @@ mod tests {
|
|||||||
// it must not pick up an identity matrix multiply for the sake of
|
// it must not pick up an identity matrix multiply for the sake of
|
||||||
// generality. Asserted against the source rather than against timing,
|
// generality. Asserted against the source rather than against timing,
|
||||||
// which would not fail reliably.
|
// which would not fail reliably.
|
||||||
let ops = vec![fake(&DESC_A, 1.0, false)];
|
let ops = vec![fake(DESC_A.clone(), 1.0, false)];
|
||||||
let srgb = compose_to(&ops, ColourSpace::Srgb).source;
|
let srgb = compose_to(&ops, ColourSpace::Srgb).source;
|
||||||
assert!(
|
assert!(
|
||||||
!srgb.contains("Linear sRGB -> linear sRGB"),
|
!srgb.contains("Linear sRGB -> linear sRGB"),
|
||||||
@@ -1607,7 +1678,7 @@ mod tests {
|
|||||||
// in linear sRGB, the primaries conversion carries it into the wider
|
// in linear sRGB, the primaries conversion carries it into the wider
|
||||||
// space, and only then is it clipped — clipping first would discard
|
// space, and only then is it clipped — clipping first would discard
|
||||||
// exactly the colours the wider space was chosen to keep.
|
// exactly the colours the wider space was chosen to keep.
|
||||||
let source = compose_to(&[fake(&DESC_A, 1.0, false)], ColourSpace::DisplayP3).source;
|
let source = compose_to(&[fake(DESC_A.clone(), 1.0, false)], ColourSpace::DisplayP3).source;
|
||||||
let camera = source.find("u.cam_to_srgb_0").expect("camera matrix");
|
let camera = source.find("u.cam_to_srgb_0").expect("camera matrix");
|
||||||
let convert = source
|
let convert = source
|
||||||
.find("Linear sRGB -> linear Display P3")
|
.find("Linear sRGB -> linear Display P3")
|
||||||
@@ -1659,7 +1730,7 @@ mod tests {
|
|||||||
// an export that came out sRGB and claimed to be Display P3.
|
// an export that came out sRGB and claimed to be Display P3.
|
||||||
let mut seen: Vec<u64> = Vec::new();
|
let mut seen: Vec<u64> = Vec::new();
|
||||||
for space in ColourSpace::ALL {
|
for space in ColourSpace::ALL {
|
||||||
let h = compose_to(&[fake(&DESC_A, 1.0, false)], space).structure_hash;
|
let h = compose_to(&[fake(DESC_A.clone(), 1.0, false)], space).structure_hash;
|
||||||
assert!(!seen.contains(&h), "{space:?} collides with another space");
|
assert!(!seen.contains(&h), "{space:?} collides with another space");
|
||||||
seen.push(h);
|
seen.push(h);
|
||||||
}
|
}
|
||||||
@@ -1669,7 +1740,7 @@ mod tests {
|
|||||||
fn generated_source_carries_a_do_not_edit_banner() {
|
fn generated_source_carries_a_do_not_edit_banner() {
|
||||||
// Someone will eventually find this in a debugger and try to fix it
|
// Someone will eventually find this in a debugger and try to fix it
|
||||||
// in place.
|
// in place.
|
||||||
let shader = compose(&[fake(&DESC_A, 1.0, false)]);
|
let shader = compose(&[fake(DESC_A.clone(), 1.0, false)]);
|
||||||
assert!(shader.source.starts_with("// GENERATED"));
|
assert!(shader.source.starts_with("// GENERATED"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
//! untouched means a mis-set correction shifts the channels that contribute
|
//! untouched means a mis-set correction shifts the channels that contribute
|
||||||
//! 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 std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{
|
use crate::descriptor::{
|
||||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
@@ -49,36 +50,38 @@ pub const BLUE: ParamId = ParamId("blue");
|
|||||||
/// resolution left to tune by eye at 100%.
|
/// resolution left to tune by eye at 100%.
|
||||||
const MAX_SCALE: f32 = 0.005;
|
const MAX_SCALE: f32 = 0.005;
|
||||||
|
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
attributes: &[Attribute::Optics],
|
Arc::new(OpDescriptor {
|
||||||
id: ID,
|
attributes: vec![Attribute::Optics],
|
||||||
label: LocalizedKey("op.aberration"),
|
id: ID,
|
||||||
params: &[
|
label: LocalizedKey("op.aberration"),
|
||||||
// Two independent controls rather than one: the red and blue
|
params: vec![
|
||||||
// displacements are caused by different ends of the spectrum and are
|
// Two independent controls rather than one: the red and blue
|
||||||
// not symmetric, so a single "fringing" slider could not remove both.
|
// displacements are caused by different ends of the spectrum and are
|
||||||
ParamDescriptor::scalar(
|
// not symmetric, so a single "fringing" slider could not remove both.
|
||||||
"red",
|
ParamDescriptor::scalar(
|
||||||
"param.aberration.red",
|
"red",
|
||||||
-100.0,
|
"param.aberration.red",
|
||||||
100.0,
|
-100.0,
|
||||||
0.0,
|
100.0,
|
||||||
Unit::None,
|
0.0,
|
||||||
Scale::Linear,
|
Unit::None,
|
||||||
0,
|
Scale::Linear,
|
||||||
),
|
0,
|
||||||
ParamDescriptor::scalar(
|
),
|
||||||
"blue",
|
ParamDescriptor::scalar(
|
||||||
"param.aberration.blue",
|
"blue",
|
||||||
-100.0,
|
"param.aberration.blue",
|
||||||
100.0,
|
-100.0,
|
||||||
0.0,
|
100.0,
|
||||||
Unit::None,
|
0.0,
|
||||||
Scale::Linear,
|
Unit::None,
|
||||||
0,
|
Scale::Linear,
|
||||||
),
|
0,
|
||||||
],
|
),
|
||||||
};
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct Aberration {
|
pub struct Aberration {
|
||||||
@@ -113,8 +116,8 @@ impl Aberration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Warp for Aberration {
|
impl Warp for Aberration {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
@@ -300,7 +303,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn every_default_is_neutral() {
|
fn every_default_is_neutral() {
|
||||||
let mut a = Aberration::new();
|
let mut a = Aberration::new();
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
a.set_param(p.id, p.default);
|
a.set_param(p.id, p.default);
|
||||||
}
|
}
|
||||||
assert!(!a.is_active(), "descriptor defaults must be neutral");
|
assert!(!a.is_active(), "descriptor defaults must be neutral");
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
//! would be a guess dressed as a number; `resolves` is the line the stage
|
//! would be a guess dressed as a number; `resolves` is the line the stage
|
||||||
//! already draws, and drawing it in two places differently is worse than a
|
//! already draws, and drawing it in two places differently is worse than a
|
||||||
//! visible step.
|
//! visible step.
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{
|
use crate::descriptor::{
|
||||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
@@ -163,46 +164,48 @@ const MAX_KERNEL: f32 = 48.0;
|
|||||||
/// every editor's capture sharpening starts.
|
/// every editor's capture sharpening starts.
|
||||||
const DEFAULT_RADIUS: f32 = 1.0;
|
const DEFAULT_RADIUS: f32 = 1.0;
|
||||||
|
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
id: ID,
|
Arc::new(OpDescriptor {
|
||||||
label: LocalizedKey("op.capture_sharpen"),
|
id: ID,
|
||||||
params: &[
|
label: LocalizedKey("op.capture_sharpen"),
|
||||||
// Amount carries the neutral, which is why it is first: the operation
|
params: vec![
|
||||||
// is off when this is zero regardless of the other two, so a reset is
|
// Amount carries the neutral, which is why it is first: the operation
|
||||||
// one control and the panel's ordering matches the way it is used.
|
// is off when this is zero regardless of the other two, so a reset is
|
||||||
ParamDescriptor::amount("amount", "param.amount"),
|
// one control and the panel's ordering matches the way it is used.
|
||||||
// In **source pixels** — see the module documentation. Half a photosite
|
ParamDescriptor::amount("amount", "param.amount"),
|
||||||
// is the smallest radius that means anything on a Bayer sensor, and
|
// In **source pixels** — see the module documentation. Half a photosite
|
||||||
// three is already past the point where an unsharp mask is sharpening
|
// is the smallest radius that means anything on a Bayer sensor, and
|
||||||
// rather than adding local contrast; a photographer wanting the latter
|
// three is already past the point where an unsharp mask is sharpening
|
||||||
// wants clarity, which is a different operation with a different unit.
|
// rather than adding local contrast; a photographer wanting the latter
|
||||||
ParamDescriptor::scalar(
|
// wants clarity, which is a different operation with a different unit.
|
||||||
"radius",
|
ParamDescriptor::scalar(
|
||||||
"param.radius",
|
"radius",
|
||||||
0.5,
|
"param.radius",
|
||||||
3.0,
|
0.5,
|
||||||
DEFAULT_RADIUS,
|
3.0,
|
||||||
Unit::None,
|
DEFAULT_RADIUS,
|
||||||
Scale::Linear,
|
Unit::None,
|
||||||
2,
|
Scale::Linear,
|
||||||
),
|
2,
|
||||||
// A fraction, but declared as a scalar rather than through
|
),
|
||||||
// `ParamDescriptor::fraction` for its precision alone: four decimal
|
// A fraction, but declared as a scalar rather than through
|
||||||
// places on a control whose whole useful travel is a dozen steps
|
// `ParamDescriptor::fraction` for its precision alone: four decimal
|
||||||
// reads as noise, and invites fiddling with digits that do nothing.
|
// places on a control whose whole useful travel is a dozen steps
|
||||||
ParamDescriptor::scalar(
|
// reads as noise, and invites fiddling with digits that do nothing.
|
||||||
"threshold",
|
ParamDescriptor::scalar(
|
||||||
"param.threshold",
|
"threshold",
|
||||||
0.0,
|
"param.threshold",
|
||||||
1.0,
|
0.0,
|
||||||
0.0,
|
1.0,
|
||||||
Unit::None,
|
0.0,
|
||||||
Scale::Linear,
|
Unit::None,
|
||||||
2,
|
Scale::Linear,
|
||||||
),
|
2,
|
||||||
],
|
),
|
||||||
attributes: &[Attribute::Detail],
|
],
|
||||||
};
|
attributes: vec![Attribute::Detail],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3
|
/// TRACES: FR-DEV-3
|
||||||
/// Capture sharpening: a separable unsharp mask with a contrast threshold.
|
/// Capture sharpening: a separable unsharp mask with a contrast threshold.
|
||||||
@@ -275,8 +278,8 @@ impl CaptureSharpen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Operation for CaptureSharpen {
|
impl Operation for CaptureSharpen {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
//! the band acts at full strength right up to a hard edge; and with two bands
|
//! the band acts at full strength right up to a hard edge; and with two bands
|
||||||
//! 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 std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{
|
use crate::descriptor::{
|
||||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,
|
||||||
@@ -123,8 +124,9 @@ impl Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parameter descriptors, one per band per channel. Written out rather than
|
// Parameter descriptors, one per band per channel. Written out rather than
|
||||||
// generated because `ParamDescriptor` must be `const` to live in a `static`,
|
// looped because `concat!` needs literals: every id is built from its band's
|
||||||
// and a const loop cannot build a slice. The macro keeps it honest.
|
// key, and a runtime loop has no way to spell `orange_sat`. The macro keeps it
|
||||||
|
// honest.
|
||||||
//
|
//
|
||||||
// **Every one of them is faceted**, and that is what makes the operation
|
// **Every one of them is faceted**, and that is what makes the operation
|
||||||
// legible in a panel. Thirty-six parameters presented as a flat list are
|
// legible in a panel. Thirty-six parameters presented as a flat list are
|
||||||
@@ -137,7 +139,7 @@ impl Channel {
|
|||||||
// §4.3a); this only says the parameter acts on the band centred there.
|
// §4.3a); this only says the parameter acts on the band centred there.
|
||||||
macro_rules! band_params {
|
macro_rules! band_params {
|
||||||
($(($key:literal, $hue:literal)),* $(,)?) => {
|
($(($key:literal, $hue:literal)),* $(,)?) => {
|
||||||
&[
|
vec![
|
||||||
$(
|
$(
|
||||||
ParamDescriptor::amount(
|
ParamDescriptor::amount(
|
||||||
concat!($key, "_hue"),
|
concat!($key, "_hue"),
|
||||||
@@ -175,25 +177,27 @@ macro_rules! band_params {
|
|||||||
// the same reason as those: `concat!` needs literals, so the keys and hues
|
// the same reason as those: `concat!` needs literals, so the keys and hues
|
||||||
// cannot be read out of `BANDS` here. `facets_match_their_bands` below is
|
// cannot be read out of `BANDS` here. `facets_match_their_bands` below is
|
||||||
// what keeps them from drifting.
|
// what keeps them from drifting.
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
attributes: &[Attribute::Colour],
|
Arc::new(OpDescriptor {
|
||||||
id: ID,
|
attributes: vec![Attribute::Colour],
|
||||||
label: LocalizedKey("op.colour_mixer"),
|
id: ID,
|
||||||
params: band_params![
|
label: LocalizedKey("op.colour_mixer"),
|
||||||
("red", 0.0),
|
params: band_params![
|
||||||
("orange", 30.0),
|
("red", 0.0),
|
||||||
("yellow", 60.0),
|
("orange", 30.0),
|
||||||
("chartreuse", 90.0),
|
("yellow", 60.0),
|
||||||
("green", 120.0),
|
("chartreuse", 90.0),
|
||||||
("spring", 150.0),
|
("green", 120.0),
|
||||||
("cyan", 180.0),
|
("spring", 150.0),
|
||||||
("azure", 210.0),
|
("cyan", 180.0),
|
||||||
("blue", 240.0),
|
("azure", 210.0),
|
||||||
("violet", 270.0),
|
("blue", 240.0),
|
||||||
("magenta", 300.0),
|
("violet", 270.0),
|
||||||
("rose", 330.0),
|
("magenta", 300.0),
|
||||||
],
|
("rose", 330.0),
|
||||||
};
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
static MIXER_HELPERS: &[Helper] = &[
|
static MIXER_HELPERS: &[Helper] = &[
|
||||||
helpers::LUMINANCE,
|
helpers::LUMINANCE,
|
||||||
@@ -306,8 +310,8 @@ impl ColourMixer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Operation for ColourMixer {
|
impl Operation for ColourMixer {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
@@ -505,7 +509,7 @@ mod tests {
|
|||||||
fn every_descriptor_id_resolves_to_a_band_and_channel() {
|
fn every_descriptor_id_resolves_to_a_band_and_channel() {
|
||||||
// The link between the descriptor list and the value array. A
|
// The link between the descriptor list and the value array. A
|
||||||
// mismatch would make a slider silently adjust nothing.
|
// mismatch would make a slider silently adjust nothing.
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
assert!(
|
assert!(
|
||||||
ColourMixer::index_of(p.id).is_some(),
|
ColourMixer::index_of(p.id).is_some(),
|
||||||
"{} does not map to a band",
|
"{} does not map to a band",
|
||||||
@@ -547,7 +551,7 @@ mod tests {
|
|||||||
// and a hue mistyped there would put a row's swatch on a colour the
|
// and a hue mistyped there would put a row's swatch on a colour the
|
||||||
// band does not act on — a control that lies about what it edits,
|
// band does not act on — a control that lies about what it edits,
|
||||||
// which is worse than one with no swatch at all.
|
// which is worse than one with no swatch at all.
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
let facet = p.facet.expect("every mixer parameter is faceted");
|
let facet = p.facet.expect("every mixer parameter is faceted");
|
||||||
let (band_key, _) = p.id.0.rsplit_once('_').expect("id is band_channel");
|
let (band_key, _) = p.id.0.rsplit_once('_').expect("id is band_channel");
|
||||||
let band = BANDS
|
let band = BANDS
|
||||||
@@ -577,7 +581,7 @@ mod tests {
|
|||||||
// the aspect keyed per band, grouping by it would produce thirty-six
|
// the aspect keyed per band, grouping by it would produce thirty-six
|
||||||
// groups of one and nothing would have been gained.
|
// groups of one and nothing would have been gained.
|
||||||
let mut per_aspect = std::collections::BTreeMap::new();
|
let mut per_aspect = std::collections::BTreeMap::new();
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
let facet = p.facet.expect("faceted");
|
let facet = p.facet.expect("faceted");
|
||||||
*per_aspect.entry(facet.aspect.0).or_insert(0) += 1;
|
*per_aspect.entry(facet.aspect.0).or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
//! whole composition scheme rests on (ARCH §5.6).
|
//! whole composition scheme rests on (ARCH §5.6).
|
||||||
|
|
||||||
use std::fmt::Write as _;
|
use std::fmt::Write as _;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{
|
use crate::descriptor::{
|
||||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,
|
||||||
@@ -341,11 +342,12 @@ const fn facet_of(aspect: &'static str, channel: Channel) -> Facet {
|
|||||||
|
|
||||||
/// One channel's ten descriptors, defaulted onto the identity diagonal.
|
/// One channel's ten descriptors, defaulted onto the identity diagonal.
|
||||||
///
|
///
|
||||||
/// Written out per point rather than looped because a `ParamDescriptor` has to
|
/// Written out per point rather than looped because `concat!` needs literals:
|
||||||
/// be `const` to live in a `static`, and a const loop cannot build a slice.
|
/// the parameter ids are built from the channel's prefix, and a runtime loop
|
||||||
|
/// has no way to spell `r_p0_x`.
|
||||||
macro_rules! channel_params {
|
macro_rules! channel_params {
|
||||||
($(($prefix:literal, $channel:expr)),* $(,)?) => {
|
($(($prefix:literal, $channel:expr)),* $(,)?) => {
|
||||||
&[$(
|
vec![$(
|
||||||
coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0)
|
coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0)
|
||||||
.faceted(facet_of("param.curve.p0_x", $channel)),
|
.faceted(facet_of("param.curve.p0_x", $channel)),
|
||||||
coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0)
|
coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0)
|
||||||
@@ -370,26 +372,28 @@ macro_rules! channel_params {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
// Both, and this is the case the plural exists for: the master curve is
|
Arc::new(OpDescriptor {
|
||||||
// tonal and the per-channel curves are chromatic. Filing it under one
|
// Both, and this is the case the plural exists for: the master curve is
|
||||||
// would hide it from half the people looking for it.
|
// tonal and the per-channel curves are chromatic. Filing it under one
|
||||||
attributes: &[Attribute::Tone, Attribute::Colour],
|
// would hide it from half the people looking for it.
|
||||||
id: ID,
|
attributes: vec![Attribute::Tone, Attribute::Colour],
|
||||||
label: LocalizedKey("op.tone_curve"),
|
id: ID,
|
||||||
// Defaults lie on y = x, so a fresh curve is the identity and the
|
label: LocalizedKey("op.tone_curve"),
|
||||||
// operation reports itself inactive — on every channel.
|
// Defaults lie on y = x, so a fresh curve is the identity and the
|
||||||
//
|
// operation reports itself inactive — on every channel.
|
||||||
// The master's ten come first, and stay first: a frontend addresses a
|
//
|
||||||
// point by its offset from the first parameter of the run it is drawing,
|
// The master's ten come first, and stay first: a frontend addresses a
|
||||||
// and this is also the order one falling back to sliders reads them in.
|
// point by its offset from the first parameter of the run it is drawing,
|
||||||
params: channel_params![
|
// and this is also the order one falling back to sliders reads them in.
|
||||||
("", Channel::Master),
|
params: channel_params![
|
||||||
("r_", Channel::Red),
|
("", Channel::Master),
|
||||||
("g_", Channel::Green),
|
("r_", Channel::Red),
|
||||||
("b_", Channel::Blue),
|
("g_", Channel::Green),
|
||||||
],
|
("b_", Channel::Blue),
|
||||||
};
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
/// One span of a monotone cubic Hermite spline. Shared by all four curves.
|
/// One span of a monotone cubic Hermite spline. Shared by all four curves.
|
||||||
const CURVE_SPAN: Helper = Helper {
|
const CURVE_SPAN: Helper = Helper {
|
||||||
@@ -699,8 +703,8 @@ impl ToneCurve {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Operation for ToneCurve {
|
impl Operation for ToneCurve {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
@@ -727,7 +731,7 @@ impl Operation for ToneCurve {
|
|||||||
Some(Presentation {
|
Some(Presentation {
|
||||||
// One entry: there is no second way to draw a tone curve that is
|
// One entry: there is no second way to draw a tone curve that is
|
||||||
// better than the sliders the frontend falls back to anyway.
|
// better than the sliders the frontend falls back to anyway.
|
||||||
widgets: &[WidgetKind::ToneCurve],
|
widgets: vec![WidgetKind::ToneCurve],
|
||||||
demand: WidgetDemand {
|
demand: WidgetDemand {
|
||||||
// A point is dragged in x and y together — that is what a
|
// A point is dragged in x and y together — that is what a
|
||||||
// curve *is*, and a frontend that can only move one axis at a
|
// curve *is*, and a frontend that can only move one axis at a
|
||||||
@@ -739,7 +743,7 @@ impl Operation for ToneCurve {
|
|||||||
// leave the other thirty stranded as sliders beneath the plot;
|
// leave the other thirty stranded as sliders beneath the plot;
|
||||||
// which of the four it draws at a time is its own affair, and the
|
// which of the four it draws at a time is its own affair, and the
|
||||||
// facets are what let it decide without naming a channel.
|
// facets are what let it decide without naming a channel.
|
||||||
params: &CURVE_PARAMS,
|
params: CURVE_PARAMS.to_vec(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -910,7 +914,7 @@ mod tests {
|
|||||||
// Opening an unedited image must show the image.
|
// Opening an unedited image must show the image.
|
||||||
let c = ToneCurve::new();
|
let c = ToneCurve::new();
|
||||||
assert!(!c.is_active());
|
assert!(!c.is_active());
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
assert_eq!(c.param(p.id), p.default);
|
assert_eq!(c.param(p.id), p.default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -927,7 +931,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_parameter_id_maps_to_a_point() {
|
fn every_parameter_id_maps_to_a_point() {
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
assert!(
|
assert!(
|
||||||
ToneCurve::index_of(p.id).is_some(),
|
ToneCurve::index_of(p.id).is_some(),
|
||||||
"{} does not map to a point",
|
"{} does not map to a point",
|
||||||
@@ -1196,7 +1200,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(presentation.choose(|_| false), None);
|
assert_eq!(presentation.choose(|_| false), None);
|
||||||
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
|
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
assert!(
|
assert!(
|
||||||
presentation.params.contains(&p.id),
|
presentation.params.contains(&p.id),
|
||||||
"{} is not owned by the widget",
|
"{} is not owned by the widget",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
//! set three correlated coefficients, and hand-correcting a lens with no
|
//! set three correlated coefficients, and hand-correcting a lens with no
|
||||||
//! 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 std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{
|
use crate::descriptor::{
|
||||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
@@ -35,25 +36,27 @@ use crate::operation::{Helper, Uniform};
|
|||||||
pub const ID: OpId = OpId("distortion");
|
pub const ID: OpId = OpId("distortion");
|
||||||
pub const AMOUNT: ParamId = ParamId("amount");
|
pub const AMOUNT: ParamId = ParamId("amount");
|
||||||
|
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
attributes: &[Attribute::Optics],
|
Arc::new(OpDescriptor {
|
||||||
id: ID,
|
attributes: vec![Attribute::Optics],
|
||||||
label: LocalizedKey("op.distortion"),
|
id: ID,
|
||||||
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
|
label: LocalizedKey("op.distortion"),
|
||||||
// fisheye at one end and strong pincushion at the other; beyond it the
|
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
|
||||||
// inverse mapping stops being single-valued near the corners and the
|
// fisheye at one end and strong pincushion at the other; beyond it the
|
||||||
// correction folds the image over itself.
|
// inverse mapping stops being single-valued near the corners and the
|
||||||
params: &[ParamDescriptor::scalar(
|
// correction folds the image over itself.
|
||||||
"amount",
|
params: vec![ParamDescriptor::scalar(
|
||||||
"param.distortion.amount",
|
"amount",
|
||||||
-100.0,
|
"param.distortion.amount",
|
||||||
100.0,
|
-100.0,
|
||||||
0.0,
|
100.0,
|
||||||
Unit::None,
|
0.0,
|
||||||
Scale::Linear,
|
Unit::None,
|
||||||
0,
|
Scale::Linear,
|
||||||
)],
|
0,
|
||||||
};
|
)],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
/// The cubic coefficient at full slider travel.
|
/// The cubic coefficient at full slider travel.
|
||||||
const MAX_COEFF: f32 = 0.25;
|
const MAX_COEFF: f32 = 0.25;
|
||||||
@@ -108,8 +111,8 @@ impl Distortion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Warp for Distortion {
|
impl Warp for Distortion {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
//! Declared as a plain struct here rather than imported, so that dr-pipeline
|
//! Declared as a plain struct here rather than imported, so that dr-pipeline
|
||||||
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
|
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
|
||||||
//! with `Pa`.
|
//! with `Pa`.
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||||
use crate::operation::{Operation, Uniform};
|
use crate::operation::{Operation, Uniform};
|
||||||
@@ -73,29 +74,31 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
|
|||||||
["m20", "m21", "m22"],
|
["m20", "m21", "m22"],
|
||||||
];
|
];
|
||||||
|
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
// Tone and colour both, and not `Effect`: a stock is not something applied
|
Arc::new(OpDescriptor {
|
||||||
// on top of a photograph, it is what the photograph was made on.
|
// Tone and colour both, and not `Effect`: a stock is not something applied
|
||||||
attributes: &[Attribute::Tone, Attribute::Colour],
|
// on top of a photograph, it is what the photograph was made on.
|
||||||
id: ID,
|
attributes: vec![Attribute::Tone, Attribute::Colour],
|
||||||
label: LocalizedKey("op.film_sim"),
|
id: ID,
|
||||||
params: &[
|
label: LocalizedKey("op.film_sim"),
|
||||||
ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0),
|
params: vec![
|
||||||
ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0),
|
ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0),
|
||||||
// TRACES: FR-DEV-3f
|
ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0),
|
||||||
// Development, in stops of push. Bounded by what the manufacturers
|
// TRACES: FR-DEV-3f
|
||||||
// actually published: Double-X's measured axis spans about -1 to +2,
|
// Development, in stops of push. Bounded by what the manufacturers
|
||||||
// and beyond a range like that a curve would have to be invented.
|
// actually published: Double-X's measured axis spans about -1 to +2,
|
||||||
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
|
// and beyond a range like that a curve would have to be invented.
|
||||||
// TRACES: FR-DEV-3f
|
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
|
||||||
// Which frame this was taken on — the half of the enlargement a
|
// TRACES: FR-DEV-3f
|
||||||
// photograph cannot supply. A crystal is a fixed size in micrometres,
|
// Which frame this was taken on — the half of the enlargement a
|
||||||
// so how grainy a picture looks is film size against output size, and
|
// photograph cannot supply. A crystal is a fixed size in micrometres,
|
||||||
// the same emulsion on 4x5 renders about three times smoother than on
|
// so how grainy a picture looks is film size against output size, and
|
||||||
// 35mm at the same print.
|
// the same emulsion on 4x5 renders about three times smoother than on
|
||||||
ParamDescriptor::choice("format", "param.film_sim.format", &FORMATS),
|
// 35mm at the same print.
|
||||||
],
|
ParamDescriptor::choice("format", "param.film_sim.format", FORMATS.to_vec()),
|
||||||
};
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
/// A stock reduced to what a shader runs, as `dr-film` bakes it.
|
/// A stock reduced to what a shader runs, as `dr-film` bakes it.
|
||||||
///
|
///
|
||||||
@@ -190,8 +193,8 @@ impl FilmSim {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Operation for FilmSim {
|
impl Operation for FilmSim {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
|
|||||||
@@ -153,6 +153,7 @@
|
|||||||
//! this file.
|
//! this file.
|
||||||
|
|
||||||
use std::marker::PhantomData;
|
use std::marker::PhantomData;
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||||
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
||||||
@@ -181,7 +182,12 @@ const TRUNCATION: f32 = 2.0;
|
|||||||
/// between clarity and texture can be read side by side, which is the one
|
/// between clarity and texture can be read side by side, which is the one
|
||||||
/// thing a reader comes to this file to do.
|
/// thing a reader comes to this file to do.
|
||||||
pub struct Recipe {
|
pub struct Recipe {
|
||||||
descriptor: &'static OpDescriptor,
|
/// The static this operation's descriptor is built in. A `LazyLock`
|
||||||
|
/// rather than a reference to a descriptor, because a descriptor is an
|
||||||
|
/// owned value handed out as an `Arc` now (FR-PLG-2), and a `const`
|
||||||
|
/// recipe cannot hold an `Arc` — only a reference to the static that
|
||||||
|
/// makes one.
|
||||||
|
descriptor: &'static LazyLock<Arc<OpDescriptor>>,
|
||||||
helpers: &'static [Helper],
|
helpers: &'static [Helper],
|
||||||
/// The Gaussian's σ, as a fraction of the frame's shorter edge.
|
/// The Gaussian's σ, as a fraction of the frame's shorter edge.
|
||||||
sigma: f32,
|
sigma: f32,
|
||||||
@@ -253,19 +259,23 @@ impl Band for Fine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static CLARITY_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static CLARITY_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
id: CLARITY,
|
Arc::new(OpDescriptor {
|
||||||
label: LocalizedKey("op.clarity"),
|
id: CLARITY,
|
||||||
params: &[ParamDescriptor::amount("amount", "param.clarity.amount")],
|
label: LocalizedKey("op.clarity"),
|
||||||
attributes: &[Attribute::Detail],
|
params: vec![ParamDescriptor::amount("amount", "param.clarity.amount")],
|
||||||
};
|
attributes: vec![Attribute::Detail],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
static TEXTURE_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static TEXTURE_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
id: TEXTURE,
|
Arc::new(OpDescriptor {
|
||||||
label: LocalizedKey("op.texture"),
|
id: TEXTURE,
|
||||||
params: &[ParamDescriptor::amount("amount", "param.texture.amount")],
|
label: LocalizedKey("op.texture"),
|
||||||
attributes: &[Attribute::Detail],
|
params: vec![ParamDescriptor::amount("amount", "param.texture.amount")],
|
||||||
};
|
attributes: vec![Attribute::Detail],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
/// Luminance as a position on a logarithmic scale, floored.
|
/// Luminance as a position on a logarithmic scale, floored.
|
||||||
///
|
///
|
||||||
@@ -394,8 +404,8 @@ impl<B: Band> LocalContrast<B> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<B: Band> Operation for LocalContrast<B> {
|
impl<B: Band> Operation for LocalContrast<B> {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
B::RECIPE.descriptor
|
Arc::clone(B::RECIPE.descriptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ mod tests {
|
|||||||
// perfectly and silently breaks the sidecar.
|
// perfectly and silently breaks the sidecar.
|
||||||
for mut op in chain() {
|
for mut op in chain() {
|
||||||
let descriptor = op.descriptor();
|
let descriptor = op.descriptor();
|
||||||
for p in descriptor.params {
|
for p in &descriptor.params {
|
||||||
let crate::descriptor::ParamKind::Scalar { min, max, .. } = p.kind else {
|
let crate::descriptor::ParamKind::Scalar { min, max, .. } = p.kind else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -147,6 +147,7 @@
|
|||||||
//! lie. The chroma radius, ten times larger, still resolves — which is also
|
//! lie. The chroma radius, ten times larger, still resolves — which is also
|
||||||
//! true of the fault it treats, since a blotch twenty pixels across survives
|
//! true of the fault it treats, since a blotch twenty pixels across survives
|
||||||
//! being halved.
|
//! being halved.
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{
|
use crate::descriptor::{
|
||||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
@@ -158,38 +159,40 @@ pub const ID: OpId = OpId("noise_reduction");
|
|||||||
pub const LUMINANCE: ParamId = ParamId("luminance");
|
pub const LUMINANCE: ParamId = ParamId("luminance");
|
||||||
pub const CHROMA: ParamId = ParamId("chroma");
|
pub const CHROMA: ParamId = ParamId("chroma");
|
||||||
|
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
id: ID,
|
Arc::new(OpDescriptor {
|
||||||
label: LocalizedKey("op.noise_reduction"),
|
id: ID,
|
||||||
attributes: &[Attribute::Detail],
|
label: LocalizedKey("op.noise_reduction"),
|
||||||
// Zero to a hundred rather than the symmetric `amount` shape the tonal
|
attributes: vec![Attribute::Detail],
|
||||||
// controls use. There is no meaningful negative: "minus fifty noise
|
// Zero to a hundred rather than the symmetric `amount` shape the tonal
|
||||||
// reduction" would be adding grain, which is a look rather than a repair
|
// controls use. There is no meaningful negative: "minus fifty noise
|
||||||
// and belongs to a different operation carrying `Attribute::Effect`. A
|
// reduction" would be adding grain, which is a look rather than a repair
|
||||||
// control whose left half does nothing is worse than one that stops.
|
// and belongs to a different operation carrying `Attribute::Effect`. A
|
||||||
params: &[
|
// control whose left half does nothing is worse than one that stops.
|
||||||
ParamDescriptor::scalar(
|
params: vec![
|
||||||
"luminance",
|
ParamDescriptor::scalar(
|
||||||
"param.noise_reduction.luminance",
|
"luminance",
|
||||||
0.0,
|
"param.noise_reduction.luminance",
|
||||||
100.0,
|
0.0,
|
||||||
0.0,
|
100.0,
|
||||||
Unit::None,
|
0.0,
|
||||||
Scale::Linear,
|
Unit::None,
|
||||||
0,
|
Scale::Linear,
|
||||||
),
|
0,
|
||||||
ParamDescriptor::scalar(
|
),
|
||||||
"chroma",
|
ParamDescriptor::scalar(
|
||||||
"param.noise_reduction.chroma",
|
"chroma",
|
||||||
0.0,
|
"param.noise_reduction.chroma",
|
||||||
100.0,
|
0.0,
|
||||||
0.0,
|
100.0,
|
||||||
Unit::None,
|
0.0,
|
||||||
Scale::Linear,
|
Unit::None,
|
||||||
0,
|
Scale::Linear,
|
||||||
),
|
0,
|
||||||
],
|
),
|
||||||
};
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
/// The luminance radius at the lowest and the highest amount, in **source**
|
/// The luminance radius at the lowest and the highest amount, in **source**
|
||||||
/// pixels.
|
/// pixels.
|
||||||
@@ -365,8 +368,8 @@ fn inv_spatial(kernel: u32) -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Operation for NoiseReduction {
|
impl Operation for NoiseReduction {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
//! division is the whole reason this operation must run before the tonal
|
//! division is the whole reason this operation must run before the tonal
|
||||||
//! stages: a corner recovered by two stops has to be recovered while the
|
//! stages: a corner recovered by two stops has to be recovered while the
|
||||||
//! highlight headroom to hold it still exists (ARCH §5.2).
|
//! highlight headroom to hold it still exists (ARCH §5.2).
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||||
use crate::operation::{Helper, Operation, Uniform};
|
use crate::operation::{Helper, Operation, Uniform};
|
||||||
@@ -45,19 +46,21 @@ pub const AMOUNT: ParamId = ParamId("amount");
|
|||||||
/// fast prime wide open — the case that actually needs correcting.
|
/// fast prime wide open — the case that actually needs correcting.
|
||||||
const MAX_K1: f32 = -0.5;
|
const MAX_K1: f32 = -0.5;
|
||||||
|
|
||||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
// Optics rather than effect: this carries lens-profile coefficients
|
Arc::new(OpDescriptor {
|
||||||
// and corrects what the lens did. A *creative* vignette is a different
|
// Optics rather than effect: this carries lens-profile coefficients
|
||||||
// operation that does not exist yet, and would be `Effect`.
|
// and corrects what the lens did. A *creative* vignette is a different
|
||||||
attributes: &[Attribute::Optics],
|
// operation that does not exist yet, and would be `Effect`.
|
||||||
id: ID,
|
attributes: vec![Attribute::Optics],
|
||||||
label: LocalizedKey("op.vignetting"),
|
id: ID,
|
||||||
// Bidirectional deliberately. Negative values *add* falloff, which is a
|
label: LocalizedKey("op.vignetting"),
|
||||||
// legitimate creative choice as well as a correction, and a control that
|
// Bidirectional deliberately. Negative values *add* falloff, which is a
|
||||||
// only removed vignetting would need a second one beside it to put any
|
// legitimate creative choice as well as a correction, and a control that
|
||||||
// back.
|
// only removed vignetting would need a second one beside it to put any
|
||||||
params: &[ParamDescriptor::amount("amount", "param.vignetting.amount")],
|
// back.
|
||||||
};
|
params: vec![ParamDescriptor::amount("amount", "param.vignetting.amount")],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
/// The `pa` polynomial coefficients, as Lensfun stores them.
|
/// The `pa` polynomial coefficients, as Lensfun stores them.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -107,8 +110,8 @@ impl Vignetting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Operation for Vignetting {
|
impl Operation for Vignetting {
|
||||||
fn descriptor(&self) -> &'static OpDescriptor {
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
&DESCRIPTOR
|
DESCRIPTOR.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
@@ -371,7 +374,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn every_default_is_neutral() {
|
fn every_default_is_neutral() {
|
||||||
let mut v = Vignetting::new();
|
let mut v = Vignetting::new();
|
||||||
for p in DESCRIPTOR.params {
|
for p in &DESCRIPTOR.params {
|
||||||
v.set_param(p.id, p.default);
|
v.set_param(p.id, p.default);
|
||||||
}
|
}
|
||||||
assert!(!v.is_active());
|
assert!(!v.is_active());
|
||||||
|
|||||||
@@ -1297,8 +1297,17 @@ impl PartialMask {
|
|||||||
.ops
|
.ops
|
||||||
.iter()
|
.iter()
|
||||||
.find(|o| o.descriptor().id.0 == op)
|
.find(|o| o.descriptor().id.0 == op)
|
||||||
.and_then(|o| o.descriptor().params.iter().find(|p| p.id.0 == param))
|
// The descriptor is bound inside the closure rather than
|
||||||
.map(|p| p.id)
|
// chained through: it is an owned `Arc` now, so a
|
||||||
|
// `ParamDescriptor` borrowed out of it would not outlive the
|
||||||
|
// expression. The `ParamId` is `Copy`, so it does.
|
||||||
|
.and_then(|o| {
|
||||||
|
o.descriptor()
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.id.0 == param)
|
||||||
|
.map(|p| p.id)
|
||||||
|
})
|
||||||
else {
|
else {
|
||||||
log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring");
|
log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring");
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
//! TRACES: FR-PLG-2
|
||||||
|
//! The generated path and the interpreted path produce the same operation.
|
||||||
|
//!
|
||||||
|
//! # Why this test is the point
|
||||||
|
//!
|
||||||
|
//! FR-PLG-2 says a bundled operation and a third-party plugin are the same
|
||||||
|
//! kind of thing, differing only in where the file was found. Two
|
||||||
|
//! implementations sit behind that claim: `build.rs` compiles `ops/*.yaml`
|
||||||
|
//! into Rust, and [`DeclaredOp`] interprets the same declaration at run time.
|
||||||
|
//!
|
||||||
|
//! **If the two ever disagree, the claim fails quietly.** A plugin would be a
|
||||||
|
//! second-class kind of node — one whose colours come out fractionally
|
||||||
|
//! different, or whose fragment lands in the shader with a different comment,
|
||||||
|
//! or whose uniform arrives in a different slot — and nothing would say so.
|
||||||
|
//! The photographer would see a look they could not reproduce with a built-in
|
||||||
|
//! and would have no way to find out why.
|
||||||
|
//!
|
||||||
|
//! So this parses every built-in declaration at run time and asserts the
|
||||||
|
//! composed WGSL is **byte for byte** what the generated implementation
|
||||||
|
//! produces, with the uniform block bit for bit identical, at a spread of
|
||||||
|
//! parameter values.
|
||||||
|
//!
|
||||||
|
//! # Byte-for-byte, and bit-for-bit, on purpose
|
||||||
|
//!
|
||||||
|
//! Not "equivalent", not "within an epsilon". A tolerance is where a real
|
||||||
|
//! divergence hides: the arithmetic in a declaration is `f32` at both ends and
|
||||||
|
//! there is no reason for a single bit to differ, so any difference at all is
|
||||||
|
//! a bug in one of the two backends and should read as one. The one place
|
||||||
|
//! this bites is number literals, which is why `expr::as_f32` rounds a decimal
|
||||||
|
//! exactly once — see its own documentation.
|
||||||
|
//!
|
||||||
|
//! # What is not covered, and why that is honest
|
||||||
|
//!
|
||||||
|
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
||||||
|
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture` — names a
|
||||||
|
//! hand-written type and has no declaration to interpret. It is not skipped
|
||||||
|
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
||||||
|
//! `ops/` between them, so a node that stops being declared cannot quietly
|
||||||
|
//! drop out of this file's coverage.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use dr_pipeline::declared::{builtin_helpers, decl, DeclaredOp, Node};
|
||||||
|
use dr_pipeline::descriptor::ParamKind;
|
||||||
|
use dr_pipeline::operation::{compose, ComposedShader, Operation};
|
||||||
|
use dr_pipeline::ops;
|
||||||
|
use dr_pipeline::ParamId;
|
||||||
|
|
||||||
|
/// The declarations this crate ships, read from disk rather than embedded.
|
||||||
|
///
|
||||||
|
/// From disk deliberately: `build.rs` reads these very files, so reading the
|
||||||
|
/// same bytes is what makes the comparison a comparison of the two *readers*
|
||||||
|
/// rather than of two snapshots that were taken at different times.
|
||||||
|
fn ops_dir() -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("ops")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `<id>.yaml` in `ops/`, keyed by id, excluding `_helpers.yaml`.
|
||||||
|
fn declaration_files() -> BTreeMap<String, String> {
|
||||||
|
let mut out = BTreeMap::new();
|
||||||
|
for entry in std::fs::read_dir(ops_dir()).expect("ops/ is readable") {
|
||||||
|
let path = entry.expect("a directory entry").path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.expect("a file name")
|
||||||
|
.to_string();
|
||||||
|
if stem.starts_with('_') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.insert(stem, std::fs::read_to_string(&path).expect("readable"));
|
||||||
|
}
|
||||||
|
assert!(!out.is_empty(), "ops/ declares no nodes");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one declaration the way both backends do.
|
||||||
|
fn read(id: &str, text: &str) -> Node {
|
||||||
|
let library = builtin_helpers();
|
||||||
|
decl::read_node(text, &format!("ops/{id}.yaml"), &library.names())
|
||||||
|
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The generated implementation of one node, taken out of the default chain.
|
||||||
|
///
|
||||||
|
/// Out of `chain()` rather than constructed by name, because that is how the
|
||||||
|
/// application gets one: if the two ever differed, the chain's version is the
|
||||||
|
/// one a photograph would be developed with.
|
||||||
|
fn generated(id: &str) -> Box<dyn Operation> {
|
||||||
|
let chain = ops::chain();
|
||||||
|
let index = chain
|
||||||
|
.iter()
|
||||||
|
.position(|o| o.descriptor().id.0 == id)
|
||||||
|
.unwrap_or_else(|| panic!("`{id}` is not in the default chain"));
|
||||||
|
let mut chain = chain;
|
||||||
|
chain.remove(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Values worth setting a parameter to, spanning its declared range.
|
||||||
|
///
|
||||||
|
/// Both ends, because that is where a rounding difference in a `min:` or a
|
||||||
|
/// `max:` would show; the default, because that is the neutral every
|
||||||
|
/// `is_active` is written against; and two interior points that are not
|
||||||
|
/// round numbers, because a value like 37.5 exercises the arithmetic in a way
|
||||||
|
/// 0 and 100 do not.
|
||||||
|
fn probe_values(kind: &ParamKind, default: f32) -> Vec<f32> {
|
||||||
|
match kind {
|
||||||
|
ParamKind::Scalar { min, max, .. } => {
|
||||||
|
let span = max - min;
|
||||||
|
vec![
|
||||||
|
default,
|
||||||
|
*min,
|
||||||
|
*max,
|
||||||
|
min + span * 0.375,
|
||||||
|
min + span * 0.8125,
|
||||||
|
// A value that is not representable as a short decimal, to
|
||||||
|
// catch a backend that round-trips a uniform through text.
|
||||||
|
min + span / 3.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
ParamKind::Bool => vec![0.0, 1.0],
|
||||||
|
ParamKind::Enum { variants } => (0..variants.len()).map(|i| i as f32).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put every parameter back where it started.
|
||||||
|
fn reset(op: &mut dyn Operation) {
|
||||||
|
let descriptor = op.descriptor();
|
||||||
|
for p in &descriptor.params {
|
||||||
|
op.set_param(p.id, p.default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert the two operations are indistinguishable at their current settings.
|
||||||
|
fn assert_same(id: &str, setting: &str, generated: &dyn Operation, declared: &dyn Operation) {
|
||||||
|
let g = generated.descriptor();
|
||||||
|
let d = declared.descriptor();
|
||||||
|
assert_eq!(*g, *d, "{id} [{setting}]: the descriptors differ");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
generated.is_active(),
|
||||||
|
declared.is_active(),
|
||||||
|
"{id} [{setting}]: the two disagree about whether the operation is doing anything"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
generated.wgsl_body(),
|
||||||
|
declared.wgsl_body(),
|
||||||
|
"{id} [{setting}]: the fragment bodies differ"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
generated.helpers(),
|
||||||
|
declared.helpers(),
|
||||||
|
"{id} [{setting}]: the helper sets differ"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (gu, du) = (generated.uniforms(), declared.uniforms());
|
||||||
|
assert_eq!(
|
||||||
|
gu.len(),
|
||||||
|
du.len(),
|
||||||
|
"{id} [{setting}]: different numbers of uniforms"
|
||||||
|
);
|
||||||
|
for (a, b) in gu.iter().zip(&du) {
|
||||||
|
assert_eq!(
|
||||||
|
a.name, b.name,
|
||||||
|
"{id} [{setting}]: uniforms in a different order"
|
||||||
|
);
|
||||||
|
// Bits, not values: `assert_eq!` on `f32` would call two NaNs unequal
|
||||||
|
// and would call `0.0` and `-0.0` equal, and both of those are
|
||||||
|
// differences worth failing on.
|
||||||
|
assert_eq!(
|
||||||
|
a.value.to_bits(),
|
||||||
|
b.value.to_bits(),
|
||||||
|
"{id} [{setting}]: uniform `{}` is {} generated and {} declared",
|
||||||
|
a.name,
|
||||||
|
a.value,
|
||||||
|
b.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert two compositions are the same shader.
|
||||||
|
fn assert_same_shader(what: &str, a: &ComposedShader, b: &ComposedShader) {
|
||||||
|
// The source first, and compared as whole strings: a diff in the middle of
|
||||||
|
// several kilobytes of WGSL is unreadable as an assertion message, so the
|
||||||
|
// failure below points at the first differing line instead.
|
||||||
|
if a.source != b.source {
|
||||||
|
let line = a
|
||||||
|
.source
|
||||||
|
.lines()
|
||||||
|
.zip(b.source.lines())
|
||||||
|
.position(|(x, y)| x != y);
|
||||||
|
match line {
|
||||||
|
Some(n) => panic!(
|
||||||
|
"{what}: the composed WGSL differs at line {}:\n generated: {:?}\n declared: {:?}",
|
||||||
|
n + 1,
|
||||||
|
a.source.lines().nth(n).unwrap_or(""),
|
||||||
|
b.source.lines().nth(n).unwrap_or(""),
|
||||||
|
),
|
||||||
|
None => panic!(
|
||||||
|
"{what}: the composed WGSL differs in length: {} generated, {} declared",
|
||||||
|
a.source.len(),
|
||||||
|
b.source.len()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
a.uniforms.len(),
|
||||||
|
b.uniforms.len(),
|
||||||
|
"{what}: different uniform block sizes"
|
||||||
|
);
|
||||||
|
for (i, (x, y)) in a.uniforms.iter().zip(&b.uniforms).enumerate() {
|
||||||
|
assert_eq!(
|
||||||
|
x.to_bits(),
|
||||||
|
y.to_bits(),
|
||||||
|
"{what}: uniform slot {i} is {x} generated and {y} declared"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hash is taken over the source, so it follows — but it is what the
|
||||||
|
// pipeline cache keys on, and asserting it says that a declared node and
|
||||||
|
// its generated twin would share a compiled pipeline rather than quietly
|
||||||
|
// splitting the cache in two.
|
||||||
|
assert_eq!(a.structure_hash, b.structure_hash, "{what}: structure hash");
|
||||||
|
assert_eq!(a.output_mode, b.output_mode, "{what}: output mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose a single operation, the way the display path composes a chain.
|
||||||
|
fn compose_one(op: Box<dyn Operation>) -> (ComposedShader, Box<dyn Operation>) {
|
||||||
|
let shader = compose(std::slice::from_ref(&op));
|
||||||
|
(shader, op)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-2
|
||||||
|
/// Every declared built-in composes to the same shader either way.
|
||||||
|
#[test]
|
||||||
|
fn a_declaration_read_at_run_time_composes_byte_for_byte_as_the_generated_one() {
|
||||||
|
let library = builtin_helpers();
|
||||||
|
let mut checked = 0;
|
||||||
|
|
||||||
|
for (id, text) in declaration_files() {
|
||||||
|
let Node::Declared(declaration) = read(&id, &text) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut declared =
|
||||||
|
DeclaredOp::new(&declaration, library).unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"));
|
||||||
|
let mut generated = generated(&id);
|
||||||
|
|
||||||
|
// The neutral state first: it is the state every image starts in, and
|
||||||
|
// an operation that is inactive in one path and active in the other
|
||||||
|
// would put a whole fragment into one shader and not the other.
|
||||||
|
assert_same(&id, "neutral", generated.as_ref(), &declared);
|
||||||
|
|
||||||
|
let descriptor = declared.descriptor();
|
||||||
|
for p in &descriptor.params {
|
||||||
|
for value in probe_values(&p.kind, p.default) {
|
||||||
|
reset(generated.as_mut());
|
||||||
|
reset(&mut declared);
|
||||||
|
generated.set_param(p.id, value);
|
||||||
|
declared.set_param(p.id, value);
|
||||||
|
|
||||||
|
let setting = format!("{} = {value}", p.id);
|
||||||
|
assert_same(&id, &setting, generated.as_ref(), &declared);
|
||||||
|
|
||||||
|
let (gs, back) = compose_one(generated);
|
||||||
|
generated = back;
|
||||||
|
let (ds, _) = compose_one(Box::new(declared.clone()));
|
||||||
|
assert_same_shader(&format!("{id} [{setting}]"), &gs, &ds);
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And every parameter moved at once, which is the only case that
|
||||||
|
// exercises the *order* uniforms are emitted in.
|
||||||
|
reset(generated.as_mut());
|
||||||
|
reset(&mut declared);
|
||||||
|
for p in &descriptor.params {
|
||||||
|
let value =
|
||||||
|
probe_values(&p.kind, p.default)[3.min(probe_values(&p.kind, p.default).len() - 1)];
|
||||||
|
generated.set_param(p.id, value);
|
||||||
|
declared.set_param(p.id, value);
|
||||||
|
}
|
||||||
|
assert_same(&id, "all parameters moved", generated.as_ref(), &declared);
|
||||||
|
let (gs, _) = compose_one(generated);
|
||||||
|
let (ds, _) = compose_one(Box::new(declared));
|
||||||
|
assert_same_shader(&format!("{id} [all parameters moved]"), &gs, &ds);
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A test that silently checked nothing would pass forever. There are eight
|
||||||
|
// declared nodes and several settings each, so this is a floor rather than
|
||||||
|
// a count anybody has to maintain.
|
||||||
|
assert!(
|
||||||
|
checked > 20,
|
||||||
|
"only {checked} comparisons ran; the declarations were not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-2
|
||||||
|
/// The whole chain composes identically with the declared nodes swapped in.
|
||||||
|
///
|
||||||
|
/// The single-operation test above is the sharper one — it isolates each node
|
||||||
|
/// — but it cannot see an interaction. This composes the *default develop
|
||||||
|
/// chain*, with every declared node replaced by its interpreted twin and the
|
||||||
|
/// `rust:` nodes left alone, so it covers uniform slot ordering across
|
||||||
|
/// operations, helper de-duplication between them, and the order the fragments
|
||||||
|
/// land in the shader.
|
||||||
|
#[test]
|
||||||
|
fn the_whole_chain_composes_identically_with_interpreted_nodes() {
|
||||||
|
let library = builtin_helpers();
|
||||||
|
let files = declaration_files();
|
||||||
|
|
||||||
|
let mut generated_chain = ops::chain();
|
||||||
|
let mut declared_chain = ops::chain();
|
||||||
|
let mut swapped = 0;
|
||||||
|
|
||||||
|
for i in 0..declared_chain.len() {
|
||||||
|
let id = declared_chain[i].descriptor().id.0.to_string();
|
||||||
|
let text = files
|
||||||
|
.get(&id)
|
||||||
|
.unwrap_or_else(|| panic!("`{id}` is in the chain but has no ops/{id}.yaml"));
|
||||||
|
if let Node::Declared(declaration) = read(&id, text) {
|
||||||
|
declared_chain[i] = Box::new(
|
||||||
|
DeclaredOp::new(&declaration, library)
|
||||||
|
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}")),
|
||||||
|
);
|
||||||
|
swapped += 1;
|
||||||
|
}
|
||||||
|
// Move every operation off neutral, declared or not, so the chain is
|
||||||
|
// not a list of fragments that were all omitted. A neutral chain
|
||||||
|
// composes to a shader with no operation blocks in it at all, which
|
||||||
|
// would make this test pass while asserting nothing.
|
||||||
|
let descriptor = generated_chain[i].descriptor();
|
||||||
|
for p in &descriptor.params {
|
||||||
|
let value = probe_values(&p.kind, p.default)[1];
|
||||||
|
generated_chain[i].set_param(p.id, value);
|
||||||
|
declared_chain[i].set_param(p.id, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
swapped >= 8,
|
||||||
|
"only {swapped} nodes were swapped for declared ones"
|
||||||
|
);
|
||||||
|
assert_same_shader(
|
||||||
|
"the default chain",
|
||||||
|
&compose(&generated_chain),
|
||||||
|
&compose(&declared_chain),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-2
|
||||||
|
/// Nothing in `ops/` escapes this file unnoticed.
|
||||||
|
///
|
||||||
|
/// The coverage guard. A declaration that stopped parsing, or a node that
|
||||||
|
/// quietly became `rust:`, would otherwise reduce what the parity test covers
|
||||||
|
/// without anything failing — which is exactly the silent divergence the whole
|
||||||
|
/// file exists to prevent.
|
||||||
|
#[test]
|
||||||
|
fn every_declared_node_is_checked() {
|
||||||
|
let files = declaration_files();
|
||||||
|
let mut declared = Vec::new();
|
||||||
|
let mut hand_written = Vec::new();
|
||||||
|
|
||||||
|
for (id, text) in &files {
|
||||||
|
match read(id, text) {
|
||||||
|
Node::Declared(_) => declared.push(id.clone()),
|
||||||
|
Node::Rust { ty, .. } => hand_written.push((id.clone(), ty)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every file is one or the other, and the chain holds exactly them.
|
||||||
|
assert_eq!(declared.len() + hand_written.len(), files.len());
|
||||||
|
assert_eq!(
|
||||||
|
ops::DECLARED_IDS.len(),
|
||||||
|
files.len(),
|
||||||
|
"the chain and ops/ hold different numbers of nodes"
|
||||||
|
);
|
||||||
|
for id in ops::DECLARED_IDS {
|
||||||
|
assert!(
|
||||||
|
files.contains_key(*id),
|
||||||
|
"`{id}` is in the chain but not in ops/"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named rather than counted, so that a node changing sides is a failure
|
||||||
|
// somebody reads rather than a number they update.
|
||||||
|
let hand: Vec<&str> = hand_written.iter().map(|(id, _)| id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
hand,
|
||||||
|
[
|
||||||
|
"capture_sharpen",
|
||||||
|
"clarity",
|
||||||
|
"colour_mixer",
|
||||||
|
"film_sim",
|
||||||
|
"noise_reduction",
|
||||||
|
"texture",
|
||||||
|
"tone_curve",
|
||||||
|
],
|
||||||
|
"the set of hand-written nodes changed; if that is deliberate, update \
|
||||||
|
this list and the module documentation above"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
declared.len() >= 8,
|
||||||
|
"only {} declared nodes: {declared:?}",
|
||||||
|
declared.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-PLG-2
|
||||||
|
/// A declared operation is addressed by the ids the generated one uses.
|
||||||
|
///
|
||||||
|
/// The practical form of "indistinguishable downstream": the sidecar stores
|
||||||
|
/// parameters by `(op_id, param_id)` text, so a declared node whose interned
|
||||||
|
/// ids did not compare equal to the generated constants would load an edit
|
||||||
|
/// that silently did nothing.
|
||||||
|
#[test]
|
||||||
|
fn an_interpreted_node_answers_to_the_generated_parameter_ids() {
|
||||||
|
let mut declared = DeclaredOp::from_yaml(
|
||||||
|
&std::fs::read_to_string(ops_dir().join("exposure.yaml")).expect("readable"),
|
||||||
|
"ops/exposure.yaml",
|
||||||
|
builtin_helpers(),
|
||||||
|
)
|
||||||
|
.expect("exposure is a declaration");
|
||||||
|
|
||||||
|
declared.set_param(ops::exposure::EXPOSURE, 1.5);
|
||||||
|
assert_eq!(declared.param(ParamId("exposure")), 1.5);
|
||||||
|
assert_eq!(declared.descriptor().id, ops::exposure::ID);
|
||||||
|
}
|
||||||
@@ -39,6 +39,22 @@ pub struct Chromaticities {
|
|||||||
pub white: [f64; 2],
|
pub white: [f64; 2],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Chromaticities {
|
||||||
|
/// This gamut's linear RGB to the ICC profile connection space, row-major.
|
||||||
|
///
|
||||||
|
/// The same three columns [`ColourSpace::to_pcs_xyz`] produces, for a set
|
||||||
|
/// of primaries that is *not* one of the four the pipeline knows. That is
|
||||||
|
/// the only reason this is public: a display's profile describes primaries
|
||||||
|
/// nobody chose, and the only way to ask which of the four it is nearest
|
||||||
|
/// is to put both through the same reduction and compare the numbers
|
||||||
|
/// (see `dr_plat::display`). Comparing raw xy pairs instead would be
|
||||||
|
/// wrong, because a profile's colorants have already been adapted to D50
|
||||||
|
/// and a space's published chromaticities have not.
|
||||||
|
pub fn to_pcs_xyz(&self) -> [f32; 9] {
|
||||||
|
narrow(mul(adaptation(white_xyz(self), PCS_D50), rgb_to_xyz(self)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How a space maps linear light onto the numbers stored in a file.
|
/// How a space maps linear light onto the numbers stored in a file.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub enum Transfer {
|
pub enum Transfer {
|
||||||
@@ -183,8 +199,7 @@ impl ColourSpace {
|
|||||||
/// defined at D50 and nowhere else — a profile carrying unadapted D65
|
/// defined at D50 and nowhere else — a profile carrying unadapted D65
|
||||||
/// colorants describes a space nobody asked for.
|
/// colorants describes a space nobody asked for.
|
||||||
pub fn to_pcs_xyz(self) -> [f32; 9] {
|
pub fn to_pcs_xyz(self) -> [f32; 9] {
|
||||||
let c = self.chromaticities();
|
self.chromaticities().to_pcs_xyz()
|
||||||
narrow(mul(adaptation(white_xyz(&c), PCS_D50), rgb_to_xyz(&c)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The white-point adaptation folded into [`Self::to_pcs_xyz`], row-major.
|
/// The white-point adaptation folded into [`Self::to_pcs_xyz`], row-major.
|
||||||
|
|||||||
+27
-13
@@ -13,7 +13,7 @@ chose, which accumulated because no single commit was responsible for it. The di
|
|||||||
when deciding what to touch. A TD entry is load-bearing until its "done when" is met; an entry here
|
when deciding what to touch. A TD entry is load-bearing until its "done when" is met; an entry here
|
||||||
is not defending anything.
|
is not defending anything.
|
||||||
|
|
||||||
It is also not a bug list. Everything below compiles, passes 2,007 tests and ships.
|
It is also not a bug list. Everything below compiles, passes 2,042 tests and ships.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,13 +42,13 @@ done | sort -rn
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Rust across 19 crates | ~112,000 lines; ~78,000 after comments and blanks |
|
| Rust across 19 crates | ~112,000 lines; ~78,000 after comments and blanks |
|
||||||
| Comment density | 28% overall, 20–34% per crate |
|
| Comment density | 28% overall, 20–34% per crate |
|
||||||
| Test functions | 2,007, plus 17 integration test files |
|
| Test functions | 2,042, plus 21 integration test files |
|
||||||
| `.unwrap()` in production code | **3** — one in `dr-gpu`, two in `dr-ingest` |
|
| `.unwrap()` in production code | **3** — one in `dr-gpu`, two in `dr-ingest` |
|
||||||
| `.unwrap()` in test code | ~1,500, which is where it belongs |
|
| `.unwrap()` in test code | ~1,500, which is where it belongs |
|
||||||
| `unsafe` blocks | 6 |
|
| `unsafe` blocks | 6 |
|
||||||
| `TRACES` tags / orphan tags | 732 / 0 |
|
| `TRACES` tags / orphan tags | 793 / 0 |
|
||||||
| Resolved dependencies | 826 |
|
| Resolved dependencies | 826 |
|
||||||
| Largest function | `dr-ui::run` — 1,813 lines |
|
| Largest function | `dr-ui::run` — 1,855 lines |
|
||||||
| `AppWindow` members | 282 properties + 190 callbacks |
|
| `AppWindow` members | 282 properties + 190 callbacks |
|
||||||
|
|
||||||
CI gates on `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
CI gates on `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`,
|
||||||
@@ -68,7 +68,7 @@ Six things a contributor might plausibly want to add. Each grade was checked aga
|
|||||||
| A neighbourhood operation<br>*dehaze, a sharpener* | Rust in `dr-pipeline/src/ops/` implementing `Operation` + `DetailStage`, plus a stub YAML declaring `rust:` and `order:`. | **Moderate** |
|
| A neighbourhood operation<br>*dehaze, a sharpener* | Rust in `dr-pipeline/src/ops/` implementing `Operation` + `DetailStage`, plus a stub YAML declaring `rust:` and `order:`. | **Moderate** |
|
||||||
| A parameter widget<br>*a colour wheel* | A `WidgetKind` variant, `develop::supported()`, the panel model, a Slint component. | **Moderate** |
|
| A parameter widget<br>*a colour wheel* | A `WidgetKind` variant, `develop::supported()`, the panel model, a Slint component. | **Moderate** |
|
||||||
| A second sync backend<br>*S3, WebDAV, a local folder* | `RemoteBackend` is the easy half. Seven UI files construct `NextcloudBackend` directly and ten signatures take it concretely — see [CH-2](#ch-2). | **Hard** |
|
| A second sync backend<br>*S3, WebDAV, a local folder* | `RemoteBackend` is the easy half. Seven UI files construct `NextcloudBackend` directly and ten signatures take it concretely — see [CH-2](#ch-2). | **Hard** |
|
||||||
| Anything with its own UI | `app.slint`'s root component, a ~1,000-line `wire()`, and `run()` at 1,813 lines — see [CH-1](#ch-1). | **Hard** |
|
| Anything with its own UI | `app.slint`'s root component, a ~1,000-line `wire()`, and `run()` at 1,855 lines — see [CH-1](#ch-1). | **Hard** |
|
||||||
|
|
||||||
The top half of that table is the good half, and it is very good. The bottom half is one problem
|
The top half of that table is the good half, and it is very good. The bottom half is one problem
|
||||||
wearing two hats: **`dr-ui` has no seams, so every UI feature lands in the same three files.**
|
wearing two hats: **`dr-ui` has no seams, so every UI feature lands in the same three files.**
|
||||||
@@ -115,7 +115,7 @@ functions, and `run()`.
|
|||||||
|
|
||||||
| Function | Lines |
|
| Function | Lines |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `lib.rs::run` | 1,813 |
|
| `lib.rs::run` | 1,855 |
|
||||||
| `library_ui::wire` | 998 |
|
| `library_ui::wire` | 998 |
|
||||||
| `collections_ui::wire` | 964 |
|
| `collections_ui::wire` | 964 |
|
||||||
| `identity_ui::wire` | 478 |
|
| `identity_ui::wire` | 478 |
|
||||||
@@ -137,7 +137,7 @@ days since, the numbers that document itself used have moved:
|
|||||||
|
|
||||||
| Its measure | 2026-08-09 | 2026-08-27 |
|
| Its measure | 2026-08-09 | 2026-08-27 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `run()` | 500 lines | **1,813** |
|
| `run()` | 500 lines | **1,855** |
|
||||||
| `library_ui` distinct window properties | 24 | **54** |
|
| `library_ui` distinct window properties | 24 | **54** |
|
||||||
| `lib.rs` distinct window properties | 23 | **52** |
|
| `lib.rs` distinct window properties | 23 | **52** |
|
||||||
| `collections_ui` | 9 | 11 |
|
| `collections_ui` | 9 | 11 |
|
||||||
@@ -156,7 +156,7 @@ multiplies the conjunctions rather than adding to them.
|
|||||||
|
|
||||||
None of this is difficult work. It is simply work that every feature must now do, in files every
|
None of this is difficult work. It is simply work that every feature must now do, in files every
|
||||||
other feature is also editing — which is why two contributors working in parallel conflict by
|
other feature is also editing — which is why two contributors working in parallel conflict by
|
||||||
construction, and why a newcomer must read a 1,813-line startup sequence with real ordering
|
construction, and why a newcomer must read a 1,855-line startup sequence with real ordering
|
||||||
constraints before safely inserting a line into it.
|
constraints before safely inserting a line into it.
|
||||||
|
|
||||||
### What to do
|
### What to do
|
||||||
@@ -219,6 +219,18 @@ fake, which today it is not.
|
|||||||
**Done when:** `NextcloudBackend` is named in at most one file in `ui/`, and a stub backend can be
|
**Done when:** `NextcloudBackend` is named in at most one file in `ui/`, and a stub backend can be
|
||||||
substituted in a test without touching the UI.
|
substituted in a test without touching the UI.
|
||||||
|
|
||||||
|
**Done**, with one half deliberately left. `ui/dr-ui/src/remote.rs` is now the only file in the
|
||||||
|
interface that names a connector; the ten worker functions take `&dyn RemoteBackend` and will accept
|
||||||
|
a stub. Every method the UI ever called on a backend — `get`, `put`, `list`, `delete`, `create_dir`,
|
||||||
|
`move_to` — was already on the trait, so nothing had to be added to it.
|
||||||
|
|
||||||
|
What remains is **credentials**. `AppCredentials` is an app password obtained through Login Flow v2,
|
||||||
|
which is a Nextcloud protocol rather than a general notion of how one authenticates to a remote, and
|
||||||
|
seven files still name it. Abstracting it needs a decision about what an account *is* across
|
||||||
|
backends — an OAuth token, a bucket key pair and an app password have no useful common shape — and
|
||||||
|
making that decision before a second backend exists would produce a confident wrong answer. It is a
|
||||||
|
design problem rather than a mechanical one, and it should wait for the backend that forces it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## <a id="ch-3"></a>CH-3 — There is no path in for a contributor who is not already here
|
## <a id="ch-3"></a>CH-3 — There is no path in for a contributor who is not already here
|
||||||
@@ -342,11 +354,13 @@ It measures structure, discipline and coupling. It does **not** assess runtime c
|
|||||||
shader behaviour, security posture, or whether any tagged requirement is actually implemented —
|
shader behaviour, security posture, or whether any tagged requirement is actually implemented —
|
||||||
[CH-4](#ch-4) is precisely the observation that the last of those is unmeasured.
|
[CH-4](#ch-4) is precisely the observation that the last of those is unmeasured.
|
||||||
|
|
||||||
Line counts and function sizes are proxies. `run()` being 1,813 lines is a real problem because
|
Line counts and function sizes are proxies. `run()` being 1,855 lines is a real problem because
|
||||||
every feature must edit it, not because 1,813 is a bad number; `build.rs::emit_node` at 558 lines is
|
every feature must edit it, not because 1,855 is a bad number; `build.rs::emit_node` at 558 lines is
|
||||||
not a problem at all. Where a figure appears above, the sentence around it says which of the two it
|
not a problem at all. Where a figure appears above, the sentence around it says which of the two it
|
||||||
is.
|
is.
|
||||||
|
|
||||||
The audit was performed against the working tree at branch `android-bundled-face-models` on
|
The audit was first measured against `origin/master` at `d4a34ef`, then re-measured after
|
||||||
2026-08-27, which had five modified files in `ui/dr-ui/` uncommitted at the time. Those changes were
|
`android-bundled-face-models` merged in: `run()` moved from 1,810 lines to 1,855, and the whole-
|
||||||
included in the counts.
|
library face sweep added its fetching path to `library.rs`. The seam grades are unchanged by that
|
||||||
|
merge — the work went into the seams that already existed rather than cutting new ones, which is
|
||||||
|
itself the pressure CH-1 describes.
|
||||||
|
|||||||
@@ -20,24 +20,37 @@ and the reason is that some of the work is done and untagged.
|
|||||||
| Requirement | Reality |
|
| Requirement | Reality |
|
||||||
|---|---|
|
|---|---|
|
||||||
| FR-DSP-1 proxy rendering | **Done.** The develop view renders at viewport resolution, not source. |
|
| FR-DSP-1 proxy rendering | **Done.** The develop view renders at viewport resolution, not source. |
|
||||||
| FR-DSP-2 tiled computation | **Absent.** The fused pass renders the whole viewport in one dispatch. |
|
| FR-DSP-2 tiled computation | **Absent, and §2 now says it should stay that way.** Measured: the fused pass is inside the budget everywhere. See [frame-budget.md](frame-budget.md). |
|
||||||
| FR-DSP-3 interactive latency | **Unmeasured.** No frame budget is asserted anywhere. |
|
| FR-DSP-3 interactive latency | **Measured and asserted** for the fused path — `core/dr-gpu/tests/frame_budget.rs`. Missed by one operation, clarity, for the reason recorded as TD-4. |
|
||||||
| FR-DSP-4 progressive refinement | **Absent.** Every render is full quality. |
|
| FR-DSP-4 progressive refinement | **Absent**, and §4's condition did not fire. Every render is full quality and can afford to be. |
|
||||||
| FR-DSP-5 zoom and pan | **Substantially done, untagged.** `Framing::view` shrinks the sampled region while the render target keeps its size, so zooming *raises* the resolution the pipeline works at. That is FR-DSP-5's requirement, arrived at without tiles. |
|
| FR-DSP-5 zoom and pan | **Done and tagged**, against tests that fail if the behaviour is removed — `core/dr-gpu/tests/zoom_resolution.rs`. `Framing::view` shrinks the sampled region while the render target keeps its size, so zooming *raises* the resolution the pipeline works at. That is FR-DSP-5's requirement, arrived at without tiles. |
|
||||||
| FR-DSP-6 colour management | **Done.** Output space is a parameter of composition. |
|
| FR-DSP-6 colour management | **Done.** Output space is a parameter of composition. |
|
||||||
| FR-DSP-7 histogram and clipping | **Done**, GPU-side, no per-frame readback. |
|
| FR-DSP-7 histogram and clipping | **Done**, GPU-side, no per-frame readback. |
|
||||||
| FR-DSP-8 per-display colour | **Absent.** One transform, not per-display. |
|
| FR-DSP-8 per-display colour | **Done**, with one caveat named in §5.4. Acquisition per display server, an sRGB fallback that is visible in About, and the canvas rendered at physical pixel size. |
|
||||||
| FR-PLG-* | **Zero tagged.** But `ops/*.yaml` + `build.rs` is already the class-1 plugin format compiled at build time rather than loaded. |
|
| FR-PLG-* | **Zero tagged.** But `ops/*.yaml` + `build.rs` is already the class-1 plugin format compiled at build time rather than loaded. |
|
||||||
|
|
||||||
Two of the five uncovered display requirements are therefore *measurement and tagging*, not
|
Two of the five uncovered display requirements are therefore *measurement and tagging*, not
|
||||||
construction. That is worth knowing before anyone plans a quarter around them.
|
construction. That is worth knowing before anyone plans a quarter around them.
|
||||||
|
|
||||||
|
**Both have since been done.** [frame-budget.md](frame-budget.md) holds the measurements §2 asks
|
||||||
|
for and the reading of its decision rule; the table above is updated to match. The rest of this
|
||||||
|
document is left as it was written, because a plan that has been overtaken by its own evidence is
|
||||||
|
more useful read in order than quietly edited into agreement.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Measure before building tiles
|
## 2. Measure before building tiles
|
||||||
|
|
||||||
**FR-DSP-2 is the one requirement in this document that may not be worth satisfying as written.**
|
**FR-DSP-2 is the one requirement in this document that may not be worth satisfying as written.**
|
||||||
|
|
||||||
|
> **Resolved.** M1–M3 were run; the numbers and the verdict are in
|
||||||
|
> [frame-budget.md](frame-budget.md). The rule below fired for *rewrite*: every point-operation
|
||||||
|
> chain is inside 16 ms at the 99th percentile at every viewport size, fit and at 1:1, the widest
|
||||||
|
> being 4.5 ms of GPU at 4K. The measurement did find a stage that misses the budget — clarity's
|
||||||
|
> 52-pixel kernel, 34 ms at 4K — and tiling makes that stage *worse*, since a tiled convolution
|
||||||
|
> reads a halo per tile. It is recorded as TD-4 with the fix its own module already names.
|
||||||
|
|
||||||
|
|
||||||
The requirement predates the fused-shader design. It assumes the pipeline is a chain of passes over
|
The requirement predates the fused-shader design. It assumes the pipeline is a chain of passes over
|
||||||
a large buffer, where recomputing everything on each frame would be ruinous and tiles are the way
|
a large buffer, where recomputing everything on each frame would be ruinous and tiles are the way
|
||||||
out. What was built instead composes every active operation into **one dispatch over a
|
out. What was built instead composes every active operation into **one dispatch over a
|
||||||
@@ -119,15 +132,44 @@ stated in the About page beside the other diagnostics so a photographer can see
|
|||||||
on rather than wonder.
|
on rather than wonder.
|
||||||
|
|
||||||
**5.2 Reacting to a move.** The transform is selected per the display currently showing the canvas
|
**5.2 Reacting to a move.** The transform is selected per the display currently showing the canvas
|
||||||
and updates when the window moves. Slint reports window moves; the composed output space is already
|
and updates when the window moves. The composed output space is already a parameter of composition
|
||||||
a parameter of composition (`compose_with_framing(..., output)`), so a display change is a
|
(`compose_with_framing(..., output)`), so a display change is a recomposition, not a pipeline
|
||||||
recomposition, not a pipeline change. This is the part the existing design makes cheap.
|
change. This is the part the existing design makes cheap.
|
||||||
|
|
||||||
|
Slint turned out *not* to report window moves — there is no `on_moved` on any backend — so the
|
||||||
|
window's position and scale factor are sampled twice a second and the platform re-surveyed only when
|
||||||
|
they differ. See §5.4 for the display server where the position itself is unavailable.
|
||||||
|
|
||||||
**5.3 Fractional scaling.** "Handled without resampling artefacts in the canvas" — the canvas is a
|
**5.3 Fractional scaling.** "Handled without resampling artefacts in the canvas" — the canvas is a
|
||||||
wgpu texture handed to the compositor, so the requirement is that we render at the *physical* pixel
|
wgpu texture handed to the compositor, so the requirement is that we render at the *physical* pixel
|
||||||
size rather than the logical one and let the compositor present 1:1. Worth an explicit test, since
|
size rather than the logical one and let the compositor present 1:1. Worth an explicit test, since
|
||||||
the failure is subtle: a slightly soft canvas that looks like a bad demosaic.
|
the failure is subtle: a slightly soft canvas that looks like a bad demosaic.
|
||||||
|
|
||||||
|
### 5.4 What landed, and the one thing that did not
|
||||||
|
|
||||||
|
`dr_plat::display` surveys the session's displays; `dr_ui::display_ui` decides which one is showing
|
||||||
|
the canvas and keeps `DevelopSession`'s output space pointed at it. Composition was already
|
||||||
|
parameterised on the space, so the pixel path changed by one argument.
|
||||||
|
|
||||||
|
Two things are worth recording because they are trades rather than omissions.
|
||||||
|
|
||||||
|
**A profile is matched to the nearest of four spaces, not applied.** A measured panel is none of
|
||||||
|
`Srgb`, `DisplayP3`, `AdobeRgb` or `ProPhoto`, and a general ICC engine is a much larger piece of
|
||||||
|
work — a CMM, rendering intents, LUT-based profiles, and a per-frame cost to argue about. The
|
||||||
|
profile is reduced to its D50-adapted colorants and matched against the four; a match that is merely
|
||||||
|
nearest is marked as such, and About says "nearest to Display P3" rather than "Display P3". A
|
||||||
|
LUT-based profile, which is what a hardware calibrator often writes, is declined by shape and falls
|
||||||
|
back to sRGB with that stated. An approximation the photographer can see beats a silent one.
|
||||||
|
|
||||||
|
**On Wayland the canvas follows the first output, not the window.** A Wayland client is never told
|
||||||
|
where its window is — `xdg_toplevel` carries no position, deliberately — so the "which display"
|
||||||
|
question cannot be answered by geometry there. The protocol's own answer is
|
||||||
|
`wp_color_management_surface_feedback_v1`, which hands a client the preferred image description for
|
||||||
|
*its surface* and re-sends it on a move; it needs the application's `wl_surface`, which Slint owns
|
||||||
|
and does not expose. So the profiles are read correctly for every output and the *selection* among
|
||||||
|
them is right on X11 and on any single-monitor Wayland session, which is most of them. Closing the
|
||||||
|
gap is a Slint surface handle, not a change to any of this.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Extensibility: the format already exists
|
## 6. Extensibility: the format already exists
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# What a frame costs
|
||||||
|
|
||||||
|
**Status:** Measured · 2026-08-27
|
||||||
|
**Companion to:** [display-and-extension.md](display-and-extension.md) §2–3 ·
|
||||||
|
[requirements.md](requirements.md) §3.4 FR-DSP-2, FR-DSP-3, FR-DSP-4
|
||||||
|
**Instrument:** [`core/dr-gpu/examples/frame_budget.rs`](../core/dr-gpu/examples/frame_budget.rs)
|
||||||
|
**Guard:** [`core/dr-gpu/tests/frame_budget.rs`](../core/dr-gpu/tests/frame_budget.rs)
|
||||||
|
|
||||||
|
[display-and-extension.md](display-and-extension.md) §2 fixed a decision rule in
|
||||||
|
advance and made three measurements the thing that settles it. This file is
|
||||||
|
those measurements, and the recommendation they support.
|
||||||
|
|
||||||
|
Rerun with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --release -p dr-gpu --example frame_budget
|
||||||
|
```
|
||||||
|
|
||||||
|
and diff this file. That is the whole point of committing numbers: a regression
|
||||||
|
should be a diff rather than somebody's recollection of how fast it used to be.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The answer, first
|
||||||
|
|
||||||
|
**FR-DSP-2 should be rewritten, not implemented.** M1 and M2 sit inside the
|
||||||
|
16 ms budget at the 99th percentile for every chain of point operations at every
|
||||||
|
viewport size measured, fit and at 1:1 — the widest case, every operation that
|
||||||
|
contributes a fragment to the fused shader at 4K, costs **4.5 ms** on the GPU and
|
||||||
|
**8.2 ms** including the composition that precedes it. Tiling the interactive
|
||||||
|
path would be optimising something that is already using a quarter of its budget.
|
||||||
|
|
||||||
|
**But the measurement did find a budget-breaker, and it is not the one tiling
|
||||||
|
fixes.** The neighbourhood stage — clarity in particular — costs **34 ms at 4K
|
||||||
|
on its own**, twice the whole budget, and tiles do not help it: a tile of a
|
||||||
|
convolution has to read its halo, so tiling raises the total tap count rather
|
||||||
|
than lowering it. §2 predicted this exactly ("a separable blur at a large radius
|
||||||
|
is the plausible budget-breaker, not the fused pass"), and the fix it needs is
|
||||||
|
the one `local_contrast`'s own module documentation already names — a base
|
||||||
|
computed at reduced resolution — which is a change to `crate::detail`, not a
|
||||||
|
tile scheduler.
|
||||||
|
|
||||||
|
There is a third finding nobody was looking for: **shader composition costs
|
||||||
|
3–5 ms of CPU per frame on a full chain**, on the UI thread, before any GPU work
|
||||||
|
is submitted. That is a fifth to a third of the budget spent formatting strings,
|
||||||
|
and it is invisible to any amount of tiling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conditions
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Adapter | NVIDIA GeForce RTX 3050 6GB Laptop GPU (Vulkan) |
|
||||||
|
| Source | 9504 × 6336 synthetic (60.2 MP, 482 MB as `rgba16f`) |
|
||||||
|
| Frames | 100 measured per row, 12 warm-up frames discarded |
|
||||||
|
| Percentile | Nearest-rank, so p99 of 100 frames is the second-worst frame |
|
||||||
|
| Build | `--release` |
|
||||||
|
| Date | 2026-08-27 |
|
||||||
|
|
||||||
|
`shader` is `EditGraph::compose` alone. `cpu` adds the detail chain and the
|
||||||
|
invalidation hash — everything `DevelopSession::render` does per frame before it
|
||||||
|
dispatches. `gpu` is submit plus wait-for-idle, which serialises the GPU work
|
||||||
|
into the frame that caused it and is therefore pessimistic. `TOTAL` ranks
|
||||||
|
`cpu + gpu` summed **within each frame**, which is the column the budget is
|
||||||
|
judged on; adding two percentiles instead would invent a stutter that no frame
|
||||||
|
actually had.
|
||||||
|
|
||||||
|
Chains: `one` is exposure. `five` is exposure, contrast, highlights/shadows,
|
||||||
|
blacks/whites, vibrance. `point` is every operation in the default chain that
|
||||||
|
contributes a fragment to the fused shader, film stock included. `all` is `point`
|
||||||
|
plus the four neighbourhood operations — noise reduction, capture sharpening,
|
||||||
|
clarity and texture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M1 — the fused pass at proxy resolution
|
||||||
|
|
||||||
|
The develop view: the whole frame fit to the viewport.
|
||||||
|
|
||||||
|
| size | chain | shader | cpu p99 | gpu p50 | gpu p99 | TOTAL | |
|
||||||
|
|------------:|------:|-------:|--------:|--------:|--------:|--------:|:-----|
|
||||||
|
| 1920 × 1200 | one | 0.08ms | 0.10ms | 1.02ms | 1.23ms | 1.31ms | |
|
||||||
|
| 1920 × 1200 | five | 0.18ms | 0.20ms | 1.01ms | 1.20ms | 1.36ms | |
|
||||||
|
| 1920 × 1200 | point | 2.79ms | 2.82ms | 1.98ms | 2.18ms | 4.83ms | |
|
||||||
|
| 1920 × 1200 | all | 3.65ms | 4.73ms | 6.86ms | 7.37ms | 12.02ms | |
|
||||||
|
| 2560 × 1600 | one | 0.08ms | 0.10ms | 1.73ms | 2.00ms | 2.12ms | |
|
||||||
|
| 2560 × 1600 | five | 0.24ms | 0.27ms | 1.73ms | 2.26ms | 2.46ms | |
|
||||||
|
| 2560 × 1600 | point | 2.82ms | 2.85ms | 2.37ms | 2.65ms | 5.38ms | |
|
||||||
|
| 2560 × 1600 | all | 3.37ms | 4.35ms | 14.31ms | 15.65ms | 18.42ms | OVER |
|
||||||
|
| 3840 × 2160 | one | 0.10ms | 0.14ms | 3.09ms | 3.31ms | 3.42ms | |
|
||||||
|
| 3840 × 2160 | five | 0.30ms | 0.32ms | 3.03ms | 3.40ms | 3.61ms | |
|
||||||
|
| 3840 × 2160 | point | 3.62ms | 3.65ms | 4.12ms | 4.52ms | 8.23ms | |
|
||||||
|
| 3840 × 2160 | all | 4.12ms | 5.07ms | 37.73ms | 40.17ms | 43.24ms | OVER |
|
||||||
|
|
||||||
|
Read the `point` rows: **the fused dispatch scales with pixels and almost not at
|
||||||
|
all with chain length.** Going from one operation to the entire point chain at
|
||||||
|
4K costs 1.2 ms of GPU. Going from 2.3 M pixels to 8.3 M costs 2.3 ms. Both are
|
||||||
|
small, and the second is the one tiling would address.
|
||||||
|
|
||||||
|
The `all` rows go over, and the `point` rows in the same block are what say why:
|
||||||
|
the difference between them is the neighbourhood stage, measured on its own in
|
||||||
|
M3 and arriving at almost exactly the same figure.
|
||||||
|
|
||||||
|
## M2 — the same, zoomed to 1:1 on the 60 MP source
|
||||||
|
|
||||||
|
FR-DSP-5's case. `Framing::view` shrinks the sampled region while the render
|
||||||
|
target keeps its size, so one render pixel lands on one source pixel.
|
||||||
|
|
||||||
|
| size | chain | shader | cpu p99 | gpu p50 | gpu p99 | TOTAL | |
|
||||||
|
|------------:|------:|-------:|--------:|--------:|--------:|--------:|:-----|
|
||||||
|
| 1920 × 1200 | one | 0.12ms | 0.14ms | 0.42ms | 0.66ms | 0.75ms | |
|
||||||
|
| 1920 × 1200 | five | 0.27ms | 0.30ms | 0.49ms | 1.14ms | 1.22ms | |
|
||||||
|
| 1920 × 1200 | point | 3.49ms | 3.52ms | 1.18ms | 1.39ms | 4.85ms | |
|
||||||
|
| 1920 × 1200 | all | 3.64ms | 5.18ms | 8.96ms | 9.55ms | 14.30ms | |
|
||||||
|
| 2560 × 1600 | one | 0.11ms | 0.12ms | 0.56ms | 0.99ms | 1.06ms | |
|
||||||
|
| 2560 × 1600 | five | 0.22ms | 0.25ms | 0.77ms | 1.02ms | 1.17ms | |
|
||||||
|
| 2560 × 1600 | point | 2.96ms | 2.99ms | 2.03ms | 2.52ms | 5.61ms | |
|
||||||
|
| 2560 × 1600 | all | 5.24ms | 7.35ms | 18.80ms | 21.62ms | 25.81ms | OVER |
|
||||||
|
| 3840 × 2160 | one | 0.10ms | 0.12ms | 1.31ms | 1.52ms | 1.63ms | |
|
||||||
|
| 3840 × 2160 | five | 0.14ms | 0.27ms | 1.39ms | 1.64ms | 1.75ms | |
|
||||||
|
| 3840 × 2160 | point | 3.14ms | 3.16ms | 4.04ms | 4.50ms | 7.21ms | |
|
||||||
|
| 3840 × 2160 | all | 4.84ms | 6.78ms | 47.22ms | 48.79ms | 54.47ms | OVER |
|
||||||
|
|
||||||
|
**A 1:1 view of a 60 MP file is cheaper than the fit view of the same file**, for
|
||||||
|
every point chain and at every size — 1.52 ms against 3.31 ms for one operation
|
||||||
|
at 4K. That is not a rounding artefact and it is worth stating plainly, because
|
||||||
|
it is the opposite of what "full resolution" sounds like it should cost. The
|
||||||
|
dispatch is the same number of pixels either way; what changes is where those
|
||||||
|
pixels read from. A fit view walks the whole 482 MB texture on a stride, and a
|
||||||
|
1:1 view reads a contiguous window of it that fits comfortably in cache.
|
||||||
|
|
||||||
|
So the resolution FR-DSP-5 promises costs nothing extra on the fused path.
|
||||||
|
Zooming is not an expensive mode to be dreaded and progressively refined into;
|
||||||
|
it is the cheap one.
|
||||||
|
|
||||||
|
The `all` rows are worse at 1:1 than fit, and that is the detail stage again for
|
||||||
|
a specific reason: noise reduction's radius is stated in *source* pixels, so
|
||||||
|
`RenderScale::ratio` climbing to 1.0 widens its kernel. Clarity's is stated as a
|
||||||
|
fraction of the frame and does not move. M3 separates the two.
|
||||||
|
|
||||||
|
## M3 — the neighbourhood stage alone
|
||||||
|
|
||||||
|
Timed with the fused dispatch deliberately reused: only a detail parameter moves,
|
||||||
|
so `render_detailed` skips the colour pass (FR-DEV-3d) and what remains is the
|
||||||
|
convolutions. `colour` counts fused dispatches over the measured frames and is
|
||||||
|
zero on every row, which is what makes these numbers mean "detail alone" rather
|
||||||
|
than asserting it.
|
||||||
|
|
||||||
|
| size | stage | view | pass | radius | colour | cpu p99 | p50 | p99 |
|
||||||
|
|------------:|---------:|:-----|-----:|-------:|-------:|--------:|--------:|--------:|
|
||||||
|
| 1920 × 1200 | clarity | fit | 2 | 29 | 0 | 1.60ms | 5.42ms | 5.99ms |
|
||||||
|
| 1920 × 1200 | all four | fit | 7 | 29 | 0 | 1.71ms | 5.78ms | 6.16ms |
|
||||||
|
| 1920 × 1200 | clarity | 1:1 | 2 | 29 | 0 | 1.12ms | 7.51ms | 8.01ms |
|
||||||
|
| 1920 × 1200 | all four | 1:1 | 9 | 29 | 0 | 2.71ms | 8.25ms | 9.11ms |
|
||||||
|
| 2560 × 1600 | clarity | fit | 2 | 38 | 0 | 1.03ms | 12.02ms | 12.44ms |
|
||||||
|
| 2560 × 1600 | all four | fit | 7 | 38 | 0 | 1.87ms | 12.49ms | 13.16ms |
|
||||||
|
| 2560 × 1600 | clarity | 1:1 | 2 | 38 | 0 | 1.87ms | 15.82ms | 16.60ms |
|
||||||
|
| 2560 × 1600 | all four | 1:1 | 9 | 38 | 0 | 2.71ms | 17.24ms | 18.06ms |
|
||||||
|
| 3840 × 2160 | clarity | fit | 2 | 52 | 0 | 1.75ms | 33.11ms | 33.89ms |
|
||||||
|
| 3840 × 2160 | all four | fit | 7 | 52 | 0 | 1.76ms | 34.21ms | 35.03ms |
|
||||||
|
| 3840 × 2160 | clarity | 1:1 | 2 | 52 | 0 | 1.08ms | 40.39ms | 41.86ms |
|
||||||
|
| 3840 × 2160 | all four | 1:1 | 9 | 52 | 0 | 2.37ms | 43.29ms | 44.72ms |
|
||||||
|
|
||||||
|
`radius` is the widest halo any pass reads, in render pixels.
|
||||||
|
|
||||||
|
Clarity alone is 97% of the cost of all four neighbourhood operations together,
|
||||||
|
at every size. Its σ is 1.2% of the shorter edge and it truncates at 2σ, so its
|
||||||
|
radius is 29 px on a 1200 px viewport and **52 px at 4K** — two separable passes
|
||||||
|
of 105 taps each, over 8.3 M pixels, which is 1.7 billion texture reads. That is
|
||||||
|
the whole of the problem, and the numbers scale as `radius × pixels` exactly as
|
||||||
|
that description predicts: 5.99 → 12.44 → 33.89 ms for radii of 29 → 38 → 52 over
|
||||||
|
2.3 → 4.1 → 8.3 M pixels.
|
||||||
|
|
||||||
|
The extra cost at 1:1 is noise reduction and capture sharpening, whose radii are
|
||||||
|
properties of the sensor rather than of the frame. That is the correct behaviour
|
||||||
|
— it is why `RenderScale` has two units — and it is bounded by the kernel caps
|
||||||
|
those operations already declare.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reading this against §2's decision rule
|
||||||
|
|
||||||
|
§2: *"If M1 and M2 sit inside 16 ms at the 99th percentile, FR-DSP-2 is
|
||||||
|
rewritten rather than implemented … If they do not, the measurement tells us
|
||||||
|
which stage to tile."*
|
||||||
|
|
||||||
|
Both halves of the rule fire, on different stages, and the honest reading takes
|
||||||
|
both.
|
||||||
|
|
||||||
|
### FR-DSP-2 — rewrite it
|
||||||
|
|
||||||
|
For the fused pass the rule passes with a wide margin. Every point chain at
|
||||||
|
every size, fit and at 1:1, is inside 16 ms — the worst `TOTAL` is 8.23 ms and
|
||||||
|
the worst GPU figure is 4.52 ms. There is no viewport size on a desktop display
|
||||||
|
where recomputing the entire point chain over every visible pixel is a problem.
|
||||||
|
|
||||||
|
Two further reasons not to build the tile scheduler as written:
|
||||||
|
|
||||||
|
1. **Panning, which is the case ARCH §5.3's tile cache is designed for, gets no
|
||||||
|
benefit here.** Reusing already-valid tiles saves recomputation. Recomputing
|
||||||
|
the whole 4K viewport costs 4.5 ms, so a perfect tile cache could save at most
|
||||||
|
4.5 ms of a 16 ms budget, at the price of a cache keyed by
|
||||||
|
`(VersionId, tile, zoom, graph_hash_prefix)` that has to stay correct across
|
||||||
|
every parameter change in the graph. That is a large correctness surface
|
||||||
|
bought with a small number.
|
||||||
|
|
||||||
|
2. **It would make the actual problem worse.** The stage that misses the budget
|
||||||
|
is a convolution, and a tiled convolution reads a halo per tile. At a 52-pixel
|
||||||
|
radius, 256-pixel tiles would read (256+104)² instead of 256² — very nearly
|
||||||
|
*twice* the taps. Tiling is the wrong tool for the one stage that needs a
|
||||||
|
tool.
|
||||||
|
|
||||||
|
So FR-DSP-2 becomes what §2 said it actually is for this architecture: a
|
||||||
|
scheduling concern for export and thumbnailing, both of which already run off
|
||||||
|
the frame path. The interactive path does not tile.
|
||||||
|
|
||||||
|
### The stage that does need work — and it is not tiling
|
||||||
|
|
||||||
|
The measurement's real product is naming the stage. It is `local_contrast`, and
|
||||||
|
the fix is stated in that module's own documentation:
|
||||||
|
|
||||||
|
> The right optimisation is a base computed at reduced resolution, which needs a
|
||||||
|
> detail stage that can write a smaller target than it reads; that is a change to
|
||||||
|
> `crate::detail`, not to this file.
|
||||||
|
|
||||||
|
A Gaussian base at a quarter resolution is 1/16 the pixels at 1/4 the radius —
|
||||||
|
about 1/64 of the work — and the result is visually identical because a base at
|
||||||
|
σ = 26 px has no content above the quarter-resolution Nyquist to lose. That is a
|
||||||
|
change to two files with a bounded blast radius, and it is what the 34 ms buys
|
||||||
|
back. It should be tracked as its own item rather than smuggled in under a
|
||||||
|
requirement about tiles.
|
||||||
|
|
||||||
|
### FR-DSP-3 — the clause that should be narrowed
|
||||||
|
|
||||||
|
§3.3 proposes narrowing "when a full-resolution result is needed it is computed
|
||||||
|
asynchronously, and the proxy result remains on screen until it is ready" to
|
||||||
|
export and 1:1 zoom, or striking it.
|
||||||
|
|
||||||
|
**M2 says strike it.** The clause exists to hide the latency of a
|
||||||
|
full-resolution render behind a proxy. There is no such latency: the 1:1 view is
|
||||||
|
*faster* than the fit view on the fused path, and there is no second
|
||||||
|
full-resolution code path to be asynchronous about — `Framing::view` is the
|
||||||
|
whole mechanism. Export renders its own frames on a worker already. Keeping the
|
||||||
|
clause would mean building a progressive-swap machine to conceal a render that
|
||||||
|
completes in 1.4 ms.
|
||||||
|
|
||||||
|
### FR-DSP-4 — satisfied vacuously, on the fused path
|
||||||
|
|
||||||
|
§4 makes progressive refinement conditional on M1 failing. On the fused path M1
|
||||||
|
passes, so reduced-quality rendering during a drag would buy nothing and cost the
|
||||||
|
visible softness the requirement itself warns against.
|
||||||
|
|
||||||
|
The neighbourhood stage is the exception, and it is worth being precise: what
|
||||||
|
that stage needs is not *progressive* refinement — it is a permanently cheaper
|
||||||
|
base, computed at reduced resolution and correct at any moment the user stops.
|
||||||
|
"Render coarse while dragging, sharpen when it settles" would paper over the same
|
||||||
|
34 ms with a visible swap. Fix the stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is not measured here
|
||||||
|
|
||||||
|
Stated because §7 of [display-and-extension.md](display-and-extension.md) asks
|
||||||
|
for it, and because each of these could move the numbers.
|
||||||
|
|
||||||
|
- **Local adjustments.** The mask stack is a separate chain per layer and is not
|
||||||
|
in any row above. `render_masked` takes them and the fused shader addresses
|
||||||
|
them per layer, so a heavily masked edit costs more than `all`.
|
||||||
|
- **Spot repairs.** These add detail passes, and their cost is per spot.
|
||||||
|
- **Lens corrections.** Not part of `EditGraph::default_chain` — they are built
|
||||||
|
from a matched profile — so the `point` row does not include the warp chain.
|
||||||
|
- **Demosaic.** Once per photograph on a worker, not on the frame path.
|
||||||
|
- **Presentation.** The bench waits for the device to go idle inside the frame it
|
||||||
|
measures. A real compositor overlaps frames, so these figures are an upper
|
||||||
|
bound rather than an estimate.
|
||||||
|
- **One adapter.** A discrete laptop GPU. The Intel iGPU on the same machine, and
|
||||||
|
Android, will be slower — which is an argument for the conclusion rather than
|
||||||
|
against it: the stage with no headroom has none to lose.
|
||||||
|
|
||||||
|
## The CPU finding, which deserves its own item
|
||||||
|
|
||||||
|
`EditGraph::compose` costs 2.8–5.2 ms per frame on a full chain, at every
|
||||||
|
resolution, because it is resolution-independent: it assembles a WGSL string and
|
||||||
|
hashes it. On the `all` rows it is a third of what is left of the budget after
|
||||||
|
the GPU has taken its share, and at 1920 × 1200 it is larger than the entire
|
||||||
|
fused dispatch.
|
||||||
|
|
||||||
|
Nothing in this document's recommendations changes it, and it is the cheapest
|
||||||
|
remaining win. The generated *source* depends only on the structure of the graph
|
||||||
|
— that is what `structure_hash` already identifies, and it is precisely what does
|
||||||
|
not change while a slider is being dragged, which is why the pipeline cache in
|
||||||
|
`AdjustPass` does not recompile. The uniforms do change, but assembling them is a
|
||||||
|
handful of floats per operation. So caching the source string against the
|
||||||
|
structure hash and rebuilding only the uniforms would take these milliseconds to
|
||||||
|
approximately nothing, on the path that needs them most. Worth its own entry in
|
||||||
|
[technical-debt.md](technical-debt.md).
|
||||||
@@ -142,6 +142,103 @@ in well under a second.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TD-4 — The local-contrast base is computed at full render resolution
|
||||||
|
|
||||||
|
**Where:** `dr_pipeline::ops::local_contrast::LocalContrast::passes` — the `base` and `combine`
|
||||||
|
passes, and the stage that dispatches them, `dr_pipeline::detail`.
|
||||||
|
|
||||||
|
Breaks **FR-DSP-3** at large viewports. Measured, and the numbers are in
|
||||||
|
[frame-budget.md](frame-budget.md) §M3.
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
Clarity's Gaussian σ is 1.2% of the frame's shorter edge, truncated at 2σ, so its kernel radius is
|
||||||
|
a property of the *viewport*: 29 px at 1920 × 1200, 38 px at 2560 × 1600, **52 px at 4K**. The two
|
||||||
|
separable passes therefore run 105 taps each over 8.3 M pixels at 4K, which is 1.7 billion texture
|
||||||
|
reads for one control.
|
||||||
|
|
||||||
|
| viewport | radius | clarity alone, p99 |
|
||||||
|
|---|---:|---:|
|
||||||
|
| 1920 × 1200 | 29 | 5.99 ms |
|
||||||
|
| 2560 × 1600 | 38 | 12.44 ms |
|
||||||
|
| 3840 × 2160 | 52 | **33.89 ms** |
|
||||||
|
|
||||||
|
RTX 3050 laptop, `examples/frame_budget`, fused dispatch reused so this is the convolutions alone.
|
||||||
|
Clarity is 97% of the cost of all four neighbourhood operations together at every size.
|
||||||
|
|
||||||
|
For scale: the entire fused chain — every point operation active, film stock included — costs
|
||||||
|
4.5 ms at the same 4K viewport. **A single slider is seven times the rest of the pipeline.**
|
||||||
|
|
||||||
|
### Why
|
||||||
|
|
||||||
|
Because the stage cannot do otherwise yet. `dr_pipeline::detail` dispatches every pass at the
|
||||||
|
render size; there is no way to express "read this target and write a smaller one". The module's own
|
||||||
|
documentation has said so since it was written:
|
||||||
|
|
||||||
|
> The right optimisation is a base computed at reduced resolution, which needs a detail stage that
|
||||||
|
> can write a smaller target than it reads; that is a change to `crate::detail`, not to this file.
|
||||||
|
|
||||||
|
It was the right call to ship the correct answer slowly rather than a fast approximation nobody had
|
||||||
|
checked — the halo behaviour is the hard part of this operation and it is tested.
|
||||||
|
|
||||||
|
### Not a tiling problem
|
||||||
|
|
||||||
|
Worth saying because ARCH §5.3 offers a tile cache and this is the stage that looks like it wants
|
||||||
|
one. It does not: a tiled convolution reads a halo per tile, so at a 52-pixel radius, 256-pixel
|
||||||
|
tiles would read (256 + 104)² instead of 256² — very nearly **twice** the taps.
|
||||||
|
[display-and-extension.md](display-and-extension.md) §2's decision rule was resolved on this
|
||||||
|
evidence; see [frame-budget.md](frame-budget.md).
|
||||||
|
|
||||||
|
### Paying it off
|
||||||
|
|
||||||
|
A detail pass that declares an output scale, so the base can be computed at a quarter resolution and
|
||||||
|
sampled back up in `combine`. A quarter-resolution base is 1/16 the pixels at 1/4 the radius —
|
||||||
|
about **1/64 of the work** — and is visually identical, because a base at σ = 26 px holds no content
|
||||||
|
above the quarter-resolution Nyquist to lose. Texture's σ is a decade finer and must stay at full
|
||||||
|
resolution; the scale therefore belongs on the `DetailPass`, not on the stage.
|
||||||
|
|
||||||
|
**Done when:** clarity at 100% is inside the frame budget at 3840 × 2160, the halo tests in
|
||||||
|
`tests/local_contrast.rs` still pass unchanged, and `examples/frame_budget`'s M3 table in
|
||||||
|
[frame-budget.md](frame-budget.md) has been rerun and committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TD-5 — The fused shader is reassembled from strings on every frame
|
||||||
|
|
||||||
|
**Where:** `dr_pipeline::operation::compose_full`, called from `DevelopSession::render`.
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
`EditGraph::compose` walks the active operations and formats a WGSL source string, per frame, on
|
||||||
|
the UI thread. On a full chain that is **2.8–5.2 ms** — at 1920 × 1200 it is larger than the entire
|
||||||
|
fused dispatch it precedes, and on the chains that also carry a detail stage it is a third of what
|
||||||
|
is left of the 16 ms budget after the GPU has taken its share. It does not vary with resolution,
|
||||||
|
because it is not pixel work.
|
||||||
|
|
||||||
|
### Why
|
||||||
|
|
||||||
|
Because it was free until the chain got long. Composition was written when an edit was two or three
|
||||||
|
operations, and the cost is roughly linear in generated source: the colour mixer emits twelve hue bands
|
||||||
|
and the tone curve emits a spline evaluator, so a full chain is a large string built from scratch
|
||||||
|
sixty times a second.
|
||||||
|
|
||||||
|
### Paying it off
|
||||||
|
|
||||||
|
The generated source depends only on the *structure* of the graph — which is precisely what
|
||||||
|
`ComposedShader::structure_hash` already identifies, and precisely what does not change while a
|
||||||
|
slider is being dragged. `AdjustPass` relies on that already: it caches compiled pipelines against
|
||||||
|
that hash and does not recompile during a drag. Caching the source string against the same hash and
|
||||||
|
rebuilding only the uniforms — a handful of floats per operation — takes this to approximately
|
||||||
|
nothing on the path that needs it most.
|
||||||
|
|
||||||
|
The care needed is in what the hash covers. It deliberately excludes parameter *magnitudes*, so a
|
||||||
|
cache keyed on it is sound for the source and would be wrong for anything else in `ComposedShader`.
|
||||||
|
|
||||||
|
**Done when:** the `shader` column of [frame-budget.md](frame-budget.md)'s M1 table is under a
|
||||||
|
millisecond for the `point` and `all` chains, and the codegen tests still pass byte for byte.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Related, and deliberately not here
|
## Related, and deliberately not here
|
||||||
|
|
||||||
The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed*
|
The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed*
|
||||||
|
|||||||
+72
-72
@@ -9,18 +9,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Source files scanned | 254 |
|
| Source files scanned | 274 |
|
||||||
| TRACES tags found | 734 |
|
| TRACES tags found | 793 |
|
||||||
| Requirements defined | 177 |
|
| Requirements defined | 177 |
|
||||||
| Requirements covered | 98 |
|
| Requirements covered | 106 |
|
||||||
| **Coverage** | **55.4%** (98/177) |
|
| **Coverage** | **59.9%** (106/177) |
|
||||||
|
|
||||||
### By type
|
### By type
|
||||||
|
|
||||||
| Type | Covered | Defined |
|
| Type | Covered | Defined |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FR | 78 | 122 |
|
| FR | 84 | 122 |
|
||||||
| NFR | 18 | 49 |
|
| NFR | 20 | 49 |
|
||||||
| R | 2 | 6 |
|
| R | 2 | 6 |
|
||||||
|
|
||||||
## Orphan tags
|
## Orphan tags
|
||||||
@@ -33,108 +33,116 @@ _None._
|
|||||||
|
|
||||||
| ID | Tagged in |
|
| ID | Tagged in |
|
||||||
|---|---|
|
|---|---|
|
||||||
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
||||||
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1108`](../ui/dr-ui/src/lib.rs#L1108), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1008`](../ui/dr-ui/ui/library.slint#L1008), [`ui/dr-ui/ui/library.slint:1170`](../ui/dr-ui/ui/library.slint#L1170), [`ui/dr-ui/ui/library.slint:857`](../ui/dr-ui/ui/library.slint#L857) |
|
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs: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:1110`](../ui/dr-ui/src/lib.rs#L1110), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1008`](../ui/dr-ui/ui/library.slint#L1008), [`ui/dr-ui/ui/library.slint:1170`](../ui/dr-ui/ui/library.slint#L1170), [`ui/dr-ui/ui/library.slint:857`](../ui/dr-ui/ui/library.slint#L857) |
|
||||||
| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1108`](../ui/dr-ui/src/lib.rs#L1108), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:2162`](../ui/dr-ui/src/library.rs#L2162), [`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:1110`](../ui/dr-ui/src/lib.rs#L1110), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:2162`](../ui/dr-ui/src/library.rs#L2162), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||||
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) |
|
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118) |
|
||||||
| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) |
|
| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) |
|
||||||
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:583`](../core/dr-catalog/src/schema.rs#L583), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:180`](../ui/dr-ui/src/library.rs#L180), [`ui/dr-ui/src/library.rs:209`](../ui/dr-ui/src/library.rs#L209), [`ui/dr-ui/src/library.rs:3574`](../ui/dr-ui/src/library.rs#L3574), [`ui/dr-ui/src/library.rs:3608`](../ui/dr-ui/src/library.rs#L3608), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:757`](../ui/dr-ui/src/library_ui.rs#L757), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
|
| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:583`](../core/dr-catalog/src/schema.rs#L583), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`core/dr-sync/src/lib.rs:124`](../core/dr-sync/src/lib.rs#L124), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1225`](../ui/dr-ui/src/collections_ui.rs#L1225), [`ui/dr-ui/src/collections_ui.rs:1995`](../ui/dr-ui/src/collections_ui.rs#L1995), [`ui/dr-ui/src/library.rs:163`](../ui/dr-ui/src/library.rs#L163), [`ui/dr-ui/src/library.rs:180`](../ui/dr-ui/src/library.rs#L180), [`ui/dr-ui/src/library.rs:209`](../ui/dr-ui/src/library.rs#L209), [`ui/dr-ui/src/library.rs:3576`](../ui/dr-ui/src/library.rs#L3576), [`ui/dr-ui/src/library.rs:3610`](../ui/dr-ui/src/library.rs#L3610), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:757`](../ui/dr-ui/src/library_ui.rs#L757), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) |
|
||||||
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
|
| FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) |
|
||||||
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||||
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489), [`ui/dr-ui/src/library.rs:2639`](../ui/dr-ui/src/library.rs#L2639), [`ui/dr-ui/src/library.rs:3108`](../ui/dr-ui/src/library.rs#L3108), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3626`](../ui/dr-ui/src/library_ui.rs#L3626), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:540`](../ui/dr-ui/ui/app.slint#L540), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
| FR-CAT-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:2639`](../ui/dr-ui/src/library.rs#L2639), [`ui/dr-ui/src/library.rs:3110`](../ui/dr-ui/src/library.rs#L3110), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3626`](../ui/dr-ui/src/library_ui.rs#L3626), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:547`](../ui/dr-ui/ui/app.slint#L547), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||||
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:304`](../core/dr-catalog/src/schema.rs#L304), [`ui/dr-ui/src/library.rs:190`](../ui/dr-ui/src/library.rs#L190), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/schema.rs:304`](../core/dr-catalog/src/schema.rs#L304), [`ui/dr-ui/src/library.rs:190`](../ui/dr-ui/src/library.rs#L190), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
|
||||||
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1001`](../core/dr-catalog/src/schema.rs#L1001), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3622`](../ui/dr-ui/src/library.rs#L3622), [`ui/dr-ui/src/library_ui.rs:6273`](../ui/dr-ui/src/library_ui.rs#L6273), [`ui/dr-ui/src/library_ui.rs:6284`](../ui/dr-ui/src/library_ui.rs#L6284), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6297`](../ui/dr-ui/src/library_ui.rs#L6297), [`ui/dr-ui/src/library_ui.rs:6312`](../ui/dr-ui/src/library_ui.rs#L6312), [`ui/dr-ui/src/library_ui.rs:6321`](../ui/dr-ui/src/library_ui.rs#L6321), [`ui/dr-ui/ui/app.slint:485`](../ui/dr-ui/ui/app.slint#L485), [`ui/dr-ui/ui/app.slint:658`](../ui/dr-ui/ui/app.slint#L658), [`ui/dr-ui/ui/library.slint:1219`](../ui/dr-ui/ui/library.slint#L1219), [`ui/dr-ui/ui/library.slint:1222`](../ui/dr-ui/ui/library.slint#L1222), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:850`](../ui/dr-ui/ui/library.slint#L850), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902) |
|
| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1001`](../core/dr-catalog/src/schema.rs#L1001), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/collections_ui.rs:1578`](../ui/dr-ui/src/collections_ui.rs#L1578), [`ui/dr-ui/src/collections_ui.rs:1597`](../ui/dr-ui/src/collections_ui.rs#L1597), [`ui/dr-ui/src/collections_ui.rs:243`](../ui/dr-ui/src/collections_ui.rs#L243), [`ui/dr-ui/src/collections_ui.rs:340`](../ui/dr-ui/src/collections_ui.rs#L340), [`ui/dr-ui/src/collections_ui.rs:89`](../ui/dr-ui/src/collections_ui.rs#L89), [`ui/dr-ui/src/library.rs:3624`](../ui/dr-ui/src/library.rs#L3624), [`ui/dr-ui/src/library_ui.rs:6273`](../ui/dr-ui/src/library_ui.rs#L6273), [`ui/dr-ui/src/library_ui.rs:6284`](../ui/dr-ui/src/library_ui.rs#L6284), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6297`](../ui/dr-ui/src/library_ui.rs#L6297), [`ui/dr-ui/src/library_ui.rs:6312`](../ui/dr-ui/src/library_ui.rs#L6312), [`ui/dr-ui/src/library_ui.rs:6321`](../ui/dr-ui/src/library_ui.rs#L6321), [`ui/dr-ui/ui/app.slint:492`](../ui/dr-ui/ui/app.slint#L492), [`ui/dr-ui/ui/app.slint:665`](../ui/dr-ui/ui/app.slint#L665), [`ui/dr-ui/ui/library.slint:1219`](../ui/dr-ui/ui/library.slint#L1219), [`ui/dr-ui/ui/library.slint:1222`](../ui/dr-ui/ui/library.slint#L1222), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:850`](../ui/dr-ui/ui/library.slint#L850), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902) |
|
||||||
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:3868`](../ui/dr-ui/src/library.rs#L3868), [`ui/dr-ui/src/library_ui.rs:315`](../ui/dr-ui/src/library_ui.rs#L315), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:5142`](../ui/dr-ui/src/library_ui.rs#L5142), [`ui/dr-ui/src/library_ui.rs:5195`](../ui/dr-ui/src/library_ui.rs#L5195), [`ui/dr-ui/src/library_ui.rs:6413`](../ui/dr-ui/src/library_ui.rs#L6413), [`ui/dr-ui/ui/app.slint:488`](../ui/dr-ui/ui/app.slint#L488), [`ui/dr-ui/ui/app.slint:658`](../ui/dr-ui/ui/app.slint#L658), [`ui/dr-ui/ui/app.slint:897`](../ui/dr-ui/ui/app.slint#L897), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1288`](../ui/dr-ui/ui/library.slint#L1288), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/settings.slint:128`](../ui/dr-ui/ui/settings.slint#L128) |
|
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:63`](../core/dr-types/src/settings.rs#L63), [`core/dr-types/src/time.rs:67`](../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:3870`](../ui/dr-ui/src/library.rs#L3870), [`ui/dr-ui/src/library_ui.rs:315`](../ui/dr-ui/src/library_ui.rs#L315), [`ui/dr-ui/src/library_ui.rs:387`](../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:5142`](../ui/dr-ui/src/library_ui.rs#L5142), [`ui/dr-ui/src/library_ui.rs:5195`](../ui/dr-ui/src/library_ui.rs#L5195), [`ui/dr-ui/src/library_ui.rs:6413`](../ui/dr-ui/src/library_ui.rs#L6413), [`ui/dr-ui/ui/app.slint:495`](../ui/dr-ui/ui/app.slint#L495), [`ui/dr-ui/ui/app.slint:665`](../ui/dr-ui/ui/app.slint#L665), [`ui/dr-ui/ui/app.slint:904`](../ui/dr-ui/ui/app.slint#L904), [`ui/dr-ui/ui/library.slint:109`](../ui/dr-ui/ui/library.slint#L109), [`ui/dr-ui/ui/library.slint:1288`](../ui/dr-ui/ui/library.slint#L1288), [`ui/dr-ui/ui/library.slint:902`](../ui/dr-ui/ui/library.slint#L902), [`ui/dr-ui/ui/settings.slint:147`](../ui/dr-ui/ui/settings.slint#L147) |
|
||||||
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3484`](../ui/dr-ui/src/library_ui.rs#L3484), [`ui/dr-ui/src/library_ui.rs:4182`](../ui/dr-ui/src/library_ui.rs#L4182), [`ui/dr-ui/ui/app.slint:653`](../ui/dr-ui/ui/app.slint#L653), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2528`](../ui/dr-ui/ui/library.slint#L2528), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) |
|
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library_ui.rs:3484`](../ui/dr-ui/src/library_ui.rs#L3484), [`ui/dr-ui/src/library_ui.rs:4182`](../ui/dr-ui/src/library_ui.rs#L4182), [`ui/dr-ui/ui/app.slint:660`](../ui/dr-ui/ui/app.slint#L660), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:2528`](../ui/dr-ui/ui/library.slint#L2528), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) |
|
||||||
| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:136`](../core/dr-pipeline/src/ops/curve.rs#L136), [`core/dr-pipeline/src/ops/curve.rs:652`](../core/dr-pipeline/src/ops/curve.rs#L652), [`core/dr-pipeline/src/sidecar.rs:1627`](../core/dr-pipeline/src/sidecar.rs#L1627), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:3316`](../ui/dr-ui/src/develop.rs#L3316), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1369`](../ui/dr-ui/src/lib.rs#L1369), [`ui/dr-ui/src/lib.rs:1742`](../ui/dr-ui/src/lib.rs#L1742), [`ui/dr-ui/src/lib.rs:1878`](../ui/dr-ui/src/lib.rs#L1878), [`ui/dr-ui/src/lib.rs:480`](../ui/dr-ui/src/lib.rs#L480), [`ui/dr-ui/src/lib.rs:905`](../ui/dr-ui/src/lib.rs#L905), [`ui/dr-ui/src/library.rs:1571`](../ui/dr-ui/src/library.rs#L1571), [`ui/dr-ui/src/library.rs:375`](../ui/dr-ui/src/library.rs#L375), [`ui/dr-ui/src/library.rs:422`](../ui/dr-ui/src/library.rs#L422), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:707`](../ui/dr-ui/src/library.rs#L707), [`ui/dr-ui/src/library_ui.rs:4884`](../ui/dr-ui/src/library_ui.rs#L4884), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
| FR-CAT-8 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/ops/curve.rs:137`](../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../core/dr-pipeline/tests/tone_curve.rs#L34), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:3374`](../ui/dr-ui/src/develop.rs#L3374), [`ui/dr-ui/src/export.rs:752`](../ui/dr-ui/src/export.rs#L752), [`ui/dr-ui/src/lib.rs:1371`](../ui/dr-ui/src/lib.rs#L1371), [`ui/dr-ui/src/lib.rs:1772`](../ui/dr-ui/src/lib.rs#L1772), [`ui/dr-ui/src/lib.rs:1908`](../ui/dr-ui/src/lib.rs#L1908), [`ui/dr-ui/src/lib.rs:482`](../ui/dr-ui/src/lib.rs#L482), [`ui/dr-ui/src/lib.rs:907`](../ui/dr-ui/src/lib.rs#L907), [`ui/dr-ui/src/library.rs:1571`](../ui/dr-ui/src/library.rs#L1571), [`ui/dr-ui/src/library.rs:375`](../ui/dr-ui/src/library.rs#L375), [`ui/dr-ui/src/library.rs:422`](../ui/dr-ui/src/library.rs#L422), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:707`](../ui/dr-ui/src/library.rs#L707), [`ui/dr-ui/src/library_ui.rs:4884`](../ui/dr-ui/src/library_ui.rs#L4884), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||||
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2812`](../ui/dr-ui/src/develop.rs#L2812), [`ui/dr-ui/src/library.rs:149`](../ui/dr-ui/src/library.rs#L149), [`ui/dr-ui/src/library.rs:1531`](../ui/dr-ui/src/library.rs#L1531), [`ui/dr-ui/src/library.rs:1608`](../ui/dr-ui/src/library.rs#L1608), [`ui/dr-ui/src/library.rs:231`](../ui/dr-ui/src/library.rs#L231), [`ui/dr-ui/src/library.rs:3975`](../ui/dr-ui/src/library.rs#L3975), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:691`](../ui/dr-ui/src/library.rs#L691), [`ui/dr-ui/src/library.rs:707`](../ui/dr-ui/src/library.rs#L707), [`ui/dr-ui/src/library.rs:761`](../ui/dr-ui/src/library.rs#L761), [`ui/dr-ui/src/library_ui.rs:1564`](../ui/dr-ui/src/library_ui.rs#L1564), [`ui/dr-ui/src/library_ui.rs:1590`](../ui/dr-ui/src/library_ui.rs#L1590), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1700`](../ui/dr-ui/src/library_ui.rs#L1700), [`ui/dr-ui/src/library_ui.rs:226`](../ui/dr-ui/src/library_ui.rs#L226), [`ui/dr-ui/src/library_ui.rs:2316`](../ui/dr-ui/src/library_ui.rs#L2316), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:2754`](../ui/dr-ui/src/library_ui.rs#L2754), [`ui/dr-ui/src/library_ui.rs:2978`](../ui/dr-ui/src/library_ui.rs#L2978), [`ui/dr-ui/src/library_ui.rs:3259`](../ui/dr-ui/src/library_ui.rs#L3259), [`ui/dr-ui/src/library_ui.rs:3347`](../ui/dr-ui/src/library_ui.rs#L3347), [`ui/dr-ui/src/library_ui.rs:3535`](../ui/dr-ui/src/library_ui.rs#L3535), [`ui/dr-ui/src/library_ui.rs:3653`](../ui/dr-ui/src/library_ui.rs#L3653), [`ui/dr-ui/src/library_ui.rs:440`](../ui/dr-ui/src/library_ui.rs#L440), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5240`](../ui/dr-ui/src/library_ui.rs#L5240), [`ui/dr-ui/src/library_ui.rs:5355`](../ui/dr-ui/src/library_ui.rs#L5355), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`ui/dr-ui/src/develop.rs:2870`](../ui/dr-ui/src/develop.rs#L2870), [`ui/dr-ui/src/library.rs:149`](../ui/dr-ui/src/library.rs#L149), [`ui/dr-ui/src/library.rs:1531`](../ui/dr-ui/src/library.rs#L1531), [`ui/dr-ui/src/library.rs:1608`](../ui/dr-ui/src/library.rs#L1608), [`ui/dr-ui/src/library.rs:231`](../ui/dr-ui/src/library.rs#L231), [`ui/dr-ui/src/library.rs:3977`](../ui/dr-ui/src/library.rs#L3977), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:691`](../ui/dr-ui/src/library.rs#L691), [`ui/dr-ui/src/library.rs:707`](../ui/dr-ui/src/library.rs#L707), [`ui/dr-ui/src/library.rs:761`](../ui/dr-ui/src/library.rs#L761), [`ui/dr-ui/src/library_ui.rs:1564`](../ui/dr-ui/src/library_ui.rs#L1564), [`ui/dr-ui/src/library_ui.rs:1590`](../ui/dr-ui/src/library_ui.rs#L1590), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:1700`](../ui/dr-ui/src/library_ui.rs#L1700), [`ui/dr-ui/src/library_ui.rs:226`](../ui/dr-ui/src/library_ui.rs#L226), [`ui/dr-ui/src/library_ui.rs:2316`](../ui/dr-ui/src/library_ui.rs#L2316), [`ui/dr-ui/src/library_ui.rs:259`](../ui/dr-ui/src/library_ui.rs#L259), [`ui/dr-ui/src/library_ui.rs:2754`](../ui/dr-ui/src/library_ui.rs#L2754), [`ui/dr-ui/src/library_ui.rs:2978`](../ui/dr-ui/src/library_ui.rs#L2978), [`ui/dr-ui/src/library_ui.rs:3259`](../ui/dr-ui/src/library_ui.rs#L3259), [`ui/dr-ui/src/library_ui.rs:3347`](../ui/dr-ui/src/library_ui.rs#L3347), [`ui/dr-ui/src/library_ui.rs:3535`](../ui/dr-ui/src/library_ui.rs#L3535), [`ui/dr-ui/src/library_ui.rs:3653`](../ui/dr-ui/src/library_ui.rs#L3653), [`ui/dr-ui/src/library_ui.rs:440`](../ui/dr-ui/src/library_ui.rs#L440), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/library_ui.rs:5240`](../ui/dr-ui/src/library_ui.rs#L5240), [`ui/dr-ui/src/library_ui.rs:5355`](../ui/dr-ui/src/library_ui.rs#L5355), [`ui/dr-ui/src/presets.rs:326`](../ui/dr-ui/src/presets.rs#L326), [`ui/dr-ui/src/presets.rs:338`](../ui/dr-ui/src/presets.rs#L338), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||||
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
|
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
|
||||||
| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1776`](../ui/dr-ui/src/develop.rs#L1776), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1850`](../ui/dr-ui/src/lib.rs#L1850), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
| FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/develop.rs:119`](../ui/dr-ui/src/develop.rs#L119), [`ui/dr-ui/src/develop.rs:128`](../ui/dr-ui/src/develop.rs#L128), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:194`](../ui/dr-ui/src/develop.rs#L194), [`ui/dr-ui/src/develop.rs:589`](../ui/dr-ui/src/develop.rs#L589), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1880`](../ui/dr-ui/src/lib.rs#L1880), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||||
| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
| FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||||
| FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
| FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||||
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) |
|
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:464`](../ui/dr-ui/src/import.rs#L464) |
|
||||||
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:375`](../ui/dr-ui/src/library.rs#L375) |
|
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:135`](../core/dr-pipeline/src/sidecar.rs#L135), [`ui/dr-ui/src/library.rs:214`](../ui/dr-ui/src/library.rs#L214), [`ui/dr-ui/src/library.rs:375`](../ui/dr-ui/src/library.rs#L375) |
|
||||||
| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:343`](../core/dr-catalog/src/schema.rs#L343), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2640`](../ui/dr-ui/src/library.rs#L2640), [`ui/dr-ui/src/library.rs:2744`](../ui/dr-ui/src/library.rs#L2744), [`ui/dr-ui/ui/settings.slint:385`](../ui/dr-ui/ui/settings.slint#L385), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) |
|
| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:343`](../core/dr-catalog/src/schema.rs#L343), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2640`](../ui/dr-ui/src/library.rs#L2640), [`ui/dr-ui/src/library.rs:2744`](../ui/dr-ui/src/library.rs#L2744), [`ui/dr-ui/ui/settings.slint:404`](../ui/dr-ui/ui/settings.slint#L404), [`ui/dr-ui/ui/settings.slint:81`](../ui/dr-ui/ui/settings.slint#L81) |
|
||||||
| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) |
|
| FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) |
|
||||||
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) |
|
| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) |
|
||||||
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:362`](../core/dr-pipeline/src/framing.rs#L362), [`core/dr-pipeline/src/framing.rs:617`](../core/dr-pipeline/src/framing.rs#L617), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:573`](../core/dr-pipeline/src/graph.rs#L573), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:479`](../core/dr-pipeline/src/operation.rs#L479), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:218`](../core/dr-pipeline/src/ops/curve.rs#L218), [`core/dr-pipeline/src/ops/curve.rs:631`](../core/dr-pipeline/src/ops/curve.rs#L631), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1627`](../core/dr-pipeline/src/sidecar.rs#L1627), [`core/dr-pipeline/src/sidecar.rs:1687`](../core/dr-pipeline/src/sidecar.rs#L1687), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1280`](../ui/dr-ui/src/develop.rs#L1280), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1758`](../ui/dr-ui/src/develop.rs#L1758), [`ui/dr-ui/src/develop.rs:1776`](../ui/dr-ui/src/develop.rs#L1776), [`ui/dr-ui/src/develop.rs:1790`](../ui/dr-ui/src/develop.rs#L1790), [`ui/dr-ui/src/develop.rs:1812`](../ui/dr-ui/src/develop.rs#L1812), [`ui/dr-ui/src/develop.rs:1958`](../ui/dr-ui/src/develop.rs#L1958), [`ui/dr-ui/src/develop.rs:2056`](../ui/dr-ui/src/develop.rs#L2056), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3851`](../ui/dr-ui/src/develop.rs#L3851), [`ui/dr-ui/src/develop.rs:3905`](../ui/dr-ui/src/develop.rs#L3905), [`ui/dr-ui/src/develop.rs:3949`](../ui/dr-ui/src/develop.rs#L3949), [`ui/dr-ui/src/develop.rs:3999`](../ui/dr-ui/src/develop.rs#L3999), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1426`](../ui/dr-ui/src/lib.rs#L1426), [`ui/dr-ui/src/lib.rs:2128`](../ui/dr-ui/src/lib.rs#L2128), [`ui/dr-ui/src/lib.rs:301`](../ui/dr-ui/src/lib.rs#L301), [`ui/dr-ui/src/library.rs:422`](../ui/dr-ui/src/library.rs#L422), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:2105`](../ui/dr-ui/ui/app.slint#L2105), [`ui/dr-ui/ui/app.slint:984`](../ui/dr-ui/ui/app.slint#L984) |
|
| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1297`](../ui/dr-ui/src/develop.rs#L1297), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1775`](../ui/dr-ui/src/develop.rs#L1775), [`ui/dr-ui/src/develop.rs:1793`](../ui/dr-ui/src/develop.rs#L1793), [`ui/dr-ui/src/develop.rs:1807`](../ui/dr-ui/src/develop.rs#L1807), [`ui/dr-ui/src/develop.rs:1829`](../ui/dr-ui/src/develop.rs#L1829), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/develop.rs:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3345`](../ui/dr-ui/src/develop.rs#L3345), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3909`](../ui/dr-ui/src/develop.rs#L3909), [`ui/dr-ui/src/develop.rs:3963`](../ui/dr-ui/src/develop.rs#L3963), [`ui/dr-ui/src/develop.rs:4007`](../ui/dr-ui/src/develop.rs#L4007), [`ui/dr-ui/src/develop.rs:4057`](../ui/dr-ui/src/develop.rs#L4057), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1456`](../ui/dr-ui/src/lib.rs#L1456), [`ui/dr-ui/src/lib.rs:2158`](../ui/dr-ui/src/lib.rs#L2158), [`ui/dr-ui/src/lib.rs:303`](../ui/dr-ui/src/lib.rs#L303), [`ui/dr-ui/src/library.rs:422`](../ui/dr-ui/src/library.rs#L422), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:2118`](../ui/dr-ui/ui/app.slint#L2118), [`ui/dr-ui/ui/app.slint:991`](../ui/dr-ui/ui/app.slint#L991) |
|
||||||
| FR-DEV-3a | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328), [`core/dr-pipeline/src/ops/curve.rs:318`](../core/dr-pipeline/src/ops/curve.rs#L318), [`ui/dr-ui/src/develop.rs:1177`](../ui/dr-ui/src/develop.rs#L1177), [`ui/dr-ui/src/lib.rs:595`](../ui/dr-ui/src/lib.rs#L595) |
|
| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1194`](../ui/dr-ui/src/develop.rs#L1194), [`ui/dr-ui/src/lib.rs:597`](../ui/dr-ui/src/lib.rs#L597), [`ui/dr-ui/tests/ui_names_no_operation.rs:1`](../ui/dr-ui/tests/ui_names_no_operation.rs#L1) |
|
||||||
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:328`](../core/dr-pipeline/src/operation.rs#L328) |
|
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) |
|
||||||
| FR-DEV-3c | [`core/dr-pipeline/build.rs:1807`](../core/dr-pipeline/build.rs#L1807), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:954`](../core/dr-pipeline/src/mask.rs#L954), [`ui/dr-ui/src/develop.rs:4578`](../ui/dr-ui/src/develop.rs#L4578) |
|
| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4636`](../ui/dr-ui/src/develop.rs#L4636) |
|
||||||
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:612`](../core/dr-pipeline/src/graph.rs#L612), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) |
|
| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) |
|
||||||
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1424`](../core/dr-pipeline/src/operation.rs#L1424), [`core/dr-pipeline/src/operation.rs:1505`](../core/dr-pipeline/src/operation.rs#L1505), [`core/dr-pipeline/src/operation.rs:1530`](../core/dr-pipeline/src/operation.rs#L1530), [`core/dr-pipeline/src/operation.rs:1545`](../core/dr-pipeline/src/operation.rs#L1545), [`core/dr-pipeline/src/operation.rs:1569`](../core/dr-pipeline/src/operation.rs#L1569), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/operation.rs:403`](../core/dr-pipeline/src/operation.rs#L403), [`core/dr-pipeline/src/operation.rs:413`](../core/dr-pipeline/src/operation.rs#L413), [`core/dr-pipeline/src/operation.rs:563`](../core/dr-pipeline/src/operation.rs#L563) |
|
| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) |
|
||||||
| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1028`](../core/dr-pipeline/src/operation.rs#L1028), [`core/dr-pipeline/src/operation.rs:1057`](../core/dr-pipeline/src/operation.rs#L1057), [`core/dr-pipeline/src/operation.rs:1424`](../core/dr-pipeline/src/operation.rs#L1424), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:279`](../core/dr-pipeline/src/operation.rs#L279), [`core/dr-pipeline/src/ops/film_sim.rs:126`](../core/dr-pipeline/src/ops/film_sim.rs#L126), [`core/dr-pipeline/src/ops/film_sim.rs:150`](../core/dr-pipeline/src/ops/film_sim.rs#L150), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:328`](../core/dr-pipeline/src/ops/film_sim.rs#L328), [`core/dr-pipeline/src/ops/film_sim.rs:42`](../core/dr-pipeline/src/ops/film_sim.rs#L42), [`core/dr-pipeline/src/ops/film_sim.rs:85`](../core/dr-pipeline/src/ops/film_sim.rs#L85), [`core/dr-pipeline/src/ops/film_sim.rs:90`](../core/dr-pipeline/src/ops/film_sim.rs#L90), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1948`](../core/dr-pipeline/src/sidecar.rs#L1948), [`core/dr-pipeline/src/sidecar.rs:2024`](../core/dr-pipeline/src/sidecar.rs#L2024), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2827`](../ui/dr-ui/src/develop.rs#L2827), [`ui/dr-ui/src/develop.rs:2844`](../ui/dr-ui/src/develop.rs#L2844), [`ui/dr-ui/src/develop.rs:2856`](../ui/dr-ui/src/develop.rs#L2856), [`ui/dr-ui/src/develop.rs:2894`](../ui/dr-ui/src/develop.rs#L2894), [`ui/dr-ui/src/develop.rs:2903`](../ui/dr-ui/src/develop.rs#L2903), [`ui/dr-ui/src/develop.rs:3006`](../ui/dr-ui/src/develop.rs#L3006), [`ui/dr-ui/src/develop.rs:3324`](../ui/dr-ui/src/develop.rs#L3324), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/lib.rs:2102`](../ui/dr-ui/src/lib.rs#L2102), [`ui/dr-ui/src/lib.rs:528`](../ui/dr-ui/src/lib.rs#L528), [`ui/dr-ui/src/lib.rs:586`](../ui/dr-ui/src/lib.rs#L586), [`ui/dr-ui/src/library.rs:413`](../ui/dr-ui/src/library.rs#L413), [`ui/dr-ui/src/library.rs:662`](../ui/dr-ui/src/library.rs#L662), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:959`](../ui/dr-ui/ui/adjust.slint#L959), [`ui/dr-ui/ui/app.slint:2717`](../ui/dr-ui/ui/app.slint#L2717), [`ui/dr-ui/ui/app.slint:778`](../ui/dr-ui/ui/app.slint#L778) |
|
| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2885`](../ui/dr-ui/src/develop.rs#L2885), [`ui/dr-ui/src/develop.rs:2902`](../ui/dr-ui/src/develop.rs#L2902), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:2952`](../ui/dr-ui/src/develop.rs#L2952), [`ui/dr-ui/src/develop.rs:2961`](../ui/dr-ui/src/develop.rs#L2961), [`ui/dr-ui/src/develop.rs:3064`](../ui/dr-ui/src/develop.rs#L3064), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/lib.rs:2132`](../ui/dr-ui/src/lib.rs#L2132), [`ui/dr-ui/src/lib.rs:530`](../ui/dr-ui/src/lib.rs#L530), [`ui/dr-ui/src/lib.rs:588`](../ui/dr-ui/src/lib.rs#L588), [`ui/dr-ui/src/library.rs:413`](../ui/dr-ui/src/library.rs#L413), [`ui/dr-ui/src/library.rs:662`](../ui/dr-ui/src/library.rs#L662), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:959`](../ui/dr-ui/ui/adjust.slint#L959), [`ui/dr-ui/ui/app.slint:2730`](../ui/dr-ui/ui/app.slint#L2730), [`ui/dr-ui/ui/app.slint:785`](../ui/dr-ui/ui/app.slint#L785) |
|
||||||
| FR-DEV-3h | [`core/dr-decode/src/lib.rs:404`](../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-pipeline/src/framing.rs:362`](../core/dr-pipeline/src/framing.rs#L362), [`core/dr-pipeline/src/framing.rs:924`](../core/dr-pipeline/src/framing.rs#L924), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1975`](../ui/dr-ui/src/develop.rs#L1975), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) |
|
| 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:205`](../core/dr-pipeline/src/framing.rs#L205), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:927`](../core/dr-pipeline/src/framing.rs#L927), [`core/dr-types/src/lib.rs:336`](../core/dr-types/src/lib.rs#L336), [`core/dr-types/src/lib.rs:444`](../core/dr-types/src/lib.rs#L444), [`core/dr-types/src/lib.rs:456`](../core/dr-types/src/lib.rs#L456), [`core/dr-types/src/lib.rs:472`](../core/dr-types/src/lib.rs#L472), [`ui/dr-ui/src/develop.rs:138`](../ui/dr-ui/src/develop.rs#L138), [`ui/dr-ui/src/develop.rs:1992`](../ui/dr-ui/src/develop.rs#L1992), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322) |
|
||||||
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) |
|
||||||
| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2856`](../ui/dr-ui/src/develop.rs#L2856), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3369`](../ui/dr-ui/src/develop.rs#L3369), [`ui/dr-ui/src/develop.rs:3382`](../ui/dr-ui/src/develop.rs#L3382), [`ui/dr-ui/src/develop.rs:3394`](../ui/dr-ui/src/develop.rs#L3394), [`ui/dr-ui/src/develop.rs:3410`](../ui/dr-ui/src/develop.rs#L3410), [`ui/dr-ui/src/develop.rs:3442`](../ui/dr-ui/src/develop.rs#L3442), [`ui/dr-ui/src/develop.rs:3446`](../ui/dr-ui/src/develop.rs#L3446), [`ui/dr-ui/src/develop.rs:3465`](../ui/dr-ui/src/develop.rs#L3465), [`ui/dr-ui/src/develop.rs:3481`](../ui/dr-ui/src/develop.rs#L3481), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1390`](../ui/dr-ui/src/lib.rs#L1390), [`ui/dr-ui/src/lib.rs:1404`](../ui/dr-ui/src/lib.rs#L1404), [`ui/dr-ui/src/lib.rs:1412`](../ui/dr-ui/src/lib.rs#L1412), [`ui/dr-ui/src/lib.rs:2298`](../ui/dr-ui/src/lib.rs#L2298), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
| FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:345`](../core/dr-pipeline/src/graph.rs#L345), [`core/dr-pipeline/src/graph.rs:384`](../core/dr-pipeline/src/graph.rs#L384), [`core/dr-pipeline/src/history.rs:102`](../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/state.rs:1`](../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop.rs:2914`](../ui/dr-ui/src/develop.rs#L2914), [`ui/dr-ui/src/develop.rs:3397`](../ui/dr-ui/src/develop.rs#L3397), [`ui/dr-ui/src/develop.rs:3427`](../ui/dr-ui/src/develop.rs#L3427), [`ui/dr-ui/src/develop.rs:3440`](../ui/dr-ui/src/develop.rs#L3440), [`ui/dr-ui/src/develop.rs:3452`](../ui/dr-ui/src/develop.rs#L3452), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/develop.rs:3504`](../ui/dr-ui/src/develop.rs#L3504), [`ui/dr-ui/src/develop.rs:3523`](../ui/dr-ui/src/develop.rs#L3523), [`ui/dr-ui/src/develop.rs:3539`](../ui/dr-ui/src/develop.rs#L3539), [`ui/dr-ui/src/develop.rs:610`](../ui/dr-ui/src/develop.rs#L610), [`ui/dr-ui/src/labels.rs:12`](../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:215`](../ui/dr-ui/src/labels.rs#L215), [`ui/dr-ui/src/lib.rs:1404`](../ui/dr-ui/src/lib.rs#L1404), [`ui/dr-ui/src/lib.rs:1434`](../ui/dr-ui/src/lib.rs#L1434), [`ui/dr-ui/src/lib.rs:1442`](../ui/dr-ui/src/lib.rs#L1442), [`ui/dr-ui/src/lib.rs:2328`](../ui/dr-ui/src/lib.rs#L2328), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
||||||
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3281`](../ui/dr-ui/src/develop.rs#L3281), [`ui/dr-ui/src/develop.rs:3302`](../ui/dr-ui/src/develop.rs#L3302), [`ui/dr-ui/src/lib.rs:1369`](../ui/dr-ui/src/lib.rs#L1369), [`ui/dr-ui/src/library.rs:1571`](../ui/dr-ui/src/library.rs#L1571), [`ui/dr-ui/src/library.rs:375`](../ui/dr-ui/src/library.rs#L375), [`ui/dr-ui/src/library.rs:403`](../ui/dr-ui/src/library.rs#L403), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2978`](../ui/dr-ui/src/library_ui.rs#L2978), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:547`](../ui/dr-ui/src/settings_ui.rs#L547), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1315`](../ui/dr-ui/ui/library.slint#L1315), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:913`](../ui/dr-ui/ui/library.slint#L913), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) |
|
| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:182`](../core/dr-types/src/settings.rs#L182), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/develop.rs:3360`](../ui/dr-ui/src/develop.rs#L3360), [`ui/dr-ui/src/lib.rs:1371`](../ui/dr-ui/src/lib.rs#L1371), [`ui/dr-ui/src/library.rs:1571`](../ui/dr-ui/src/library.rs#L1571), [`ui/dr-ui/src/library.rs:375`](../ui/dr-ui/src/library.rs#L375), [`ui/dr-ui/src/library.rs:403`](../ui/dr-ui/src/library.rs#L403), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2978`](../ui/dr-ui/src/library_ui.rs#L2978), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:547`](../ui/dr-ui/src/settings_ui.rs#L547), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1315`](../ui/dr-ui/ui/library.slint#L1315), [`ui/dr-ui/ui/library.slint:831`](../ui/dr-ui/ui/library.slint#L831), [`ui/dr-ui/ui/library.slint:913`](../ui/dr-ui/ui/library.slint#L913), [`ui/dr-ui/ui/settings.slint:100`](../ui/dr-ui/ui/settings.slint#L100) |
|
||||||
| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3410`](../ui/dr-ui/src/develop.rs#L3410), [`ui/dr-ui/src/develop.rs:3442`](../ui/dr-ui/src/develop.rs#L3442), [`ui/dr-ui/src/lib.rs:1412`](../ui/dr-ui/src/lib.rs#L1412), [`ui/dr-ui/src/lib.rs:2298`](../ui/dr-ui/src/lib.rs#L2298), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
| FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop.rs:3468`](../ui/dr-ui/src/develop.rs#L3468), [`ui/dr-ui/src/develop.rs:3500`](../ui/dr-ui/src/develop.rs#L3500), [`ui/dr-ui/src/lib.rs:1442`](../ui/dr-ui/src/lib.rs#L1442), [`ui/dr-ui/src/lib.rs:2328`](../ui/dr-ui/src/lib.rs#L2328), [`ui/dr-ui/ui/history.slint:1`](../ui/dr-ui/ui/history.slint#L1) |
|
||||||
| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:681`](../core/dr-pipeline/src/graph.rs#L681), [`core/dr-pipeline/src/operation.rs:299`](../core/dr-pipeline/src/operation.rs#L299), [`core/dr-pipeline/src/operation.rs:517`](../core/dr-pipeline/src/operation.rs#L517), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2127`](../ui/dr-ui/src/develop.rs#L2127), [`ui/dr-ui/src/develop.rs:2165`](../ui/dr-ui/src/develop.rs#L2165), [`ui/dr-ui/src/develop.rs:2230`](../ui/dr-ui/src/develop.rs#L2230), [`ui/dr-ui/src/develop.rs:2313`](../ui/dr-ui/src/develop.rs#L2313), [`ui/dr-ui/src/develop.rs:2327`](../ui/dr-ui/src/develop.rs#L2327), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1433`](../ui/dr-ui/src/lib.rs#L1433), [`ui/dr-ui/src/lib.rs:2395`](../ui/dr-ui/src/lib.rs#L2395), [`ui/dr-ui/src/lib.rs:306`](../ui/dr-ui/src/lib.rs#L306), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:676`](../ui/dr-ui/ui/adjust.slint#L676), [`ui/dr-ui/ui/app.slint:1840`](../ui/dr-ui/ui/app.slint#L1840), [`ui/dr-ui/ui/app.slint:2357`](../ui/dr-ui/ui/app.slint#L2357), [`ui/dr-ui/ui/app.slint:2623`](../ui/dr-ui/ui/app.slint#L2623), [`ui/dr-ui/ui/app.slint:332`](../ui/dr-ui/ui/app.slint#L332), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) |
|
| FR-DEV-8 | [`core/dr-gpu/src/detail.rs:252`](../core/dr-gpu/src/detail.rs#L252), [`core/dr-gpu/src/detail.rs:434`](../core/dr-gpu/src/detail.rs#L434), [`core/dr-gpu/tests/detail_instances.rs:1`](../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:363`](../core/dr-pipeline/src/detail.rs#L363), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:422`](../core/dr-pipeline/src/detail.rs#L422), [`core/dr-pipeline/src/detail.rs:496`](../core/dr-pipeline/src/detail.rs#L496), [`core/dr-pipeline/src/graph.rs:113`](../core/dr-pipeline/src/graph.rs#L113), [`core/dr-pipeline/src/graph.rs:191`](../core/dr-pipeline/src/graph.rs#L191), [`core/dr-pipeline/src/graph.rs:685`](../core/dr-pipeline/src/graph.rs#L685), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:554`](../core/dr-pipeline/src/operation.rs#L554), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:672`](../core/dr-pipeline/src/sidecar.rs#L672), [`core/dr-pipeline/src/sidecar.rs:808`](../core/dr-pipeline/src/sidecar.rs#L808), [`core/dr-pipeline/src/sidecar.rs:862`](../core/dr-pipeline/src/sidecar.rs#L862), [`core/dr-pipeline/src/sidecar.rs:892`](../core/dr-pipeline/src/sidecar.rs#L892), [`core/dr-pipeline/src/spot.rs:115`](../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop.rs:2144`](../ui/dr-ui/src/develop.rs#L2144), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/develop.rs:2247`](../ui/dr-ui/src/develop.rs#L2247), [`ui/dr-ui/src/develop.rs:2330`](../ui/dr-ui/src/develop.rs#L2330), [`ui/dr-ui/src/develop.rs:2344`](../ui/dr-ui/src/develop.rs#L2344), [`ui/dr-ui/src/develop.rs:658`](../ui/dr-ui/src/develop.rs#L658), [`ui/dr-ui/src/labels.rs:52`](../ui/dr-ui/src/labels.rs#L52), [`ui/dr-ui/src/lib.rs:1463`](../ui/dr-ui/src/lib.rs#L1463), [`ui/dr-ui/src/lib.rs:2425`](../ui/dr-ui/src/lib.rs#L2425), [`ui/dr-ui/src/lib.rs:308`](../ui/dr-ui/src/lib.rs#L308), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/adjust.slint:676`](../ui/dr-ui/ui/adjust.slint#L676), [`ui/dr-ui/ui/app.slint:1853`](../ui/dr-ui/ui/app.slint#L1853), [`ui/dr-ui/ui/app.slint:2370`](../ui/dr-ui/ui/app.slint#L2370), [`ui/dr-ui/ui/app.slint:2636`](../ui/dr-ui/ui/app.slint#L2636), [`ui/dr-ui/ui/app.slint:339`](../ui/dr-ui/ui/app.slint#L339), [`ui/dr-ui/ui/spots.slint:48`](../ui/dr-ui/ui/spots.slint#L48), [`ui/dr-ui/ui/spots.slint:5`](../ui/dr-ui/ui/spots.slint#L5) |
|
||||||
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:543`](../core/dr-pipeline/src/graph.rs#L543), [`core/dr-pipeline/src/graph.rs:573`](../core/dr-pipeline/src/graph.rs#L573), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:654`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L654), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:662`](../core/dr-pipeline/src/ops/local_contrast.rs#L662), [`core/dr-pipeline/src/ops/noise_reduction.rs:695`](../core/dr-pipeline/src/ops/noise_reduction.rs#L695), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2627`](../ui/dr-ui/src/develop.rs#L2627), [`ui/dr-ui/src/develop.rs:3640`](../ui/dr-ui/src/develop.rs#L3640), [`ui/dr-ui/src/develop.rs:4123`](../ui/dr-ui/src/develop.rs#L4123), [`ui/dr-ui/src/develop.rs:4157`](../ui/dr-ui/src/develop.rs#L4157), [`ui/dr-ui/src/lib.rs:70`](../ui/dr-ui/src/lib.rs#L70), [`ui/dr-ui/src/lib.rs:736`](../ui/dr-ui/src/lib.rs#L736), [`ui/dr-ui/src/lib.rs:795`](../ui/dr-ui/src/lib.rs#L795) |
|
| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2088`](../core/dr-gpu/src/adjust.rs#L2088), [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:2250`](../core/dr-gpu/src/adjust.rs#L2250), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:263`](../core/dr-gpu/tests/local_contrast.rs#L263), [`core/dr-gpu/tests/noise_reduction.rs:378`](../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/graph.rs:547`](../core/dr-pipeline/src/graph.rs#L547), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:657`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L657), [`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:672`](../core/dr-pipeline/src/ops/local_contrast.rs#L672), [`core/dr-pipeline/src/ops/noise_reduction.rs:698`](../core/dr-pipeline/src/ops/noise_reduction.rs#L698), [`core/dr-pipeline/src/spot.rs:673`](../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop.rs:2668`](../ui/dr-ui/src/develop.rs#L2668), [`ui/dr-ui/src/develop.rs:3698`](../ui/dr-ui/src/develop.rs#L3698), [`ui/dr-ui/src/develop.rs:4181`](../ui/dr-ui/src/develop.rs#L4181), [`ui/dr-ui/src/develop.rs:4215`](../ui/dr-ui/src/develop.rs#L4215), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72), [`ui/dr-ui/src/lib.rs:738`](../ui/dr-ui/src/lib.rs#L738), [`ui/dr-ui/src/lib.rs:797`](../ui/dr-ui/src/lib.rs#L797) |
|
||||||
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) |
|
| FR-DSP-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101) |
|
||||||
| 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:2693`](../ui/dr-ui/src/develop.rs#L2693), [`ui/dr-ui/src/develop.rs:5225`](../ui/dr-ui/src/develop.rs#L5225), [`ui/dr-ui/src/develop.rs:5257`](../ui/dr-ui/src/develop.rs#L5257), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1489`](../ui/dr-ui/src/lib.rs#L1489), [`ui/dr-ui/src/lib.rs:296`](../ui/dr-ui/src/lib.rs#L296), [`ui/dr-ui/ui/app.slint:292`](../ui/dr-ui/ui/app.slint#L292), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
|
| FR-DSP-5 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101), [`core/dr-gpu/tests/zoom_resolution.rs:135`](../core/dr-gpu/tests/zoom_resolution.rs#L135), [`core/dr-gpu/tests/zoom_resolution.rs:166`](../core/dr-gpu/tests/zoom_resolution.rs#L166), [`core/dr-gpu/tests/zoom_resolution.rs:1`](../core/dr-gpu/tests/zoom_resolution.rs#L1), [`core/dr-gpu/tests/zoom_resolution.rs:216`](../core/dr-gpu/tests/zoom_resolution.rs#L216) |
|
||||||
|
| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/lib.rs:1419`](../ui/dr-ui/src/lib.rs#L1419), [`ui/dr-ui/src/lib.rs:2631`](../ui/dr-ui/src/lib.rs#L2631) |
|
||||||
|
| 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:2747`](../ui/dr-ui/src/develop.rs#L2747), [`ui/dr-ui/src/develop.rs:5283`](../ui/dr-ui/src/develop.rs#L5283), [`ui/dr-ui/src/develop.rs:5315`](../ui/dr-ui/src/develop.rs#L5315), [`ui/dr-ui/src/develop.rs:621`](../ui/dr-ui/src/develop.rs#L621), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1519`](../ui/dr-ui/src/lib.rs#L1519), [`ui/dr-ui/src/lib.rs:298`](../ui/dr-ui/src/lib.rs#L298), [`ui/dr-ui/ui/app.slint:299`](../ui/dr-ui/ui/app.slint#L299), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) |
|
||||||
|
| FR-DSP-8 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/icc.rs:1`](../platform/dr-plat/src/display/icc.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1), [`ui/dr-ui/src/develop.rs:2644`](../ui/dr-ui/src/develop.rs#L2644), [`ui/dr-ui/src/develop.rs:2698`](../ui/dr-ui/src/develop.rs#L2698), [`ui/dr-ui/src/develop.rs:5473`](../ui/dr-ui/src/develop.rs#L5473), [`ui/dr-ui/src/develop.rs:5519`](../ui/dr-ui/src/develop.rs#L5519), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`ui/dr-ui/src/develop.rs:689`](../ui/dr-ui/src/develop.rs#L689), [`ui/dr-ui/src/display_ui.rs:192`](../ui/dr-ui/src/display_ui.rs#L192), [`ui/dr-ui/src/display_ui.rs:1`](../ui/dr-ui/src/display_ui.rs#L1), [`ui/dr-ui/src/display_ui.rs:325`](../ui/dr-ui/src/display_ui.rs#L325), [`ui/dr-ui/src/display_ui.rs:346`](../ui/dr-ui/src/display_ui.rs#L346), [`ui/dr-ui/src/display_ui.rs:379`](../ui/dr-ui/src/display_ui.rs#L379), [`ui/dr-ui/src/lib.rs:1393`](../ui/dr-ui/src/lib.rs#L1393), [`ui/dr-ui/src/lib.rs:1419`](../ui/dr-ui/src/lib.rs#L1419), [`ui/dr-ui/src/lib.rs:2608`](../ui/dr-ui/src/lib.rs#L2608), [`ui/dr-ui/src/lib.rs:2631`](../ui/dr-ui/src/lib.rs#L2631), [`ui/dr-ui/ui/app.slint:1713`](../ui/dr-ui/ui/app.slint#L1713), [`ui/dr-ui/ui/app.slint:278`](../ui/dr-ui/ui/app.slint#L278), [`ui/dr-ui/ui/settings.slint:120`](../ui/dr-ui/ui/settings.slint#L120), [`ui/dr-ui/ui/settings.slint:731`](../ui/dr-ui/ui/settings.slint#L731) |
|
||||||
| 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:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:533`](../core/dr-pipeline/src/graph.rs#L533), [`core/dr-pipeline/src/graph.rs:587`](../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/operation.rs:446`](../core/dr-pipeline/src/operation.rs#L446), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2398`](../core/dr-gpu/src/adjust.rs#L2398), [`core/dr-pipeline/src/graph.rs:537`](../core/dr-pipeline/src/graph.rs#L537), [`core/dr-pipeline/src/graph.rs:591`](../core/dr-pipeline/src/graph.rs#L591), [`core/dr-pipeline/src/operation.rs:483`](../core/dr-pipeline/src/operation.rs#L483), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:595`](../core/dr-types/src/settings.rs#L595), [`ui/dr-ui/src/develop.rs:5538`](../ui/dr-ui/src/develop.rs#L5538), [`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:344`](../ui/dr-ui/src/lib.rs#L344), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) |
|
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:346`](../ui/dr-ui/src/lib.rs#L346), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:602`](../ui/dr-ui/src/settings_ui.rs#L602) |
|
||||||
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:186`](../ui/dr-ui/src/lib.rs#L186), [`ui/dr-ui/src/lib.rs:2044`](../ui/dr-ui/src/lib.rs#L2044), [`ui/dr-ui/src/lib.rs:344`](../ui/dr-ui/src/lib.rs#L344), [`ui/dr-ui/src/lib.rs:379`](../ui/dr-ui/src/lib.rs#L379), [`ui/dr-ui/src/lib.rs:406`](../ui/dr-ui/src/lib.rs#L406), [`ui/dr-ui/src/library_ui.rs:3364`](../ui/dr-ui/src/library_ui.rs#L3364), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/library_ui.rs:6255`](../ui/dr-ui/src/library_ui.rs#L6255), [`ui/dr-ui/src/library_ui.rs:6332`](../ui/dr-ui/src/library_ui.rs#L6332), [`ui/dr-ui/src/library_ui.rs:6344`](../ui/dr-ui/src/library_ui.rs#L6344), [`ui/dr-ui/src/library_ui.rs:660`](../ui/dr-ui/src/library_ui.rs#L660), [`ui/dr-ui/src/library_ui.rs:717`](../ui/dr-ui/src/library_ui.rs#L717), [`ui/dr-ui/ui/app.slint:1120`](../ui/dr-ui/ui/app.slint#L1120), [`ui/dr-ui/ui/app.slint:1548`](../ui/dr-ui/ui/app.slint#L1548), [`ui/dr-ui/ui/library.slint:1323`](../ui/dr-ui/ui/library.slint#L1323), [`ui/dr-ui/ui/library.slint:835`](../ui/dr-ui/ui/library.slint#L835), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) |
|
| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188), [`ui/dr-ui/src/lib.rs:2074`](../ui/dr-ui/src/lib.rs#L2074), [`ui/dr-ui/src/lib.rs:346`](../ui/dr-ui/src/lib.rs#L346), [`ui/dr-ui/src/lib.rs:381`](../ui/dr-ui/src/lib.rs#L381), [`ui/dr-ui/src/lib.rs:408`](../ui/dr-ui/src/lib.rs#L408), [`ui/dr-ui/src/library_ui.rs:3364`](../ui/dr-ui/src/library_ui.rs#L3364), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/library_ui.rs:6255`](../ui/dr-ui/src/library_ui.rs#L6255), [`ui/dr-ui/src/library_ui.rs:6332`](../ui/dr-ui/src/library_ui.rs#L6332), [`ui/dr-ui/src/library_ui.rs:6344`](../ui/dr-ui/src/library_ui.rs#L6344), [`ui/dr-ui/src/library_ui.rs:660`](../ui/dr-ui/src/library_ui.rs#L660), [`ui/dr-ui/src/library_ui.rs:717`](../ui/dr-ui/src/library_ui.rs#L717), [`ui/dr-ui/ui/app.slint:1127`](../ui/dr-ui/ui/app.slint#L1127), [`ui/dr-ui/ui/app.slint:1558`](../ui/dr-ui/ui/app.slint#L1558), [`ui/dr-ui/ui/library.slint:1323`](../ui/dr-ui/ui/library.slint#L1323), [`ui/dr-ui/ui/library.slint:835`](../ui/dr-ui/ui/library.slint#L835), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) |
|
||||||
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1164`](../core/dr-decode/src/locate.rs#L1164), [`core/dr-decode/src/locate.rs:1223`](../core/dr-decode/src/locate.rs#L1223), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:652`](../core/dr-types/src/lib.rs#L652), [`core/dr-types/src/settings.rs:313`](../core/dr-types/src/settings.rs#L313), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:779`](../ui/dr-ui/src/export.rs#L779), [`ui/dr-ui/src/export.rs:796`](../ui/dr-ui/src/export.rs#L796), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||||
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2771`](../ui/dr-ui/src/develop.rs#L2771), [`ui/dr-ui/src/lib.rs:344`](../ui/dr-ui/src/lib.rs#L344) |
|
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1068`](../core/dr-gpu/src/adjust.rs#L1068), [`ui/dr-ui/src/develop.rs:2829`](../ui/dr-ui/src/develop.rs#L2829), [`ui/dr-ui/src/lib.rs:346`](../ui/dr-ui/src/lib.rs#L346) |
|
||||||
| 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:406`](../ui/dr-ui/src/lib.rs#L406), [`ui/dr-ui/src/library.rs:1608`](../ui/dr-ui/src/library.rs#L1608), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:761`](../ui/dr-ui/src/library.rs#L761), [`ui/dr-ui/src/library.rs:964`](../ui/dr-ui/src/library.rs#L964), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:3364`](../ui/dr-ui/src/library_ui.rs#L3364), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:408`](../ui/dr-ui/src/lib.rs#L408), [`ui/dr-ui/src/library.rs:1608`](../ui/dr-ui/src/library.rs#L1608), [`ui/dr-ui/src/library.rs:459`](../ui/dr-ui/src/library.rs#L459), [`ui/dr-ui/src/library.rs:761`](../ui/dr-ui/src/library.rs#L761), [`ui/dr-ui/src/library.rs:964`](../ui/dr-ui/src/library.rs#L964), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:3364`](../ui/dr-ui/src/library_ui.rs#L3364), [`ui/dr-ui/src/library_ui.rs:498`](../ui/dr-ui/src/library_ui.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) |
|
||||||
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
|
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../ui/dr-ui/src/remote.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), [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
|
||||||
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs: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:2639`](../ui/dr-ui/src/library.rs#L2639), [`ui/dr-ui/src/library.rs:3108`](../ui/dr-ui/src/library.rs#L3108), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3626`](../ui/dr-ui/src/library_ui.rs#L3626), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:540`](../ui/dr-ui/ui/app.slint#L540), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library.rs:2639`](../ui/dr-ui/src/library.rs#L2639), [`ui/dr-ui/src/library.rs:3110`](../ui/dr-ui/src/library.rs#L3110), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3626`](../ui/dr-ui/src/library_ui.rs#L3626), [`ui/dr-ui/src/library_ui.rs:4592`](../ui/dr-ui/src/library_ui.rs#L4592), [`ui/dr-ui/ui/app.slint:547`](../ui/dr-ui/ui/app.slint#L547), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) |
|
||||||
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
|
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
|
||||||
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) |
|
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:489`](../ui/dr-ui/src/import.rs#L489) |
|
||||||
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
| FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) |
|
||||||
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/schema.rs:956`](../core/dr-catalog/src/schema.rs#L956), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1773`](../ui/dr-ui/src/lib.rs#L1773), [`ui/dr-ui/src/lib.rs:2651`](../ui/dr-ui/src/lib.rs#L2651), [`ui/dr-ui/src/library.rs:1350`](../ui/dr-ui/src/library.rs#L1350), [`ui/dr-ui/src/library.rs:1373`](../ui/dr-ui/src/library.rs#L1373), [`ui/dr-ui/src/library.rs:1531`](../ui/dr-ui/src/library.rs#L1531), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1531`](../ui/dr-ui/src/library_ui.rs#L1531), [`ui/dr-ui/src/library_ui.rs:2058`](../ui/dr-ui/src/library_ui.rs#L2058), [`ui/dr-ui/src/library_ui.rs:267`](../ui/dr-ui/src/library_ui.rs#L267), [`ui/dr-ui/src/library_ui.rs:278`](../ui/dr-ui/src/library_ui.rs#L278), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:298`](../ui/dr-ui/src/library_ui.rs#L298), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:399`](../ui/dr-ui/src/library_ui.rs#L399), [`ui/dr-ui/src/library_ui.rs:409`](../ui/dr-ui/src/library_ui.rs#L409), [`ui/dr-ui/src/library_ui.rs:451`](../ui/dr-ui/src/library_ui.rs#L451), [`ui/dr-ui/src/library_ui.rs:514`](../ui/dr-ui/src/library_ui.rs#L514), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:5275`](../ui/dr-ui/src/library_ui.rs#L5275), [`ui/dr-ui/src/library_ui.rs:5287`](../ui/dr-ui/src/library_ui.rs#L5287), [`ui/dr-ui/src/library_ui.rs:545`](../ui/dr-ui/src/library_ui.rs#L545), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2796`](../ui/dr-ui/ui/app.slint#L2796), [`ui/dr-ui/ui/app.slint:647`](../ui/dr-ui/ui/app.slint#L647), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:974`](../ui/dr-ui/ui/library.slint#L974) |
|
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-catalog/src/schema.rs:956`](../core/dr-catalog/src/schema.rs#L956), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/lib.rs:1803`](../ui/dr-ui/src/lib.rs#L1803), [`ui/dr-ui/src/lib.rs:2694`](../ui/dr-ui/src/lib.rs#L2694), [`ui/dr-ui/src/library.rs:1350`](../ui/dr-ui/src/library.rs#L1350), [`ui/dr-ui/src/library.rs:1373`](../ui/dr-ui/src/library.rs#L1373), [`ui/dr-ui/src/library.rs:1531`](../ui/dr-ui/src/library.rs#L1531), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/src/library_ui.rs:1239`](../ui/dr-ui/src/library_ui.rs#L1239), [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294), [`ui/dr-ui/src/library_ui.rs:1412`](../ui/dr-ui/src/library_ui.rs#L1412), [`ui/dr-ui/src/library_ui.rs:1531`](../ui/dr-ui/src/library_ui.rs#L1531), [`ui/dr-ui/src/library_ui.rs:2058`](../ui/dr-ui/src/library_ui.rs#L2058), [`ui/dr-ui/src/library_ui.rs:267`](../ui/dr-ui/src/library_ui.rs#L267), [`ui/dr-ui/src/library_ui.rs:278`](../ui/dr-ui/src/library_ui.rs#L278), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:298`](../ui/dr-ui/src/library_ui.rs#L298), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:399`](../ui/dr-ui/src/library_ui.rs#L399), [`ui/dr-ui/src/library_ui.rs:409`](../ui/dr-ui/src/library_ui.rs#L409), [`ui/dr-ui/src/library_ui.rs:451`](../ui/dr-ui/src/library_ui.rs#L451), [`ui/dr-ui/src/library_ui.rs:514`](../ui/dr-ui/src/library_ui.rs#L514), [`ui/dr-ui/src/library_ui.rs:5257`](../ui/dr-ui/src/library_ui.rs#L5257), [`ui/dr-ui/src/library_ui.rs:5275`](../ui/dr-ui/src/library_ui.rs#L5275), [`ui/dr-ui/src/library_ui.rs:5287`](../ui/dr-ui/src/library_ui.rs#L5287), [`ui/dr-ui/src/library_ui.rs:545`](../ui/dr-ui/src/library_ui.rs#L545), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/app.slint:2809`](../ui/dr-ui/ui/app.slint#L2809), [`ui/dr-ui/ui/app.slint:654`](../ui/dr-ui/ui/app.slint#L654), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:974`](../ui/dr-ui/ui/library.slint#L974) |
|
||||||
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) |
|
| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1294`](../ui/dr-ui/src/library_ui.rs#L1294) |
|
||||||
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
|
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:119`](../core/dr-types/src/lib.rs#L119), [`core/dr-types/src/lib.rs:201`](../core/dr-types/src/lib.rs#L201), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:3195`](../ui/dr-ui/src/collections_ui.rs#L3195), [`ui/dr-ui/src/collections_ui.rs:621`](../ui/dr-ui/src/collections_ui.rs#L621), [`ui/dr-ui/src/collections_ui.rs:683`](../ui/dr-ui/src/collections_ui.rs#L683), [`ui/dr-ui/src/library_ui.rs:1065`](../ui/dr-ui/src/library_ui.rs#L1065), [`ui/dr-ui/src/library_ui.rs:1138`](../ui/dr-ui/src/library_ui.rs#L1138), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) |
|
||||||
| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3108`](../ui/dr-ui/src/library.rs#L3108), [`ui/dr-ui/src/library_ui.rs:3626`](../ui/dr-ui/src/library_ui.rs#L3626), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353) |
|
| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3110`](../ui/dr-ui/src/library.rs#L3110), [`ui/dr-ui/src/library_ui.rs:3626`](../ui/dr-ui/src/library_ui.rs#L3626), [`ui/dr-ui/ui/settings.slint:372`](../ui/dr-ui/ui/settings.slint#L372) |
|
||||||
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1108`](../ui/dr-ui/src/lib.rs#L1108), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:116`](../core/dr-types/src/settings.rs#L116), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1110`](../ui/dr-ui/src/lib.rs#L1110), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||||
| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:123`](../ui/dr-ui/src/import.rs#L123), [`ui/dr-ui/src/import.rs:337`](../ui/dr-ui/src/import.rs#L337), [`ui/dr-ui/src/import.rs:585`](../ui/dr-ui/src/import.rs#L585), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1108`](../ui/dr-ui/src/lib.rs#L1108) |
|
| 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:1110`](../ui/dr-ui/src/lib.rs#L1110) |
|
||||||
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1742`](../ui/dr-ui/src/lib.rs#L1742), [`ui/dr-ui/src/library.rs:375`](../ui/dr-ui/src/library.rs#L375), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466) |
|
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:118`](../core/dr-pipeline/src/sidecar.rs#L118), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1772`](../ui/dr-ui/src/lib.rs#L1772), [`ui/dr-ui/src/library.rs:375`](../ui/dr-ui/src/library.rs#L375), [`ui/dr-ui/src/library_ui.rs:466`](../ui/dr-ui/src/library_ui.rs#L466) |
|
||||||
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2024`](../core/dr-pipeline/src/sidecar.rs#L2024), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:761`](../ui/dr-ui/src/library.rs#L761), [`ui/dr-ui/src/library.rs:891`](../ui/dr-ui/src/library.rs#L891) |
|
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:498`](../core/dr-catalog/src/schema.rs#L498), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:183`](../core/dr-pipeline/src/sidecar.rs#L183), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:352`](../core/dr-pipeline/src/sidecar.rs#L352), [`core/dr-pipeline/src/sidecar.rs:450`](../core/dr-pipeline/src/sidecar.rs#L450), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`ui/dr-ui/src/library.rs:761`](../ui/dr-ui/src/library.rs#L761), [`ui/dr-ui/src/library.rs:891`](../ui/dr-ui/src/library.rs#L891) |
|
||||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
|
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) |
|
||||||
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
|
||||||
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:845`](../ui/dr-ui/src/lib.rs#L845), [`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), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`ui/dr-ui/src/lib.rs:847`](../ui/dr-ui/src/lib.rs#L847), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||||
|
| FR-PLAT-LIN-2 | [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/display/wayland.rs:1`](../platform/dr-plat/src/display/wayland.rs#L1), [`platform/dr-plat/src/display/x11.rs:1`](../platform/dr-plat/src/display/x11.rs#L1) |
|
||||||
|
| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) |
|
||||||
|
| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) |
|
||||||
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) |
|
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) |
|
||||||
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) |
|
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) |
|
||||||
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:186`](../ui/dr-ui/src/lib.rs#L186) |
|
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:188`](../ui/dr-ui/src/lib.rs#L188) |
|
||||||
| 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:2733`](../ui/dr-ui/src/lib.rs#L2733), [`ui/dr-ui/src/lib.rs:78`](../ui/dr-ui/src/lib.rs#L78), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:1040`](../ui/dr-ui/ui/library.slint#L1040) |
|
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2776`](../ui/dr-ui/src/lib.rs#L2776), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/ui/library.slint:1040`](../ui/dr-ui/ui/library.slint#L1040) |
|
||||||
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:78`](../ui/dr-ui/src/lib.rs#L78), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:5142`](../ui/dr-ui/src/library_ui.rs#L5142), [`ui/dr-ui/src/library_ui.rs:5287`](../ui/dr-ui/src/library_ui.rs#L5287), [`ui/dr-ui/src/library_ui.rs:6363`](../ui/dr-ui/src/library_ui.rs#L6363), [`ui/dr-ui/ui/app.slint:2463`](../ui/dr-ui/ui/app.slint#L2463), [`ui/dr-ui/ui/app.slint:278`](../ui/dr-ui/ui/app.slint#L278), [`ui/dr-ui/ui/app.slint:677`](../ui/dr-ui/ui/app.slint#L677), [`ui/dr-ui/ui/app.slint:684`](../ui/dr-ui/ui/app.slint#L684), [`ui/dr-ui/ui/library.slint:1196`](../ui/dr-ui/ui/library.slint#L1196), [`ui/dr-ui/ui/library.slint:1203`](../ui/dr-ui/ui/library.slint#L1203), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2278`](../ui/dr-ui/ui/library.slint#L2278), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106) |
|
| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/lib.rs:80`](../ui/dr-ui/src/lib.rs#L80), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:5142`](../ui/dr-ui/src/library_ui.rs#L5142), [`ui/dr-ui/src/library_ui.rs:5287`](../ui/dr-ui/src/library_ui.rs#L5287), [`ui/dr-ui/src/library_ui.rs:6363`](../ui/dr-ui/src/library_ui.rs#L6363), [`ui/dr-ui/ui/app.slint:2476`](../ui/dr-ui/ui/app.slint#L2476), [`ui/dr-ui/ui/app.slint:285`](../ui/dr-ui/ui/app.slint#L285), [`ui/dr-ui/ui/app.slint:684`](../ui/dr-ui/ui/app.slint#L684), [`ui/dr-ui/ui/app.slint:691`](../ui/dr-ui/ui/app.slint#L691), [`ui/dr-ui/ui/library.slint:1196`](../ui/dr-ui/ui/library.slint#L1196), [`ui/dr-ui/ui/library.slint:1203`](../ui/dr-ui/ui/library.slint#L1203), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2278`](../ui/dr-ui/ui/library.slint#L2278), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/settings.slint:106`](../ui/dr-ui/ui/settings.slint#L106) |
|
||||||
| FR-UI-3 | [`ui/dr-ui/src/develop.rs:2056`](../ui/dr-ui/src/develop.rs#L2056), [`ui/dr-ui/src/develop.rs:2165`](../ui/dr-ui/src/develop.rs#L2165), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2105`](../ui/dr-ui/ui/app.slint#L2105), [`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:2073`](../ui/dr-ui/src/develop.rs#L2073), [`ui/dr-ui/src/develop.rs:2182`](../ui/dr-ui/src/develop.rs#L2182), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:908`](../ui/dr-ui/src/masks_ui.rs#L908), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/spots_ui.rs:19`](../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2118`](../ui/dr-ui/ui/app.slint#L2118), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) |
|
||||||
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/library_ui.rs:4432`](../ui/dr-ui/src/library_ui.rs#L4432), [`ui/dr-ui/src/library_ui.rs:4535`](../ui/dr-ui/src/library_ui.rs#L4535), [`ui/dr-ui/src/library_ui.rs:4563`](../ui/dr-ui/src/library_ui.rs#L4563), [`ui/dr-ui/src/library_ui.rs:5275`](../ui/dr-ui/src/library_ui.rs#L5275), [`ui/dr-ui/src/library_ui.rs:5287`](../ui/dr-ui/src/library_ui.rs#L5287), [`ui/dr-ui/ui/app.slint:1777`](../ui/dr-ui/ui/app.slint#L1777), [`ui/dr-ui/ui/app.slint:653`](../ui/dr-ui/ui/app.slint#L653), [`ui/dr-ui/ui/app.slint:684`](../ui/dr-ui/ui/app.slint#L684), [`ui/dr-ui/ui/library.slint:1196`](../ui/dr-ui/ui/library.slint#L1196), [`ui/dr-ui/ui/library.slint:1203`](../ui/dr-ui/ui/library.slint#L1203), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2278`](../ui/dr-ui/ui/library.slint#L2278), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) |
|
| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:1005`](../ui/dr-ui/src/collections_ui.rs#L1005), [`ui/dr-ui/src/collections_ui.rs:118`](../ui/dr-ui/src/collections_ui.rs#L118), [`ui/dr-ui/src/collections_ui.rs:131`](../ui/dr-ui/src/collections_ui.rs#L131), [`ui/dr-ui/src/collections_ui.rs:1508`](../ui/dr-ui/src/collections_ui.rs#L1508), [`ui/dr-ui/src/collections_ui.rs:1522`](../ui/dr-ui/src/collections_ui.rs#L1522), [`ui/dr-ui/src/collections_ui.rs:1568`](../ui/dr-ui/src/collections_ui.rs#L1568), [`ui/dr-ui/src/collections_ui.rs:159`](../ui/dr-ui/src/collections_ui.rs#L159), [`ui/dr-ui/src/collections_ui.rs:1666`](../ui/dr-ui/src/collections_ui.rs#L1666), [`ui/dr-ui/src/collections_ui.rs:1693`](../ui/dr-ui/src/collections_ui.rs#L1693), [`ui/dr-ui/src/collections_ui.rs:485`](../ui/dr-ui/src/collections_ui.rs#L485), [`ui/dr-ui/src/collections_ui.rs:514`](../ui/dr-ui/src/collections_ui.rs#L514), [`ui/dr-ui/src/collections_ui.rs:582`](../ui/dr-ui/src/collections_ui.rs#L582), [`ui/dr-ui/src/collections_ui.rs:995`](../ui/dr-ui/src/collections_ui.rs#L995), [`ui/dr-ui/src/library_ui.rs:4387`](../ui/dr-ui/src/library_ui.rs#L4387), [`ui/dr-ui/src/library_ui.rs:4432`](../ui/dr-ui/src/library_ui.rs#L4432), [`ui/dr-ui/src/library_ui.rs:4535`](../ui/dr-ui/src/library_ui.rs#L4535), [`ui/dr-ui/src/library_ui.rs:4563`](../ui/dr-ui/src/library_ui.rs#L4563), [`ui/dr-ui/src/library_ui.rs:5275`](../ui/dr-ui/src/library_ui.rs#L5275), [`ui/dr-ui/src/library_ui.rs:5287`](../ui/dr-ui/src/library_ui.rs#L5287), [`ui/dr-ui/ui/app.slint:1790`](../ui/dr-ui/ui/app.slint#L1790), [`ui/dr-ui/ui/app.slint:660`](../ui/dr-ui/ui/app.slint#L660), [`ui/dr-ui/ui/app.slint:691`](../ui/dr-ui/ui/app.slint#L691), [`ui/dr-ui/ui/library.slint:1196`](../ui/dr-ui/ui/library.slint#L1196), [`ui/dr-ui/ui/library.slint:1203`](../ui/dr-ui/ui/library.slint#L1203), [`ui/dr-ui/ui/library.slint:1209`](../ui/dr-ui/ui/library.slint#L1209), [`ui/dr-ui/ui/library.slint:2278`](../ui/dr-ui/ui/library.slint#L2278), [`ui/dr-ui/ui/library.slint:823`](../ui/dr-ui/ui/library.slint#L823), [`ui/dr-ui/ui/library.slint:873`](../ui/dr-ui/ui/library.slint#L873), [`ui/dr-ui/ui/library.slint:892`](../ui/dr-ui/ui/library.slint#L892) |
|
||||||
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2360`](../ui/dr-ui/src/lib.rs#L2360), [`ui/dr-ui/src/lib.rs:2767`](../ui/dr-ui/src/lib.rs#L2767), [`ui/dr-ui/src/lib.rs:2952`](../ui/dr-ui/src/lib.rs#L2952), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:313`](../ui/dr-ui/ui/app.slint#L313), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
|
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2390`](../ui/dr-ui/src/lib.rs#L2390), [`ui/dr-ui/src/lib.rs:2810`](../ui/dr-ui/src/lib.rs#L2810), [`ui/dr-ui/src/lib.rs:2995`](../ui/dr-ui/src/lib.rs#L2995), [`ui/dr-ui/src/masks_ui.rs:863`](../ui/dr-ui/src/masks_ui.rs#L863), [`ui/dr-ui/ui/app.slint:320`](../ui/dr-ui/ui/app.slint#L320), [`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:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262) |
|
||||||
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2744`](../ui/dr-ui/src/library.rs#L2744) |
|
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:2744`](../ui/dr-ui/src/library.rs#L2744) |
|
||||||
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:2078`](../ui/dr-ui/src/lib.rs#L2078), [`ui/dr-ui/ui/app.slint:1131`](../ui/dr-ui/ui/app.slint#L1131), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) |
|
| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1555`](../ui/dr-ui/src/export.rs#L1555), [`ui/dr-ui/src/export.rs:1581`](../ui/dr-ui/src/export.rs#L1581), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:2108`](../ui/dr-ui/src/lib.rs#L2108), [`ui/dr-ui/ui/app.slint:1138`](../ui/dr-ui/ui/app.slint#L1138), [`ui/dr-ui/ui/library.slint:928`](../ui/dr-ui/ui/library.slint#L928) |
|
||||||
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) |
|
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`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:121`](../core/dr-decode/src/preview.rs#L121) |
|
| NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
|
||||||
| NFR-P5 | [`core/dr-catalog/src/schema.rs:304`](../core/dr-catalog/src/schema.rs#L304), [`ui/dr-ui/src/library.rs:3708`](../ui/dr-ui/src/library.rs#L3708), [`ui/dr-ui/src/library.rs:4481`](../ui/dr-ui/src/library.rs#L4481), [`ui/dr-ui/src/library_ui.rs:4072`](../ui/dr-ui/src/library_ui.rs#L4072), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) |
|
| NFR-P5 | [`core/dr-catalog/src/schema.rs:304`](../core/dr-catalog/src/schema.rs#L304), [`ui/dr-ui/src/library.rs:3710`](../ui/dr-ui/src/library.rs#L3710), [`ui/dr-ui/src/library.rs:4483`](../ui/dr-ui/src/library.rs#L4483), [`ui/dr-ui/src/library_ui.rs:4072`](../ui/dr-ui/src/library_ui.rs#L4072), [`ui/dr-ui/src/library_ui.rs:64`](../ui/dr-ui/src/library_ui.rs#L64) |
|
||||||
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
|
| NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/export.rs:944`](../ui/dr-ui/src/export.rs#L944), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) |
|
||||||
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302) |
|
| NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`platform/dr-plat/src/display.rs:1`](../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62) |
|
||||||
|
| NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1) |
|
||||||
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:964`](../ui/dr-ui/src/library.rs#L964) |
|
| NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:964`](../ui/dr-ui/src/library.rs#L964) |
|
||||||
| 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:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:70`](../ui/dr-ui/src/lib.rs#L70) |
|
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:72`](../ui/dr-ui/src/lib.rs#L72) |
|
||||||
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2639`](../ui/dr-ui/src/library.rs#L2639) |
|
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/schema.rs:555`](../core/dr-catalog/src/schema.rs#L555), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/library.rs:2639`](../ui/dr-ui/src/library.rs#L2639) |
|
||||||
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
|
||||||
|
| NFR-SEC-2 | [`platform/dr-plat/src/secrets.rs:82`](../platform/dr-plat/src/secrets.rs#L82) |
|
||||||
| NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
| NFR-SEC-5 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) |
|
||||||
| 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) |
|
||||||
|
|
||||||
## Not yet tagged
|
## Not yet tagged
|
||||||
|
|
||||||
79 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
71 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
|
||||||
|
|
||||||
<details><summary>Show untagged requirements</summary>
|
<details><summary>Show untagged requirements</summary>
|
||||||
|
|
||||||
@@ -146,27 +154,21 @@ _None._
|
|||||||
- FR-DEV-1
|
- FR-DEV-1
|
||||||
- FR-DEV-3g
|
- FR-DEV-3g
|
||||||
- FR-DSP-2
|
- FR-DSP-2
|
||||||
- FR-DSP-3
|
|
||||||
- FR-DSP-4
|
- FR-DSP-4
|
||||||
- FR-DSP-5
|
|
||||||
- FR-DSP-8
|
|
||||||
- FR-NC-11
|
- FR-NC-11
|
||||||
- FR-PLAT-AND-2
|
- FR-PLAT-AND-2
|
||||||
- FR-PLAT-AND-4
|
- FR-PLAT-AND-4
|
||||||
- FR-PLAT-AND-5
|
- FR-PLAT-AND-5
|
||||||
- FR-PLAT-AND-6
|
- FR-PLAT-AND-6
|
||||||
- FR-PLAT-LIN-2
|
|
||||||
- FR-PLAT-LIN-3
|
- FR-PLAT-LIN-3
|
||||||
- FR-PLG-1
|
- FR-PLG-1
|
||||||
- FR-PLG-10
|
- FR-PLG-10
|
||||||
- FR-PLG-11
|
- FR-PLG-11
|
||||||
- FR-PLG-12
|
- FR-PLG-12
|
||||||
- FR-PLG-1a
|
- FR-PLG-1a
|
||||||
- FR-PLG-2
|
|
||||||
- FR-PLG-2a
|
- FR-PLG-2a
|
||||||
- FR-PLG-2b
|
- FR-PLG-2b
|
||||||
- FR-PLG-2c
|
- FR-PLG-2c
|
||||||
- FR-PLG-2d
|
|
||||||
- FR-PLG-3
|
- FR-PLG-3
|
||||||
- FR-PLG-3a
|
- FR-PLG-3a
|
||||||
- FR-PLG-4
|
- FR-PLG-4
|
||||||
@@ -203,13 +205,11 @@ _None._
|
|||||||
- NFR-P7
|
- NFR-P7
|
||||||
- NFR-P8
|
- NFR-P8
|
||||||
- NFR-PORT-2
|
- NFR-PORT-2
|
||||||
- NFR-PORT-3
|
|
||||||
- NFR-R3
|
- NFR-R3
|
||||||
- NFR-R4
|
- NFR-R4
|
||||||
- NFR-R6
|
- NFR-R6
|
||||||
- NFR-RES-2
|
- NFR-RES-2
|
||||||
- NFR-RES-3
|
- NFR-RES-3
|
||||||
- NFR-SEC-2
|
|
||||||
- NFR-SEC-3
|
- NFR-SEC-3
|
||||||
- NFR-SEC-4
|
- NFR-SEC-4
|
||||||
- NFR-SEC-6
|
- NFR-SEC-6
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ log.workspace = true
|
|||||||
|
|
||||||
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies]
|
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies]
|
||||||
keyring.workspace = true
|
keyring.workspace = true
|
||||||
|
# The two display servers FR-PLAT-LIN-2 names, each asked for the profile it
|
||||||
|
# knows how to state (FR-DSP-8, `display.rs`). Both are already in the tree
|
||||||
|
# via winit, so this adds compilation of nothing that was not already built.
|
||||||
|
x11rb.workspace = true
|
||||||
|
wayland-client.workspace = true
|
||||||
|
wayland-protocols.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
android-native-keyring-store.workspace = true
|
android-native-keyring-store.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//! What this machine's display server will say about its monitors.
|
||||||
|
//!
|
||||||
|
//! The same probe the application runs at startup, printed instead of used.
|
||||||
|
//! It exists for the reason `keyring_check` does: the answer depends entirely
|
||||||
|
//! on the desktop the code is running on, so the only way to know which
|
||||||
|
//! acquisition path a given machine takes is to ask it there.
|
||||||
|
//!
|
||||||
|
//! cargo run -p dr-plat --example display_probe
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
env_logger::init();
|
||||||
|
|
||||||
|
let survey = dr_plat::DisplaySurvey::probe();
|
||||||
|
println!("display server: {}", survey.server.label());
|
||||||
|
println!();
|
||||||
|
for (index, display) in survey.displays.iter().enumerate() {
|
||||||
|
println!("[{index}] {}", display.name);
|
||||||
|
println!(" space: {}", display.profile.space.label());
|
||||||
|
println!(" reading: {}", display.profile.describe());
|
||||||
|
println!(
|
||||||
|
" source: {:?} measured: {} approximated: {}",
|
||||||
|
display.profile.source,
|
||||||
|
display.profile.source.is_measured(),
|
||||||
|
display.profile.approximated
|
||||||
|
);
|
||||||
|
match display.bounds {
|
||||||
|
Some(b) => println!(
|
||||||
|
" bounds: {}×{} at ({}, {})",
|
||||||
|
b.width, b.height, b.x, b.y
|
||||||
|
),
|
||||||
|
// Expected on Wayland; see `DisplaySurvey::containing`.
|
||||||
|
None => println!(" bounds: not stated by this display server"),
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2 | NFR-PORT-1
|
||||||
|
//! What colour the screen in front of the photographer actually is.
|
||||||
|
//!
|
||||||
|
//! The display path is colour-managed by encoding the render into the output
|
||||||
|
//! device's space (FR-DSP-6); that space is a parameter of composition, so
|
||||||
|
//! getting it right is a question of *asking the platform* rather than of
|
||||||
|
//! doing anything to the pipeline. This module is the asking.
|
||||||
|
//!
|
||||||
|
//! # Why this is a correctness problem and not a polish one
|
||||||
|
//!
|
||||||
|
//! FR-DSP-8 says so itself: "on a multi-monitor desktop with differing
|
||||||
|
//! profiles, showing wrong colours on the second display is a correctness
|
||||||
|
//! defect, not a polish item". Every other display requirement fails by being
|
||||||
|
//! slow. This one fails by being *quietly wrong* — a photographer grades a
|
||||||
|
//! skin tone on a wide-gamut panel, the app encodes sRGB into it, and the
|
||||||
|
//! result is oversaturated on screen and correct in the file, or the reverse.
|
||||||
|
//! Nothing about the image looks broken. That is the whole danger.
|
||||||
|
//!
|
||||||
|
//! # Acquisition is stated per display server, because it differs entirely
|
||||||
|
//!
|
||||||
|
//! - **X11** publishes ICC profiles as root-window properties: `_ICC_PROFILE`
|
||||||
|
//! for the first output and `_ICC_PROFILE_<n>` for the rest, a convention
|
||||||
|
//! from the ICC Profiles in X specification that colord, xiccd and
|
||||||
|
//! `xcalib` all write to. The bytes are a whole ICC profile.
|
||||||
|
//! - **Wayland** has no such back door — a client cannot read another
|
||||||
|
//! client's or the compositor's state — and the protocol that replaces it,
|
||||||
|
//! `wp_color_manager_v1`, is still *staging* upstream. Where the compositor
|
||||||
|
//! advertises it we ask it; where it does not, there is no mechanism at all
|
||||||
|
//! and we say so.
|
||||||
|
//!
|
||||||
|
//! FR-DSP-8 anticipates exactly that gap and demands "a defined fallback where
|
||||||
|
//! Wayland provides no profile". The fallback is sRGB, and the requirement's
|
||||||
|
//! wording — *defined*, not *silent* — is why [`ProfileSource`] carries the
|
||||||
|
//! reason as data rather than logging it: the About page renders it, so a
|
||||||
|
//! photographer being shown sRGB because the compositor would not say
|
||||||
|
//! otherwise can find that out rather than wonder.
|
||||||
|
//!
|
||||||
|
//! # Four spaces, and no ICC engine
|
||||||
|
//!
|
||||||
|
//! The pipeline can encode into four spaces ([`ColourSpace`]). A real display
|
||||||
|
//! profile is a measurement of one panel and is none of them. Rather than
|
||||||
|
//! grow a general ICC transform engine — a much larger piece of work, with a
|
||||||
|
//! CMM, rendering intents, LUT-based profiles and a per-frame cost to argue
|
||||||
|
//! about — this reduces the profile to its primaries and picks the nearest of
|
||||||
|
//! the four, and then *says that it did*. See [`nearest_space`].
|
||||||
|
//!
|
||||||
|
//! An approximation the photographer can see beats an exact answer they
|
||||||
|
//! cannot, and beats a silent approximation by a much larger margin.
|
||||||
|
|
||||||
|
use dr_types::colour::Chromaticities;
|
||||||
|
use dr_types::ColourSpace;
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "android")))]
|
||||||
|
mod icc;
|
||||||
|
#[cfg(all(unix, not(target_os = "android")))]
|
||||||
|
mod wayland;
|
||||||
|
#[cfg(all(unix, not(target_os = "android")))]
|
||||||
|
mod x11;
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "android")))]
|
||||||
|
pub use icc::{read_profile, IccSummary};
|
||||||
|
|
||||||
|
/// Which display server the session is running on.
|
||||||
|
///
|
||||||
|
/// Decided from the environment rather than by trying to connect, because the
|
||||||
|
/// answer decides *which* connection to attempt: `DISPLAY` is set inside a
|
||||||
|
/// Wayland session too (Xwayland sets it), so probing X11 first would put
|
||||||
|
/// every GNOME and KDE user on the X11 path and read an Xwayland root window
|
||||||
|
/// that no colour manager necessarily writes to.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DisplayServer {
|
||||||
|
Wayland,
|
||||||
|
X11,
|
||||||
|
/// Neither — a headless test runner, Android, or a build with no display
|
||||||
|
/// integration. Not an error: it is the case the fallback exists for.
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayServer {
|
||||||
|
/// What the session says it is.
|
||||||
|
pub fn detect() -> Self {
|
||||||
|
// `WAYLAND_DISPLAY` first and unconditionally. See the type comment:
|
||||||
|
// both variables are set in a Wayland session, and only this order
|
||||||
|
// gets such a session onto the path its compositor actually owns.
|
||||||
|
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||||
|
Self::Wayland
|
||||||
|
} else if std::env::var_os("DISPLAY").is_some() {
|
||||||
|
Self::X11
|
||||||
|
} else {
|
||||||
|
Self::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Wayland => "Wayland",
|
||||||
|
Self::X11 => "X11",
|
||||||
|
Self::None => "no display server",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a display ended up on the sRGB fallback.
|
||||||
|
///
|
||||||
|
/// Carried as data because the About page renders it. FR-DSP-8 asks for a
|
||||||
|
/// *defined* fallback, and a fallback whose reason cannot be shown is
|
||||||
|
/// indistinguishable from a display that genuinely is sRGB.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum FallbackReason {
|
||||||
|
/// Wayland, and the compositor does not advertise `wp_color_manager_v1`.
|
||||||
|
/// The common case today and the one FR-DSP-8's wording exists for.
|
||||||
|
NoWaylandProtocol,
|
||||||
|
/// The compositor or X server was asked and had nothing to say about this
|
||||||
|
/// display — no `_ICC_PROFILE` atom, or an image description that failed.
|
||||||
|
NoProfileForDisplay,
|
||||||
|
/// Something was found and could not be read. Distinguished from "nothing
|
||||||
|
/// was there" because it is a bug report rather than a configuration.
|
||||||
|
Unreadable(String),
|
||||||
|
/// The connection itself failed, or there is no display server.
|
||||||
|
NoConnection(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FallbackReason {
|
||||||
|
/// A phrase for the About page, reading after "sRGB assumed:".
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::NoWaylandProtocol => {
|
||||||
|
"the compositor does not offer colour management".to_string()
|
||||||
|
}
|
||||||
|
Self::NoProfileForDisplay => "no profile is assigned to this display".to_string(),
|
||||||
|
Self::Unreadable(why) => format!("the profile could not be read ({why})"),
|
||||||
|
Self::NoConnection(why) => format!("no display server ({why})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a display's colour was established.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum ProfileSource {
|
||||||
|
/// An ICC profile read from an X11 root-window property, named here so a
|
||||||
|
/// bug report can quote which one.
|
||||||
|
X11RootProperty(String),
|
||||||
|
/// The compositor's own answer, over `wp_color_manager_v1`.
|
||||||
|
WaylandColourManagement,
|
||||||
|
/// FR-DSP-8's stated fallback.
|
||||||
|
FallbackSrgb(FallbackReason),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileSource {
|
||||||
|
/// Whether this is a profile the platform stated, rather than an assumption.
|
||||||
|
pub fn is_measured(&self) -> bool {
|
||||||
|
!matches!(self, Self::FallbackSrgb(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short phrase naming the acquisition path, for the About page.
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::X11RootProperty(atom) => format!("X11 {atom}"),
|
||||||
|
Self::WaylandColourManagement => "Wayland colour management".to_string(),
|
||||||
|
Self::FallbackSrgb(_) => "fallback".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour of one display, reduced to something the pipeline can encode.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DisplayProfile {
|
||||||
|
/// The space to compose the canvas into while this display is showing it.
|
||||||
|
pub space: ColourSpace,
|
||||||
|
/// Where `space` came from.
|
||||||
|
pub source: ProfileSource,
|
||||||
|
/// The profile's own name, where it carried one. A photographer who
|
||||||
|
/// calibrated this panel recognises the string their calibrator wrote,
|
||||||
|
/// which is the quickest way to confirm we are reading the right profile.
|
||||||
|
pub described_as: Option<String>,
|
||||||
|
/// Whether `space` is the *nearest* of the four rather than a match.
|
||||||
|
///
|
||||||
|
/// True for essentially every measured panel, and that is the honest
|
||||||
|
/// answer rather than a defect: a calibration profile describes one piece
|
||||||
|
/// of glass and no standard space describes it exactly.
|
||||||
|
pub approximated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayProfile {
|
||||||
|
/// The sRGB fallback FR-DSP-8 defines, carrying why it was reached.
|
||||||
|
pub fn fallback(reason: FallbackReason) -> Self {
|
||||||
|
Self {
|
||||||
|
space: ColourSpace::Srgb,
|
||||||
|
source: ProfileSource::FallbackSrgb(reason),
|
||||||
|
described_as: None,
|
||||||
|
// Not an approximation of anything. sRGB is being *assumed*, which
|
||||||
|
// is a different claim and reads differently on the About page.
|
||||||
|
approximated: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line for the About page: the space, then how we know.
|
||||||
|
///
|
||||||
|
/// Written here rather than in the interface because the three cases —
|
||||||
|
/// measured and exact, measured and approximated, assumed — differ in
|
||||||
|
/// what they are claiming, and a format string assembled in Slint would
|
||||||
|
/// lose that distinction the first time someone edited it.
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
let space = self.space.label();
|
||||||
|
match &self.source {
|
||||||
|
ProfileSource::FallbackSrgb(reason) => {
|
||||||
|
format!("{space} assumed — {}", reason.describe())
|
||||||
|
}
|
||||||
|
source if self.approximated => {
|
||||||
|
let named = self
|
||||||
|
.described_as
|
||||||
|
.as_deref()
|
||||||
|
.map_or_else(String::new, |d| format!("{d}, "));
|
||||||
|
format!("nearest to {space} ({named}via {})", source.label())
|
||||||
|
}
|
||||||
|
source => format!("{space} (via {})", source.label()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A display, as the platform describes it.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct DisplayInfo {
|
||||||
|
/// The connector or output name — "DP-1", "eDP-1" — where the server gave
|
||||||
|
/// one, otherwise an index. Shown in About so a two-monitor user can tell
|
||||||
|
/// which row is which.
|
||||||
|
pub name: String,
|
||||||
|
/// Where this display sits in the desktop's coordinate space, if the
|
||||||
|
/// server said. `None` on Wayland, which deliberately does not tell a
|
||||||
|
/// client where anything is; see [`DisplaySurvey::containing`].
|
||||||
|
pub bounds: Option<Bounds>,
|
||||||
|
pub profile: DisplayProfile,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A display's rectangle in the display server's own screen coordinates.
|
||||||
|
///
|
||||||
|
/// Physical device pixels on X11, which is what RandR states a CRTC's origin
|
||||||
|
/// and size in — and, not by coincidence, the same space Slint reports a
|
||||||
|
/// window's position in. The two are comparable without conversion, and a
|
||||||
|
/// conversion inserted "for tidiness" is how they would stop being.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Bounds {
|
||||||
|
pub x: i32,
|
||||||
|
pub y: i32,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bounds {
|
||||||
|
pub fn contains(&self, x: i32, y: i32) -> bool {
|
||||||
|
x >= self.x
|
||||||
|
&& y >= self.y
|
||||||
|
&& x < self.x.saturating_add_unsigned(self.width)
|
||||||
|
&& y < self.y.saturating_add_unsigned(self.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every display the session has, and how each was asked.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct DisplaySurvey {
|
||||||
|
pub server: DisplayServer,
|
||||||
|
pub displays: Vec<DisplayInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplaySurvey {
|
||||||
|
/// Ask the platform. Never fails: a failure *is* the fallback.
|
||||||
|
///
|
||||||
|
/// Cheap enough to repeat — an X11 property fetch or a Wayland roundtrip —
|
||||||
|
/// but not free, so callers hold the result and re-survey on a display
|
||||||
|
/// change rather than per frame.
|
||||||
|
pub fn probe() -> Self {
|
||||||
|
let server = DisplayServer::detect();
|
||||||
|
#[cfg(all(unix, not(target_os = "android")))]
|
||||||
|
let displays = match server {
|
||||||
|
DisplayServer::Wayland => wayland::probe(),
|
||||||
|
DisplayServer::X11 => x11::probe(),
|
||||||
|
DisplayServer::None => Err(FallbackReason::NoConnection(
|
||||||
|
"neither WAYLAND_DISPLAY nor DISPLAY is set".to_string(),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
// Android draws through the platform compositor and has its own
|
||||||
|
// wide-gamut path (FR-DSP-6); neither display server exists there, so
|
||||||
|
// the crates are not even compiled in. sRGB, stated as such.
|
||||||
|
#[cfg(not(all(unix, not(target_os = "android"))))]
|
||||||
|
let displays: Result<Vec<DisplayInfo>, FallbackReason> = Err(FallbackReason::NoConnection(
|
||||||
|
"no display-server integration on this platform".to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let displays = match displays {
|
||||||
|
// A server that connected but listed nothing is the same situation
|
||||||
|
// as one that would not connect: there is no display to describe,
|
||||||
|
// and the canvas still has to be composed into *something*.
|
||||||
|
Ok(found) if !found.is_empty() => found,
|
||||||
|
Ok(_) => vec![Self::assumed(FallbackReason::NoProfileForDisplay)],
|
||||||
|
Err(reason) => vec![Self::assumed(reason)],
|
||||||
|
};
|
||||||
|
Self { server, displays }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assumed(reason: FallbackReason) -> DisplayInfo {
|
||||||
|
DisplayInfo {
|
||||||
|
name: "display".to_string(),
|
||||||
|
bounds: None,
|
||||||
|
profile: DisplayProfile::fallback(reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A survey with no platform in it, for tests and for the headless case.
|
||||||
|
pub fn assumed_srgb(reason: FallbackReason) -> Self {
|
||||||
|
Self {
|
||||||
|
server: DisplayServer::None,
|
||||||
|
displays: vec![Self::assumed(reason)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which display is showing a window at `(x, y)` in the display server's
|
||||||
|
/// screen coordinates, as an index into [`Self::displays`].
|
||||||
|
///
|
||||||
|
/// **The point is the window's centre, and the caller computes it.** A
|
||||||
|
/// window straddling two monitors is showing more of itself on one of
|
||||||
|
/// them, and its centre is the cheapest statement of which.
|
||||||
|
///
|
||||||
|
/// # Wayland returns 0, and that is the protocol's answer, not a bug
|
||||||
|
///
|
||||||
|
/// A Wayland client is not told where its window is — there is no
|
||||||
|
/// equivalent of `XGetGeometry` against the root, deliberately, and
|
||||||
|
/// `xdg_toplevel` carries no position. The protocol's own answer to "which
|
||||||
|
/// output am I on" is `wl_surface.enter`, or better,
|
||||||
|
/// `wp_color_management_surface_feedback_v1`, which hands the client the
|
||||||
|
/// preferred image description for *its surface* and re-sends it when the
|
||||||
|
/// window moves. Both require the application's `wl_surface`, which Slint
|
||||||
|
/// owns and does not expose, so this falls back to the first display and
|
||||||
|
/// the About page shows every display's profile rather than one.
|
||||||
|
///
|
||||||
|
/// The consequence is worth stating plainly: on a multi-monitor Wayland
|
||||||
|
/// desktop the canvas is composed for the *first* output. That is a
|
||||||
|
/// smaller error than composing for the wrong space entirely — the
|
||||||
|
/// profiles are read correctly, and a single-monitor session, which is
|
||||||
|
/// most of them, is exactly right — and the fix is a Slint surface handle
|
||||||
|
/// rather than anything in this module.
|
||||||
|
pub fn containing(&self, x: i32, y: i32) -> usize {
|
||||||
|
self.displays
|
||||||
|
.iter()
|
||||||
|
.position(|d| d.bounds.is_some_and(|b| b.contains(x, y)))
|
||||||
|
// Off every known rectangle: a window dragged past the edge of the
|
||||||
|
// desktop, or a server that gave no geometry. The first display is
|
||||||
|
// the primary one on both servers we speak to.
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The profile for a display index, tolerating an index that has gone
|
||||||
|
/// stale because a monitor was unplugged between survey and render.
|
||||||
|
pub fn profile(&self, index: usize) -> &DisplayProfile {
|
||||||
|
self.displays
|
||||||
|
.get(index)
|
||||||
|
.or_else(|| self.displays.first())
|
||||||
|
.map(|d| &d.profile)
|
||||||
|
.expect("probe always yields at least one display")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nearest of the four spaces the pipeline can encode, and whether it is
|
||||||
|
/// near enough to call a match.
|
||||||
|
///
|
||||||
|
/// # Compared as colorants, not as chromaticity pairs
|
||||||
|
///
|
||||||
|
/// The obvious comparison — eight xy numbers against eight xy numbers — is
|
||||||
|
/// wrong, and wrong in a way that would pass a casual test. A profile's
|
||||||
|
/// colorants have already been chromatically adapted to the D50 connection
|
||||||
|
/// space; a colour space's *published* chromaticities have not. Comparing the
|
||||||
|
/// two directly makes every D65 space look like it has the wrong white point
|
||||||
|
/// and pushes matches towards ProPhoto, the only D50 member of the four.
|
||||||
|
///
|
||||||
|
/// So both sides go through [`Chromaticities::to_pcs_xyz`] and are compared as
|
||||||
|
/// the nine numbers a matrix/TRC profile would carry. That is the same
|
||||||
|
/// reduction `dr-export` writes into a file, which means a profile this
|
||||||
|
/// pipeline produced round-trips back to the space it was produced from — the
|
||||||
|
/// test at the bottom of this module.
|
||||||
|
///
|
||||||
|
/// # Primaries and not the tone curve
|
||||||
|
///
|
||||||
|
/// The four spaces are distinguished by gamut first: sRGB and Display P3
|
||||||
|
/// share a transfer function entirely. A measured panel's TRC is a tabulated
|
||||||
|
/// curve fitted to a target gamma and matching it against three analytic
|
||||||
|
/// curves would decide nothing the primaries have not already decided. The
|
||||||
|
/// transfer function comes along with whichever space the primaries choose,
|
||||||
|
/// which is the right pairing — encoding P3 primaries through an Adobe RGB
|
||||||
|
/// gamma would be a space nobody has.
|
||||||
|
pub fn nearest_space(measured: &Chromaticities) -> (ColourSpace, bool) {
|
||||||
|
nearest_by_colorants(&measured.to_pcs_xyz())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`nearest_space`], entered from the nine numbers directly.
|
||||||
|
///
|
||||||
|
/// The two acquisition paths arrive with different halves of the same thing:
|
||||||
|
/// an ICC profile carries D50-adapted colorants and nothing else, while
|
||||||
|
/// Wayland's `primaries` event carries xy chromaticities and no matrix. Both
|
||||||
|
/// reduce to this, so there is one metric and not two that can drift apart.
|
||||||
|
pub fn nearest_by_colorants(want: &[f32; 9]) -> (ColourSpace, bool) {
|
||||||
|
let (space, distance) = ColourSpace::ALL
|
||||||
|
.into_iter()
|
||||||
|
.map(|candidate| {
|
||||||
|
let have = candidate.to_pcs_xyz();
|
||||||
|
let d: f32 = want
|
||||||
|
.iter()
|
||||||
|
.zip(have.iter())
|
||||||
|
.map(|(a, b)| (a - b) * (a - b))
|
||||||
|
.sum();
|
||||||
|
(candidate, d)
|
||||||
|
})
|
||||||
|
// `f32` and no NaN: every input is a finite colorant matrix, so
|
||||||
|
// `total_cmp` here is a formality that avoids `partial_cmp().unwrap()`.
|
||||||
|
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||||
|
.expect("ColourSpace::ALL is not empty");
|
||||||
|
|
||||||
|
// The threshold is a sum of nine squared differences over numbers of order
|
||||||
|
// one, so 1e-6 is roughly "every colorant agrees to three decimal places"
|
||||||
|
// — tight enough that a genuinely different gamut never passes, loose
|
||||||
|
// enough that a profile written from these same numbers and read back
|
||||||
|
// through s15Fixed16 rounding does. It is not a perceptual threshold and
|
||||||
|
// is not trying to be: this decides whether to *say* "approximated", and
|
||||||
|
// saying it when in doubt is the honest direction to err.
|
||||||
|
(space, distance < 1e-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn chromaticities_of(space: ColourSpace) -> Chromaticities {
|
||||||
|
space.chromaticities()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_known_space_recognises_itself_exactly() {
|
||||||
|
for space in ColourSpace::ALL {
|
||||||
|
let (found, exact) = nearest_space(&chromaticities_of(space));
|
||||||
|
assert_eq!(found, space, "{} matched the wrong space", space.label());
|
||||||
|
assert!(exact, "{} was not recognised as itself", space.label());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_panel_between_two_gamuts_is_reported_as_an_approximation() {
|
||||||
|
// A plausible measured wide-gamut panel: near P3's red and green, not
|
||||||
|
// on them. The requirement is not that it picks P3 — it is that it
|
||||||
|
// picks *something* and admits it is not exact.
|
||||||
|
let measured = Chromaticities {
|
||||||
|
red: [0.6790, 0.3190],
|
||||||
|
green: [0.2680, 0.6880],
|
||||||
|
blue: [0.1490, 0.0570],
|
||||||
|
white: [0.3127, 0.3290],
|
||||||
|
};
|
||||||
|
let (found, exact) = nearest_space(&measured);
|
||||||
|
assert_eq!(found, ColourSpace::DisplayP3);
|
||||||
|
assert!(!exact, "a measured panel must not claim to be a standard");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_d50_wide_gamut_panel_does_not_collapse_onto_srgb() {
|
||||||
|
// The failure this guards: comparing raw xy pairs rather than adapted
|
||||||
|
// colorants makes white-point differences dominate, and everything
|
||||||
|
// lands on whichever space shares its illuminant. ProPhoto's gamut is
|
||||||
|
// unmistakable, so if the metric is right this cannot be anything else.
|
||||||
|
let (found, _) = nearest_space(&chromaticities_of(ColourSpace::ProPhoto));
|
||||||
|
assert_eq!(found, ColourSpace::ProPhoto);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adobe_rgb_and_srgb_are_told_apart_despite_sharing_two_primaries() {
|
||||||
|
// Adobe RGB (1998) differs from sRGB in the green corner alone. A
|
||||||
|
// metric summing over all three colorants must still separate them,
|
||||||
|
// or a wide-gamut photo monitor is driven as sRGB.
|
||||||
|
let (found, exact) = nearest_space(&chromaticities_of(ColourSpace::AdobeRgb));
|
||||||
|
assert_eq!(found, ColourSpace::AdobeRgb);
|
||||||
|
assert!(exact);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_fallback_says_srgb_and_says_why() {
|
||||||
|
let profile = DisplayProfile::fallback(FallbackReason::NoWaylandProtocol);
|
||||||
|
assert_eq!(profile.space, ColourSpace::Srgb);
|
||||||
|
assert!(!profile.source.is_measured());
|
||||||
|
let described = profile.describe();
|
||||||
|
assert!(described.contains("sRGB"), "{described}");
|
||||||
|
assert!(described.contains("assumed"), "{described}");
|
||||||
|
// The specific reason has to survive into the string the About page
|
||||||
|
// renders, or the fallback is defined in the code and invisible on
|
||||||
|
// screen — which is the half of FR-DSP-8 that is easy to skip.
|
||||||
|
assert!(described.contains("colour management"), "{described}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_approximated_profile_says_nearest_rather_than_claiming_the_space() {
|
||||||
|
let profile = DisplayProfile {
|
||||||
|
space: ColourSpace::DisplayP3,
|
||||||
|
source: ProfileSource::X11RootProperty("_ICC_PROFILE".to_string()),
|
||||||
|
described_as: Some("Studio Display calibrated".to_string()),
|
||||||
|
approximated: true,
|
||||||
|
};
|
||||||
|
let described = profile.describe();
|
||||||
|
assert!(described.contains("nearest to Display P3"), "{described}");
|
||||||
|
assert!(
|
||||||
|
described.contains("Studio Display calibrated"),
|
||||||
|
"{described}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probing_a_platform_that_cannot_answer_still_yields_a_display() {
|
||||||
|
// Whatever happens, composition needs a space. The one thing this
|
||||||
|
// module may never do is hand back nothing.
|
||||||
|
let survey = DisplaySurvey::probe();
|
||||||
|
assert!(!survey.displays.is_empty());
|
||||||
|
assert_eq!(survey.profile(99).space, survey.displays[0].profile.space);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_window_is_located_on_the_display_its_centre_falls_in() {
|
||||||
|
let survey = DisplaySurvey {
|
||||||
|
server: DisplayServer::X11,
|
||||||
|
displays: vec![
|
||||||
|
DisplayInfo {
|
||||||
|
name: "DP-1".to_string(),
|
||||||
|
bounds: Some(Bounds {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
}),
|
||||||
|
profile: DisplayProfile::fallback(FallbackReason::NoProfileForDisplay),
|
||||||
|
},
|
||||||
|
DisplayInfo {
|
||||||
|
name: "DP-2".to_string(),
|
||||||
|
bounds: Some(Bounds {
|
||||||
|
x: 2560,
|
||||||
|
y: 0,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
}),
|
||||||
|
profile: DisplayProfile {
|
||||||
|
space: ColourSpace::AdobeRgb,
|
||||||
|
source: ProfileSource::X11RootProperty("_ICC_PROFILE_1".to_string()),
|
||||||
|
described_as: None,
|
||||||
|
approximated: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(survey.containing(100, 100), 0);
|
||||||
|
assert_eq!(survey.containing(3000, 500), 1);
|
||||||
|
// The seam: the first pixel of the second monitor belongs to it, and
|
||||||
|
// the last pixel of the first does not. An inclusive upper bound here
|
||||||
|
// would flip the transform one pixel early on every drag across.
|
||||||
|
assert_eq!(survey.containing(2559, 0), 0);
|
||||||
|
assert_eq!(survey.containing(2560, 0), 1);
|
||||||
|
// Dragged off the desktop entirely — the primary, not a panic.
|
||||||
|
assert_eq!(survey.containing(-4000, 0), 0);
|
||||||
|
|
||||||
|
// And the point of the whole exercise: the two displays yield
|
||||||
|
// different output spaces.
|
||||||
|
assert_eq!(survey.profile(0).space, ColourSpace::Srgb);
|
||||||
|
assert_eq!(survey.profile(1).space, ColourSpace::AdobeRgb);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
//! TRACES: FR-DSP-8
|
||||||
|
//! Reading just enough of an ICC profile to say which of four spaces it is.
|
||||||
|
//!
|
||||||
|
//! # Deliberately not a profile parser
|
||||||
|
//!
|
||||||
|
//! ICC.1 is a large specification, and a complete reader of it is the front
|
||||||
|
//! half of a colour management module: LUT-based transforms, rendering
|
||||||
|
//! intents, per-intent tag sets, v2 and v4 divergence. None of that helps
|
||||||
|
//! here, because the pipeline does not *apply* a profile — it encodes into one
|
||||||
|
//! of four spaces it already knows the numbers for (`dr_types::colour`). The
|
||||||
|
//! only question a display profile has to answer is which of the four it is
|
||||||
|
//! nearest to, and three tags answer it.
|
||||||
|
//!
|
||||||
|
//! So this reads the tag table, pulls the three colorants and the description,
|
||||||
|
//! and refuses everything else with a reason the About page can show. A
|
||||||
|
//! profile it refuses is not a failure of the photographer's setup and must
|
||||||
|
//! not read like one; it means "this panel is described in a way we do not
|
||||||
|
//! approximate", and sRGB is assumed as FR-DSP-8 defines.
|
||||||
|
//!
|
||||||
|
//! # What it does read
|
||||||
|
//!
|
||||||
|
//! `rXYZ`, `gXYZ`, `bXYZ` — the three colorant tags of a matrix/TRC profile,
|
||||||
|
//! which are the space's primaries already adapted to the D50 connection
|
||||||
|
//! space. That is the same reduction `ColourSpace::to_pcs_xyz` produces, which
|
||||||
|
//! is what makes the comparison in [`super::nearest_by_colorants`] an
|
||||||
|
//! apples-to-apples one, and what makes a profile this project *wrote*
|
||||||
|
//! round-trip back to the space it was written from.
|
||||||
|
//!
|
||||||
|
//! `desc` (v2) or `mluc` (v4) for the profile's own name, which exists only to
|
||||||
|
//! be shown: a photographer recognises the string their calibrator wrote and
|
||||||
|
//! can tell at a glance whether we are reading the profile they think we are.
|
||||||
|
|
||||||
|
/// What a display profile was reduced to.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct IccSummary {
|
||||||
|
/// The three colorant columns, row-major, in the D50 connection space.
|
||||||
|
pub colorants: [f32; 9],
|
||||||
|
/// The profile's own description, where it carried a readable one.
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ICC header's fixed size. Everything before the tag count.
|
||||||
|
const HEADER: usize = 128;
|
||||||
|
|
||||||
|
/// Reduce a profile to the parts that decide an output space.
|
||||||
|
///
|
||||||
|
/// `Err` carries a phrase for [`super::FallbackReason::Unreadable`], so it is
|
||||||
|
/// written to read after "the profile could not be read (…)" on the About page
|
||||||
|
/// rather than as a developer's error string.
|
||||||
|
pub fn read_profile(bytes: &[u8]) -> Result<IccSummary, String> {
|
||||||
|
if bytes.len() < HEADER + 4 {
|
||||||
|
return Err("it is too short to be a profile".to_string());
|
||||||
|
}
|
||||||
|
// Offset 36 is `acsp`, the profile file signature. Checked because the X11
|
||||||
|
// property is a byte array with no type discipline at all: anything can
|
||||||
|
// write anything to a root window, and a profile-shaped blob that is not
|
||||||
|
// one would otherwise be read as colorants of arbitrary magnitude.
|
||||||
|
if &bytes[36..40] != b"acsp" {
|
||||||
|
return Err("it does not carry the ICC file signature".to_string());
|
||||||
|
}
|
||||||
|
// The header's own length field. A profile whose declared size exceeds the
|
||||||
|
// property is truncated — X11 properties have a fetch length, and reading
|
||||||
|
// colorants out of a half-transferred profile would be silently wrong.
|
||||||
|
let declared = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
|
||||||
|
if declared > bytes.len() {
|
||||||
|
return Err(format!(
|
||||||
|
"it declares {declared} bytes and only {} arrived",
|
||||||
|
bytes.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = be_u32(bytes, HEADER)? as usize;
|
||||||
|
// 12 bytes per tag table entry. The bound is not paranoia: `count` comes
|
||||||
|
// from the profile, and a corrupt one multiplied out is how a reader ends
|
||||||
|
// up indexing megabytes past the end.
|
||||||
|
let table_end = HEADER + 4 + count.checked_mul(12).ok_or("its tag table is absurd")?;
|
||||||
|
if table_end > bytes.len() {
|
||||||
|
return Err("its tag table runs past the end of the profile".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tags: Vec<([u8; 4], usize, usize)> = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let at = HEADER + 4 + i * 12;
|
||||||
|
let sig = [bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]];
|
||||||
|
let offset = be_u32(bytes, at + 4)? as usize;
|
||||||
|
let size = be_u32(bytes, at + 8)? as usize;
|
||||||
|
// Silently skipping a tag that does not fit rather than rejecting the
|
||||||
|
// whole profile: the three we want may all be well-formed while some
|
||||||
|
// fourth tag we will never look at is not.
|
||||||
|
if offset
|
||||||
|
.checked_add(size)
|
||||||
|
.is_some_and(|end| end <= bytes.len())
|
||||||
|
{
|
||||||
|
tags.push((sig, offset, size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let find = |want: &[u8; 4]| {
|
||||||
|
tags.iter()
|
||||||
|
.find(|(sig, _, _)| sig == want)
|
||||||
|
.map(|&(_, offset, size)| &bytes[offset..offset + size])
|
||||||
|
};
|
||||||
|
|
||||||
|
let (Some(r), Some(g), Some(b)) = (find(b"rXYZ"), find(b"gXYZ"), find(b"bXYZ")) else {
|
||||||
|
// The honest description of a LUT-based profile, which is what a
|
||||||
|
// hardware calibrator often produces. It describes the panel more
|
||||||
|
// accurately than a matrix could, and approximating it would need the
|
||||||
|
// CMM this module exists to avoid. Named as a shape rather than as an
|
||||||
|
// error, because nothing is wrong with such a profile.
|
||||||
|
return Err("it is not a matrix/TRC profile".to_string());
|
||||||
|
};
|
||||||
|
let r = xyz_tag(r)?;
|
||||||
|
let g = xyz_tag(g)?;
|
||||||
|
let b = xyz_tag(b)?;
|
||||||
|
|
||||||
|
Ok(IccSummary {
|
||||||
|
// Row-major, columns R/G/B — the layout `to_pcs_xyz` produces, so the
|
||||||
|
// two can be subtracted entry by entry. Transposing one of them is the
|
||||||
|
// single most plausible bug in this file, which is why the round-trip
|
||||||
|
// test asserts a written-then-read profile lands back on its own space
|
||||||
|
// rather than merely on *some* space.
|
||||||
|
colorants: [r[0], g[0], b[0], r[1], g[1], b[1], r[2], g[2], b[2]],
|
||||||
|
description: find(b"desc").and_then(text_tag),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An `XYZType` tag: signature, four reserved bytes, then s15Fixed16 triples.
|
||||||
|
///
|
||||||
|
/// Only the first triple is read. A colorant tag carries exactly one; the
|
||||||
|
/// array form exists for other tags that share the type.
|
||||||
|
fn xyz_tag(data: &[u8]) -> Result<[f32; 3], String> {
|
||||||
|
if data.len() < 20 || &data[0..4] != b"XYZ " {
|
||||||
|
return Err("a colorant tag is not an XYZ value".to_string());
|
||||||
|
}
|
||||||
|
Ok([
|
||||||
|
s15_fixed16(be_i32(data, 8)?),
|
||||||
|
s15_fixed16(be_i32(data, 12)?),
|
||||||
|
s15_fixed16(be_i32(data, 16)?),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A profile's description, from either of the two types that carry one.
|
||||||
|
///
|
||||||
|
/// Returns `None` rather than an error throughout: the name is decoration. A
|
||||||
|
/// profile with unreadable text still has perfectly good colorants, and
|
||||||
|
/// refusing it over a string would put a correctly-described display on the
|
||||||
|
/// fallback for a cosmetic reason.
|
||||||
|
fn text_tag(data: &[u8]) -> Option<String> {
|
||||||
|
match data.get(0..4)? {
|
||||||
|
// ICC v2 `textDescriptionType`: signature, reserved, an ASCII byte
|
||||||
|
// count, then that many bytes with a trailing NUL included in the
|
||||||
|
// count.
|
||||||
|
b"desc" => {
|
||||||
|
let count = be_u32(data, 8).ok()? as usize;
|
||||||
|
let text = data.get(12..12 + count)?;
|
||||||
|
let text = text.split(|&b| b == 0).next().unwrap_or(text);
|
||||||
|
Some(String::from_utf8_lossy(text).trim().to_string()).filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
// ICC v4 `multiLocalizedUnicodeType`: UTF-16BE records. The first
|
||||||
|
// record is taken rather than the one matching the user's locale — a
|
||||||
|
// profile name is an identifier here, shown so it can be recognised,
|
||||||
|
// and picking a locale would be answering a question nobody asked.
|
||||||
|
b"mluc" => {
|
||||||
|
let records = be_u32(data, 8).ok()? as usize;
|
||||||
|
let size = be_u32(data, 12).ok()? as usize;
|
||||||
|
if records == 0 || size < 12 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let length = be_u32(data, 16 + 4).ok()? as usize;
|
||||||
|
let offset = be_u32(data, 16 + 8).ok()? as usize;
|
||||||
|
let raw = data.get(offset..offset + length)?;
|
||||||
|
let units: Vec<u16> = raw
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|p| u16::from_be_bytes([p[0], p[1]]))
|
||||||
|
.collect();
|
||||||
|
Some(String::from_utf16_lossy(&units).trim().to_string()).filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn be_u32(bytes: &[u8], at: usize) -> Result<u32, String> {
|
||||||
|
bytes
|
||||||
|
.get(at..at + 4)
|
||||||
|
.map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
|
||||||
|
.ok_or_else(|| "it ends in the middle of a field".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn be_i32(bytes: &[u8], at: usize) -> Result<i32, String> {
|
||||||
|
be_u32(bytes, at).map(|v| v as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ICC's fixed-point number: sixteen integer bits, sixteen fractional.
|
||||||
|
fn s15_fixed16(v: i32) -> f32 {
|
||||||
|
v as f32 / 65536.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::display::{nearest_by_colorants, nearest_space};
|
||||||
|
use dr_types::ColourSpace;
|
||||||
|
|
||||||
|
/// A minimal matrix/TRC profile carrying one space's colorants.
|
||||||
|
///
|
||||||
|
/// Assembled here rather than taken from `dr-export`, deliberately. That
|
||||||
|
/// crate writes profiles from the same `to_pcs_xyz` this reads back, so a
|
||||||
|
/// round-trip through it would confirm the two halves of *one* set of
|
||||||
|
/// assumptions agree with each other and would still pass if both were
|
||||||
|
/// transposed. Laying the bytes out by hand against ICC.1's field offsets
|
||||||
|
/// is the independent statement.
|
||||||
|
fn profile_for(space: ColourSpace, name: &str) -> Vec<u8> {
|
||||||
|
let m = space.to_pcs_xyz();
|
||||||
|
let colorant = |col: usize| {
|
||||||
|
let mut tag = b"XYZ \0\0\0\0".to_vec();
|
||||||
|
for row in 0..3 {
|
||||||
|
let v = (m[row * 3 + col] * 65536.0).round() as i32;
|
||||||
|
tag.extend_from_slice(&v.to_be_bytes());
|
||||||
|
}
|
||||||
|
tag
|
||||||
|
};
|
||||||
|
let mut desc = b"desc\0\0\0\0".to_vec();
|
||||||
|
let text = format!("{name}\0");
|
||||||
|
desc.extend_from_slice(&(text.len() as u32).to_be_bytes());
|
||||||
|
desc.extend_from_slice(text.as_bytes());
|
||||||
|
|
||||||
|
let tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
|
||||||
|
(b"rXYZ", colorant(0)),
|
||||||
|
(b"gXYZ", colorant(1)),
|
||||||
|
(b"bXYZ", colorant(2)),
|
||||||
|
(b"desc", desc),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut header = vec![0u8; HEADER];
|
||||||
|
header[36..40].copy_from_slice(b"acsp");
|
||||||
|
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||||
|
let mut body = Vec::new();
|
||||||
|
let base = HEADER + 4 + tags.len() * 12;
|
||||||
|
for (sig, data) in &tags {
|
||||||
|
table.extend_from_slice(*sig);
|
||||||
|
table.extend_from_slice(&((base + body.len()) as u32).to_be_bytes());
|
||||||
|
table.extend_from_slice(&(data.len() as u32).to_be_bytes());
|
||||||
|
body.extend_from_slice(data);
|
||||||
|
}
|
||||||
|
let mut out = header;
|
||||||
|
out.extend_from_slice(&table);
|
||||||
|
out.extend_from_slice(&body);
|
||||||
|
let len = out.len() as u32;
|
||||||
|
out[0..4].copy_from_slice(&len.to_be_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_profile_is_recognised_as_the_space_it_describes() {
|
||||||
|
// The whole acquisition path in one assertion: bytes that a colour
|
||||||
|
// manager would publish for a P3 panel must reach the pipeline as
|
||||||
|
// Display P3, and not as sRGB.
|
||||||
|
for space in ColourSpace::ALL {
|
||||||
|
let bytes = profile_for(space, space.label());
|
||||||
|
let summary = read_profile(&bytes).expect("a well-formed profile");
|
||||||
|
let (found, exact) = nearest_by_colorants(&summary.colorants);
|
||||||
|
assert_eq!(found, space, "{} was read as {found:?}", space.label());
|
||||||
|
assert!(
|
||||||
|
exact,
|
||||||
|
"{} did not survive s15Fixed16 rounding",
|
||||||
|
space.label()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_colorant_matrix_is_not_transposed() {
|
||||||
|
// Reading the three tags into rows rather than columns produces a
|
||||||
|
// matrix that is still plausible, still finite, and describes a
|
||||||
|
// different gamut. Adobe RGB is the case that catches it: it differs
|
||||||
|
// from sRGB in one primary, so a transposition moves it onto a space
|
||||||
|
// that is genuinely close and the nearest-match would hide it.
|
||||||
|
let bytes = profile_for(ColourSpace::AdobeRgb, "Adobe RGB");
|
||||||
|
let summary = read_profile(&bytes).expect("a well-formed profile");
|
||||||
|
let want = ColourSpace::AdobeRgb.to_pcs_xyz();
|
||||||
|
|
||||||
|
// Not `assert_eq`: the values went out through s15Fixed16 and came
|
||||||
|
// back, so they agree to about 1e-5 and never exactly. The tolerance
|
||||||
|
// is three orders of magnitude tighter than the smallest off-diagonal
|
||||||
|
// difference below, so it still catches the thing it is here for.
|
||||||
|
for (have, want) in summary.colorants.iter().zip(want.iter()) {
|
||||||
|
assert!((have - want).abs() < 1e-4, "{:?}", summary.colorants);
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the guard that makes the assertion above mean something: a
|
||||||
|
// symmetric matrix would satisfy it transposed as well.
|
||||||
|
assert!(
|
||||||
|
(want[1] - want[3]).abs() > 1e-2,
|
||||||
|
"the colorant matrix must not be symmetric or this proves nothing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_profile_names_itself_where_it_can() {
|
||||||
|
let bytes = profile_for(ColourSpace::Srgb, "EIZO CG279X calibrated 2024-03");
|
||||||
|
let summary = read_profile(&bytes).expect("a well-formed profile");
|
||||||
|
assert_eq!(
|
||||||
|
summary.description.as_deref(),
|
||||||
|
Some("EIZO CG279X calibrated 2024-03")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_v4_profile_names_itself_from_its_unicode_records() {
|
||||||
|
let mut mluc = b"mluc\0\0\0\0".to_vec();
|
||||||
|
let text: Vec<u8> = "LG UltraFine"
|
||||||
|
.encode_utf16()
|
||||||
|
.flat_map(u16::to_be_bytes)
|
||||||
|
.collect();
|
||||||
|
mluc.extend_from_slice(&1u32.to_be_bytes()); // one record
|
||||||
|
mluc.extend_from_slice(&12u32.to_be_bytes()); // record size
|
||||||
|
mluc.extend_from_slice(b"enUS");
|
||||||
|
mluc.extend_from_slice(&(text.len() as u32).to_be_bytes());
|
||||||
|
mluc.extend_from_slice(&28u32.to_be_bytes()); // offset within the tag
|
||||||
|
mluc.extend_from_slice(&text);
|
||||||
|
assert_eq!(text_tag(&mluc).as_deref(), Some("LG UltraFine"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_lut_profile_is_refused_by_shape_and_not_by_crashing() {
|
||||||
|
// A hardware calibrator's `mAB `-based profile. There is nothing wrong
|
||||||
|
// with it; we simply cannot approximate it without the CMM this module
|
||||||
|
// exists to avoid, and the About page has to be able to say so.
|
||||||
|
let mut header = vec![0u8; HEADER];
|
||||||
|
header[36..40].copy_from_slice(b"acsp");
|
||||||
|
header.extend_from_slice(&0u32.to_be_bytes());
|
||||||
|
let len = header.len() as u32;
|
||||||
|
header[0..4].copy_from_slice(&len.to_be_bytes());
|
||||||
|
let err = read_profile(&header).expect_err("no colorants to read");
|
||||||
|
assert!(err.contains("matrix/TRC"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rubbish_on_a_root_window_is_refused_rather_than_read_as_colour() {
|
||||||
|
// An X11 property is untyped and world-writable by convention. None of
|
||||||
|
// these may panic, and none may produce a colour space.
|
||||||
|
assert!(read_profile(&[]).is_err());
|
||||||
|
assert!(read_profile(&[0u8; 200]).is_err());
|
||||||
|
|
||||||
|
let mut truncated = profile_for(ColourSpace::Srgb, "sRGB");
|
||||||
|
truncated.truncate(truncated.len() / 2);
|
||||||
|
assert!(read_profile(&truncated).is_err());
|
||||||
|
|
||||||
|
// A profile whose tag table claims far more tags than there are bytes.
|
||||||
|
let mut absurd = profile_for(ColourSpace::Srgb, "sRGB");
|
||||||
|
absurd[HEADER..HEADER + 4].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||||
|
assert!(read_profile(&absurd).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_two_entry_points_agree() {
|
||||||
|
// `nearest_space` (chromaticities, from Wayland) and
|
||||||
|
// `nearest_by_colorants` (a matrix, from ICC) must not drift apart:
|
||||||
|
// they are the same decision reached from the two halves of the same
|
||||||
|
// definition, and a session that answered differently depending on
|
||||||
|
// which display server it was on would be the worst kind of bug to
|
||||||
|
// reproduce.
|
||||||
|
for space in ColourSpace::ALL {
|
||||||
|
let bytes = profile_for(space, "x");
|
||||||
|
let summary = read_profile(&bytes).expect("a well-formed profile");
|
||||||
|
assert_eq!(
|
||||||
|
nearest_by_colorants(&summary.colorants).0,
|
||||||
|
nearest_space(&space.chromaticities()).0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2
|
||||||
|
//! Acquisition on Wayland: ask the compositor, or admit that we cannot.
|
||||||
|
//!
|
||||||
|
//! # Why there is no back door here
|
||||||
|
//!
|
||||||
|
//! X11's mechanism works because any client may read any root-window
|
||||||
|
//! property. Wayland has no such shared state by design — a client sees its
|
||||||
|
//! own surfaces and nothing else — so the profile has to be *given* to us, and
|
||||||
|
//! the protocol that gives it, `wp_color_manager_v1`, is still staging
|
||||||
|
//! upstream. That is the concrete form of FR-DSP-8's "a defined fallback where
|
||||||
|
//! Wayland provides no profile": on a compositor that does not advertise the
|
||||||
|
//! global there is no mechanism to fall back *from*, and pretending otherwise
|
||||||
|
//! would mean reading the Xwayland root window and presenting a value the
|
||||||
|
//! compositor never agreed to.
|
||||||
|
//!
|
||||||
|
//! So: bind the global if it is there, ask each `wl_output` for its image
|
||||||
|
//! description, and where it is not there return
|
||||||
|
//! [`FallbackReason::NoWaylandProtocol`], which the About page renders in
|
||||||
|
//! words.
|
||||||
|
//!
|
||||||
|
//! # Two forms of the same answer
|
||||||
|
//!
|
||||||
|
//! The compositor may describe an output either by handing over an ICC profile
|
||||||
|
//! on a file descriptor or by stating primaries as CIE xy chromaticities.
|
||||||
|
//! Both are accepted, and both reduce to the same nearest-space decision — see
|
||||||
|
//! `super::nearest_by_colorants` for why that reduction has to happen in one
|
||||||
|
//! place. The ICC form is preferred where both arrive, because it carries the
|
||||||
|
//! profile's own name, which is the string a photographer recognises.
|
||||||
|
//!
|
||||||
|
//! # Position is deliberately not asked for
|
||||||
|
//!
|
||||||
|
//! [`super::DisplaySurvey::containing`] explains the consequence at length:
|
||||||
|
//! `bounds` is `None` on every Wayland output, not because `wl_output` refuses
|
||||||
|
//! to state its position but because a client is never told where its *own*
|
||||||
|
//! window is, so a rectangle to test against would have no point to test.
|
||||||
|
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
|
||||||
|
use wayland_client::protocol::{wl_output, wl_registry};
|
||||||
|
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||||
|
use wayland_protocols::wp::color_management::v1::client::{
|
||||||
|
wp_color_management_output_v1, wp_color_manager_v1, wp_image_description_info_v1,
|
||||||
|
wp_image_description_v1,
|
||||||
|
};
|
||||||
|
|
||||||
|
use dr_types::colour::Chromaticities;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
nearest_by_colorants, nearest_space, DisplayInfo, DisplayProfile, FallbackReason, ProfileSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The highest protocol version this client knows how to receive.
|
||||||
|
///
|
||||||
|
/// Bound as high as the compositor offers rather than at 1, because
|
||||||
|
/// `get_image_description` fails with `low_version` when the client cannot
|
||||||
|
/// receive every event the output's description needs — so asking for less
|
||||||
|
/// than we understand turns a describable display into a fallback.
|
||||||
|
const MAX_MANAGER_VERSION: u32 = 3;
|
||||||
|
|
||||||
|
/// `wl_output.name` arrived in version 4. Below it an output has no stable
|
||||||
|
/// identifier and is listed by index, which is cosmetic only.
|
||||||
|
const MAX_OUTPUT_VERSION: u32 = 4;
|
||||||
|
|
||||||
|
/// What one output told us, accumulated across the events that describe it.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Pending {
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
/// The ICC profile the compositor handed over, if it did.
|
||||||
|
icc: Option<Vec<u8>>,
|
||||||
|
/// Primaries as CIE xy, if it stated them instead.
|
||||||
|
primaries: Option<Chromaticities>,
|
||||||
|
/// Set when the image description could not be produced at all.
|
||||||
|
failed: Option<String>,
|
||||||
|
/// Whether anything at all came back for this output.
|
||||||
|
answered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
manager: Option<wp_color_manager_v1::WpColorManagerV1>,
|
||||||
|
outputs: Vec<wl_output::WlOutput>,
|
||||||
|
pending: Vec<Pending>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every output the compositor has, with whatever colour it will state.
|
||||||
|
pub fn probe() -> Result<Vec<DisplayInfo>, FallbackReason> {
|
||||||
|
let conn = Connection::connect_to_env()
|
||||||
|
.map_err(|e| FallbackReason::NoConnection(format!("Wayland: {e}")))?;
|
||||||
|
let mut queue = conn.new_event_queue();
|
||||||
|
let qh = queue.handle();
|
||||||
|
conn.display().get_registry(&qh, ());
|
||||||
|
|
||||||
|
let mut state = State {
|
||||||
|
manager: None,
|
||||||
|
outputs: Vec::new(),
|
||||||
|
pending: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// First roundtrip: the registry's globals, which is where both the manager
|
||||||
|
// and the outputs are bound.
|
||||||
|
roundtrip(&mut queue, &mut state)?;
|
||||||
|
// Second: the events those newly bound objects emit — `wl_output.name`,
|
||||||
|
// and the manager's capability advertisements. A single roundtrip cannot
|
||||||
|
// carry them, because the objects did not exist when the first request
|
||||||
|
// went out.
|
||||||
|
roundtrip(&mut queue, &mut state)?;
|
||||||
|
|
||||||
|
let Some(manager) = state.manager.clone() else {
|
||||||
|
// The case FR-DSP-8's fallback clause is written for, and today the
|
||||||
|
// common one. Reported as its own reason rather than folded into
|
||||||
|
// "no profile", because the two lead a user somewhere different: this
|
||||||
|
// one is the compositor's feature set, not their calibration.
|
||||||
|
return Err(FallbackReason::NoWaylandProtocol);
|
||||||
|
};
|
||||||
|
if state.outputs.is_empty() {
|
||||||
|
return Err(FallbackReason::NoConnection(
|
||||||
|
"Wayland: the compositor listed no outputs".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask every output at once and let one roundtrip settle them all. Asking
|
||||||
|
// serially would be a roundtrip per monitor on a path that runs at startup
|
||||||
|
// and on every display change.
|
||||||
|
for (index, output) in state.outputs.clone().iter().enumerate() {
|
||||||
|
let cm_output = manager.get_output(output, &qh, index);
|
||||||
|
cm_output.get_image_description(&qh, index);
|
||||||
|
}
|
||||||
|
roundtrip(&mut queue, &mut state)?;
|
||||||
|
// `get_information` is only legal once the description is ready, so it
|
||||||
|
// cannot be batched with the request above; this is the second and last
|
||||||
|
// roundtrip of the exchange.
|
||||||
|
roundtrip(&mut queue, &mut state)?;
|
||||||
|
|
||||||
|
Ok(state
|
||||||
|
.pending
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, pending)| DisplayInfo {
|
||||||
|
name: pending
|
||||||
|
.name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("display {}", index + 1)),
|
||||||
|
// See the module comment: a rectangle with no point to test.
|
||||||
|
bounds: None,
|
||||||
|
profile: resolve(pending),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn one output's accumulated events into an output space.
|
||||||
|
fn resolve(pending: &Pending) -> DisplayProfile {
|
||||||
|
if let Some(why) = &pending.failed {
|
||||||
|
return DisplayProfile::fallback(FallbackReason::Unreadable(format!("Wayland: {why}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ICC form first: it is the only one that carries a name, and a named
|
||||||
|
// profile is what lets a photographer confirm we are reading theirs.
|
||||||
|
if let Some(bytes) = &pending.icc {
|
||||||
|
return match super::icc::read_profile(bytes) {
|
||||||
|
Ok(summary) => {
|
||||||
|
let (space, exact) = nearest_by_colorants(&summary.colorants);
|
||||||
|
DisplayProfile {
|
||||||
|
space,
|
||||||
|
source: ProfileSource::WaylandColourManagement,
|
||||||
|
described_as: summary.description.or_else(|| pending.description.clone()),
|
||||||
|
approximated: !exact,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(why) => DisplayProfile::fallback(FallbackReason::Unreadable(why)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(primaries) = pending.primaries {
|
||||||
|
let (space, exact) = nearest_space(&primaries);
|
||||||
|
return DisplayProfile {
|
||||||
|
space,
|
||||||
|
source: ProfileSource::WaylandColourManagement,
|
||||||
|
described_as: pending.description.clone(),
|
||||||
|
approximated: !exact,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The protocol was there and the output had nothing to say through it.
|
||||||
|
DisplayProfile::fallback(if pending.answered {
|
||||||
|
FallbackReason::NoProfileForDisplay
|
||||||
|
} else {
|
||||||
|
FallbackReason::Unreadable("Wayland: the compositor did not answer".to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn roundtrip(
|
||||||
|
queue: &mut wayland_client::EventQueue<State>,
|
||||||
|
state: &mut State,
|
||||||
|
) -> Result<(), FallbackReason> {
|
||||||
|
queue
|
||||||
|
.roundtrip(state)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| FallbackReason::NoConnection(format!("Wayland: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let wl_registry::Event::Global {
|
||||||
|
name,
|
||||||
|
interface,
|
||||||
|
version,
|
||||||
|
} = event
|
||||||
|
else {
|
||||||
|
// `GlobalRemove` is a monitor being unplugged mid-probe. Ignored
|
||||||
|
// here: the survey is re-run on a display change anyway, and
|
||||||
|
// reacting to it inside a two-roundtrip exchange would only make
|
||||||
|
// the indices disagree with the vector.
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if interface == wp_color_manager_v1::WpColorManagerV1::interface().name {
|
||||||
|
state.manager = Some(registry.bind(name, version.min(MAX_MANAGER_VERSION), qh, ()));
|
||||||
|
} else if interface == wl_output::WlOutput::interface().name {
|
||||||
|
let index = state.outputs.len();
|
||||||
|
state.pending.push(Pending::default());
|
||||||
|
state
|
||||||
|
.outputs
|
||||||
|
.push(registry.bind(name, version.min(MAX_OUTPUT_VERSION), qh, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_output::WlOutput, usize> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &wl_output::WlOutput,
|
||||||
|
event: wl_output::Event,
|
||||||
|
index: &usize,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let Some(pending) = state.pending.get_mut(*index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match event {
|
||||||
|
// The connector name — "DP-1", "eDP-1" — which is what a user with
|
||||||
|
// two monitors recognises in a list.
|
||||||
|
wl_output::Event::Name { name } => pending.name = Some(name),
|
||||||
|
wl_output::Event::Description { description } => {
|
||||||
|
pending.description = Some(description)
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wp_color_manager_v1::WpColorManagerV1, ()> for State {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &wp_color_manager_v1::WpColorManagerV1,
|
||||||
|
_: wp_color_manager_v1::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
// The manager advertises which intents, features, transfer functions
|
||||||
|
// and named primaries it supports. All of that constrains what a
|
||||||
|
// client may *create*; this one only reads what the outputs already
|
||||||
|
// are, so none of it applies.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wp_color_management_output_v1::WpColorManagementOutputV1, usize> for State {
|
||||||
|
fn event(
|
||||||
|
_: &mut Self,
|
||||||
|
_: &wp_color_management_output_v1::WpColorManagementOutputV1,
|
||||||
|
_: wp_color_management_output_v1::Event,
|
||||||
|
_: &usize,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
// `image_description_changed` says the output's colour has changed —
|
||||||
|
// a user switching their monitor's picture mode, or assigning a new
|
||||||
|
// profile. Not acted on here: this object lives for the length of one
|
||||||
|
// probe and is dropped with it. The application re-surveys instead,
|
||||||
|
// which also covers the X11 side, where there is no such event.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wp_image_description_v1::WpImageDescriptionV1, usize> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
description: &wp_image_description_v1::WpImageDescriptionV1,
|
||||||
|
event: wp_image_description_v1::Event,
|
||||||
|
index: &usize,
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let Some(pending) = state.pending.get_mut(*index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match event {
|
||||||
|
// `ready` at protocol version 1, `ready2` from version 2. Both
|
||||||
|
// mean the same thing and both are handled, because the version
|
||||||
|
// bound depends on the compositor.
|
||||||
|
wp_image_description_v1::Event::Ready { .. }
|
||||||
|
| wp_image_description_v1::Event::Ready2 { .. } => {
|
||||||
|
pending.answered = true;
|
||||||
|
description.get_information(qh, *index);
|
||||||
|
}
|
||||||
|
wp_image_description_v1::Event::Failed { cause, msg } => {
|
||||||
|
pending.answered = true;
|
||||||
|
pending.failed = Some(format!("{cause:?}: {msg}"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wp_image_description_info_v1::WpImageDescriptionInfoV1, usize> for State {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
_: &wp_image_description_info_v1::WpImageDescriptionInfoV1,
|
||||||
|
event: wp_image_description_info_v1::Event,
|
||||||
|
index: &usize,
|
||||||
|
_: &Connection,
|
||||||
|
_: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
let Some(pending) = state.pending.get_mut(*index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match event {
|
||||||
|
wp_image_description_info_v1::Event::IccFile { icc, icc_size } => {
|
||||||
|
match read_fd(icc, icc_size as usize) {
|
||||||
|
Ok(bytes) => pending.icc = Some(bytes),
|
||||||
|
Err(e) => log::warn!("reading the compositor's ICC profile: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wp_image_description_info_v1::Event::Primaries {
|
||||||
|
r_x,
|
||||||
|
r_y,
|
||||||
|
g_x,
|
||||||
|
g_y,
|
||||||
|
b_x,
|
||||||
|
b_y,
|
||||||
|
w_x,
|
||||||
|
w_y,
|
||||||
|
} => {
|
||||||
|
// The protocol carries six decimals by multiplying by a
|
||||||
|
// million; undoing it here rather than anywhere downstream
|
||||||
|
// keeps `Chromaticities` meaning one thing everywhere.
|
||||||
|
let xy = |x: i32, y: i32| [f64::from(x) / 1e6, f64::from(y) / 1e6];
|
||||||
|
pending.primaries = Some(Chromaticities {
|
||||||
|
red: xy(r_x, r_y),
|
||||||
|
green: xy(g_x, g_y),
|
||||||
|
blue: xy(b_x, b_y),
|
||||||
|
white: xy(w_x, w_y),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a profile out of the file descriptor the compositor passed.
|
||||||
|
///
|
||||||
|
/// Seeks to the start first. The descriptor is a dup of the compositor's own,
|
||||||
|
/// so it shares a file offset with it; assuming zero is the kind of assumption
|
||||||
|
/// that works on every compositor until it does not.
|
||||||
|
fn read_fd(fd: std::os::fd::OwnedFd, size: usize) -> std::io::Result<Vec<u8>> {
|
||||||
|
let mut file = std::fs::File::from(fd);
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
|
let mut bytes = Vec::with_capacity(size.min(1 << 20));
|
||||||
|
// Bounded by what the compositor declared, and separately by a megabyte:
|
||||||
|
// a display profile is a few kilobytes, and the size is a number from
|
||||||
|
// another process.
|
||||||
|
file.take(size.min(1 << 20) as u64)
|
||||||
|
.read_to_end(&mut bytes)?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
//! TRACES: FR-DSP-8 | FR-PLAT-LIN-2
|
||||||
|
//! Acquisition on X11: ICC profiles as root-window properties.
|
||||||
|
//!
|
||||||
|
//! The mechanism is the ICC Profiles in X specification, and it predates
|
||||||
|
//! everything else in this module by two decades. A colour manager — colord
|
||||||
|
//! under GNOME, `xiccd`, `xcalib`, or the desktop's own — loads the profile
|
||||||
|
//! for each output and publishes its bytes on the *root window* as a property:
|
||||||
|
//! `_ICC_PROFILE` for the first output, `_ICC_PROFILE_1`, `_ICC_PROFILE_2` and
|
||||||
|
//! so on for the rest, numbered by the output's position in RandR's list.
|
||||||
|
//!
|
||||||
|
//! Any client can read them, which is exactly why this works and exactly why
|
||||||
|
//! [`super::icc::read_profile`] validates so carefully: a root-window property
|
||||||
|
//! is untyped shared state that anything on the display may write.
|
||||||
|
//!
|
||||||
|
//! # Geometry comes from RandR, and it is what makes a move detectable
|
||||||
|
//!
|
||||||
|
//! X11 will also say where each output sits in the desktop's coordinate space,
|
||||||
|
//! and will tell a client where its own window is. Those two facts together
|
||||||
|
//! are the whole of FR-DSP-8's "updates when the window moves between
|
||||||
|
//! displays" on this display server — the window's centre falls in one
|
||||||
|
//! rectangle or another, and the answer changes as it is dragged. Wayland
|
||||||
|
//! gives neither, which is why `wayland.rs` says what it says.
|
||||||
|
|
||||||
|
use x11rb::connection::Connection;
|
||||||
|
use x11rb::protocol::randr::ConnectionExt as RandrExt;
|
||||||
|
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as XExt};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
nearest_by_colorants, Bounds, DisplayInfo, DisplayProfile, FallbackReason, ProfileSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Every output the X server has, with whatever profile is published for it.
|
||||||
|
pub fn probe() -> Result<Vec<DisplayInfo>, FallbackReason> {
|
||||||
|
let (conn, screen_num) =
|
||||||
|
x11rb::connect(None).map_err(|e| FallbackReason::NoConnection(format!("X11: {e}")))?;
|
||||||
|
let root = conn
|
||||||
|
.setup()
|
||||||
|
.roots
|
||||||
|
.get(screen_num)
|
||||||
|
.ok_or_else(|| FallbackReason::NoConnection("X11: no screen".to_string()))?
|
||||||
|
.root;
|
||||||
|
|
||||||
|
// RandR describes the outputs. Its absence is survivable: a server without
|
||||||
|
// it still has a root window with `_ICC_PROFILE` on it, so fall through to
|
||||||
|
// a single unnamed display carrying the first property rather than giving
|
||||||
|
// up on colour management entirely.
|
||||||
|
let outputs = enumerate(&conn, root).unwrap_or_default();
|
||||||
|
let outputs = if outputs.is_empty() {
|
||||||
|
vec![(String::from("display"), None)]
|
||||||
|
} else {
|
||||||
|
outputs
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(outputs
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, (name, bounds))| DisplayInfo {
|
||||||
|
name,
|
||||||
|
bounds,
|
||||||
|
profile: profile_for(&conn, root, index),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An output's connector name and the rectangle it occupies, where RandR
|
||||||
|
/// would say. Named so that the order of the two is fixed in one place.
|
||||||
|
type Output = (String, Option<Bounds>);
|
||||||
|
|
||||||
|
/// The connected outputs, in RandR order, with their desktop rectangles.
|
||||||
|
///
|
||||||
|
/// Order is load-bearing rather than cosmetic: it is what numbers the
|
||||||
|
/// `_ICC_PROFILE_<n>` properties, so reordering this list would hand every
|
||||||
|
/// monitor its neighbour's profile.
|
||||||
|
fn enumerate(conn: &impl Connection, root: u32) -> Result<Vec<Output>, Box<dyn std::error::Error>> {
|
||||||
|
let resources = conn.randr_get_screen_resources_current(root)?.reply()?;
|
||||||
|
let stamp = resources.config_timestamp;
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for output in resources.outputs {
|
||||||
|
let Ok(info) = conn.randr_get_output_info(output, stamp)?.reply() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// `crtc == 0` is an output with no CRTC driving it: a port with
|
||||||
|
// nothing plugged in, or a disabled monitor. It occupies no part of
|
||||||
|
// the desktop and takes no place in the `_ICC_PROFILE_<n>` numbering.
|
||||||
|
if info.crtc == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = String::from_utf8_lossy(&info.name).to_string();
|
||||||
|
let bounds = conn
|
||||||
|
.randr_get_crtc_info(info.crtc, stamp)
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| c.reply().ok())
|
||||||
|
.map(|c| Bounds {
|
||||||
|
x: i32::from(c.x),
|
||||||
|
y: i32::from(c.y),
|
||||||
|
width: u32::from(c.width),
|
||||||
|
height: u32::from(c.height),
|
||||||
|
});
|
||||||
|
found.push((name, bounds));
|
||||||
|
}
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property this output's profile would be published on, read and reduced.
|
||||||
|
fn profile_for(conn: &impl Connection, root: u32, index: usize) -> DisplayProfile {
|
||||||
|
// The specification's numbering: the first output's property carries no
|
||||||
|
// suffix. Writing `_ICC_PROFILE_0` instead reads nothing on every desktop.
|
||||||
|
let name = if index == 0 {
|
||||||
|
"_ICC_PROFILE".to_string()
|
||||||
|
} else {
|
||||||
|
format!("_ICC_PROFILE_{index}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = match fetch(conn, root, &name) {
|
||||||
|
Ok(Some(bytes)) => bytes,
|
||||||
|
// No property. The overwhelmingly common case on a stock desktop with
|
||||||
|
// no calibration installed, and not a fault of any kind.
|
||||||
|
Ok(None) => return DisplayProfile::fallback(FallbackReason::NoProfileForDisplay),
|
||||||
|
Err(e) => return DisplayProfile::fallback(FallbackReason::Unreadable(format!("X11: {e}"))),
|
||||||
|
};
|
||||||
|
|
||||||
|
match super::icc::read_profile(&bytes) {
|
||||||
|
Ok(summary) => {
|
||||||
|
let (space, exact) = nearest_by_colorants(&summary.colorants);
|
||||||
|
DisplayProfile {
|
||||||
|
space,
|
||||||
|
source: ProfileSource::X11RootProperty(name),
|
||||||
|
described_as: summary.description,
|
||||||
|
approximated: !exact,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(why) => DisplayProfile::fallback(FallbackReason::Unreadable(why)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a root-window property whole.
|
||||||
|
///
|
||||||
|
/// The length is asked for in 32-bit units, which is X11's unit for this call
|
||||||
|
/// and a trap worth naming: a display profile is a few kilobytes and a request
|
||||||
|
/// stated in bytes would truncate it into a shape `read_profile` then rejects
|
||||||
|
/// as corrupt. `u32::MAX / 4` asks for all of it.
|
||||||
|
fn fetch(
|
||||||
|
conn: &impl Connection,
|
||||||
|
root: u32,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
|
||||||
|
let atom = conn.intern_atom(true, name.as_bytes())?.reply()?.atom;
|
||||||
|
// `only_if_exists` above: an atom that no one has interned is a property
|
||||||
|
// no one has set, and interning it ourselves would litter the server with
|
||||||
|
// atoms for the monitors this desktop does not have.
|
||||||
|
if atom == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let reply = conn
|
||||||
|
.get_property(false, root, atom, AtomEnum::CARDINAL, 0, u32::MAX / 4)?
|
||||||
|
.reply()?;
|
||||||
|
if reply.value.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(reply.value))
|
||||||
|
}
|
||||||
@@ -4,10 +4,15 @@
|
|||||||
//! construction, so `core/` contains no `#[cfg(target_os)]` (NFR-PORT-1,
|
//! construction, so `core/` contains no `#[cfg(target_os)]` (NFR-PORT-1,
|
||||||
//! ARCH §10).
|
//! ARCH §10).
|
||||||
|
|
||||||
|
pub mod display;
|
||||||
pub mod secrets;
|
pub mod secrets;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod volumes;
|
pub mod volumes;
|
||||||
|
|
||||||
|
pub use display::{
|
||||||
|
Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason,
|
||||||
|
ProfileSource,
|
||||||
|
};
|
||||||
pub use secrets::{
|
pub use secrets::{
|
||||||
EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore,
|
EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -222,22 +222,16 @@ impl Default for PlatformSecretStore {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
impl SecretStore for PlatformSecretStore {
|
impl SecretStore for PlatformSecretStore {
|
||||||
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> {
|
fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> {
|
||||||
use keyring_core::api::CredentialApi;
|
|
||||||
|
|
||||||
self.entry(secret_ref)?
|
self.entry(secret_ref)?
|
||||||
.set_password(secret)
|
.set_password(secret)
|
||||||
.map_err(map_err)
|
.map_err(map_err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn retrieve(&self, secret_ref: &SecretRef) -> Result<String, SecretError> {
|
fn retrieve(&self, secret_ref: &SecretRef) -> Result<String, SecretError> {
|
||||||
use keyring_core::api::CredentialApi;
|
|
||||||
|
|
||||||
self.entry(secret_ref)?.get_password().map_err(map_err)
|
self.entry(secret_ref)?.get_password().map_err(map_err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> {
|
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> {
|
||||||
use keyring_core::api::CredentialApi;
|
|
||||||
|
|
||||||
match self.entry(secret_ref)?.delete_credential() {
|
match self.entry(secret_ref)?.delete_credential() {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
// Logout must be idempotent, as on Linux.
|
// Logout must be idempotent, as on Linux.
|
||||||
|
|||||||
@@ -27,7 +27,10 @@
|
|||||||
//! directory is reported alongside and weighted more heavily than the flag:
|
//! directory is reported alongside and weighted more heavily than the flag:
|
||||||
//! a volume with `DCIM` on it is a camera card whatever sysfs believes.
|
//! a volume with `DCIM` on it is a camera card whatever sysfs believes.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
|
// Only the mount-table reader below borrows a path; `Volume` owns one.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
/// Somewhere a card might be.
|
/// Somewhere a card might be.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -136,6 +139,7 @@ fn platform_volumes() -> Vec<Volume> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One line of the mount table.
|
/// One line of the mount table.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct Mount {
|
struct Mount {
|
||||||
device: String,
|
device: String,
|
||||||
@@ -147,6 +151,7 @@ struct Mount {
|
|||||||
///
|
///
|
||||||
/// Split out from the reading so it can be tested against real tables without
|
/// Split out from the reading so it can be tested against real tables without
|
||||||
/// a card plugged in — which is the only way this file is testable at all.
|
/// a card plugged in — which is the only way this file is testable at all.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
fn parse_mounts(table: &str) -> Vec<Mount> {
|
fn parse_mounts(table: &str) -> Vec<Mount> {
|
||||||
table
|
table
|
||||||
.lines()
|
.lines()
|
||||||
@@ -169,6 +174,7 @@ fn parse_mounts(table: &str) -> Vec<Mount> {
|
|||||||
/// A card is very often labelled with a space in it — `EOS DIGITAL` is what
|
/// A card is very often labelled with a space in it — `EOS DIGITAL` is what
|
||||||
/// Canon writes — and mounted at `/run/media/duncan/EOS\040DIGITAL`. Without
|
/// Canon writes — and mounted at `/run/media/duncan/EOS\040DIGITAL`. Without
|
||||||
/// this the path does not exist and the card is invisible.
|
/// this the path does not exist and the card is invisible.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
fn unescape(field: &str) -> String {
|
fn unescape(field: &str) -> String {
|
||||||
let mut out = String::with_capacity(field.len());
|
let mut out = String::with_capacity(field.len());
|
||||||
let bytes = field.as_bytes();
|
let bytes = field.as_bytes();
|
||||||
@@ -192,6 +198,7 @@ fn unescape(field: &str) -> String {
|
|||||||
///
|
///
|
||||||
/// Excludes the pseudo-filesystems, which is most of the table, and anything
|
/// Excludes the pseudo-filesystems, which is most of the table, and anything
|
||||||
/// not backed by a block device. A card reader always is.
|
/// not backed by a block device. A card reader always is.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
fn is_candidate(m: &Mount) -> bool {
|
fn is_candidate(m: &Mount) -> bool {
|
||||||
const FILESYSTEMS: &[&str] = &[
|
const FILESYSTEMS: &[&str] = &[
|
||||||
// What cameras format cards as, in practice: FAT32 below 32 GB,
|
// What cameras format cards as, in practice: FAT32 below 32 GB,
|
||||||
@@ -204,6 +211,7 @@ fn is_candidate(m: &Mount) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the desktop automounted this, which means it already decided.
|
/// Whether the desktop automounted this, which means it already decided.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
fn under_media_dir(mount_point: &Path) -> bool {
|
fn under_media_dir(mount_point: &Path) -> bool {
|
||||||
let s = mount_point.to_string_lossy();
|
let s = mount_point.to_string_lossy();
|
||||||
s.starts_with("/run/media/") || s.starts_with("/media/") || s.starts_with("/mnt/media/")
|
s.starts_with("/run/media/") || s.starts_with("/media/") || s.starts_with("/mnt/media/")
|
||||||
@@ -217,6 +225,7 @@ fn under_media_dir(mount_point: &Path) -> bool {
|
|||||||
/// lose only the `pN` suffix — strip naively and `mmcblk0p1` becomes
|
/// lose only the `pN` suffix — strip naively and `mmcblk0p1` becomes
|
||||||
/// `mmcblk`, which does not exist, and every SD card in an internal reader
|
/// `mmcblk`, which does not exist, and every SD card in an internal reader
|
||||||
/// reports itself fixed.
|
/// reports itself fixed.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
fn device_is_removable(device: &str) -> Option<bool> {
|
fn device_is_removable(device: &str) -> Option<bool> {
|
||||||
let name = device.strip_prefix("/dev/")?;
|
let name = device.strip_prefix("/dev/")?;
|
||||||
let disk = whole_disk(name);
|
let disk = whole_disk(name);
|
||||||
@@ -225,6 +234,7 @@ fn device_is_removable(device: &str) -> Option<bool> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The whole-disk name a partition belongs to.
|
/// The whole-disk name a partition belongs to.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
fn whole_disk(partition: &str) -> String {
|
fn whole_disk(partition: &str) -> String {
|
||||||
// `mmcblk0p1` -> `mmcblk0`, `nvme0n1p3` -> `nvme0n1`: these spell the
|
// `mmcblk0p1` -> `mmcblk0`, `nvme0n1p3` -> `nvme0n1`: these spell the
|
||||||
// partition with a `p`, and the disk name legitimately ends in a digit.
|
// partition with a `p`, and the disk name legitimately ends in a digit.
|
||||||
@@ -243,6 +253,7 @@ fn whole_disk(partition: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// What to call a volume in a list.
|
/// What to call a volume in a list.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
fn label_for(mount_point: &Path) -> String {
|
fn label_for(mount_point: &Path) -> String {
|
||||||
mount_point
|
mount_point
|
||||||
.file_name()
|
.file_name()
|
||||||
@@ -250,7 +261,9 @@ fn label_for(mount_point: &Path) -> String {
|
|||||||
.unwrap_or_else(|| mount_point.to_string_lossy().to_string())
|
.unwrap_or_else(|| mount_point.to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
// Reading `/proc/mounts` and naming block devices is what these test, so
|
||||||
|
// they are as Linux-bound as the code above.
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Pinned so CI, the Android image and a contributor's laptop agree.
|
||||||
|
#
|
||||||
|
# `build-and-test.yml` already installs exactly 1.92.0 and says why: a floating
|
||||||
|
# toolchain turns an unrelated push into a mystery failure. That reasoning
|
||||||
|
# applies with more force locally than in CI, because the two checks most
|
||||||
|
# sensitive to the compiler version are the two that gate every push.
|
||||||
|
#
|
||||||
|
# `cargo fmt --all -- --check` is the sharp one. rustfmt's output changes
|
||||||
|
# between releases, so a contributor on a newer stable can reformat a file in a
|
||||||
|
# way this CI rejects — a diff nobody wrote, on a line nobody touched.
|
||||||
|
# `clippy -D warnings` is the same story with new lints.
|
||||||
|
#
|
||||||
|
# `rust-version = "1.92"` in the workspace manifest is a *minimum* and stays
|
||||||
|
# where it is; it produces a clear error on an older toolchain rather than a
|
||||||
|
# confusing one. This file is the other half: an upper bound as well as a lower
|
||||||
|
# one, so everyone compiles with the same compiler rather than merely a
|
||||||
|
# sufficient one.
|
||||||
|
#
|
||||||
|
# rustup reads this automatically and will fetch the toolchain on first use.
|
||||||
|
|
||||||
|
[toolchain]
|
||||||
|
channel = "1.92.0"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -18,7 +18,12 @@ use anyhow::{bail, Context, Result};
|
|||||||
use traceability::*;
|
use traceability::*;
|
||||||
|
|
||||||
/// Directories scanned for tags.
|
/// Directories scanned for tags.
|
||||||
const SOURCE_ROOTS: &[&str] = &["core", "ui", "apps", "tools"];
|
///
|
||||||
|
/// `platform` belongs here as much as the rest: it is where the display-server
|
||||||
|
/// and secret-store integrations live, so leaving it out made every
|
||||||
|
/// FR-PLAT-\* and NFR-PORT-\* tag in `dr-plat` invisible and understated the
|
||||||
|
/// matrix by exactly the requirements the platform layer exists to satisfy.
|
||||||
|
const SOURCE_ROOTS: &[&str] = &["core", "ui", "platform", "apps", "tools"];
|
||||||
|
|
||||||
/// Minimum coverage the gate accepts.
|
/// Minimum coverage the gate accepts.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
||||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
use dr_sync_nextcloud::AppCredentials;
|
||||||
use dr_thumbs::ThumbStore;
|
use dr_thumbs::ThumbStore;
|
||||||
|
|
||||||
/// Folder under the library root holding derived state.
|
/// Folder under the library root holding derived state.
|
||||||
@@ -117,7 +117,7 @@ pub fn spawn_sync(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(SyncMessage::Failed(e.to_string()));
|
let _ = tx.send(SyncMessage::Failed(e.to_string()));
|
||||||
@@ -125,7 +125,7 @@ pub fn spawn_sync(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match run(&backend, &root, &thumbs_dir, &catalog_path, &scratch, &tx).await {
|
match run(&*backend, &root, &thumbs_dir, &catalog_path, &scratch, &tx).await {
|
||||||
Ok(report) => {
|
Ok(report) => {
|
||||||
let _ = tx.send(SyncMessage::Finished(Box::new(report)));
|
let _ = tx.send(SyncMessage::Finished(Box::new(report)));
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ pub fn spawn_sync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run(
|
async fn run(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
root: &str,
|
root: &str,
|
||||||
thumbs_dir: &Path,
|
thumbs_dir: &Path,
|
||||||
catalog_path: &Path,
|
catalog_path: &Path,
|
||||||
@@ -193,7 +193,7 @@ fn derived_path(root: &str) -> RemotePath {
|
|||||||
/// namespace, and [`ThumbStore::adopted`] then tracks what has been merged by
|
/// namespace, and [`ThumbStore::adopted`] then tracks what has been merged by
|
||||||
/// remote name instead of by our own ids.
|
/// remote name instead of by our own ids.
|
||||||
async fn sync_shards(
|
async fn sync_shards(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
base: &RemotePath,
|
base: &RemotePath,
|
||||||
thumbs_dir: &Path,
|
thumbs_dir: &Path,
|
||||||
scratch: &Path,
|
scratch: &Path,
|
||||||
@@ -323,7 +323,7 @@ async fn sync_shards(
|
|||||||
/// carrier. So this exports the catalog's new faces into the local shard store
|
/// carrier. So this exports the catalog's new faces into the local shard store
|
||||||
/// first, syncs the shards, and imports whatever arrived back into the catalog.
|
/// first, syncs the shards, and imports whatever arrived back into the catalog.
|
||||||
async fn sync_face_shards(
|
async fn sync_face_shards(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
base: &RemotePath,
|
base: &RemotePath,
|
||||||
catalog_path: &Path,
|
catalog_path: &Path,
|
||||||
scratch: &Path,
|
scratch: &Path,
|
||||||
@@ -482,7 +482,7 @@ fn legacy_upload_of_ours(store: &ThumbStore, id: u32, remote_size: u64) -> bool
|
|||||||
/// describes local state (folder ETags, cache paths, job rows) and importing
|
/// describes local state (folder ETags, cache paths, job rows) and importing
|
||||||
/// another device's version would be actively wrong.
|
/// another device's version would be actively wrong.
|
||||||
async fn sync_catalog(
|
async fn sync_catalog(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
base: &RemotePath,
|
base: &RemotePath,
|
||||||
catalog_path: &Path,
|
catalog_path: &Path,
|
||||||
scratch: &Path,
|
scratch: &Path,
|
||||||
|
|||||||
+180
-15
@@ -686,6 +686,22 @@ pub struct DevelopSession {
|
|||||||
/// open. One value rather than one per operation, for the same reason
|
/// open. One value rather than one per operation, for the same reason
|
||||||
/// `curve_samples` is one polyline: the panel draws one curve.
|
/// `curve_samples` is one polyline: the panel draws one curve.
|
||||||
curve_channel: usize,
|
curve_channel: usize,
|
||||||
|
/// TRACES: FR-DSP-8
|
||||||
|
/// The space the canvas is encoded into, for the display now showing it.
|
||||||
|
///
|
||||||
|
/// **Not part of the edit, and not interface state either.** It is a fact
|
||||||
|
/// about the glass in front of the photographer: the same graph on the
|
||||||
|
/// same file composes differently on a wide-gamut second monitor, and
|
||||||
|
/// neither the sidecar nor the undo stack has any business knowing about
|
||||||
|
/// it. That is also why it lives here rather than on the `EditGraph` —
|
||||||
|
/// `compose_for` deliberately takes the space per call because "the same
|
||||||
|
/// edit goes to the screen in the display's space and to a file in
|
||||||
|
/// whatever the export asks for, and neither is more authoritative".
|
||||||
|
///
|
||||||
|
/// sRGB until the application says otherwise, which is the same answer
|
||||||
|
/// `dr_plat::display`'s fallback gives and means a session constructed in
|
||||||
|
/// a test behaves exactly as it did before this existed.
|
||||||
|
display_space: dr_types::ColourSpace,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DevelopSession {
|
impl DevelopSession {
|
||||||
@@ -756,6 +772,7 @@ impl DevelopSession {
|
|||||||
show_overlay: false,
|
show_overlay: false,
|
||||||
active_tab: None,
|
active_tab: None,
|
||||||
curve_channel: 0,
|
curve_channel: 0,
|
||||||
|
display_space: dr_types::ColourSpace::Srgb,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,7 +1213,7 @@ fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option<Vec<Curv
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut runs: Vec<CurveRun> = Vec::new();
|
let mut runs: Vec<CurveRun> = Vec::new();
|
||||||
for id in presentation.params {
|
for id in &presentation.params {
|
||||||
// The widget addresses points by offset from the first of its run, so
|
// The widget addresses points by offset from the first of its run, so
|
||||||
// a run has to be contiguous in the capability list.
|
// a run has to be contiguous in the capability list.
|
||||||
let at = op.params.iter().position(|p| p.id == *id)?;
|
let at = op.params.iter().position(|p| p.id == *id)?;
|
||||||
@@ -2624,6 +2641,30 @@ impl DevelopSession {
|
|||||||
Some((op.id, param.id))
|
Some((op.id, param.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-8
|
||||||
|
/// Encode the canvas for a different display from now on.
|
||||||
|
///
|
||||||
|
/// Returns whether anything changed, so a caller polling for window moves
|
||||||
|
/// can redraw only when the answer is genuinely different — the poll runs
|
||||||
|
/// far more often than a monitor is changed, and a redraw per poll would
|
||||||
|
/// undo the point of rendering on demand.
|
||||||
|
///
|
||||||
|
/// Nothing is invalidated here and nothing needs to be. The next
|
||||||
|
/// [`Self::render`] composes against the new space, the pipeline cache
|
||||||
|
/// distinguishes the two shaders by the structure hash the space enters,
|
||||||
|
/// and the mask array — rasterised in source space, sampled through the
|
||||||
|
/// framing — is unaffected because a colour space is not a geometry.
|
||||||
|
pub fn set_display_space(&mut self, space: dr_types::ColourSpace) -> bool {
|
||||||
|
let changed = self.display_space != space;
|
||||||
|
self.display_space = space;
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The space the canvas is currently being encoded into.
|
||||||
|
pub fn display_space(&self) -> dr_types::ColourSpace {
|
||||||
|
self.display_space
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DSP-1 | AC-8
|
/// TRACES: FR-DSP-1 | AC-8
|
||||||
/// Render at the requested display size and hand back a Slint image.
|
/// Render at the requested display size and hand back a Slint image.
|
||||||
///
|
///
|
||||||
@@ -2654,11 +2695,24 @@ impl DevelopSession {
|
|||||||
let (fw, fh) = self.graph.output_size(sw, sh);
|
let (fw, fh) = self.graph.output_size(sw, sh);
|
||||||
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
|
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
|
||||||
|
|
||||||
let shader = self.graph.compose();
|
// TRACES: FR-DSP-8 | FR-DSP-6
|
||||||
|
// **Composed for the display that is showing this canvas**, not for
|
||||||
|
// sRGB. This is the whole of FR-DSP-8's second half arriving at the
|
||||||
|
// pipeline: a display change is a *recomposition* and nothing more,
|
||||||
|
// because the output space was always a parameter of composition and
|
||||||
|
// always entered the structure hash. Moving the window to a P3 panel
|
||||||
|
// therefore costs one shader compile and no pipeline change at all.
|
||||||
|
let space = self.display_space;
|
||||||
|
let shader = self.graph.compose_for(space);
|
||||||
|
|
||||||
// Rasterise the masks first: the shader addresses array slices by
|
// Rasterise the masks first: the shader addresses array slices by
|
||||||
// index, so the array has to describe *this* stack before it is bound.
|
// index, so the array has to describe *this* stack before it is bound.
|
||||||
self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?;
|
//
|
||||||
|
// The same `space` to both, necessarily: where a detail stage exists
|
||||||
|
// it is the *last* pass that performs the output transform, and two
|
||||||
|
// halves composed for different spaces would encode the frame twice
|
||||||
|
// or not at all.
|
||||||
|
self.render_with_masks(&shader, w, h, space)?;
|
||||||
let texture = self.adjust.output().ok_or("nothing was rendered")?;
|
let texture = self.adjust.output().ok_or("nothing was rendered")?;
|
||||||
|
|
||||||
// The import is fallible on format and usage only, and both are fixed
|
// The import is fallible on format and usage only, and both are fixed
|
||||||
@@ -2702,7 +2756,11 @@ impl DevelopSession {
|
|||||||
/// explicit about. It is in the output colour space, which is what
|
/// explicit about. It is in the output colour space, which is what
|
||||||
/// FR-DSP-7 asks for — the levels counted are the levels the display will
|
/// FR-DSP-7 asks for — the levels counted are the levels the display will
|
||||||
/// show, so a clipped bin means a highlight that is actually gone rather
|
/// show, so a clipped bin means a highlight that is actually gone rather
|
||||||
/// than one the transform might still recover. And when the view is zoomed
|
/// than one the transform might still recover. Since FR-DSP-8 that is the
|
||||||
|
/// space of *this display* rather than sRGB, which makes the reading more
|
||||||
|
/// truthful and not less: a highlight that survives on a wide-gamut panel
|
||||||
|
/// and clips on the laptop's screen genuinely is two different facts, and
|
||||||
|
/// the histogram now reports whichever one the photographer is looking at. And when the view is zoomed
|
||||||
/// or cropped it describes the visible region, not the whole file: a
|
/// or cropped it describes the visible region, not the whole file: a
|
||||||
/// photographer inspecting a highlight at 4× is asking about *that*
|
/// photographer inspecting a highlight at 4× is asking about *that*
|
||||||
/// highlight, and a histogram of the parts of the frame off screen would
|
/// highlight, and a histogram of the parts of the frame off screen would
|
||||||
@@ -4480,15 +4538,15 @@ mod tests {
|
|||||||
presentation: Some(Presentation {
|
presentation: Some(Presentation {
|
||||||
// Prefers a gradient handle; this frontend has none, so it
|
// Prefers a gradient handle; this frontend has none, so it
|
||||||
// falls back to the next entry, which the canvas does host.
|
// falls back to the next entry, which the canvas does host.
|
||||||
widgets: &[WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
widgets: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
||||||
demand: WidgetDemand {
|
demand: WidgetDemand {
|
||||||
two_dimensional: true,
|
two_dimensional: true,
|
||||||
precise_pointing: false,
|
precise_pointing: false,
|
||||||
},
|
},
|
||||||
params: &[ParamId("a"), ParamId("b")],
|
params: vec![ParamId("a"), ParamId("b")],
|
||||||
}),
|
}),
|
||||||
params: vec![param("a"), param("b")],
|
params: vec![param("a"), param("b")],
|
||||||
attributes: &[dr_pipeline::Attribute::Tone],
|
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(rows_from(&[on_canvas]).is_empty());
|
assert!(rows_from(&[on_canvas]).is_empty());
|
||||||
@@ -4612,7 +4670,7 @@ mod tests {
|
|||||||
id: ParamId("method"),
|
id: ParamId("method"),
|
||||||
label: LocalizedKey("param.invented.method"),
|
label: LocalizedKey("param.invented.method"),
|
||||||
kind: ParamKind::Enum {
|
kind: ParamKind::Enum {
|
||||||
variants: &[
|
variants: vec![
|
||||||
LocalizedKey("param.invented.method.fast"),
|
LocalizedKey("param.invented.method.fast"),
|
||||||
LocalizedKey("param.invented.method.exact"),
|
LocalizedKey("param.invented.method.exact"),
|
||||||
],
|
],
|
||||||
@@ -4622,7 +4680,7 @@ mod tests {
|
|||||||
facet: None,
|
facet: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
attributes: &[dr_pipeline::Attribute::Tone],
|
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows = rows_from(&[invented]);
|
let rows = rows_from(&[invented]);
|
||||||
@@ -4660,12 +4718,12 @@ mod tests {
|
|||||||
label: LocalizedKey("op.grading"),
|
label: LocalizedKey("op.grading"),
|
||||||
active: false,
|
active: false,
|
||||||
presentation: Some(Presentation {
|
presentation: Some(Presentation {
|
||||||
widgets: &[WidgetKind::ColourWheel],
|
widgets: vec![WidgetKind::ColourWheel],
|
||||||
demand: WidgetDemand {
|
demand: WidgetDemand {
|
||||||
two_dimensional: true,
|
two_dimensional: true,
|
||||||
precise_pointing: false,
|
precise_pointing: false,
|
||||||
},
|
},
|
||||||
params: &[ParamId("hue"), ParamId("strength")],
|
params: vec![ParamId("hue"), ParamId("strength")],
|
||||||
}),
|
}),
|
||||||
params: vec![
|
params: vec![
|
||||||
ParamCapability {
|
ParamCapability {
|
||||||
@@ -4697,7 +4755,7 @@ mod tests {
|
|||||||
facet: None,
|
facet: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
attributes: &[dr_pipeline::Attribute::Tone],
|
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(!supported(WidgetKind::ColourWheel), "precondition");
|
assert!(!supported(WidgetKind::ColourWheel), "precondition");
|
||||||
@@ -5146,15 +5204,15 @@ mod tests {
|
|||||||
label: LocalizedKey("op.invented_curve"),
|
label: LocalizedKey("op.invented_curve"),
|
||||||
active: false,
|
active: false,
|
||||||
presentation: Some(Presentation {
|
presentation: Some(Presentation {
|
||||||
widgets: &[WidgetKind::ToneCurve],
|
widgets: vec![WidgetKind::ToneCurve],
|
||||||
demand: WidgetDemand {
|
demand: WidgetDemand {
|
||||||
two_dimensional: true,
|
two_dimensional: true,
|
||||||
precise_pointing: true,
|
precise_pointing: true,
|
||||||
},
|
},
|
||||||
params: &IDS,
|
params: IDS.to_vec(),
|
||||||
}),
|
}),
|
||||||
params: IDS.iter().map(|id| param(*id)).collect(),
|
params: IDS.iter().map(|id| param(*id)).collect(),
|
||||||
attributes: &[dr_pipeline::Attribute::Tone],
|
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||||
};
|
};
|
||||||
|
|
||||||
let presentation = plain.presentation.as_ref().expect("declares a widget");
|
let presentation = plain.presentation.as_ref().expect("declares a widget");
|
||||||
@@ -5407,4 +5465,111 @@ mod tests {
|
|||||||
session.set_active_tab(99);
|
session.set_active_tab(99);
|
||||||
assert_eq!(session.rows().len(), all);
|
assert_eq!(session.rows().len(), all);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Per-display colour (FR-DSP-8)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-8 | FR-DSP-6
|
||||||
|
/// The canvas is encoded for the display, not always for sRGB.
|
||||||
|
///
|
||||||
|
/// This is the assertion the requirement is actually about. Before it,
|
||||||
|
/// `render` composed `ColourSpace::Srgb` unconditionally, and a second
|
||||||
|
/// monitor with a different profile got sRGB pixels *labelled* as its own
|
||||||
|
/// space by the compositor — the silent wrongness FR-DSP-8 calls a
|
||||||
|
/// correctness defect. If someone re-hardcodes the space, these pixels
|
||||||
|
/// stop differing and this fails.
|
||||||
|
///
|
||||||
|
/// A saturated red is the probe deliberately: it sits near the edge of
|
||||||
|
/// sRGB's gamut, so re-encoding it into a wider one moves it a long way,
|
||||||
|
/// where a mid grey would move by almost nothing in any of the four and
|
||||||
|
/// the test would pass on a broken build.
|
||||||
|
#[test]
|
||||||
|
fn the_canvas_is_encoded_for_the_display_showing_it() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [230u8, 20, 20, 255]).collect();
|
||||||
|
let mut session =
|
||||||
|
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session.display_space(),
|
||||||
|
dr_types::ColourSpace::Srgb,
|
||||||
|
"a session starts on the fallback, so nothing changes for a \
|
||||||
|
desktop whose display server will not say otherwise"
|
||||||
|
);
|
||||||
|
let on_srgb = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||||||
|
|
||||||
|
assert!(session.set_display_space(dr_types::ColourSpace::AdobeRgb));
|
||||||
|
let on_wide = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||||||
|
|
||||||
|
assert_ne!(
|
||||||
|
on_srgb, on_wide,
|
||||||
|
"the same edit rendered for two displays produced the same pixels"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And back again, because a photographer dragging a window between
|
||||||
|
// two monitors expects the first one to look as it did rather than to
|
||||||
|
// accumulate a transform.
|
||||||
|
assert!(session.set_display_space(dr_types::ColourSpace::Srgb));
|
||||||
|
let returned = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||||||
|
assert_eq!(on_srgb, returned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-8
|
||||||
|
/// A move that changes nothing reports nothing, so nothing is redrawn.
|
||||||
|
///
|
||||||
|
/// The window's position is polled twice a second and two displays often
|
||||||
|
/// share a profile. A setter that reported a change every time it was
|
||||||
|
/// called would turn that poll into a redraw loop.
|
||||||
|
#[test]
|
||||||
|
fn setting_the_same_display_space_twice_is_not_a_change() {
|
||||||
|
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");
|
||||||
|
|
||||||
|
assert!(session.set_display_space(dr_types::ColourSpace::DisplayP3));
|
||||||
|
assert!(!session.set_display_space(dr_types::ColourSpace::DisplayP3));
|
||||||
|
assert_eq!(session.display_space(), dr_types::ColourSpace::DisplayP3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-8 | FR-EXP-2
|
||||||
|
/// The display's space is the *canvas's*, and reaches nothing else.
|
||||||
|
///
|
||||||
|
/// A thumbnail goes into a shard that syncs between devices and an export
|
||||||
|
/// claims the space the export dialogue asked for. Letting the monitor in
|
||||||
|
/// front of the photographer decide either would write a file whose
|
||||||
|
/// profile describes the desk it was made at.
|
||||||
|
#[test]
|
||||||
|
fn a_wide_gamut_monitor_does_not_reach_the_thumbnail_or_the_export() {
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let rgba: Vec<u8> = (0..32 * 32).flat_map(|_| [230u8, 20, 20, 255]).collect();
|
||||||
|
let mut session =
|
||||||
|
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
|
||||||
|
let thumb_before = session.render_thumbnail(16).expect("thumbnail");
|
||||||
|
let export_before = session
|
||||||
|
.render_for_export(dr_types::ColourSpace::Srgb)
|
||||||
|
.expect("export")
|
||||||
|
.rgba;
|
||||||
|
|
||||||
|
session.set_display_space(dr_types::ColourSpace::ProPhoto);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session.render_thumbnail(16).expect("thumbnail"),
|
||||||
|
thumb_before,
|
||||||
|
"the grid's thumbnail followed the monitor"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.render_for_export(dr_types::ColourSpace::Srgb)
|
||||||
|
.expect("export")
|
||||||
|
.rgba,
|
||||||
|
export_before,
|
||||||
|
"an sRGB export followed the monitor"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
//! TRACES: FR-DSP-8
|
||||||
|
//! Following the canvas from one display to the next.
|
||||||
|
//!
|
||||||
|
//! `dr_plat::display` establishes what each display *is*. This decides which
|
||||||
|
//! of them is showing the canvas right now, keeps the develop session's output
|
||||||
|
//! space pointed at it, and renders at that display's physical pixel size
|
||||||
|
//! rather than its logical one.
|
||||||
|
//!
|
||||||
|
//! # Why a poll and not a callback
|
||||||
|
//!
|
||||||
|
//! Slint reports a window's size and its scale factor and does not report that
|
||||||
|
//! it has moved — there is no `on_moved`, on any backend. What there is, is
|
||||||
|
//! `Window::position()`, so this samples it. A sample is three integers
|
||||||
|
//! compared against three integers and happens twice a second; a monitor is
|
||||||
|
//! changed at human speed, and nothing here runs on the frame path.
|
||||||
|
//!
|
||||||
|
//! The re-survey is what costs something — a Wayland roundtrip pair, or an X11
|
||||||
|
//! property fetch — so it is deliberately *not* done every tick. It runs when
|
||||||
|
//! the geometry actually changed, which is also the moment a monitor could
|
||||||
|
//! have been plugged in or unplugged: both of those move or rescale the window
|
||||||
|
//! on every desktop that does anything sensible.
|
||||||
|
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use dr_plat::DisplaySurvey;
|
||||||
|
use slint::ComponentHandle;
|
||||||
|
|
||||||
|
use crate::AppWindow;
|
||||||
|
|
||||||
|
/// How often the window's geometry is sampled.
|
||||||
|
const POLL: std::time::Duration = std::time::Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// The render-target size for a canvas that occupies `logical` logical pixels
|
||||||
|
/// on a display scaled by `scale`.
|
||||||
|
///
|
||||||
|
/// # FR-DSP-8's scaling clause, in one multiplication
|
||||||
|
///
|
||||||
|
/// "Fractional and mixed DPI scaling are handled without resampling artefacts
|
||||||
|
/// in the canvas." The canvas is a `wgpu::Texture` handed straight to the
|
||||||
|
/// compositor (ARCH §6.1), which draws it into a box measured in *logical*
|
||||||
|
/// pixels. Render 1600 logical pixels wide onto a display at 1.25 and the
|
||||||
|
/// compositor has 1600 samples to fill 2000 device pixels, so every frame is
|
||||||
|
/// resampled up by a quarter — a softness that reads like a bad demosaic
|
||||||
|
/// rather than like a scaling bug, which is exactly why the requirement calls
|
||||||
|
/// it out and why it is worth an explicit test.
|
||||||
|
///
|
||||||
|
/// Rendering the physical count instead makes the presentation 1:1. It costs
|
||||||
|
/// what the extra pixels cost — 56% more work at 1.25 — and that cost is the
|
||||||
|
/// requirement: the alternative is not cheaper, it is blurrier.
|
||||||
|
///
|
||||||
|
/// A `scale` of zero or worse is clamped rather than trusted. It arrives from
|
||||||
|
/// the windowing system, and a zero would collapse the render target to one
|
||||||
|
/// pixel and blank the canvas.
|
||||||
|
pub(crate) fn physical(logical: (u32, u32), scale: f32) -> (u32, u32) {
|
||||||
|
let scale = if scale.is_finite() && scale > 0.01 {
|
||||||
|
scale
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let convert = |v: u32| ((v as f32) * scale).round().max(1.0) as u32;
|
||||||
|
(convert(logical.0), convert(logical.1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The geometry the window had when the display was last resolved.
|
||||||
|
///
|
||||||
|
/// Position *and* scale factor, because either changing can mean a different
|
||||||
|
/// display: dragging across a seam moves the window, and a compositor that
|
||||||
|
/// rescales in place — a fractional-scaling change, or a monitor unplugged
|
||||||
|
/// from under a window — changes only the second.
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
struct Geometry {
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
scale_milli: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Geometry {
|
||||||
|
fn of(window: &AppWindow) -> Self {
|
||||||
|
let position = window.window().position();
|
||||||
|
Self {
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
// Compared as thousandths rather than as a float: this is an
|
||||||
|
// equality test running twice a second, and a scale factor that
|
||||||
|
// differs in its last bit must not read as a display change and
|
||||||
|
// provoke a re-survey on every tick.
|
||||||
|
scale_milli: (window.window().scale_factor().max(0.0) * 1000.0) as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the window needs to keep the canvas on the right display.
|
||||||
|
pub(crate) struct DisplayWatch {
|
||||||
|
survey: RefCell<DisplaySurvey>,
|
||||||
|
/// Which entry of the survey is showing the canvas.
|
||||||
|
current: Cell<usize>,
|
||||||
|
seen: Cell<Option<Geometry>>,
|
||||||
|
/// The canvas's size in *logical* pixels, as Slint last reported it.
|
||||||
|
///
|
||||||
|
/// Kept so that a scale-factor change can re-derive the physical size
|
||||||
|
/// without waiting for a resize that may never come: moving a window
|
||||||
|
/// between a 1× and a 2× display changes what to render and not how large
|
||||||
|
/// the box is.
|
||||||
|
logical_canvas: Cell<(u32, u32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayWatch {
|
||||||
|
/// Ask the platform once, at startup.
|
||||||
|
pub(crate) fn probe() -> Rc<Self> {
|
||||||
|
let survey = DisplaySurvey::probe();
|
||||||
|
log::info!(
|
||||||
|
"display server: {}, {} display(s)",
|
||||||
|
survey.server.label(),
|
||||||
|
survey.displays.len()
|
||||||
|
);
|
||||||
|
for display in &survey.displays {
|
||||||
|
log::info!(" {}: {}", display.name, display.profile.describe());
|
||||||
|
}
|
||||||
|
Rc::new(Self {
|
||||||
|
survey: RefCell::new(survey),
|
||||||
|
current: Cell::new(0),
|
||||||
|
seen: Cell::new(None),
|
||||||
|
logical_canvas: Cell::new((1024, 768)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The output space the canvas should be encoded into right now.
|
||||||
|
pub(crate) fn space(&self) -> dr_types::ColourSpace {
|
||||||
|
self.survey.borrow().profile(self.current.get()).space
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the canvas's logical size and return the size to render at.
|
||||||
|
pub(crate) fn canvas_resized(&self, window: &AppWindow, logical: (u32, u32)) -> (u32, u32) {
|
||||||
|
self.logical_canvas.set(logical);
|
||||||
|
physical(logical, window.window().scale_factor())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The size the canvas should be rendered at, from the last known logical
|
||||||
|
/// size and the current scale factor.
|
||||||
|
fn canvas_physical(&self, window: &AppWindow) -> (u32, u32) {
|
||||||
|
physical(self.logical_canvas.get(), window.window().scale_factor())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the About-page readouts.
|
||||||
|
fn publish(&self, window: &AppWindow) {
|
||||||
|
let Some(readouts) = readouts(&self.survey.borrow(), self.current.get()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
window.set_display_name(readouts.name.into());
|
||||||
|
window.set_display_colour(readouts.colour.into());
|
||||||
|
window.set_display_others(readouts.others.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-ask the platform and re-resolve which display is showing the canvas.
|
||||||
|
///
|
||||||
|
/// Returns whether the output space changed, which is what decides a
|
||||||
|
/// redraw. Nothing is pushed into the develop session from here: the
|
||||||
|
/// session is asked for its space on the way into every render (see
|
||||||
|
/// `render_now` in the crate root), so "the canvas is composed for the
|
||||||
|
/// display showing it" is structural rather than something a callback has
|
||||||
|
/// to remember to do. A session opened while the window sits on the second
|
||||||
|
/// monitor is then right on its first frame, without this having to know
|
||||||
|
/// that a photograph was opened at all.
|
||||||
|
fn resolve(&self, window: &AppWindow) -> bool {
|
||||||
|
let before = self.space();
|
||||||
|
*self.survey.borrow_mut() = DisplaySurvey::probe();
|
||||||
|
|
||||||
|
// The window's *centre*, in the display server's own screen
|
||||||
|
// coordinates. A window straddling a seam is showing more of itself on
|
||||||
|
// one side, and its centre says which — where its top-left corner
|
||||||
|
// would flip the transform as soon as one pixel crossed.
|
||||||
|
let position = window.window().position();
|
||||||
|
let size = window.window().size();
|
||||||
|
let centre_x = position.x.saturating_add_unsigned(size.width / 2);
|
||||||
|
let centre_y = position.y.saturating_add_unsigned(size.height / 2);
|
||||||
|
|
||||||
|
let index = self.survey.borrow().containing(centre_x, centre_y);
|
||||||
|
self.current.set(index);
|
||||||
|
self.publish(window);
|
||||||
|
self.space() != before
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The three strings the About page shows about the session's colour path.
|
||||||
|
pub(crate) struct Readouts {
|
||||||
|
pub name: String,
|
||||||
|
pub colour: String,
|
||||||
|
pub others: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-8
|
||||||
|
/// Turn a survey into what the About page says.
|
||||||
|
///
|
||||||
|
/// A free function over the survey rather than a method that writes into the
|
||||||
|
/// window, because the requirement's visibility clause is the part worth
|
||||||
|
/// asserting: FR-DSP-8 asks for a fallback that is *defined*, and a reason
|
||||||
|
/// that reaches the code but never the screen satisfies half of that. This is
|
||||||
|
/// where a test can hold the strings.
|
||||||
|
///
|
||||||
|
/// `None` only for a survey with no displays at all, which `probe` does not
|
||||||
|
/// produce.
|
||||||
|
pub(crate) fn readouts(survey: &DisplaySurvey, index: usize) -> Option<Readouts> {
|
||||||
|
// Clamped rather than indexed: a monitor unplugged between the survey and
|
||||||
|
// this call leaves an index pointing past the end, and the page must
|
||||||
|
// describe *a* display rather than nothing.
|
||||||
|
let index = if index < survey.displays.len() {
|
||||||
|
index
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let here = survey.displays.get(index)?;
|
||||||
|
|
||||||
|
// The other monitors, listed rather than hidden. FR-DSP-8 is a
|
||||||
|
// multi-monitor requirement and the failure it names — the second display
|
||||||
|
// showing wrong colours — is invisible from the first, so a page that
|
||||||
|
// described only the display it was being read on would report nothing
|
||||||
|
// about the case that matters.
|
||||||
|
let others: Vec<String> = survey
|
||||||
|
.displays
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(i, _)| *i != index)
|
||||||
|
.map(|(_, d)| format!("{}: {}", d.name, d.profile.describe()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Some(Readouts {
|
||||||
|
name: format!("{} ({})", here.name, survey.server.label()),
|
||||||
|
colour: here.profile.describe(),
|
||||||
|
others: others.join(" · "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start following the window's display, and answer once immediately.
|
||||||
|
///
|
||||||
|
/// `redraw` is the window's ordinary re-render, so a display change costs
|
||||||
|
/// exactly one recomposition and one dispatch — the property the fused design
|
||||||
|
/// was always going to give us here (`compose_with_framing`'s output space is
|
||||||
|
/// a parameter, and enters the structure hash).
|
||||||
|
pub(crate) fn attach(
|
||||||
|
window: &AppWindow,
|
||||||
|
watch: &Rc<DisplayWatch>,
|
||||||
|
viewport: &Rc<RefCell<(u32, u32)>>,
|
||||||
|
redraw: Rc<dyn Fn(&AppWindow)>,
|
||||||
|
) {
|
||||||
|
// The first answer, before any frame is drawn. Without it the canvas's
|
||||||
|
// first render is sRGB on every desktop and corrects itself half a second
|
||||||
|
// later, which on a wide-gamut panel is a visible flash of the wrong
|
||||||
|
// colour on every image opened.
|
||||||
|
watch.seen.set(Some(Geometry::of(window)));
|
||||||
|
watch.resolve(window);
|
||||||
|
|
||||||
|
let timer = slint::Timer::default();
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let watch = watch.clone();
|
||||||
|
let viewport = viewport.clone();
|
||||||
|
timer.start(slint::TimerMode::Repeated, POLL, move || {
|
||||||
|
let Some(window) = weak.upgrade() else { return };
|
||||||
|
let now = Geometry::of(&window);
|
||||||
|
if watch.seen.get() == Some(now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
watch.seen.set(Some(now));
|
||||||
|
|
||||||
|
// The physical size follows the scale factor, so a move onto a
|
||||||
|
// differently-scaled display changes what to render as well as what
|
||||||
|
// to render it into (FR-DSP-8's two halves arriving together).
|
||||||
|
let size = watch.canvas_physical(&window);
|
||||||
|
let resized = *viewport.borrow() != size;
|
||||||
|
if resized {
|
||||||
|
*viewport.borrow_mut() = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if watch.resolve(&window) || resized {
|
||||||
|
redraw(&window);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// The timer stops when it is dropped, and it must outlive this function.
|
||||||
|
// Leaked deliberately rather than threaded through the window's state:
|
||||||
|
// it lives exactly as long as the process, and there is nothing that
|
||||||
|
// could sensibly stop it.
|
||||||
|
std::mem::forget(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use dr_plat::{Bounds, DisplayInfo, DisplayProfile, DisplayServer, FallbackReason};
|
||||||
|
|
||||||
|
fn two_monitors() -> DisplaySurvey {
|
||||||
|
DisplaySurvey {
|
||||||
|
server: DisplayServer::X11,
|
||||||
|
displays: vec![
|
||||||
|
DisplayInfo {
|
||||||
|
name: "eDP-1".to_string(),
|
||||||
|
bounds: Some(Bounds {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1920,
|
||||||
|
height: 1200,
|
||||||
|
}),
|
||||||
|
profile: DisplayProfile::fallback(FallbackReason::NoWaylandProtocol),
|
||||||
|
},
|
||||||
|
DisplayInfo {
|
||||||
|
name: "DP-2".to_string(),
|
||||||
|
bounds: Some(Bounds {
|
||||||
|
x: 1920,
|
||||||
|
y: 0,
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
}),
|
||||||
|
profile: DisplayProfile {
|
||||||
|
space: dr_types::ColourSpace::DisplayP3,
|
||||||
|
source: dr_plat::ProfileSource::X11RootProperty(
|
||||||
|
"_ICC_PROFILE_1".to_string(),
|
||||||
|
),
|
||||||
|
described_as: Some("EIZO CG279X".to_string()),
|
||||||
|
approximated: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-8
|
||||||
|
/// The fallback is defined *and visible*, which is what the requirement
|
||||||
|
/// asks for and the half that is easy to leave out.
|
||||||
|
#[test]
|
||||||
|
fn the_about_page_says_which_acquisition_path_the_session_is_on() {
|
||||||
|
let survey = two_monitors();
|
||||||
|
let shown = readouts(&survey, 0).expect("a display");
|
||||||
|
|
||||||
|
assert!(shown.name.contains("eDP-1"), "{}", shown.name);
|
||||||
|
assert!(shown.name.contains("X11"), "{}", shown.name);
|
||||||
|
// A photographer being shown sRGB because the compositor would not say
|
||||||
|
// otherwise must be able to find that out rather than wonder.
|
||||||
|
assert!(shown.colour.contains("sRGB"), "{}", shown.colour);
|
||||||
|
assert!(shown.colour.contains("assumed"), "{}", shown.colour);
|
||||||
|
assert!(
|
||||||
|
shown.colour.contains("colour management"),
|
||||||
|
"the reason for the fallback did not reach the page: {}",
|
||||||
|
shown.colour
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-8
|
||||||
|
/// The second monitor is described on the page read on the first.
|
||||||
|
///
|
||||||
|
/// The requirement's whole subject is the display the reader is *not*
|
||||||
|
/// looking at: "showing wrong colours on the second display is a
|
||||||
|
/// correctness defect". A page that listed only the current one would say
|
||||||
|
/// nothing about the case it exists for.
|
||||||
|
#[test]
|
||||||
|
fn the_other_monitor_is_named_on_the_page_read_from_this_one() {
|
||||||
|
let survey = two_monitors();
|
||||||
|
let shown = readouts(&survey, 0).expect("a display");
|
||||||
|
assert!(shown.others.contains("DP-2"), "{}", shown.others);
|
||||||
|
assert!(shown.others.contains("Display P3"), "{}", shown.others);
|
||||||
|
// Approximated, and saying so. An honest approximation the user can
|
||||||
|
// see is the whole trade made in `dr_plat::display`.
|
||||||
|
assert!(shown.others.contains("nearest to"), "{}", shown.others);
|
||||||
|
assert!(shown.others.contains("EIZO CG279X"), "{}", shown.others);
|
||||||
|
|
||||||
|
// And from the second monitor's point of view, the first is the other.
|
||||||
|
let shown = readouts(&survey, 1).expect("a display");
|
||||||
|
assert!(shown.colour.contains("Display P3"), "{}", shown.colour);
|
||||||
|
assert!(shown.others.contains("eDP-1"), "{}", shown.others);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single-monitor desktop leaves the row empty rather than repeating
|
||||||
|
/// itself, which is what makes the row conditional in `settings.slint`.
|
||||||
|
#[test]
|
||||||
|
fn one_display_has_no_others_to_list() {
|
||||||
|
let survey = DisplaySurvey::assumed_srgb(FallbackReason::NoWaylandProtocol);
|
||||||
|
let shown = readouts(&survey, 0).expect("a display");
|
||||||
|
assert!(shown.others.is_empty(), "{}", shown.others);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-8
|
||||||
|
/// Fractional scaling renders more pixels, not the same pixels stretched.
|
||||||
|
#[test]
|
||||||
|
fn a_fractionally_scaled_canvas_is_rendered_at_its_physical_size() {
|
||||||
|
// GNOME's fractional steps, against a canvas of a plausible size. The
|
||||||
|
// failure being guarded is silent: at 1.25 the compositor is handed
|
||||||
|
// 1600 samples for 2000 device pixels and the photograph goes soft.
|
||||||
|
assert_eq!(physical((1600, 1000), 1.25), (2000, 1250));
|
||||||
|
assert_eq!(physical((1600, 1000), 1.5), (2400, 1500));
|
||||||
|
assert_eq!(physical((1600, 1000), 1.75), (2800, 1750));
|
||||||
|
assert_eq!(physical((1600, 1000), 2.0), (3200, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unscaled_display_renders_exactly_what_it_is_asked_for() {
|
||||||
|
// The other half of the same requirement: 1:1 must stay 1:1 rather
|
||||||
|
// than acquiring a rounding error that resamples every frame by a
|
||||||
|
// fraction of a pixel.
|
||||||
|
assert_eq!(physical((1923, 1081), 1.0), (1923, 1081));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nonsense_scale_factor_does_not_blank_the_canvas() {
|
||||||
|
// It comes from the windowing system, and a zero would collapse the
|
||||||
|
// render target to a single pixel with no error anywhere.
|
||||||
|
assert_eq!(physical((800, 600), 0.0), (800, 600));
|
||||||
|
assert_eq!(physical((800, 600), f32::NAN), (800, 600));
|
||||||
|
assert_eq!(physical((800, 600), -2.0), (800, 600));
|
||||||
|
// And a canvas that has been collapsed to nothing by a dragged
|
||||||
|
// splitter still has to produce a renderable target.
|
||||||
|
assert_eq!(physical((0, 0), 2.0), (1, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,8 +61,8 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use dr_export::{Encoded, NameContext};
|
use dr_export::{Encoded, NameContext};
|
||||||
use dr_sync::{RemoteBackend, RemotePath};
|
use dr_sync::RemotePath;
|
||||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
use dr_sync_nextcloud::AppCredentials;
|
||||||
use dr_types::{ExportSettings, ExportTarget};
|
use dr_types::{ExportSettings, ExportTarget};
|
||||||
|
|
||||||
use crate::AppWindow;
|
use crate::AppWindow;
|
||||||
@@ -339,7 +339,7 @@ pub fn spawn_upload(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(UploadMessage::Finished {
|
let _ = tx.send(UploadMessage::Finished {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ use std::sync::Arc;
|
|||||||
use dr_ingest::{Candidate, DupKey, Imported, Ingest, Options, Report, Shot, TransferMode};
|
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;
|
||||||
use dr_types::{FormatFilter, RootId};
|
use dr_types::{FormatFilter, RootId};
|
||||||
|
|
||||||
/// Which root the card is granted as, and which the library is.
|
/// Which root the card is granted as, and which the library is.
|
||||||
@@ -385,7 +385,7 @@ fn upload_all(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&upload.credentials, &upload.user_id) {
|
let backend = match crate::remote::connect(&upload.credentials, &upload.user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("connecting to upload: {e}");
|
log::warn!("connecting to upload: {e}");
|
||||||
@@ -423,7 +423,7 @@ fn upload_all(
|
|||||||
|
|
||||||
// The same folder segments the local copy went into, so the two
|
// The same folder segments the local copy went into, so the two
|
||||||
// libraries have the same shape (FR-NC-7a).
|
// libraries have the same shape (FR-NC-7a).
|
||||||
match dr_sync::upload_original(&backend, &root, &image.folders, &image.name, bytes)
|
match dr_sync::upload_original(&*backend, &root, &image.folders, &image.name, bytes)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(placed) => {
|
Ok(placed) => {
|
||||||
@@ -445,7 +445,7 @@ fn upload_all(
|
|||||||
Err(e) => log::warn!("uploading {}: {e}", image.name),
|
Err(e) => log::warn!("uploading {}: {e}", image.name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let filed = file_thumbnails(&backend, &root, imported, &sent, &upload.thumbs).await;
|
let filed = file_thumbnails(&*backend, &root, imported, &sent, &upload.thumbs).await;
|
||||||
(sent, filed)
|
(sent, filed)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -502,7 +502,7 @@ fn make_thumbnail(bytes: &[u8]) -> Option<dr_thumbs::Thumbnail> {
|
|||||||
/// Best-effort throughout. A thumbnail that cannot be filed costs the grid one
|
/// Best-effort throughout. A thumbnail that cannot be filed costs the grid one
|
||||||
/// preview fetch later; failing the import over it would be the wrong trade.
|
/// preview fetch later; failing the import over it would be the wrong trade.
|
||||||
async fn file_thumbnails(
|
async fn file_thumbnails(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
library: &RemotePath,
|
library: &RemotePath,
|
||||||
imported: &[Imported],
|
imported: &[Imported],
|
||||||
sent: &[Sent],
|
sent: &[Sent],
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ use std::cell::RefCell;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use dr_plat::PlatformSecretStore;
|
use dr_plat::PlatformSecretStore;
|
||||||
use dr_sync::{RemoteBackend, RemotePath};
|
use dr_sync::RemotePath;
|
||||||
use dr_sync_nextcloud::{auth, NextcloudBackend, Session, SessionStore};
|
use dr_sync_nextcloud::{auth, Session, SessionStore};
|
||||||
|
|
||||||
use slint::ComponentHandle;
|
use slint::ComponentHandle;
|
||||||
|
|
||||||
@@ -608,7 +608,7 @@ fn spawn_folder_list(weak: slint::Weak<AppWindow>, ctl: Rc<LaunchController>, pa
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
match NextcloudBackend::new(&creds, &user_id) {
|
match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
|
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
|
||||||
Ok(entries) => {
|
Ok(entries) => {
|
||||||
let mut dirs: Vec<String> = entries
|
let mut dirs: Vec<String> = entries
|
||||||
|
|||||||
+44
-1
@@ -23,6 +23,7 @@ mod activity;
|
|||||||
mod collections_ui;
|
mod collections_ui;
|
||||||
mod derived_sync;
|
mod derived_sync;
|
||||||
mod develop;
|
mod develop;
|
||||||
|
mod display_ui;
|
||||||
mod export;
|
mod export;
|
||||||
pub mod faces;
|
pub mod faces;
|
||||||
mod gradient;
|
mod gradient;
|
||||||
@@ -39,6 +40,7 @@ mod live_style;
|
|||||||
mod masks_ui;
|
mod masks_ui;
|
||||||
mod net_runtime;
|
mod net_runtime;
|
||||||
mod presets;
|
mod presets;
|
||||||
|
mod remote;
|
||||||
mod segmentation;
|
mod segmentation;
|
||||||
mod settings_store;
|
mod settings_store;
|
||||||
mod settings_ui;
|
mod settings_ui;
|
||||||
@@ -1381,8 +1383,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
|
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
|
||||||
window.set_adjust_rows(rows.clone().into());
|
window.set_adjust_rows(rows.clone().into());
|
||||||
// Viewport size, tracked so a re-render after a slider move matches it.
|
// Viewport size, tracked so a re-render after a slider move matches it.
|
||||||
|
//
|
||||||
|
// In *physical* pixels, which is what the render target wants and what
|
||||||
|
// FR-DSP-8 means by handling fractional scaling without resampling. The
|
||||||
|
// one caller that thinks in logical pixels is Slint's resize callback,
|
||||||
|
// and it converts on the way in.
|
||||||
let viewport = Rc::new(RefCell::new((1024u32, 768u32)));
|
let viewport = Rc::new(RefCell::new((1024u32, 768u32)));
|
||||||
|
|
||||||
|
// TRACES: FR-DSP-8
|
||||||
|
// What the displays are, and which one the canvas is on. Probed once here
|
||||||
|
// so that the About page can describe the session's colour path before any
|
||||||
|
// photograph is opened — the acquisition path is a property of the desktop
|
||||||
|
// and not of the image.
|
||||||
|
let display = display_ui::DisplayWatch::probe();
|
||||||
|
|
||||||
// Re-render the current session into the canvas.
|
// Re-render the current session into the canvas.
|
||||||
//
|
//
|
||||||
// Called on every slider change, so it must do no more than run the
|
// Called on every slider change, so it must do no more than run the
|
||||||
@@ -1397,10 +1411,26 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let viewport = viewport.clone();
|
let viewport = viewport.clone();
|
||||||
let drawn_history = drawn_history.clone();
|
let drawn_history = drawn_history.clone();
|
||||||
|
let display = display.clone();
|
||||||
Rc::new(move |window: &AppWindow, draft: bool| {
|
Rc::new(move |window: &AppWindow, draft: bool| {
|
||||||
let mut slot = session.borrow_mut();
|
let mut slot = session.borrow_mut();
|
||||||
let Some(s) = slot.as_mut() else { return };
|
let Some(s) = slot.as_mut() else { return };
|
||||||
|
|
||||||
|
// TRACES: FR-DSP-8 | FR-DSP-6
|
||||||
|
// **Every canvas render is encoded for the display showing it.**
|
||||||
|
//
|
||||||
|
// Set here rather than pushed from the display watch, and rather
|
||||||
|
// than set once when a photograph is opened, because this is the
|
||||||
|
// one path every frame takes. A session opened while the window
|
||||||
|
// sits on the second monitor is then correct on its *first* frame
|
||||||
|
// — where a push would leave it sRGB until the next poll, which is
|
||||||
|
// a visible flash of the wrong colour on every image opened.
|
||||||
|
//
|
||||||
|
// Free when nothing has changed: the field is compared before it
|
||||||
|
// is written, and an unchanged output space composes to the same
|
||||||
|
// structure hash and the same cached pipeline.
|
||||||
|
s.set_display_space(display.space());
|
||||||
|
|
||||||
// TRACES: FR-DEV-5
|
// TRACES: FR-DEV-5
|
||||||
// Whether undo has anywhere to go, pushed from here because every
|
// Whether undo has anywhere to go, pushed from here because every
|
||||||
// edit ends in a redraw and nothing else is on all of their paths:
|
// edit ends in a redraw and nothing else is on all of their paths:
|
||||||
@@ -2574,13 +2604,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
|
|
||||||
// Track the canvas size so the adjust pass renders at viewport
|
// Track the canvas size so the adjust pass renders at viewport
|
||||||
// resolution rather than sensor resolution (FR-DSP-1).
|
// resolution rather than sensor resolution (FR-DSP-1).
|
||||||
|
//
|
||||||
|
// TRACES: FR-DSP-8
|
||||||
|
// Slint reports the canvas in *logical* pixels, which is the box the
|
||||||
|
// compositor will draw into and not the number of device pixels it will
|
||||||
|
// fill. `display_ui::physical` converts, so that a fractionally scaled
|
||||||
|
// desktop is presented 1:1 rather than resampled — see that function for
|
||||||
|
// why a soft canvas is the failure being avoided here.
|
||||||
{
|
{
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let viewport = viewport.clone();
|
let viewport = viewport.clone();
|
||||||
let redraw = redraw.clone();
|
let redraw = redraw.clone();
|
||||||
|
let display = display.clone();
|
||||||
window.on_canvas_resized(move |w_px, h_px| {
|
window.on_canvas_resized(move |w_px, h_px| {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
let size = (w_px.max(1) as u32, h_px.max(1) as u32);
|
let logical = (w_px.max(1) as u32, h_px.max(1) as u32);
|
||||||
|
let size = display.canvas_resized(&w, logical);
|
||||||
if *viewport.borrow() == size {
|
if *viewport.borrow() == size {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2589,6 +2628,10 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DSP-8 | FR-DSP-6
|
||||||
|
// And which display that canvas is on, from now until the window closes.
|
||||||
|
display_ui::attach(&window, &display, &viewport, redraw.clone());
|
||||||
|
|
||||||
// FR-UI-1: layout class from window width. Computed here rather than in
|
// FR-UI-1: layout class from window width. Computed here rather than in
|
||||||
// Slint because a property that both derives from and feeds the layout is
|
// Slint because a property that both derives from and feeds the layout is
|
||||||
// a binding loop.
|
// a binding loop.
|
||||||
|
|||||||
+22
-22
@@ -26,7 +26,7 @@ use std::sync::mpsc::{Receiver, Sender};
|
|||||||
|
|
||||||
use dr_catalog::{Catalog, JobKind, Priority};
|
use dr_catalog::{Catalog, JobKind, Priority};
|
||||||
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
use dr_sync::{RemoteBackend, RemoteId, RemotePath};
|
||||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
use dr_sync_nextcloud::AppCredentials;
|
||||||
use dr_thumbs::ThumbStore;
|
use dr_thumbs::ThumbStore;
|
||||||
|
|
||||||
use crate::sidecar_cache::SidecarCache;
|
use crate::sidecar_cache::SidecarCache;
|
||||||
@@ -541,11 +541,11 @@ pub fn spawn_sidecar_writes(
|
|||||||
let report = match rt {
|
let report = match rt {
|
||||||
None => queue_all(),
|
None => queue_all(),
|
||||||
Some(rt) => rt.block_on(async {
|
Some(rt) => rt.block_on(async {
|
||||||
match NextcloudBackend::new(&creds, &user_id) {
|
match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => {
|
Ok(b) => {
|
||||||
let mut report = SidecarReport::default();
|
let mut report = SidecarReport::default();
|
||||||
for w in &writes {
|
for w in &writes {
|
||||||
match write_one_sidecar_online(&b, &cache, w).await {
|
match write_one_sidecar_online(&*b, &cache, w).await {
|
||||||
Ok(Outcome::Uploaded) => report.written += 1,
|
Ok(Outcome::Uploaded) => report.written += 1,
|
||||||
Ok(Outcome::Queued) => report.queued += 1,
|
Ok(Outcome::Queued) => report.queued += 1,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -707,7 +707,7 @@ fn write_one_sidecar(cache: &SidecarCache, w: &SidecarWrite) -> Result<Outcome,
|
|||||||
/// TRACES: FR-CAT-8 | FR-CAT-9
|
/// TRACES: FR-CAT-8 | FR-CAT-9
|
||||||
/// Read-modify-write one sidecar, with a server to read from and send to.
|
/// Read-modify-write one sidecar, with a server to read from and send to.
|
||||||
async fn write_one_sidecar_online(
|
async fn write_one_sidecar_online(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
cache: &SidecarCache,
|
cache: &SidecarCache,
|
||||||
w: &SidecarWrite,
|
w: &SidecarWrite,
|
||||||
) -> Result<Outcome, String> {
|
) -> Result<Outcome, String> {
|
||||||
@@ -810,7 +810,7 @@ pub fn spawn_outbox_drain(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(SidecarMessage::Finished {
|
let _ = tx.send(SidecarMessage::Finished {
|
||||||
@@ -825,7 +825,7 @@ pub fn spawn_outbox_drain(
|
|||||||
|
|
||||||
let mut report = SidecarReport::default();
|
let mut report = SidecarReport::default();
|
||||||
for path_str in &queued {
|
for path_str in &queued {
|
||||||
match drain_one(&backend, &cache, path_str).await {
|
match drain_one(&*backend, &cache, path_str).await {
|
||||||
Ok(()) => report.written += 1,
|
Ok(()) => report.written += 1,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Warn, for the reason the write path does: this is
|
// Warn, for the reason the write path does: this is
|
||||||
@@ -854,7 +854,7 @@ pub fn spawn_outbox_drain(
|
|||||||
|
|
||||||
/// Reconcile one queued sidecar with the server and upload it.
|
/// Reconcile one queued sidecar with the server and upload it.
|
||||||
async fn drain_one(
|
async fn drain_one(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
cache: &SidecarCache,
|
cache: &SidecarCache,
|
||||||
path_str: &str,
|
path_str: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -1100,14 +1100,14 @@ fn run_scan(
|
|||||||
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
|
let rt = crate::net_runtime::build().map_err(ScanFailure::local)?;
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = NextcloudBackend::new(&creds, &user_id).map_err(ScanFailure::local)?;
|
let backend = crate::remote::connect(&creds, &user_id).map_err(ScanFailure::local)?;
|
||||||
|
|
||||||
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
|
// Stored folder ETags, so an unchanged subtree is skipped whole. On a
|
||||||
// first run this is empty and the walk is complete; on every run after
|
// first run this is empty and the walk is complete; on every run after
|
||||||
// it is what keeps cost proportional to what changed (ARCH §8.4).
|
// it is what keeps cost proportional to what changed (ARCH §8.4).
|
||||||
let known = load_folder_etags(&catalog, &root);
|
let known = load_folder_etags(&catalog, &root);
|
||||||
|
|
||||||
let result = dr_sync::scan(&backend, &RemotePath::new(&root), &filter, &known, |p| {
|
let result = dr_sync::scan(&*backend, &RemotePath::new(&root), &filter, &known, |p| {
|
||||||
let _ = tx.send(ScanMessage::Progress {
|
let _ = tx.send(ScanMessage::Progress {
|
||||||
directories: p.directories_listed,
|
directories: p.directories_listed,
|
||||||
pruned: p.directories_pruned,
|
pruned: p.directories_pruned,
|
||||||
@@ -1446,7 +1446,7 @@ pub fn spawn_pin_fetch(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(PinMessage::Failed {
|
let _ = tx.send(PinMessage::Failed {
|
||||||
@@ -1635,7 +1635,7 @@ pub fn spawn_sidecar_fetch(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!("sidecar fetch backend: {e}");
|
log::debug!("sidecar fetch backend: {e}");
|
||||||
@@ -1723,7 +1723,7 @@ pub fn spawn_full_fetch(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(Err(FetchFailure::local(e)));
|
let _ = tx.send(Err(FetchFailure::local(e)));
|
||||||
@@ -1873,7 +1873,7 @@ pub fn spawn_thumbnails(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
for req in &to_fetch {
|
for req in &to_fetch {
|
||||||
@@ -1898,7 +1898,7 @@ pub fn spawn_thumbnails(
|
|||||||
let mut offline = false;
|
let mut offline = false;
|
||||||
|
|
||||||
for req in to_fetch {
|
for req in to_fetch {
|
||||||
let msg = fetch_one(&backend, store.as_mut(), &req, &mut found).await;
|
let msg = fetch_one(&*backend, store.as_mut(), &req, &mut found).await;
|
||||||
offline = matches!(msg, ThumbnailMessage::Offline { .. });
|
offline = matches!(msg, ThumbnailMessage::Offline { .. });
|
||||||
// A closed channel means the window went away mid-fetch.
|
// A closed channel means the window went away mid-fetch.
|
||||||
if tx.send(msg).is_err() || offline {
|
if tx.send(msg).is_err() || offline {
|
||||||
@@ -1923,7 +1923,7 @@ pub fn spawn_thumbnails(
|
|||||||
if tx.send(ThumbnailMessage::DateProgress).is_err() {
|
if tx.send(ThumbnailMessage::DateProgress).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
read_metadata_only(&backend, &req, &mut found).await;
|
read_metadata_only(&*backend, &req, &mut found).await;
|
||||||
|
|
||||||
if found.len() >= FLUSH_EVERY {
|
if found.len() >= FLUSH_EVERY {
|
||||||
flush_metadata(&catalog_path, &mut found, &tx);
|
flush_metadata(&catalog_path, &mut found, &tx);
|
||||||
@@ -1942,7 +1942,7 @@ pub fn spawn_thumbnails(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_one(
|
async fn fetch_one(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
store: Option<&mut ThumbStore>,
|
store: Option<&mut ThumbStore>,
|
||||||
req: &ThumbnailRequest,
|
req: &ThumbnailRequest,
|
||||||
found_metadata: &mut Vec<MetadataFound>,
|
found_metadata: &mut Vec<MetadataFound>,
|
||||||
@@ -1999,7 +1999,7 @@ enum PreviewOutcome {
|
|||||||
/// the embedded JPEG partway through, and decoders render a truncated JPEG as
|
/// the embedded JPEG partway through, and decoders render a truncated JPEG as
|
||||||
/// the top fraction of the frame rather than reporting an error.
|
/// the top fraction of the frame rather than reporting an error.
|
||||||
async fn fetch_preview(
|
async fn fetch_preview(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
req: &ThumbnailRequest,
|
req: &ThumbnailRequest,
|
||||||
found_metadata: &mut Vec<MetadataFound>,
|
found_metadata: &mut Vec<MetadataFound>,
|
||||||
) -> PreviewOutcome {
|
) -> PreviewOutcome {
|
||||||
@@ -2219,7 +2219,7 @@ fn flush_metadata(
|
|||||||
/// happened both leave `found` untouched, and recording the second as "this
|
/// happened both leave `found` untouched, and recording the second as "this
|
||||||
/// image has no date" would let one lock mark it dateless for good.
|
/// image has no date" would let one lock mark it dateless for good.
|
||||||
async fn read_metadata_only(
|
async fn read_metadata_only(
|
||||||
backend: &NextcloudBackend,
|
backend: &dyn RemoteBackend,
|
||||||
req: &ThumbnailRequest,
|
req: &ThumbnailRequest,
|
||||||
found: &mut Vec<MetadataFound>,
|
found: &mut Vec<MetadataFound>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
@@ -2444,7 +2444,7 @@ pub fn spawn_sweep(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let Ok(backend) = NextcloudBackend::new(&creds, &user_id) else {
|
let Ok(backend) = crate::remote::connect(&creds, &user_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2483,7 +2483,7 @@ pub fn spawn_sweep(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let results = futures_join_all(lanes.into_iter().map(|lane| {
|
let results = futures_join_all(lanes.into_iter().map(|lane| {
|
||||||
let backend = &backend;
|
let backend: &dyn RemoteBackend = &*backend;
|
||||||
async move {
|
async move {
|
||||||
let mut found = Vec::new();
|
let mut found = Vec::new();
|
||||||
let mut reached = Vec::new();
|
let mut reached = Vec::new();
|
||||||
@@ -3213,7 +3213,7 @@ pub fn spawn_thumbnail_sweep(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("thumbnail sweep: {e}");
|
log::warn!("thumbnail sweep: {e}");
|
||||||
@@ -3235,7 +3235,7 @@ pub fn spawn_thumbnail_sweep(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let results = futures_join_all(lanes.into_iter().map(|lane| {
|
let results = futures_join_all(lanes.into_iter().map(|lane| {
|
||||||
let backend = &backend;
|
let backend: &dyn RemoteBackend = &*backend;
|
||||||
async move {
|
async move {
|
||||||
let mut made: Vec<(u64, dr_thumbs::Thumbnail)> = Vec::new();
|
let mut made: Vec<(u64, dr_thumbs::Thumbnail)> = Vec::new();
|
||||||
let mut found = Vec::new();
|
let mut found = Vec::new();
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// TRACES: FR-NC-12
|
||||||
|
//! The one place the interface names a backend.
|
||||||
|
//!
|
||||||
|
//! `dr-sync` defines [`RemoteBackend`] and a capability model the engine adapts
|
||||||
|
//! to, so that a second backend can be added without touching the code that
|
||||||
|
//! uses one (ARCH §8.1). Until this module existed that boundary was
|
||||||
|
//! documentation: seven files in `dr-ui` constructed a `NextcloudBackend`
|
||||||
|
//! directly and ten functions took one by concrete type, so the abstraction
|
||||||
|
//! bought nothing it was designed for and a WebDAV or local-folder backend
|
||||||
|
//! would have had nowhere to go.
|
||||||
|
//!
|
||||||
|
//! Everything above this module now works through `&dyn RemoteBackend`. Adding
|
||||||
|
//! a backend is implementing the trait and changing [`connect`] — not editing
|
||||||
|
//! seven files.
|
||||||
|
//!
|
||||||
|
//! ## What is deliberately still Nextcloud-shaped
|
||||||
|
//!
|
||||||
|
//! Credentials. [`AppCredentials`] is an app password obtained through Login
|
||||||
|
//! Flow v2, which is a Nextcloud protocol rather than a general notion of
|
||||||
|
//! "how one authenticates to a remote". Abstracting it needs a decision about
|
||||||
|
//! what an account *is* across backends — an OAuth token, a bucket key pair
|
||||||
|
//! and an app password have no useful common shape — and inventing one before
|
||||||
|
//! a second backend exists would produce a wrong answer confidently. That is
|
||||||
|
//! the remaining half of this seam, and it is a design problem rather than a
|
||||||
|
//! mechanical one.
|
||||||
|
|
||||||
|
use dr_sync::{RemoteBackend, RemoteError};
|
||||||
|
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
||||||
|
|
||||||
|
/// Open a connection to the configured remote.
|
||||||
|
///
|
||||||
|
/// Returns the trait object every caller should hold. The error type is
|
||||||
|
/// `dr-sync`'s rather than the connector's, so a caller handles a failure
|
||||||
|
/// without learning which backend produced it.
|
||||||
|
pub(crate) fn connect(
|
||||||
|
creds: &AppCredentials,
|
||||||
|
user_id: &str,
|
||||||
|
) -> Result<Box<dyn RemoteBackend>, RemoteError> {
|
||||||
|
Ok(Box::new(NextcloudBackend::new(creds, user_id)?))
|
||||||
|
}
|
||||||
@@ -619,8 +619,7 @@ pub fn spawn_folder_list(
|
|||||||
user_id: String,
|
user_id: String,
|
||||||
path: String,
|
path: String,
|
||||||
) {
|
) {
|
||||||
use dr_sync::{RemoteBackend, RemotePath};
|
use dr_sync::RemotePath;
|
||||||
use dr_sync_nextcloud::NextcloudBackend;
|
|
||||||
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
|
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<String>, String>>();
|
||||||
|
|
||||||
@@ -638,7 +637,7 @@ pub fn spawn_folder_list(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
match NextcloudBackend::new(&creds, &user_id) {
|
match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
|
Ok(b) => match b.list(&RemotePath::new(&path), None).await {
|
||||||
Ok(entries) => {
|
Ok(entries) => {
|
||||||
let mut dirs: Vec<String> = entries
|
let mut dirs: Vec<String> = entries
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ use std::path::PathBuf;
|
|||||||
use std::sync::mpsc::Receiver;
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
use dr_catalog::{trash, Catalog};
|
use dr_catalog::{trash, Catalog};
|
||||||
use dr_sync::{RemoteBackend, RemoteError, RemoteId, RemotePath};
|
use dr_sync::{RemoteError, RemoteId, RemotePath};
|
||||||
use dr_sync_nextcloud::{AppCredentials, NextcloudBackend};
|
use dr_sync_nextcloud::AppCredentials;
|
||||||
use dr_types::ImageId;
|
use dr_types::ImageId;
|
||||||
|
|
||||||
/// What a trash operation reports back to the UI.
|
/// What a trash operation reports back to the UI.
|
||||||
@@ -184,7 +184,7 @@ pub fn spawn_move(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(TrashMessage::Done {
|
let _ = tx.send(TrashMessage::Done {
|
||||||
@@ -297,7 +297,7 @@ pub fn spawn_purge(
|
|||||||
};
|
};
|
||||||
|
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let backend = match NextcloudBackend::new(&creds, &user_id) {
|
let backend = match crate::remote::connect(&creds, &user_id) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = tx.send(TrashMessage::Done {
|
let _ = tx.send(TrashMessage::Done {
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
// TRACES: FR-DEV-3a
|
||||||
|
//! The interface must not name an operation.
|
||||||
|
//!
|
||||||
|
//! This is the property the whole declarative pipeline rests on. A node in
|
||||||
|
//! `core/dr-pipeline/ops/` is one file precisely because nothing in `ui/` has
|
||||||
|
//! to learn about it: the panel builds controls from the parameter kinds the
|
||||||
|
//! core publishes and never asks which operation it is drawing (ARCH §4.3a,
|
||||||
|
//! FR-DEV-3a).
|
||||||
|
//!
|
||||||
|
//! Until now that was maintained by discipline alone, and the failure mode is
|
||||||
|
//! silent and cumulative. Special-casing one operation to fix a layout problem
|
||||||
|
//! is defensible on its own; by the fifth the panel names half the chain and
|
||||||
|
//! "a new operation is one file" has quietly stopped being true, without any
|
||||||
|
//! single commit having broken it.
|
||||||
|
//!
|
||||||
|
//! ## What counts as naming
|
||||||
|
//!
|
||||||
|
//! An operation id appearing as a **string literal** — `"exposure"`, in Rust
|
||||||
|
//! or in Slint. That is the form a special case takes, because the id is how
|
||||||
|
//! the panel would have to recognise the operation it wanted to treat
|
||||||
|
//! differently.
|
||||||
|
//!
|
||||||
|
//! Bare words are deliberately not matched. Three ids — `texture`, `contrast`
|
||||||
|
//! and `clarity` — are also ordinary graphics and English terms, and `texture`
|
||||||
|
//! in particular appears throughout `dr-ui` meaning a GPU texture. Matching
|
||||||
|
//! those would produce a test that fails constantly for reasons unrelated to
|
||||||
|
//! the invariant, which is a test people delete.
|
||||||
|
//!
|
||||||
|
//! ## What is exempt
|
||||||
|
//!
|
||||||
|
//! Code under `#[cfg(test)]`. The invariant is about the interface that ships;
|
||||||
|
//! a unit test asserting that `labels::resolve("clarity")` yields `"Clarity"`
|
||||||
|
//! is testing the localisation table, not special-casing an operation. There is
|
||||||
|
//! exactly one such case today, in `labels.rs`.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Below this, assume the scan broke rather than that the code is clean.
|
||||||
|
///
|
||||||
|
/// The dangerous failure of a test like this is not a false alarm, which
|
||||||
|
/// someone investigates — it is silently examining nothing and reporting
|
||||||
|
/// success. An early version cut each file at the first *textual* match of
|
||||||
|
/// `#[cfg(test)]`, which in `develop.rs` is a doc comment discussing the
|
||||||
|
/// attribute: it read 18% of the most important file in the scan and passed.
|
||||||
|
///
|
||||||
|
/// `dr-ui` is currently 75% shipping code by line. The floor is set well under
|
||||||
|
/// that so ordinary movement does not trip it, and well over the number a
|
||||||
|
/// parsing bug produces.
|
||||||
|
const MIN_SHIPPING_FRACTION: f64 = 0.60;
|
||||||
|
|
||||||
|
/// Every operation id the pipeline declares.
|
||||||
|
///
|
||||||
|
/// Read from the declarations rather than listed here, so a node added
|
||||||
|
/// tomorrow is covered without anyone remembering to extend this file — the
|
||||||
|
/// same reason `traceability` parses its denominators from `requirements.md`
|
||||||
|
/// at run time.
|
||||||
|
fn operation_ids(ops_dir: &Path) -> Vec<String> {
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
|
||||||
|
let entries =
|
||||||
|
fs::read_dir(ops_dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", ops_dir.display()));
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.expect("read dir entry").path();
|
||||||
|
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// `_helpers.yaml` declares the shared WGSL library, not a node, and has
|
||||||
|
// no `id:`. The leading underscore is the documented marker.
|
||||||
|
if path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.starts_with('_'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
||||||
|
|
||||||
|
let id = text
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("id:"))
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_else(|| panic!("{} declares no id:", path.display()));
|
||||||
|
|
||||||
|
ids.push(id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!ids.is_empty(),
|
||||||
|
"found no operation declarations in {} — the path is wrong, and a scan \
|
||||||
|
over nothing passes for the wrong reason",
|
||||||
|
ops_dir.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every `.rs` and `.slint` file under a directory.
|
||||||
|
fn sources(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||||
|
let entries =
|
||||||
|
fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()));
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let path = entry.expect("read dir entry").path();
|
||||||
|
if path.is_dir() {
|
||||||
|
sources(&path, out);
|
||||||
|
} else if matches!(
|
||||||
|
path.extension().and_then(|e| e.to_str()),
|
||||||
|
Some("rs") | Some("slint")
|
||||||
|
) {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blank out string literals and line comments, so a brace inside either does
|
||||||
|
/// not move the depth count.
|
||||||
|
///
|
||||||
|
/// Raw strings and block comments are not handled. Neither appears around a
|
||||||
|
/// `#[cfg(test)]` item in this crate, and [`MIN_SHIPPING_FRACTION`] is the
|
||||||
|
/// backstop if that ever stops being true.
|
||||||
|
fn strip_literals_and_comments(line: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(line.len());
|
||||||
|
let mut chars = line.chars().peekable();
|
||||||
|
let mut in_string = false;
|
||||||
|
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if in_string {
|
||||||
|
match c {
|
||||||
|
'\\' => {
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
'"' => in_string = false,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match c {
|
||||||
|
'"' => in_string = true,
|
||||||
|
'/' if chars.peek() == Some(&'/') => break,
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lines of a file that ship, each with its 1-based number.
|
||||||
|
///
|
||||||
|
/// For Rust, every `#[cfg(test)]` item is dropped. The attribute must be the
|
||||||
|
/// whole line — a doc comment *mentioning* it is prose, not an attribute, and
|
||||||
|
/// treating the two alike is the bug [`MIN_SHIPPING_FRACTION`] describes.
|
||||||
|
fn shipping_lines(path: &Path, text: &str) -> Vec<(usize, String)> {
|
||||||
|
let lines: Vec<&str> = text.lines().collect();
|
||||||
|
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||||
|
return lines
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, l)| (i + 1, (*l).to_string()))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
|
||||||
|
while i < lines.len() {
|
||||||
|
if lines[i].trim() != "#[cfg(test)]" {
|
||||||
|
out.push((i + 1, lines[i].to_string()));
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the item the attribute applies to: through its brace block, or
|
||||||
|
// to the `;` if it has none — `#[cfg(test)] use ...;` is legal.
|
||||||
|
let mut j = i + 1;
|
||||||
|
let mut depth: i32 = 0;
|
||||||
|
let mut opened = false;
|
||||||
|
|
||||||
|
while j < lines.len() {
|
||||||
|
let stripped = strip_literals_and_comments(lines[j]);
|
||||||
|
depth += stripped.matches('{').count() as i32;
|
||||||
|
depth -= stripped.matches('}').count() as i32;
|
||||||
|
|
||||||
|
if stripped.contains('{') {
|
||||||
|
opened = true;
|
||||||
|
}
|
||||||
|
if opened && depth <= 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !opened && stripped.contains(';') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
i = j + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_interface_names_no_operation() {
|
||||||
|
let crate_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let repo = crate_root
|
||||||
|
.parent()
|
||||||
|
.and_then(Path::parent)
|
||||||
|
.expect("ui/dr-ui has a grandparent");
|
||||||
|
|
||||||
|
let ids = operation_ids(&repo.join("core/dr-pipeline/ops"));
|
||||||
|
|
||||||
|
let mut files = Vec::new();
|
||||||
|
sources(&crate_root.join("src"), &mut files);
|
||||||
|
sources(&crate_root.join("ui"), &mut files);
|
||||||
|
|
||||||
|
let mut offences = Vec::new();
|
||||||
|
let mut total = 0usize;
|
||||||
|
let mut scanned = 0usize;
|
||||||
|
|
||||||
|
for path in &files {
|
||||||
|
let text = fs::read_to_string(path)
|
||||||
|
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
|
||||||
|
|
||||||
|
total += text.lines().count();
|
||||||
|
let shipping = shipping_lines(path, &text);
|
||||||
|
scanned += shipping.len();
|
||||||
|
|
||||||
|
for (n, line) in shipping {
|
||||||
|
for id in &ids {
|
||||||
|
if line.contains(&format!("\"{id}\"")) {
|
||||||
|
offences.push(format!(
|
||||||
|
" {}:{n} names \"{id}\"\n {}",
|
||||||
|
path.strip_prefix(repo).unwrap_or(path).display(),
|
||||||
|
line.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fraction = scanned as f64 / total as f64;
|
||||||
|
assert!(
|
||||||
|
fraction >= MIN_SHIPPING_FRACTION,
|
||||||
|
"only {scanned} of {total} lines ({:.0}%) were scanned, below the {:.0}% \
|
||||||
|
floor — `shipping_lines` is dropping code it should be reading, and \
|
||||||
|
this test is passing without having looked. Fix the scan before \
|
||||||
|
trusting the result.",
|
||||||
|
fraction * 100.0,
|
||||||
|
MIN_SHIPPING_FRACTION * 100.0
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
offences.is_empty(),
|
||||||
|
"\n\nFR-DEV-3a: the interface must not name an operation.\n\n{}\n\n\
|
||||||
|
The core declares capabilities and the frontend composes them, which is \
|
||||||
|
why adding a develop node is one file in core/dr-pipeline/ops/ and \
|
||||||
|
touches no UI. A special case here takes that away from every node \
|
||||||
|
added afterwards.\n\n\
|
||||||
|
If a node needs presentation the panel cannot currently give it, the \
|
||||||
|
answer is a `presentation:` hint in its declaration and a `WidgetKind` \
|
||||||
|
the panel implements for every node that asks — see \
|
||||||
|
core/dr-pipeline/ops/README.md and `develop::supported`.\n\n\
|
||||||
|
Test code is exempt; this scans only what ships.\n",
|
||||||
|
offences.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
+18
-5
@@ -275,6 +275,13 @@ 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-DSP-8
|
||||||
|
/// The display showing the canvas and the colour transform it is getting.
|
||||||
|
/// Shown under ABOUT in Settings; see `settings.slint` for why the second
|
||||||
|
/// one is a sentence rather than a name.
|
||||||
|
in property <string> display-name: "detecting…";
|
||||||
|
in property <string> display-colour: "detecting…";
|
||||||
|
in property <string> display-others: "";
|
||||||
/// TRACES: FR-UI-2
|
/// TRACES: FR-UI-2
|
||||||
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
|
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
|
||||||
/// the binary it is part of.
|
/// the binary it is part of.
|
||||||
@@ -1267,6 +1274,9 @@ in property <bool> panel-visible: true;
|
|||||||
fps: root.fps;
|
fps: root.fps;
|
||||||
layout-class: root.layout-class;
|
layout-class: root.layout-class;
|
||||||
app-version: root.app-version;
|
app-version: root.app-version;
|
||||||
|
display-name: root.display-name;
|
||||||
|
display-colour: root.display-colour;
|
||||||
|
display-others: root.display-others;
|
||||||
|
|
||||||
original-budget: root.settings-original-budget;
|
original-budget: root.settings-original-budget;
|
||||||
original-unlimited: root.settings-original-unlimited;
|
original-unlimited: root.settings-original-unlimited;
|
||||||
@@ -1700,11 +1710,14 @@ in property <bool> panel-visible: true;
|
|||||||
// that far. Below 1:1 it stays smooth, where filtering is
|
// that far. Below 1:1 it stays smooth, where filtering is
|
||||||
// what keeps the image from aliasing.
|
// what keeps the image from aliasing.
|
||||||
//
|
//
|
||||||
// This matters even at moderate zoom on a HiDPI display:
|
// TRACES: FR-DSP-8
|
||||||
// the canvas is rendered at *logical* size and Slint scales
|
// The canvas is rendered at the *physical* pixel size of
|
||||||
// it up by the device pixel ratio, so the buffer is
|
// the box this image occupies, so `contain` presents it
|
||||||
// resampled on its way to the screen whatever the pipeline
|
// 1:1 and neither filter is reached at all until the
|
||||||
// did.
|
// photographer zooms. That is the point: on a fractionally
|
||||||
|
// scaled desktop the buffer used to be a logical-sized one
|
||||||
|
// that the compositor stretched, and no choice of filter
|
||||||
|
// recovers detail that was never rendered.
|
||||||
image-rendering: root.magnified
|
image-rendering: root.magnified
|
||||||
? ImageRendering.pixelated
|
? ImageRendering.pixelated
|
||||||
: ImageRendering.smooth;
|
: ImageRendering.smooth;
|
||||||
|
|||||||
@@ -117,6 +117,25 @@ export component SettingsPage inherits Rectangle {
|
|||||||
in property <string> layout-class;
|
in property <string> layout-class;
|
||||||
in property <string> app-version;
|
in property <string> app-version;
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-8
|
||||||
|
/// The display showing the canvas, and the colour it is being given.
|
||||||
|
///
|
||||||
|
/// These are the same kind of thing as the two above — a fact about the
|
||||||
|
/// session, not a setting — with one difference that earns them their own
|
||||||
|
/// row rather than a line in a log. FR-DSP-8's fallback is *defined* to be
|
||||||
|
/// sRGB where the display server will not say otherwise, and a photographer
|
||||||
|
/// being shown sRGB because their compositor has no colour-management
|
||||||
|
/// protocol has no other way to find that out. `display-colour` says which
|
||||||
|
/// of the acquisition paths this session is on, in words.
|
||||||
|
in property <string> display-name;
|
||||||
|
in property <string> display-colour;
|
||||||
|
/// The *other* monitors, where there are any. Empty on a single-display
|
||||||
|
/// desktop, which is why the row below is conditional: FR-DSP-8 is about
|
||||||
|
/// the second display, and the failure it names is invisible from the
|
||||||
|
/// first, so this page has to be readable about a monitor the reader is
|
||||||
|
/// not currently looking at.
|
||||||
|
in property <string> display-others;
|
||||||
|
|
||||||
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);
|
||||||
@@ -709,10 +728,46 @@ export component SettingsPage inherits Rectangle {
|
|||||||
Value { text: root.layout-class; horizontal-stretch: 1; }
|
Value { text: root.layout-class; horizontal-stretch: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DSP-8
|
||||||
|
// Which display, and what colour it is being sent.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: Theme.gap;
|
||||||
|
Label { text: "Display"; }
|
||||||
|
Value {
|
||||||
|
text: root.display-name;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: Theme.gap;
|
||||||
|
Label { text: "Display colour"; }
|
||||||
|
Value {
|
||||||
|
text: root.display-colour;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
// Wraps rather than elides: this is the one
|
||||||
|
// value on the page that is a sentence, and
|
||||||
|
// eliding it would cut off the half that says
|
||||||
|
// *why* — which is the half FR-DSP-8 asks for.
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.display-others != "": HorizontalLayout {
|
||||||
|
spacing: Theme.gap;
|
||||||
|
Label { text: "Other displays"; }
|
||||||
|
Value {
|
||||||
|
text: root.display-others;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Caption {
|
Caption {
|
||||||
text: "Graphics and frame rate describe this session, "
|
text: "Graphics, frame rate and display colour "
|
||||||
+ "not a setting — they are here so a bug report "
|
+ "describe this session, not a setting — they "
|
||||||
+ "can quote them.";
|
+ "are here so a bug report can quote them.";
|
||||||
wrap: word-wrap;
|
wrap: word-wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user