Bring master's display and parity work under the new checks
Build and test / Desktop (Linux) (push) Successful in 21m3s
Build and test / Layer separation (push) Successful in 38s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 34s
Build and test / Android (aarch64) (push) Failing after 33m40s
Build and test / Desktop (Linux) (push) Successful in 21m3s
Build and test / Layer separation (push) Successful in 38s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 34s
Build and test / Android (aarch64) (push) Failing after 33m40s
Master moved sixteen commits while these fixes were being written — per-display colour, the frame-budget measurement that decides FR-DSP-2, and a declared node running without being compiled. Merged here rather than on master so the conflicts are resolved where they can be tested. Two files overlapped and neither was interesting. `lib.rs` gained `mod remote` from this branch and `mod display_ui` from master, which git resolved on its own. `docs/traceability.md` is generated, so it was regenerated from the merged tree rather than hand-resolved — hand-editing a generated matrix produces one that agrees with neither side. Coverage reads 59.9% (106/177), up from 55.4%, entirely from master's tagging. The check worth having run is `the_interface_names_no_operation` against master's new `display_ui.rs` and its 195 changed lines of `develop.rs`: a new UI module written without knowledge of this gate passes it. That is the evidence the gate is not merely satisfiable by the code that shipped with it. fmt clean, clippy clean at -D warnings, 2087 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+3
@@ -1528,6 +1528,9 @@ dependencies = [
|
||||
"keyring-core",
|
||||
"log",
|
||||
"thiserror 2.0.20",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+19
@@ -115,6 +115,25 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
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
|
||||
# (FR-NC-2). Credentials never touch the catalog or a plain file.
|
||||
# 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
|
||||
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`
|
||||
# (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
|
||||
|
||||
+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
|
||||
[`Operation`](../src/operation.rs), generated into `OUT_DIR`. The result is
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
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
|
||||
//! `core/` must not depend on one (NFR-A11Y-1).
|
||||
|
||||
use std::collections::HashSet;
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.0)
|
||||
@@ -24,6 +87,13 @@ impl fmt::Display for ParamId {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
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 {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.0)
|
||||
@@ -34,6 +104,13 @@ impl fmt::Display for OpId {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
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.
|
||||
///
|
||||
/// Photographic controls are rarely linear in their underlying quantity:
|
||||
@@ -170,7 +247,11 @@ pub enum ParamKind {
|
||||
Enum {
|
||||
/// In index order. The label is a localisation key, resolved by the
|
||||
/// 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
|
||||
/// on a workstation and a usable one on a phone without the core knowing
|
||||
/// which it is talking to.
|
||||
pub widgets: &'static [WidgetKind],
|
||||
pub widgets: Vec<WidgetKind>,
|
||||
/// 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
|
||||
@@ -215,7 +296,7 @@ pub struct Presentation {
|
||||
///
|
||||
/// Parameters absent from this list are presented normally, so an
|
||||
/// operation can pair a curve with an ordinary strength slider.
|
||||
pub params: &'static [ParamId],
|
||||
pub params: Vec<ParamId>,
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
@@ -328,11 +409,13 @@ impl ParamDescriptor {
|
||||
/// 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
|
||||
/// listed them in the wrong order.
|
||||
pub const fn choice(
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
variants: &'static [LocalizedKey],
|
||||
) -> Self {
|
||||
//
|
||||
// Not `const`, unlike its four siblings, and the reason is the `Vec` in
|
||||
// [`ParamKind::Enum`]: a heap allocation cannot happen in a const context.
|
||||
// Nothing is lost — every descriptor now lives inside a `LazyLock`
|
||||
// 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 {
|
||||
id: ParamId(id),
|
||||
label: LocalizedKey(label),
|
||||
@@ -366,13 +449,15 @@ impl ParamDescriptor {
|
||||
|
||||
/// A general scalar with an explicit range and default.
|
||||
//
|
||||
// Eight arguments, and a builder would be the usual answer — but this has
|
||||
// to be `const` so descriptors can be `static`, which rules out the
|
||||
// `&mut self` builder the pattern usually takes. (A `self`-by-value step
|
||||
// *is* const-callable — `faceted` below is one — but eight of them would
|
||||
// be eight methods to say what one call already says.) The two common
|
||||
// shapes have their own constructors above; this is the escape hatch for
|
||||
// the rest.
|
||||
// Eight arguments, and a builder would be the usual answer — but eight
|
||||
// `&mut self` steps would be eight methods to say what one call already
|
||||
// says, and each one would be a place for a caller to forget a field. The
|
||||
// two common 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)]
|
||||
pub const fn scalar(
|
||||
id: &'static str,
|
||||
@@ -404,10 +489,13 @@ impl ParamDescriptor {
|
||||
/// 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
|
||||
/// 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
|
||||
/// value is what keeps it usable in the `static` descriptors — a `&mut
|
||||
/// self` builder is what cannot be `const`.
|
||||
pub const fn faceted(mut self, facet: Facet) -> Self {
|
||||
/// twin would double the list above to say one thing.
|
||||
//
|
||||
// No longer `const`: a `ParamDescriptor` can now carry a `Vec` (an enum's
|
||||
// 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
|
||||
}
|
||||
@@ -418,10 +506,10 @@ impl ParamDescriptor {
|
||||
/// bounds, or a sidecar written by a newer version with a wider range,
|
||||
/// must not produce out-of-range uniforms.
|
||||
pub fn clamp(&self, value: f32) -> f32 {
|
||||
match self.kind {
|
||||
match &self.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
if value.is_finite() {
|
||||
value.clamp(min, max)
|
||||
value.clamp(*min, *max)
|
||||
} else {
|
||||
// A NaN from a corrupt sidecar would otherwise poison the
|
||||
// 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)]
|
||||
pub struct OpDescriptor {
|
||||
pub id: OpId,
|
||||
pub label: LocalizedKey,
|
||||
pub params: &'static [ParamDescriptor],
|
||||
pub params: Vec<ParamDescriptor>,
|
||||
/// What this operation is about (ARCH §4.3a).
|
||||
///
|
||||
/// **Never empty**, and `build.rs` refuses to generate an operation that
|
||||
/// declares none. An operation with no attribute would be invisible to a
|
||||
/// frontend that filters by them, and a control that silently does not
|
||||
/// exist is a worse failure than a build that stops — particularly when
|
||||
/// the cause would be a missing line in a YAML file nobody looked at.
|
||||
pub attributes: &'static [Attribute],
|
||||
/// **Never empty**, and both the build-time and the load-time reader
|
||||
/// refuse an operation that declares none. An operation with no attribute
|
||||
/// would be invisible to a frontend that filters by them, and a control
|
||||
/// that silently does not exist is a worse failure than a build that stops
|
||||
/// — particularly when the cause would be a missing line in a YAML file
|
||||
/// nobody looked at.
|
||||
pub attributes: Vec<Attribute>,
|
||||
}
|
||||
|
||||
impl OpDescriptor {
|
||||
|
||||
@@ -519,7 +519,13 @@ pub fn compose_detail_with(
|
||||
) -> ComposedDetail {
|
||||
// Every pass of every active detail operation, flattened, carrying the
|
||||
// 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() {
|
||||
planned.push((
|
||||
crate::spot::SPOT_ID,
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
//! 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
|
||||
//! output the result is visibly not a box blur rather than subtly wrong.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
|
||||
@@ -36,28 +37,30 @@ use crate::descriptor::{
|
||||
use crate::detail::{DetailPass, DetailStage, RenderScale};
|
||||
use crate::operation::{Affects, Operation, Uniform};
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: OpId("detail_probe"),
|
||||
label: LocalizedKey("op.detail_probe"),
|
||||
params: &[ParamDescriptor {
|
||||
id: ParamId("radius"),
|
||||
label: LocalizedKey("param.detail_probe.radius"),
|
||||
// A fraction of the frame's shorter edge, which is the unit
|
||||
// `RenderScale::frame_fraction` converts and the unit a mask feather
|
||||
// is already stored in. Stating it in pixels is the mistake this
|
||||
// whole stage is arranged to make impossible.
|
||||
kind: ParamKind::Scalar {
|
||||
min: 0.0,
|
||||
max: 0.25,
|
||||
scale: Scale::Linear,
|
||||
unit: Unit::None,
|
||||
precision: 4,
|
||||
},
|
||||
default: 0.0,
|
||||
facet: None,
|
||||
}],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("detail_probe"),
|
||||
label: LocalizedKey("op.detail_probe"),
|
||||
params: vec![ParamDescriptor {
|
||||
id: ParamId("radius"),
|
||||
label: LocalizedKey("param.detail_probe.radius"),
|
||||
// A fraction of the frame's shorter edge, which is the unit
|
||||
// `RenderScale::frame_fraction` converts and the unit a mask feather
|
||||
// is already stored in. Stating it in pixels is the mistake this
|
||||
// whole stage is arranged to make impossible.
|
||||
kind: ParamKind::Scalar {
|
||||
min: 0.0,
|
||||
max: 0.25,
|
||||
scale: Scale::Linear,
|
||||
unit: Unit::None,
|
||||
precision: 4,
|
||||
},
|
||||
default: 0.0,
|
||||
facet: None,
|
||||
}],
|
||||
attributes: vec![Attribute::Detail],
|
||||
})
|
||||
});
|
||||
|
||||
/// A separable box blur whose radius is a fraction of the frame's shorter edge.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
@@ -85,8 +88,8 @@ impl BoxBlur {
|
||||
}
|
||||
|
||||
impl Operation for BoxBlur {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
|
||||
use std::f32::consts::PI;
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
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,
|
||||
];
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
// The shape of the frame, and the only operation that changes the
|
||||
// output's dimensions.
|
||||
attributes: &[Attribute::Geometry],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.framing"),
|
||||
params: &[
|
||||
// Straightening. Degrees rather than a normalised amount because a
|
||||
// photographer reading "-1.4°" off a horizon knows what it means.
|
||||
ParamDescriptor::scalar(
|
||||
"angle",
|
||||
"param.angle",
|
||||
-MAX_STRAIGHTEN,
|
||||
MAX_STRAIGHTEN,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
||||
// and lossless, and because reorienting a frame is a different
|
||||
// gesture from nudging a horizon.
|
||||
ParamDescriptor::scalar(
|
||||
"rotation",
|
||||
"param.rotation",
|
||||
0.0,
|
||||
3.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
||||
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
||||
// The crop rect, in fractions of the source. Normalised rather than
|
||||
// in pixels so a crop survives being applied to a proxy, a full
|
||||
// resolution render, or an export at another size — the same reason
|
||||
// the viewport renders at display resolution (FR-DSP-1).
|
||||
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
||||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
||||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||||
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
// The shape of the frame, and the only operation that changes the
|
||||
// output's dimensions.
|
||||
attributes: vec![Attribute::Geometry],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.framing"),
|
||||
params: vec![
|
||||
// Straightening. Degrees rather than a normalised amount because a
|
||||
// photographer reading "-1.4°" off a horizon knows what it means.
|
||||
ParamDescriptor::scalar(
|
||||
"angle",
|
||||
"param.angle",
|
||||
-MAX_STRAIGHTEN,
|
||||
MAX_STRAIGHTEN,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
||||
// and lossless, and because reorienting a frame is a different
|
||||
// gesture from nudging a horizon.
|
||||
ParamDescriptor::scalar(
|
||||
"rotation",
|
||||
"param.rotation",
|
||||
0.0,
|
||||
3.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
||||
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
||||
// The crop rect, in fractions of the source. Normalised rather than
|
||||
// in pixels so a crop survives being applied to a proxy, a full
|
||||
// resolution render, or an export at another size — the same reason
|
||||
// the viewport renders at display resolution (FR-DSP-1).
|
||||
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
||||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -252,8 +255,8 @@ impl Framing {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
pub fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn presentation(&self) -> Option<Presentation> {
|
||||
Some(Presentation {
|
||||
widgets: &[WidgetKind::CropOverlay],
|
||||
widgets: vec![WidgetKind::CropOverlay],
|
||||
demand: WidgetDemand {
|
||||
// A crop rect is dragged by its corners; nothing about that
|
||||
// reduces to one axis at a time.
|
||||
@@ -290,7 +293,7 @@ impl Framing {
|
||||
// modality, so a thumb is as workable as a mouse.
|
||||
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() {
|
||||
// The whole descriptor driven to both ends, which is what a codegen
|
||||
// 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] {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(p.id, p.clamp(value));
|
||||
@@ -1610,7 +1613,7 @@ mod tests {
|
||||
// The same contract the operations honour, checked against the
|
||||
// descriptor rather than a literal.
|
||||
let mut f = Framing::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
f.set_param(p.id, p.default);
|
||||
}
|
||||
assert!(!f.is_active(), "descriptor defaults must be neutral");
|
||||
@@ -1618,7 +1621,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ pub struct OpCapability {
|
||||
/// 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.
|
||||
///
|
||||
/// Never empty; `build.rs` refuses an operation that declares none.
|
||||
pub attributes: &'static [Attribute],
|
||||
/// Never empty; both readers refuse an operation that declares none.
|
||||
pub attributes: Vec<Attribute>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
||||
@@ -243,7 +243,7 @@ impl EditGraph {
|
||||
/// because this is what the codegen tests count `---- ` shader blocks
|
||||
/// against, and framing generates a prologue rather than a colour block.
|
||||
/// 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()
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ impl EditGraph {
|
||||
})
|
||||
.collect(),
|
||||
presentation: op.presentation(),
|
||||
attributes: desc.attributes,
|
||||
attributes: desc.attributes.clone(),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -310,7 +310,7 @@ impl EditGraph {
|
||||
// sliders for framing *without naming framing* — see
|
||||
// `Framing::presentation`.
|
||||
presentation: self.framing.presentation(),
|
||||
attributes: desc.attributes,
|
||||
attributes: desc.attributes.clone(),
|
||||
};
|
||||
|
||||
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) {
|
||||
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");
|
||||
return;
|
||||
};
|
||||
@@ -469,7 +473,7 @@ impl EditGraph {
|
||||
/// Reset every parameter of every operation, and the framing, to default.
|
||||
pub fn reset(&mut self) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -625,7 +629,7 @@ impl EditGraph {
|
||||
// structure key deliberately omits because they do not recompile a
|
||||
// shader. Both matter to a cached *result*, so both are here.
|
||||
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 = mix(
|
||||
geometry,
|
||||
@@ -944,7 +948,7 @@ mod tests {
|
||||
.capabilities()
|
||||
.iter()
|
||||
.flat_map(|c| {
|
||||
c.params.iter().map(move |p| match p.kind {
|
||||
c.params.iter().map(move |p| match &p.kind {
|
||||
ParamKind::Scalar {
|
||||
min,
|
||||
max,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
//! wearing the same lens, which defeats the point of a lens profile.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::descriptor::{OpDescriptor, ParamId};
|
||||
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
|
||||
/// graph holds `Box<dyn Warp>` in order, so the geometry chain is data.
|
||||
pub trait Warp: Send + Sync {
|
||||
/// Static description, driving UI generation exactly as for an operation.
|
||||
fn descriptor(&self) -> &'static OpDescriptor;
|
||||
/// This warp's description, driving UI generation exactly as for an
|
||||
/// 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.
|
||||
fn set_param(&mut self, id: ParamId, value: f32);
|
||||
@@ -204,32 +207,38 @@ fn sanitise(id: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use super::*;
|
||||
use crate::descriptor::Attribute;
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor};
|
||||
|
||||
static DESC_A: OpDescriptor = OpDescriptor {
|
||||
id: OpId("warp_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: &[ParamDescriptor::amount("amount", "a.amount")],
|
||||
attributes: &[Attribute::Tone],
|
||||
};
|
||||
static DESC_B: OpDescriptor = OpDescriptor {
|
||||
id: OpId("warp_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: &[ParamDescriptor::amount("amount", "b.amount")],
|
||||
attributes: &[Attribute::Tone],
|
||||
};
|
||||
static DESC_A: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("warp_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: vec![ParamDescriptor::amount("amount", "a.amount")],
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
});
|
||||
static DESC_B: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("warp_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: vec![ParamDescriptor::amount("amount", "b.amount")],
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
});
|
||||
|
||||
struct Fake {
|
||||
desc: &'static OpDescriptor,
|
||||
desc: Arc<OpDescriptor>,
|
||||
amount: f32,
|
||||
splits: bool,
|
||||
}
|
||||
|
||||
impl Warp for Fake {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
self.desc
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
self.desc.clone()
|
||||
}
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
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 {
|
||||
desc,
|
||||
amount,
|
||||
@@ -266,7 +275,7 @@ mod tests {
|
||||
fn no_active_warp_composes_to_nothing() {
|
||||
// The property that keeps the common case free: an image with no lens
|
||||
// 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.uniforms.is_empty());
|
||||
assert!(!composed.splits_channels);
|
||||
@@ -274,7 +283,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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.body.contains("---- warp: warp_a ----"));
|
||||
assert!(composed.body.contains("u.warp_a_amount"));
|
||||
@@ -284,7 +293,10 @@ mod tests {
|
||||
fn uniforms_are_prefixed_so_warps_cannot_collide() {
|
||||
// Both fakes declare `amount`; without prefixing the generated struct
|
||||
// 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_b_amount: f32"));
|
||||
assert_eq!(composed.uniforms, vec![1.0, 2.0]);
|
||||
@@ -294,7 +306,10 @@ mod tests {
|
||||
fn channel_splitting_is_requested_by_any_active_warp() {
|
||||
// One CA warp among several must switch the whole stage to the
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -302,14 +317,20 @@ mod tests {
|
||||
fn an_inactive_splitting_warp_does_not_force_three_samples() {
|
||||
// CA present but at neutral must cost nothing — otherwise every image
|
||||
// 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.splits_channels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 b = composed.body.find("warp_b").expect("b present");
|
||||
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
|
||||
//! data neither would be physically meaningful (ARCH §5.2).
|
||||
|
||||
pub mod declared;
|
||||
pub mod descriptor;
|
||||
pub mod detail;
|
||||
pub mod framing;
|
||||
@@ -45,6 +46,7 @@ pub mod sidecar;
|
||||
pub mod spot;
|
||||
pub mod state;
|
||||
|
||||
pub use declared::{Declaration, DeclaredOp};
|
||||
pub use descriptor::{
|
||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind,
|
||||
Presentation, Scale, Unit, WidgetDemand, WidgetKind,
|
||||
@@ -81,13 +83,13 @@ mod tests {
|
||||
let mut g = EditGraph::default_chain();
|
||||
for desc in g.descriptors() {
|
||||
for (i, p) in desc.params.iter().enumerate() {
|
||||
let v = match p.kind {
|
||||
let v = match &p.kind {
|
||||
ParamKind::Scalar { min, max, .. } => {
|
||||
// A fraction that differs per parameter, so no two
|
||||
// move in lockstep.
|
||||
let fraction = 0.15 + 0.05 * (i % 4) as f32;
|
||||
let step = (max - min) * fraction;
|
||||
if p.default + step <= max {
|
||||
if p.default + step <= *max {
|
||||
p.default + step
|
||||
} else {
|
||||
p.default - step
|
||||
@@ -217,7 +219,7 @@ mod tests {
|
||||
// A default outside its own range would mean a fresh image opens
|
||||
// with a value the UI cannot represent.
|
||||
for desc in EditGraph::default_chain().descriptors() {
|
||||
for p in desc.params {
|
||||
for p in &desc.params {
|
||||
assert_eq!(
|
||||
p.clamp(p.default),
|
||||
p.default,
|
||||
@@ -236,7 +238,7 @@ mod tests {
|
||||
// operation must read its own default as neutral.
|
||||
let g = EditGraph::default_chain();
|
||||
for desc in g.descriptors() {
|
||||
for p in desc.params {
|
||||
for p in &desc.params {
|
||||
assert_eq!(
|
||||
g.param(desc.id, p.id),
|
||||
Some(p.default),
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
//! see there for what happens when it does not match.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::descriptor::{OpDescriptor, ParamId};
|
||||
use crate::operation::Operation;
|
||||
@@ -692,7 +693,7 @@ impl Clone for MaskLayer {
|
||||
fn clone(&self) -> Self {
|
||||
let mut ops = layer_chain();
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -995,7 +996,7 @@ impl MaskLayer {
|
||||
})
|
||||
.collect(),
|
||||
presentation: op.presentation(),
|
||||
attributes: desc.attributes,
|
||||
attributes: desc.attributes.clone(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -1007,7 +1008,7 @@ impl MaskLayer {
|
||||
/// arrive at — so "start this layer's edit again" must not throw it away.
|
||||
pub fn reset_adjustments(&mut self) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1026,10 +1027,18 @@ impl MaskLayer {
|
||||
/// Every non-default parameter, for the sidecar.
|
||||
pub fn params(&self) -> impl Iterator<Item = (&'static str, &'static str, f32)> + '_ {
|
||||
self.ops.iter().flat_map(|o| {
|
||||
let id = o.descriptor().id.0;
|
||||
o.descriptor().params.iter().filter_map(move |p| {
|
||||
let v = o.param(p.id);
|
||||
(v != p.default).then_some((id, p.id.0, v))
|
||||
let desc = o.descriptor();
|
||||
let id = desc.id.0;
|
||||
// Collected rather than borrowed from `desc`: a descriptor is an
|
||||
// `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`].
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_types::{ColourSpace, Transfer};
|
||||
|
||||
@@ -171,7 +172,7 @@ impl Invalidation {
|
||||
pub(crate) fn hash_op(h: u64, op: &dyn Operation) -> u64 {
|
||||
let desc = op.descriptor();
|
||||
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 = 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 {
|
||||
/// Field name as it appears in WGSL. Prefixed with the op id by the
|
||||
/// 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 value: f32,
|
||||
}
|
||||
@@ -222,8 +229,32 @@ pub struct Uniform {
|
||||
/// Object-safe: the pipeline holds `Box<dyn Operation>` in graph order, so
|
||||
/// order is data rather than code (ARCH §3.4).
|
||||
pub trait Operation: Send + Sync {
|
||||
/// Static description, driving UI generation (FR-DEV-3a).
|
||||
fn descriptor(&self) -> &'static OpDescriptor;
|
||||
/// TRACES: FR-DEV-3a | FR-PLG-2
|
||||
/// 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.
|
||||
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
|
||||
/// request it, so shared helpers (luminance, soft clipping) are declared
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::descriptor::Attribute;
|
||||
use crate::descriptor::{LocalizedKey, OpId, ParamDescriptor};
|
||||
|
||||
static DESC_A: OpDescriptor = OpDescriptor {
|
||||
id: OpId("op_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: &[ParamDescriptor::amount("amount", "a.amount")],
|
||||
attributes: &[Attribute::Tone],
|
||||
};
|
||||
static DESC_B: OpDescriptor = OpDescriptor {
|
||||
id: OpId("op_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: &[ParamDescriptor::amount("amount", "b.amount")],
|
||||
attributes: &[Attribute::Tone],
|
||||
};
|
||||
static DESC_A: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("op_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: vec![ParamDescriptor::amount("amount", "a.amount")],
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
});
|
||||
static DESC_B: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("op_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: vec![ParamDescriptor::amount("amount", "b.amount")],
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
});
|
||||
|
||||
struct Fake {
|
||||
desc: &'static OpDescriptor,
|
||||
desc: Arc<OpDescriptor>,
|
||||
amount: f32,
|
||||
helper: Option<Helper>,
|
||||
}
|
||||
|
||||
impl Operation for Fake {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
self.desc
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
self.desc.clone()
|
||||
}
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
self.amount = value;
|
||||
@@ -1241,7 +1284,7 @@ mod tests {
|
||||
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 {
|
||||
desc,
|
||||
amount,
|
||||
@@ -1253,7 +1296,7 @@ mod tests {
|
||||
fn an_inactive_operation_contributes_nothing() {
|
||||
// The point of composing rather than branching: an op at neutral
|
||||
// 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);
|
||||
assert!(
|
||||
!shader.source.contains("op_a"),
|
||||
@@ -1274,7 +1317,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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);
|
||||
assert!(shader.source.contains("---- op_a ----"));
|
||||
assert!(shader.source.contains("u.op_a_amount"));
|
||||
@@ -1285,7 +1328,10 @@ mod tests {
|
||||
// Both fakes declare a uniform called `amount`. Without prefixing,
|
||||
// the generated struct would have a duplicate field and fail to
|
||||
// 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);
|
||||
assert!(shader.source.contains("op_a_amount: f32"));
|
||||
assert!(shader.source.contains("op_b_amount: f32"));
|
||||
@@ -1295,7 +1341,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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);
|
||||
assert_eq!(shader.uniforms[PREAMBLE_FIELDS], 1.5);
|
||||
assert_eq!(shader.uniforms[PREAMBLE_FIELDS + 1], 2.5);
|
||||
@@ -1305,7 +1354,10 @@ mod tests {
|
||||
fn a_shared_helper_is_emitted_once() {
|
||||
// Two operations wanting the same helper must not produce a
|
||||
// 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);
|
||||
assert_eq!(
|
||||
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.
|
||||
for n in 0..6 {
|
||||
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();
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(
|
||||
@@ -1335,11 +1397,14 @@ mod tests {
|
||||
fn structure_hash_ignores_values_but_tracks_the_op_set() {
|
||||
// The property the shader cache depends on: moving a slider must not
|
||||
// trigger a recompile, but enabling an operation must.
|
||||
let a1 = compose(&[fake(&DESC_A, 1.0, false)]).structure_hash;
|
||||
let a2 = compose(&[fake(&DESC_A, 9.0, false)]).structure_hash;
|
||||
let a1 = compose(&[fake(DESC_A.clone(), 1.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");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1347,8 +1412,14 @@ mod tests {
|
||||
fn structure_hash_is_order_sensitive() {
|
||||
// Operation order is data (ARCH §3.4); two orders are different
|
||||
// shaders and must not share a cache entry.
|
||||
let ab = compose(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
||||
let ba = compose(&[fake(&DESC_B, 1.0, false), fake(&DESC_A, 1.0, false)]);
|
||||
let ab = compose(&[
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1412,7 +1483,7 @@ mod tests {
|
||||
// Exposure and the tonal controls act on white-balanced values; if
|
||||
// the multiply came afterwards, every operation would be reasoning
|
||||
// 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 wb = source.find("u.as_shot_wb").expect("wb applied");
|
||||
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
|
||||
// suppressed the tail unconditionally renders every ordinary edit
|
||||
// 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("Camera space -> linear sRGB"));
|
||||
}
|
||||
@@ -1484,7 +1555,7 @@ mod tests {
|
||||
// stock loaded is the default state of every photograph in the
|
||||
// catalogue, and it must not disturb the camera's own rendering.
|
||||
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("Camera space -> linear sRGB"));
|
||||
}
|
||||
@@ -1493,7 +1564,7 @@ mod tests {
|
||||
fn the_camera_matrix_is_applied_after_the_operations() {
|
||||
// Adjustments are meaningful in sensor-native space, where highlight
|
||||
// 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 op = source.find("---- op_a ----").expect("op present");
|
||||
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
|
||||
// primaries. Applied after the conversion it would be a Canon
|
||||
// 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 op = source.find("---- op_a ----").expect("op present");
|
||||
let curve = source
|
||||
@@ -1592,7 +1663,7 @@ mod tests {
|
||||
// it must not pick up an identity matrix multiply for the sake of
|
||||
// generality. Asserted against the source rather than against timing,
|
||||
// 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;
|
||||
assert!(
|
||||
!srgb.contains("Linear sRGB -> linear sRGB"),
|
||||
@@ -1607,7 +1678,7 @@ mod tests {
|
||||
// in linear sRGB, the primaries conversion carries it into the wider
|
||||
// space, and only then is it clipped — clipping first would discard
|
||||
// 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 convert = source
|
||||
.find("Linear sRGB -> linear Display P3")
|
||||
@@ -1659,7 +1730,7 @@ mod tests {
|
||||
// an export that came out sRGB and claimed to be Display P3.
|
||||
let mut seen: Vec<u64> = Vec::new();
|
||||
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");
|
||||
seen.push(h);
|
||||
}
|
||||
@@ -1669,7 +1740,7 @@ mod tests {
|
||||
fn generated_source_carries_a_do_not_edit_banner() {
|
||||
// Someone will eventually find this in a debugger and try to fix it
|
||||
// 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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
//! untouched means a mis-set correction shifts the channels that contribute
|
||||
//! least to perceived sharpness. Scaling all three about a virtual reference
|
||||
//! would soften the image even when the correction is right.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
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%.
|
||||
const MAX_SCALE: f32 = 0.005;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
attributes: &[Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.aberration"),
|
||||
params: &[
|
||||
// Two independent controls rather than one: the red and blue
|
||||
// displacements are caused by different ends of the spectrum and are
|
||||
// not symmetric, so a single "fringing" slider could not remove both.
|
||||
ParamDescriptor::scalar(
|
||||
"red",
|
||||
"param.aberration.red",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"blue",
|
||||
"param.aberration.blue",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
attributes: vec![Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.aberration"),
|
||||
params: vec![
|
||||
// Two independent controls rather than one: the red and blue
|
||||
// displacements are caused by different ends of the spectrum and are
|
||||
// not symmetric, so a single "fringing" slider could not remove both.
|
||||
ParamDescriptor::scalar(
|
||||
"red",
|
||||
"param.aberration.red",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"blue",
|
||||
"param.aberration.blue",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Aberration {
|
||||
@@ -113,8 +116,8 @@ impl Aberration {
|
||||
}
|
||||
|
||||
impl Warp for Aberration {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -300,7 +303,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_default_is_neutral() {
|
||||
let mut a = Aberration::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
a.set_param(p.id, p.default);
|
||||
}
|
||||
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
|
||||
//! already draws, and drawing it in two places differently is worse than a
|
||||
//! visible step.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||
@@ -163,46 +164,48 @@ const MAX_KERNEL: f32 = 48.0;
|
||||
/// every editor's capture sharpening starts.
|
||||
const DEFAULT_RADIUS: f32 = 1.0;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.capture_sharpen"),
|
||||
params: &[
|
||||
// Amount carries the neutral, which is why it is first: the operation
|
||||
// is off when this is zero regardless of the other two, so a reset is
|
||||
// one control and the panel's ordering matches the way it is used.
|
||||
ParamDescriptor::amount("amount", "param.amount"),
|
||||
// In **source pixels** — see the module documentation. Half a photosite
|
||||
// is the smallest radius that means anything on a Bayer sensor, and
|
||||
// three is already past the point where an unsharp mask is sharpening
|
||||
// rather than adding local contrast; a photographer wanting the latter
|
||||
// wants clarity, which is a different operation with a different unit.
|
||||
ParamDescriptor::scalar(
|
||||
"radius",
|
||||
"param.radius",
|
||||
0.5,
|
||||
3.0,
|
||||
DEFAULT_RADIUS,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// A fraction, but declared as a scalar rather than through
|
||||
// `ParamDescriptor::fraction` for its precision alone: four decimal
|
||||
// places on a control whose whole useful travel is a dozen steps
|
||||
// reads as noise, and invites fiddling with digits that do nothing.
|
||||
ParamDescriptor::scalar(
|
||||
"threshold",
|
||||
"param.threshold",
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.capture_sharpen"),
|
||||
params: vec![
|
||||
// Amount carries the neutral, which is why it is first: the operation
|
||||
// is off when this is zero regardless of the other two, so a reset is
|
||||
// one control and the panel's ordering matches the way it is used.
|
||||
ParamDescriptor::amount("amount", "param.amount"),
|
||||
// In **source pixels** — see the module documentation. Half a photosite
|
||||
// is the smallest radius that means anything on a Bayer sensor, and
|
||||
// three is already past the point where an unsharp mask is sharpening
|
||||
// rather than adding local contrast; a photographer wanting the latter
|
||||
// wants clarity, which is a different operation with a different unit.
|
||||
ParamDescriptor::scalar(
|
||||
"radius",
|
||||
"param.radius",
|
||||
0.5,
|
||||
3.0,
|
||||
DEFAULT_RADIUS,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// A fraction, but declared as a scalar rather than through
|
||||
// `ParamDescriptor::fraction` for its precision alone: four decimal
|
||||
// places on a control whose whole useful travel is a dozen steps
|
||||
// reads as noise, and invites fiddling with digits that do nothing.
|
||||
ParamDescriptor::scalar(
|
||||
"threshold",
|
||||
"param.threshold",
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
],
|
||||
attributes: vec![Attribute::Detail],
|
||||
})
|
||||
});
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Capture sharpening: a separable unsharp mask with a contrast threshold.
|
||||
@@ -275,8 +278,8 @@ impl CaptureSharpen {
|
||||
}
|
||||
|
||||
impl Operation for CaptureSharpen {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
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
|
||||
//! 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.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId,
|
||||
@@ -123,8 +124,9 @@ impl Channel {
|
||||
}
|
||||
|
||||
// Parameter descriptors, one per band per channel. Written out rather than
|
||||
// generated because `ParamDescriptor` must be `const` to live in a `static`,
|
||||
// and a const loop cannot build a slice. The macro keeps it honest.
|
||||
// looped because `concat!` needs literals: every id is built from its band's
|
||||
// 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
|
||||
// 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.
|
||||
macro_rules! band_params {
|
||||
($(($key:literal, $hue:literal)),* $(,)?) => {
|
||||
&[
|
||||
vec![
|
||||
$(
|
||||
ParamDescriptor::amount(
|
||||
concat!($key, "_hue"),
|
||||
@@ -175,25 +177,27 @@ macro_rules! band_params {
|
||||
// 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
|
||||
// what keeps them from drifting.
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
attributes: &[Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.colour_mixer"),
|
||||
params: band_params![
|
||||
("red", 0.0),
|
||||
("orange", 30.0),
|
||||
("yellow", 60.0),
|
||||
("chartreuse", 90.0),
|
||||
("green", 120.0),
|
||||
("spring", 150.0),
|
||||
("cyan", 180.0),
|
||||
("azure", 210.0),
|
||||
("blue", 240.0),
|
||||
("violet", 270.0),
|
||||
("magenta", 300.0),
|
||||
("rose", 330.0),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
attributes: vec![Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.colour_mixer"),
|
||||
params: band_params![
|
||||
("red", 0.0),
|
||||
("orange", 30.0),
|
||||
("yellow", 60.0),
|
||||
("chartreuse", 90.0),
|
||||
("green", 120.0),
|
||||
("spring", 150.0),
|
||||
("cyan", 180.0),
|
||||
("azure", 210.0),
|
||||
("blue", 240.0),
|
||||
("violet", 270.0),
|
||||
("magenta", 300.0),
|
||||
("rose", 330.0),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
static MIXER_HELPERS: &[Helper] = &[
|
||||
helpers::LUMINANCE,
|
||||
@@ -306,8 +310,8 @@ impl ColourMixer {
|
||||
}
|
||||
|
||||
impl Operation for ColourMixer {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
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() {
|
||||
// The link between the descriptor list and the value array. A
|
||||
// mismatch would make a slider silently adjust nothing.
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
assert!(
|
||||
ColourMixer::index_of(p.id).is_some(),
|
||||
"{} 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
|
||||
// band does not act on — a control that lies about what it edits,
|
||||
// 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 (band_key, _) = p.id.0.rsplit_once('_').expect("id is band_channel");
|
||||
let band = BANDS
|
||||
@@ -577,7 +581,7 @@ mod tests {
|
||||
// the aspect keyed per band, grouping by it would produce thirty-six
|
||||
// groups of one and nothing would have been gained.
|
||||
let mut per_aspect = std::collections::BTreeMap::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
let facet = p.facet.expect("faceted");
|
||||
*per_aspect.entry(facet.aspect.0).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
//! whole composition scheme rests on (ARCH §5.6).
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
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.
|
||||
///
|
||||
/// Written out per point rather than looped because a `ParamDescriptor` has to
|
||||
/// be `const` to live in a `static`, and a const loop cannot build a slice.
|
||||
/// Written out per point rather than looped because `concat!` needs literals:
|
||||
/// 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 {
|
||||
($(($prefix:literal, $channel:expr)),* $(,)?) => {
|
||||
&[$(
|
||||
vec![$(
|
||||
coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0)
|
||||
.faceted(facet_of("param.curve.p0_x", $channel)),
|
||||
coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0)
|
||||
@@ -370,26 +372,28 @@ macro_rules! channel_params {
|
||||
};
|
||||
}
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
// Both, and this is the case the plural exists for: the master curve is
|
||||
// tonal and the per-channel curves are chromatic. Filing it under one
|
||||
// would hide it from half the people looking for it.
|
||||
attributes: &[Attribute::Tone, Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.tone_curve"),
|
||||
// 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,
|
||||
// and this is also the order one falling back to sliders reads them in.
|
||||
params: channel_params![
|
||||
("", Channel::Master),
|
||||
("r_", Channel::Red),
|
||||
("g_", Channel::Green),
|
||||
("b_", Channel::Blue),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
// Both, and this is the case the plural exists for: the master curve is
|
||||
// tonal and the per-channel curves are chromatic. Filing it under one
|
||||
// would hide it from half the people looking for it.
|
||||
attributes: vec![Attribute::Tone, Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.tone_curve"),
|
||||
// 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,
|
||||
// and this is also the order one falling back to sliders reads them in.
|
||||
params: channel_params![
|
||||
("", Channel::Master),
|
||||
("r_", Channel::Red),
|
||||
("g_", Channel::Green),
|
||||
("b_", Channel::Blue),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
/// One span of a monotone cubic Hermite spline. Shared by all four curves.
|
||||
const CURVE_SPAN: Helper = Helper {
|
||||
@@ -699,8 +703,8 @@ impl ToneCurve {
|
||||
}
|
||||
|
||||
impl Operation for ToneCurve {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -727,7 +731,7 @@ impl Operation for ToneCurve {
|
||||
Some(Presentation {
|
||||
// One entry: there is no second way to draw a tone curve that is
|
||||
// better than the sliders the frontend falls back to anyway.
|
||||
widgets: &[WidgetKind::ToneCurve],
|
||||
widgets: vec![WidgetKind::ToneCurve],
|
||||
demand: WidgetDemand {
|
||||
// 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
|
||||
@@ -739,7 +743,7 @@ impl Operation for ToneCurve {
|
||||
// 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
|
||||
// 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.
|
||||
let c = ToneCurve::new();
|
||||
assert!(!c.is_active());
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
assert_eq!(c.param(p.id), p.default);
|
||||
}
|
||||
}
|
||||
@@ -927,7 +931,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_parameter_id_maps_to_a_point() {
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
assert!(
|
||||
ToneCurve::index_of(p.id).is_some(),
|
||||
"{} does not map to a point",
|
||||
@@ -1196,7 +1200,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(presentation.choose(|_| false), None);
|
||||
assert_eq!(presentation.params.len(), DESCRIPTOR.params.len());
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
assert!(
|
||||
presentation.params.contains(&p.id),
|
||||
"{} is not owned by the widget",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
//! set three correlated coefficients, and hand-correcting a lens with no
|
||||
//! profile is a "make the horizon straight" task, which one term does well.
|
||||
//! The full triple is reachable by loading a profile.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
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 AMOUNT: ParamId = ParamId("amount");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
attributes: &[Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.distortion"),
|
||||
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
|
||||
// fisheye at one end and strong pincushion at the other; beyond it the
|
||||
// inverse mapping stops being single-valued near the corners and the
|
||||
// correction folds the image over itself.
|
||||
params: &[ParamDescriptor::scalar(
|
||||
"amount",
|
||||
"param.distortion.amount",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
)],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
attributes: vec![Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.distortion"),
|
||||
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
|
||||
// fisheye at one end and strong pincushion at the other; beyond it the
|
||||
// inverse mapping stops being single-valued near the corners and the
|
||||
// correction folds the image over itself.
|
||||
params: vec![ParamDescriptor::scalar(
|
||||
"amount",
|
||||
"param.distortion.amount",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
)],
|
||||
})
|
||||
});
|
||||
|
||||
/// The cubic coefficient at full slider travel.
|
||||
const MAX_COEFF: f32 = 0.25;
|
||||
@@ -108,8 +111,8 @@ impl Distortion {
|
||||
}
|
||||
|
||||
impl Warp for Distortion {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
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
|
||||
//! keeps its no-dependency property (ARCH §6.5a) exactly as `vignetting` does
|
||||
//! with `Pa`.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Operation, Uniform};
|
||||
@@ -73,29 +74,31 @@ static MATRIX_FIELDS: [[&str; 3]; 3] = [
|
||||
["m20", "m21", "m22"],
|
||||
];
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
// Tone and colour both, and not `Effect`: a stock is not something applied
|
||||
// on top of a photograph, it is what the photograph was made on.
|
||||
attributes: &[Attribute::Tone, Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.film_sim"),
|
||||
params: &[
|
||||
ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0),
|
||||
ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0),
|
||||
// TRACES: FR-DEV-3f
|
||||
// Development, in stops of push. Bounded by what the manufacturers
|
||||
// actually published: Double-X's measured axis spans about -1 to +2,
|
||||
// and beyond a range like that a curve would have to be invented.
|
||||
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
|
||||
// TRACES: FR-DEV-3f
|
||||
// Which frame this was taken on — the half of the enlargement a
|
||||
// photograph cannot supply. A crystal is a fixed size in micrometres,
|
||||
// so how grainy a picture looks is film size against output size, and
|
||||
// the same emulsion on 4x5 renders about three times smoother than on
|
||||
// 35mm at the same print.
|
||||
ParamDescriptor::choice("format", "param.film_sim.format", &FORMATS),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
// Tone and colour both, and not `Effect`: a stock is not something applied
|
||||
// on top of a photograph, it is what the photograph was made on.
|
||||
attributes: vec![Attribute::Tone, Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.film_sim"),
|
||||
params: vec![
|
||||
ParamDescriptor::stops("exposure", "param.film_sim.exposure", -3.0, 3.0),
|
||||
ParamDescriptor::stops("print_exposure", "param.film_sim.print_exposure", -3.0, 3.0),
|
||||
// TRACES: FR-DEV-3f
|
||||
// Development, in stops of push. Bounded by what the manufacturers
|
||||
// actually published: Double-X's measured axis spans about -1 to +2,
|
||||
// and beyond a range like that a curve would have to be invented.
|
||||
ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0),
|
||||
// TRACES: FR-DEV-3f
|
||||
// Which frame this was taken on — the half of the enlargement a
|
||||
// photograph cannot supply. A crystal is a fixed size in micrometres,
|
||||
// so how grainy a picture looks is film size against output size, and
|
||||
// the same emulsion on 4x5 renders about three times smoother than on
|
||||
// 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.
|
||||
///
|
||||
@@ -190,8 +193,8 @@ impl FilmSim {
|
||||
}
|
||||
|
||||
impl Operation for FilmSim {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
|
||||
@@ -153,6 +153,7 @@
|
||||
//! this file.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
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
|
||||
/// thing a reader comes to this file to do.
|
||||
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],
|
||||
/// The Gaussian's σ, as a fraction of the frame's shorter edge.
|
||||
sigma: f32,
|
||||
@@ -253,19 +259,23 @@ impl Band for Fine {
|
||||
};
|
||||
}
|
||||
|
||||
static CLARITY_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: CLARITY,
|
||||
label: LocalizedKey("op.clarity"),
|
||||
params: &[ParamDescriptor::amount("amount", "param.clarity.amount")],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
static CLARITY_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: CLARITY,
|
||||
label: LocalizedKey("op.clarity"),
|
||||
params: vec![ParamDescriptor::amount("amount", "param.clarity.amount")],
|
||||
attributes: vec![Attribute::Detail],
|
||||
})
|
||||
});
|
||||
|
||||
static TEXTURE_DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: TEXTURE,
|
||||
label: LocalizedKey("op.texture"),
|
||||
params: &[ParamDescriptor::amount("amount", "param.texture.amount")],
|
||||
attributes: &[Attribute::Detail],
|
||||
};
|
||||
static TEXTURE_DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: TEXTURE,
|
||||
label: LocalizedKey("op.texture"),
|
||||
params: vec![ParamDescriptor::amount("amount", "param.texture.amount")],
|
||||
attributes: vec![Attribute::Detail],
|
||||
})
|
||||
});
|
||||
|
||||
/// 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> {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
B::RECIPE.descriptor
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
Arc::clone(B::RECIPE.descriptor)
|
||||
}
|
||||
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
|
||||
@@ -179,7 +179,7 @@ mod tests {
|
||||
// perfectly and silently breaks the sidecar.
|
||||
for mut op in chain() {
|
||||
let descriptor = op.descriptor();
|
||||
for p in descriptor.params {
|
||||
for p in &descriptor.params {
|
||||
let crate::descriptor::ParamKind::Scalar { min, max, .. } = p.kind else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -147,6 +147,7 @@
|
||||
//! 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
|
||||
//! being halved.
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
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 CHROMA: ParamId = ParamId("chroma");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.noise_reduction"),
|
||||
attributes: &[Attribute::Detail],
|
||||
// Zero to a hundred rather than the symmetric `amount` shape the tonal
|
||||
// controls use. There is no meaningful negative: "minus fifty noise
|
||||
// reduction" would be adding grain, which is a look rather than a repair
|
||||
// and belongs to a different operation carrying `Attribute::Effect`. A
|
||||
// control whose left half does nothing is worse than one that stops.
|
||||
params: &[
|
||||
ParamDescriptor::scalar(
|
||||
"luminance",
|
||||
"param.noise_reduction.luminance",
|
||||
0.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"chroma",
|
||||
"param.noise_reduction.chroma",
|
||||
0.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.noise_reduction"),
|
||||
attributes: vec![Attribute::Detail],
|
||||
// Zero to a hundred rather than the symmetric `amount` shape the tonal
|
||||
// controls use. There is no meaningful negative: "minus fifty noise
|
||||
// reduction" would be adding grain, which is a look rather than a repair
|
||||
// and belongs to a different operation carrying `Attribute::Effect`. A
|
||||
// control whose left half does nothing is worse than one that stops.
|
||||
params: vec![
|
||||
ParamDescriptor::scalar(
|
||||
"luminance",
|
||||
"param.noise_reduction.luminance",
|
||||
0.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"chroma",
|
||||
"param.noise_reduction.chroma",
|
||||
0.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
/// The luminance radius at the lowest and the highest amount, in **source**
|
||||
/// pixels.
|
||||
@@ -365,8 +368,8 @@ fn inv_spatial(kernel: u32) -> f32 {
|
||||
}
|
||||
|
||||
impl Operation for NoiseReduction {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
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
|
||||
//! stages: a corner recovered by two stops has to be recovered while the
|
||||
//! 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::operation::{Helper, Operation, Uniform};
|
||||
@@ -45,19 +46,21 @@ pub const AMOUNT: ParamId = ParamId("amount");
|
||||
/// fast prime wide open — the case that actually needs correcting.
|
||||
const MAX_K1: f32 = -0.5;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
// Optics rather than effect: this carries lens-profile coefficients
|
||||
// and corrects what the lens did. A *creative* vignette is a different
|
||||
// operation that does not exist yet, and would be `Effect`.
|
||||
attributes: &[Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.vignetting"),
|
||||
// Bidirectional deliberately. Negative values *add* falloff, which is a
|
||||
// legitimate creative choice as well as a correction, and a control that
|
||||
// only removed vignetting would need a second one beside it to put any
|
||||
// back.
|
||||
params: &[ParamDescriptor::amount("amount", "param.vignetting.amount")],
|
||||
};
|
||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
// Optics rather than effect: this carries lens-profile coefficients
|
||||
// and corrects what the lens did. A *creative* vignette is a different
|
||||
// operation that does not exist yet, and would be `Effect`.
|
||||
attributes: vec![Attribute::Optics],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.vignetting"),
|
||||
// Bidirectional deliberately. Negative values *add* falloff, which is a
|
||||
// legitimate creative choice as well as a correction, and a control that
|
||||
// only removed vignetting would need a second one beside it to put any
|
||||
// back.
|
||||
params: vec![ParamDescriptor::amount("amount", "param.vignetting.amount")],
|
||||
})
|
||||
});
|
||||
|
||||
/// The `pa` polynomial coefficients, as Lensfun stores them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -107,8 +110,8 @@ impl Vignetting {
|
||||
}
|
||||
|
||||
impl Operation for Vignetting {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
@@ -371,7 +374,7 @@ mod tests {
|
||||
#[test]
|
||||
fn every_default_is_neutral() {
|
||||
let mut v = Vignetting::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
for p in &DESCRIPTOR.params {
|
||||
v.set_param(p.id, p.default);
|
||||
}
|
||||
assert!(!v.is_active());
|
||||
|
||||
@@ -1297,8 +1297,17 @@ impl PartialMask {
|
||||
.ops
|
||||
.iter()
|
||||
.find(|o| o.descriptor().id.0 == op)
|
||||
.and_then(|o| o.descriptor().params.iter().find(|p| p.id.0 == param))
|
||||
.map(|p| p.id)
|
||||
// The descriptor is bound inside the closure rather than
|
||||
// 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 {
|
||||
log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring");
|
||||
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],
|
||||
}
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Transfer {
|
||||
@@ -183,8 +199,7 @@ impl ColourSpace {
|
||||
/// defined at D50 and nowhere else — a profile carrying unadapted D65
|
||||
/// colorants describes a space nobody asked for.
|
||||
pub fn to_pcs_xyz(self) -> [f32; 9] {
|
||||
let c = self.chromaticities();
|
||||
narrow(mul(adaptation(white_xyz(&c), PCS_D50), rgb_to_xyz(&c)))
|
||||
self.chromaticities().to_pcs_xyz()
|
||||
}
|
||||
|
||||
/// The white-point adaptation folded into [`Self::to_pcs_xyz`], row-major.
|
||||
|
||||
@@ -20,24 +20,37 @@ and the reason is that some of the work is done and untagged.
|
||||
| Requirement | Reality |
|
||||
|---|---|
|
||||
| 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-3 interactive latency | **Unmeasured.** No frame budget is asserted anywhere. |
|
||||
| FR-DSP-4 progressive refinement | **Absent.** Every render is full quality. |
|
||||
| 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-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 | **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**, and §4's condition did not fire. Every render is full quality and can afford to be. |
|
||||
| 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-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. |
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
**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
|
||||
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
|
||||
@@ -119,15 +132,44 @@ stated in the About page beside the other diagnostics so a photographer can see
|
||||
on rather than wonder.
|
||||
|
||||
**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
|
||||
a parameter of composition (`compose_with_framing(..., output)`), so a display change is a
|
||||
recomposition, not a pipeline change. This is the part the existing design makes cheap.
|
||||
and updates when the window moves. The composed output space is already a parameter of composition
|
||||
(`compose_with_framing(..., output)`), so a display change is a recomposition, not a pipeline
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed*
|
||||
|
||||
+69
-69
@@ -9,18 +9,18 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Source files scanned | 256 |
|
||||
| TRACES tags found | 734 |
|
||||
| Source files scanned | 274 |
|
||||
| TRACES tags found | 791 |
|
||||
| Requirements defined | 177 |
|
||||
| Requirements covered | 98 |
|
||||
| **Coverage** | **55.4%** (98/177) |
|
||||
| Requirements covered | 106 |
|
||||
| **Coverage** | **59.9%** (106/177) |
|
||||
|
||||
### By type
|
||||
|
||||
| Type | Covered | Defined |
|
||||
|---|---|---|
|
||||
| FR | 78 | 122 |
|
||||
| NFR | 18 | 49 |
|
||||
| FR | 84 | 122 |
|
||||
| NFR | 20 | 49 |
|
||||
| R | 2 | 6 |
|
||||
|
||||
## Orphan tags
|
||||
@@ -33,108 +33,116 @@ _None._
|
||||
|
||||
| ID | Tagged in |
|
||||
|---|---|
|
||||
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200), [`core/dr-types/src/lib.rs:269`](../core/dr-types/src/lib.rs#L269), [`core/dr-types/src/lib.rs:302`](../core/dr-types/src/lib.rs#L302), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
|
||||
| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs: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:1105`](../ui/dr-ui/src/lib.rs#L1105), [`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:1105`](../ui/dr-ui/src/lib.rs#L1105), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2128`](../ui/dr-ui/src/library.rs#L2128), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) |
|
||||
| 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), [`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:1106`](../ui/dr-ui/src/lib.rs#L1106), [`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:1106`](../ui/dr-ui/src/lib.rs#L1106), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2128`](../ui/dr-ui/src/library.rs#L2128), [`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-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:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:198`](../ui/dr-ui/src/library.rs#L198), [`ui/dr-ui/src/library.rs:3095`](../ui/dr-ui/src/library.rs#L3095), [`ui/dr-ui/src/library.rs:3129`](../ui/dr-ui/src/library.rs#L3129), [`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-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:2604`](../ui/dr-ui/src/library.rs#L2604), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`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:2604`](../ui/dr-ui/src/library.rs#L2604), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:171`](../ui/dr-ui/src/library_ui.rs#L171), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`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:179`](../ui/dr-ui/src/library.rs#L179), [`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:3143`](../ui/dr-ui/src/library.rs#L3143), [`ui/dr-ui/src/library_ui.rs:6271`](../ui/dr-ui/src/library_ui.rs#L6271), [`ui/dr-ui/src/library_ui.rs:6282`](../ui/dr-ui/src/library_ui.rs#L6282), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6295`](../ui/dr-ui/src/library_ui.rs#L6295), [`ui/dr-ui/src/library_ui.rs:6310`](../ui/dr-ui/src/library_ui.rs#L6310), [`ui/dr-ui/src/library_ui.rs:6319`](../ui/dr-ui/src/library_ui.rs#L6319), [`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-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:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:3389`](../ui/dr-ui/src/library.rs#L3389), [`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:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5193`](../ui/dr-ui/src/library_ui.rs#L5193), [`ui/dr-ui/src/library_ui.rs:6411`](../ui/dr-ui/src/library_ui.rs#L6411), [`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-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:3482`](../ui/dr-ui/src/library_ui.rs#L3482), [`ui/dr-ui/src/library_ui.rs:4180`](../ui/dr-ui/src/library_ui.rs#L4180), [`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-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:1366`](../ui/dr-ui/src/lib.rs#L1366), [`ui/dr-ui/src/lib.rs:1739`](../ui/dr-ui/src/lib.rs#L1739), [`ui/dr-ui/src/lib.rs:1875`](../ui/dr-ui/src/lib.rs#L1875), [`ui/dr-ui/src/lib.rs:481`](../ui/dr-ui/src/lib.rs#L481), [`ui/dr-ui/src/lib.rs:906`](../ui/dr-ui/src/lib.rs#L906), [`ui/dr-ui/src/library.rs:1551`](../ui/dr-ui/src/library.rs#L1551), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4882`](../ui/dr-ui/src/library_ui.rs#L4882), [`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:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1511`](../ui/dr-ui/src/library.rs#L1511), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:220`](../ui/dr-ui/src/library.rs#L220), [`ui/dr-ui/src/library.rs:3496`](../ui/dr-ui/src/library.rs#L3496), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`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:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:3257`](../ui/dr-ui/src/library_ui.rs#L3257), [`ui/dr-ui/src/library_ui.rs:3345`](../ui/dr-ui/src/library_ui.rs#L3345), [`ui/dr-ui/src/library_ui.rs:3533`](../ui/dr-ui/src/library_ui.rs#L3533), [`ui/dr-ui/src/library_ui.rs:3651`](../ui/dr-ui/src/library_ui.rs#L3651), [`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:5238`](../ui/dr-ui/src/library_ui.rs#L5238), [`ui/dr-ui/src/library_ui.rs:5353`](../ui/dr-ui/src/library_ui.rs#L5353), [`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-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:3143`](../ui/dr-ui/src/library.rs#L3143), [`ui/dr-ui/src/library_ui.rs:6271`](../ui/dr-ui/src/library_ui.rs#L6271), [`ui/dr-ui/src/library_ui.rs:6282`](../ui/dr-ui/src/library_ui.rs#L6282), [`ui/dr-ui/src/library_ui.rs:628`](../ui/dr-ui/src/library_ui.rs#L628), [`ui/dr-ui/src/library_ui.rs:6295`](../ui/dr-ui/src/library_ui.rs#L6295), [`ui/dr-ui/src/library_ui.rs:6310`](../ui/dr-ui/src/library_ui.rs#L6310), [`ui/dr-ui/src/library_ui.rs:6319`](../ui/dr-ui/src/library_ui.rs#L6319), [`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:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:3389`](../ui/dr-ui/src/library.rs#L3389), [`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:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5193`](../ui/dr-ui/src/library_ui.rs#L5193), [`ui/dr-ui/src/library_ui.rs:6411`](../ui/dr-ui/src/library_ui.rs#L6411), [`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:3482`](../ui/dr-ui/src/library_ui.rs#L3482), [`ui/dr-ui/src/library_ui.rs:4180`](../ui/dr-ui/src/library_ui.rs#L4180), [`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: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:1367`](../ui/dr-ui/src/lib.rs#L1367), [`ui/dr-ui/src/lib.rs:1768`](../ui/dr-ui/src/lib.rs#L1768), [`ui/dr-ui/src/lib.rs:1904`](../ui/dr-ui/src/lib.rs#L1904), [`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:1551`](../ui/dr-ui/src/library.rs#L1551), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library_ui.rs:4882`](../ui/dr-ui/src/library_ui.rs#L4882), [`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:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1511`](../ui/dr-ui/src/library.rs#L1511), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:220`](../ui/dr-ui/src/library.rs#L220), [`ui/dr-ui/src/library.rs:3496`](../ui/dr-ui/src/library.rs#L3496), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:696`](../ui/dr-ui/src/library.rs#L696), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`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:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`ui/dr-ui/src/library_ui.rs:3257`](../ui/dr-ui/src/library_ui.rs#L3257), [`ui/dr-ui/src/library_ui.rs:3345`](../ui/dr-ui/src/library_ui.rs#L3345), [`ui/dr-ui/src/library_ui.rs:3533`](../ui/dr-ui/src/library_ui.rs#L3533), [`ui/dr-ui/src/library_ui.rs:3651`](../ui/dr-ui/src/library_ui.rs#L3651), [`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:5238`](../ui/dr-ui/src/library_ui.rs#L5238), [`ui/dr-ui/src/library_ui.rs:5353`](../ui/dr-ui/src/library_ui.rs#L5353), [`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-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:1847`](../ui/dr-ui/src/lib.rs#L1847), [`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:1876`](../ui/dr-ui/src/lib.rs#L1876), [`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-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:203`](../ui/dr-ui/src/library.rs#L203), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364) |
|
||||
| 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/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/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-DEV-2 | [`core/dr-pipeline/src/operation.rs:352`](../core/dr-pipeline/src/operation.rs#L352) |
|
||||
| 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:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/lib.rs:2125`](../ui/dr-ui/src/lib.rs#L2125), [`ui/dr-ui/src/lib.rs:302`](../ui/dr-ui/src/lib.rs#L302), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`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-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:596`](../ui/dr-ui/src/lib.rs#L596), [`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-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-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-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-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:2099`](../ui/dr-ui/src/lib.rs#L2099), [`ui/dr-ui/src/lib.rs:529`](../ui/dr-ui/src/lib.rs#L529), [`ui/dr-ui/src/lib.rs:587`](../ui/dr-ui/src/lib.rs#L587), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`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-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-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: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:1452`](../ui/dr-ui/src/lib.rs#L1452), [`ui/dr-ui/src/lib.rs:2154`](../ui/dr-ui/src/lib.rs#L2154), [`ui/dr-ui/src/lib.rs:303`](../ui/dr-ui/src/lib.rs#L303), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`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: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: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: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: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:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) |
|
||||
| FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs: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:2128`](../ui/dr-ui/src/lib.rs#L2128), [`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:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`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: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-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:1387`](../ui/dr-ui/src/lib.rs#L1387), [`ui/dr-ui/src/lib.rs:1401`](../ui/dr-ui/src/lib.rs#L1401), [`ui/dr-ui/src/lib.rs:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:2295`](../ui/dr-ui/src/lib.rs#L2295), [`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:1366`](../ui/dr-ui/src/lib.rs#L1366), [`ui/dr-ui/src/library.rs:1551`](../ui/dr-ui/src/library.rs#L1551), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`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:1409`](../ui/dr-ui/src/lib.rs#L1409), [`ui/dr-ui/src/lib.rs:2295`](../ui/dr-ui/src/lib.rs#L2295), [`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:1430`](../ui/dr-ui/src/lib.rs#L1430), [`ui/dr-ui/src/lib.rs:2392`](../ui/dr-ui/src/lib.rs#L2392), [`ui/dr-ui/src/lib.rs:307`](../ui/dr-ui/src/lib.rs#L307), [`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-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:71`](../ui/dr-ui/src/lib.rs#L71), [`ui/dr-ui/src/lib.rs:737`](../ui/dr-ui/src/lib.rs#L737), [`ui/dr-ui/src/lib.rs:796`](../ui/dr-ui/src/lib.rs#L796) |
|
||||
| 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-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:1486`](../ui/dr-ui/src/lib.rs#L1486), [`ui/dr-ui/src/lib.rs:297`](../ui/dr-ui/src/lib.rs#L297), [`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-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:1400`](../ui/dr-ui/src/lib.rs#L1400), [`ui/dr-ui/src/lib.rs:1430`](../ui/dr-ui/src/lib.rs#L1430), [`ui/dr-ui/src/lib.rs:1438`](../ui/dr-ui/src/lib.rs#L1438), [`ui/dr-ui/src/lib.rs:2324`](../ui/dr-ui/src/lib.rs#L2324), [`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: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:1367`](../ui/dr-ui/src/lib.rs#L1367), [`ui/dr-ui/src/library.rs:1551`](../ui/dr-ui/src/library.rs#L1551), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`ui/dr-ui/src/library.rs:392`](../ui/dr-ui/src/library.rs#L392), [`ui/dr-ui/src/library_ui.rs:2576`](../ui/dr-ui/src/library_ui.rs#L2576), [`ui/dr-ui/src/library_ui.rs:2976`](../ui/dr-ui/src/library_ui.rs#L2976), [`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: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:1438`](../ui/dr-ui/src/lib.rs#L1438), [`ui/dr-ui/src/lib.rs:2324`](../ui/dr-ui/src/lib.rs#L2324), [`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: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:1459`](../ui/dr-ui/src/lib.rs#L1459), [`ui/dr-ui/src/lib.rs:2421`](../ui/dr-ui/src/lib.rs#L2421), [`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: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-3 | [`core/dr-gpu/tests/frame_budget.rs:101`](../core/dr-gpu/tests/frame_budget.rs#L101) |
|
||||
| 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:1415`](../ui/dr-ui/src/lib.rs#L1415), [`ui/dr-ui/src/lib.rs:2627`](../ui/dr-ui/src/lib.rs#L2627) |
|
||||
| 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:1515`](../ui/dr-ui/src/lib.rs#L1515), [`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:1389`](../ui/dr-ui/src/lib.rs#L1389), [`ui/dr-ui/src/lib.rs:1415`](../ui/dr-ui/src/lib.rs#L1415), [`ui/dr-ui/src/lib.rs:2604`](../ui/dr-ui/src/lib.rs#L2604), [`ui/dr-ui/src/lib.rs:2627`](../ui/dr-ui/src/lib.rs#L2627), [`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:730`](../ui/dr-ui/ui/settings.slint#L730) |
|
||||
| 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-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
|
||||
| FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
|
||||
| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:345`](../ui/dr-ui/src/lib.rs#L345), [`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:187`](../ui/dr-ui/src/lib.rs#L187), [`ui/dr-ui/src/lib.rs:2041`](../ui/dr-ui/src/lib.rs#L2041), [`ui/dr-ui/src/lib.rs:345`](../ui/dr-ui/src/lib.rs#L345), [`ui/dr-ui/src/lib.rs:380`](../ui/dr-ui/src/lib.rs#L380), [`ui/dr-ui/src/lib.rs:407`](../ui/dr-ui/src/lib.rs#L407), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/library_ui.rs:6253`](../ui/dr-ui/src/library_ui.rs#L6253), [`ui/dr-ui/src/library_ui.rs:6330`](../ui/dr-ui/src/library_ui.rs#L6330), [`ui/dr-ui/src/library_ui.rs:6342`](../ui/dr-ui/src/library_ui.rs#L6342), [`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-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:188`](../ui/dr-ui/src/lib.rs#L188), [`ui/dr-ui/src/lib.rs:2070`](../ui/dr-ui/src/lib.rs#L2070), [`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:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`ui/dr-ui/src/library_ui.rs:557`](../ui/dr-ui/src/library_ui.rs#L557), [`ui/dr-ui/src/library_ui.rs:6253`](../ui/dr-ui/src/library_ui.rs#L6253), [`ui/dr-ui/src/library_ui.rs:6330`](../ui/dr-ui/src/library_ui.rs#L6330), [`ui/dr-ui/src/library_ui.rs:6342`](../ui/dr-ui/src/library_ui.rs#L6342), [`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-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:345`](../ui/dr-ui/src/lib.rs#L345) |
|
||||
| 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-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:407`](../ui/dr-ui/src/lib.rs#L407), [`ui/dr-ui/src/library.rs:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:953`](../ui/dr-ui/src/library.rs#L953), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`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:1588`](../ui/dr-ui/src/library.rs#L1588), [`ui/dr-ui/src/library.rs:448`](../ui/dr-ui/src/library.rs#L448), [`ui/dr-ui/src/library.rs:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:953`](../ui/dr-ui/src/library.rs#L953), [`ui/dr-ui/src/library_ui.rs:1606`](../ui/dr-ui/src/library_ui.rs#L1606), [`ui/dr-ui/src/library_ui.rs:3362`](../ui/dr-ui/src/library_ui.rs#L3362), [`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), [`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-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:2604`](../ui/dr-ui/src/library.rs#L2604), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`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-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:2604`](../ui/dr-ui/src/library.rs#L2604), [`ui/dr-ui/src/library.rs:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/src/library_ui.rs:4590`](../ui/dr-ui/src/library_ui.rs#L4590), [`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-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-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:1770`](../ui/dr-ui/src/lib.rs#L1770), [`ui/dr-ui/src/lib.rs:2648`](../ui/dr-ui/src/lib.rs#L2648), [`ui/dr-ui/src/library.rs:1330`](../ui/dr-ui/src/library.rs#L1330), [`ui/dr-ui/src/library.rs:1353`](../ui/dr-ui/src/library.rs#L1353), [`ui/dr-ui/src/library.rs:1511`](../ui/dr-ui/src/library.rs#L1511), [`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:5255`](../ui/dr-ui/src/library_ui.rs#L5255), [`ui/dr-ui/src/library_ui.rs:5273`](../ui/dr-ui/src/library_ui.rs#L5273), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`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:1799`](../ui/dr-ui/src/lib.rs#L1799), [`ui/dr-ui/src/lib.rs:2690`](../ui/dr-ui/src/lib.rs#L2690), [`ui/dr-ui/src/library.rs:1330`](../ui/dr-ui/src/library.rs#L1330), [`ui/dr-ui/src/library.rs:1353`](../ui/dr-ui/src/library.rs#L1353), [`ui/dr-ui/src/library.rs:1511`](../ui/dr-ui/src/library.rs#L1511), [`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:5255`](../ui/dr-ui/src/library_ui.rs#L5255), [`ui/dr-ui/src/library_ui.rs:5273`](../ui/dr-ui/src/library_ui.rs#L5273), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`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-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:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`ui/dr-ui/ui/settings.slint:353`](../ui/dr-ui/ui/settings.slint#L353) |
|
||||
| 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:1105`](../ui/dr-ui/src/lib.rs#L1105), [`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:1105`](../ui/dr-ui/src/lib.rs#L1105) |
|
||||
| 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:1739`](../ui/dr-ui/src/lib.rs#L1739), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`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:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) |
|
||||
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) |
|
||||
| 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:2630`](../ui/dr-ui/src/library.rs#L2630), [`ui/dr-ui/src/library_ui.rs:3624`](../ui/dr-ui/src/library_ui.rs#L3624), [`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:1106`](../ui/dr-ui/src/lib.rs#L1106), [`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:1106`](../ui/dr-ui/src/lib.rs#L1106) |
|
||||
| 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:1768`](../ui/dr-ui/src/lib.rs#L1768), [`ui/dr-ui/src/library.rs:364`](../ui/dr-ui/src/library.rs#L364), [`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: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:750`](../ui/dr-ui/src/library.rs#L750), [`ui/dr-ui/src/library.rs:880`](../ui/dr-ui/src/library.rs#L880) |
|
||||
| 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-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:846`](../ui/dr-ui/src/lib.rs#L846), [`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-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:187`](../ui/dr-ui/src/lib.rs#L187) |
|
||||
| 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-UI-1 | [`ui/dr-ui/src/lib.rs:2730`](../ui/dr-ui/src/lib.rs#L2730), [`ui/dr-ui/src/lib.rs:79`](../ui/dr-ui/src/lib.rs#L79), [`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:79`](../ui/dr-ui/src/lib.rs#L79), [`ui/dr-ui/src/library_ui.rs:286`](../ui/dr-ui/src/library_ui.rs#L286), [`ui/dr-ui/src/library_ui.rs:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/src/library_ui.rs:6361`](../ui/dr-ui/src/library_ui.rs#L6361), [`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-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:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`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-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:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`ui/dr-ui/src/library_ui.rs:4430`](../ui/dr-ui/src/library_ui.rs#L4430), [`ui/dr-ui/src/library_ui.rs:4533`](../ui/dr-ui/src/library_ui.rs#L4533), [`ui/dr-ui/src/library_ui.rs:4561`](../ui/dr-ui/src/library_ui.rs#L4561), [`ui/dr-ui/src/library_ui.rs:5273`](../ui/dr-ui/src/library_ui.rs#L5273), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`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-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2357`](../ui/dr-ui/src/lib.rs#L2357), [`ui/dr-ui/src/lib.rs:2764`](../ui/dr-ui/src/lib.rs#L2764), [`ui/dr-ui/src/lib.rs:2949`](../ui/dr-ui/src/lib.rs#L2949), [`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-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-1 | [`ui/dr-ui/src/lib.rs:2772`](../ui/dr-ui/src/lib.rs#L2772), [`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: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:5140`](../ui/dr-ui/src/library_ui.rs#L5140), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`ui/dr-ui/src/library_ui.rs:6361`](../ui/dr-ui/src/library_ui.rs#L6361), [`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: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:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`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:4385`](../ui/dr-ui/src/library_ui.rs#L4385), [`ui/dr-ui/src/library_ui.rs:4430`](../ui/dr-ui/src/library_ui.rs#L4430), [`ui/dr-ui/src/library_ui.rs:4533`](../ui/dr-ui/src/library_ui.rs#L4533), [`ui/dr-ui/src/library_ui.rs:4561`](../ui/dr-ui/src/library_ui.rs#L4561), [`ui/dr-ui/src/library_ui.rs:5273`](../ui/dr-ui/src/library_ui.rs#L5273), [`ui/dr-ui/src/library_ui.rs:5285`](../ui/dr-ui/src/library_ui.rs#L5285), [`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:2386`](../ui/dr-ui/src/lib.rs#L2386), [`ui/dr-ui/src/lib.rs:2806`](../ui/dr-ui/src/lib.rs#L2806), [`ui/dr-ui/src/lib.rs:2991`](../ui/dr-ui/src/lib.rs#L2991), [`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: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) |
|
||||
| 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:2075`](../ui/dr-ui/src/lib.rs#L2075), [`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-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-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:2104`](../ui/dr-ui/src/lib.rs#L2104), [`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), [`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-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) |
|
||||
| NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../core/dr-decode/src/preview.rs#L121) |
|
||||
| NFR-P5 | [`core/dr-catalog/src/schema.rs:304`](../core/dr-catalog/src/schema.rs#L304), [`ui/dr-ui/src/library.rs:3229`](../ui/dr-ui/src/library.rs#L3229), [`ui/dr-ui/src/library.rs:4002`](../ui/dr-ui/src/library.rs#L4002), [`ui/dr-ui/src/library_ui.rs:4070`](../ui/dr-ui/src/library_ui.rs#L4070), [`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-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:953`](../ui/dr-ui/src/library.rs#L953) |
|
||||
| NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
|
||||
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
|
||||
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
|
||||
| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:86`](../core/dr-pipeline/src/history.rs#L86), [`ui/dr-ui/src/lib.rs:71`](../ui/dr-ui/src/lib.rs#L71) |
|
||||
| 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:2604`](../ui/dr-ui/src/library.rs#L2604) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
## 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>
|
||||
|
||||
@@ -146,27 +154,21 @@ _None._
|
||||
- FR-DEV-1
|
||||
- FR-DEV-3g
|
||||
- FR-DSP-2
|
||||
- FR-DSP-3
|
||||
- FR-DSP-4
|
||||
- FR-DSP-5
|
||||
- FR-DSP-8
|
||||
- FR-NC-11
|
||||
- FR-PLAT-AND-2
|
||||
- FR-PLAT-AND-4
|
||||
- FR-PLAT-AND-5
|
||||
- FR-PLAT-AND-6
|
||||
- FR-PLAT-LIN-2
|
||||
- FR-PLAT-LIN-3
|
||||
- FR-PLG-1
|
||||
- FR-PLG-10
|
||||
- FR-PLG-11
|
||||
- FR-PLG-12
|
||||
- FR-PLG-1a
|
||||
- FR-PLG-2
|
||||
- FR-PLG-2a
|
||||
- FR-PLG-2b
|
||||
- FR-PLG-2c
|
||||
- FR-PLG-2d
|
||||
- FR-PLG-3
|
||||
- FR-PLG-3a
|
||||
- FR-PLG-4
|
||||
@@ -203,13 +205,11 @@ _None._
|
||||
- NFR-P7
|
||||
- NFR-P8
|
||||
- NFR-PORT-2
|
||||
- NFR-PORT-3
|
||||
- NFR-R3
|
||||
- NFR-R4
|
||||
- NFR-R6
|
||||
- NFR-RES-2
|
||||
- NFR-RES-3
|
||||
- NFR-SEC-2
|
||||
- NFR-SEC-3
|
||||
- NFR-SEC-4
|
||||
- NFR-SEC-6
|
||||
|
||||
@@ -12,6 +12,12 @@ log.workspace = true
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "android")))'.dependencies]
|
||||
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]
|
||||
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,
|
||||
//! ARCH §10).
|
||||
|
||||
pub mod display;
|
||||
pub mod secrets;
|
||||
pub mod storage;
|
||||
pub mod volumes;
|
||||
|
||||
pub use display::{
|
||||
Bounds, DisplayInfo, DisplayProfile, DisplayServer, DisplaySurvey, FallbackReason,
|
||||
ProfileSource,
|
||||
};
|
||||
pub use secrets::{
|
||||
EphemeralSecretStore, PlatformSecretStore, SecretError, SecretKind, SecretRef, SecretStore,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,12 @@ use anyhow::{bail, Context, Result};
|
||||
use traceability::*;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
|
||||
+180
-15
@@ -686,6 +686,22 @@ pub struct DevelopSession {
|
||||
/// open. One value rather than one per operation, for the same reason
|
||||
/// `curve_samples` is one polyline: the panel draws one curve.
|
||||
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 {
|
||||
@@ -756,6 +772,7 @@ impl DevelopSession {
|
||||
show_overlay: false,
|
||||
active_tab: None,
|
||||
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();
|
||||
for id in presentation.params {
|
||||
for id in &presentation.params {
|
||||
// The widget addresses points by offset from the first of its run, so
|
||||
// a run has to be contiguous in the capability list.
|
||||
let at = op.params.iter().position(|p| p.id == *id)?;
|
||||
@@ -2624,6 +2641,30 @@ impl DevelopSession {
|
||||
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
|
||||
/// 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 (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
|
||||
// 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")?;
|
||||
|
||||
// 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
|
||||
/// 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
|
||||
/// 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
|
||||
/// photographer inspecting a highlight at 4× is asking about *that*
|
||||
/// highlight, and a histogram of the parts of the frame off screen would
|
||||
@@ -4480,15 +4538,15 @@ mod tests {
|
||||
presentation: Some(Presentation {
|
||||
// Prefers a gradient handle; this frontend has none, so it
|
||||
// falls back to the next entry, which the canvas does host.
|
||||
widgets: &[WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
||||
widgets: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: true,
|
||||
precise_pointing: false,
|
||||
},
|
||||
params: &[ParamId("a"), ParamId("b")],
|
||||
params: vec![ParamId("a"), ParamId("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());
|
||||
@@ -4612,7 +4670,7 @@ mod tests {
|
||||
id: ParamId("method"),
|
||||
label: LocalizedKey("param.invented.method"),
|
||||
kind: ParamKind::Enum {
|
||||
variants: &[
|
||||
variants: vec![
|
||||
LocalizedKey("param.invented.method.fast"),
|
||||
LocalizedKey("param.invented.method.exact"),
|
||||
],
|
||||
@@ -4622,7 +4680,7 @@ mod tests {
|
||||
facet: None,
|
||||
},
|
||||
],
|
||||
attributes: &[dr_pipeline::Attribute::Tone],
|
||||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||
};
|
||||
|
||||
let rows = rows_from(&[invented]);
|
||||
@@ -4660,12 +4718,12 @@ mod tests {
|
||||
label: LocalizedKey("op.grading"),
|
||||
active: false,
|
||||
presentation: Some(Presentation {
|
||||
widgets: &[WidgetKind::ColourWheel],
|
||||
widgets: vec![WidgetKind::ColourWheel],
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: true,
|
||||
precise_pointing: false,
|
||||
},
|
||||
params: &[ParamId("hue"), ParamId("strength")],
|
||||
params: vec![ParamId("hue"), ParamId("strength")],
|
||||
}),
|
||||
params: vec![
|
||||
ParamCapability {
|
||||
@@ -4697,7 +4755,7 @@ mod tests {
|
||||
facet: None,
|
||||
},
|
||||
],
|
||||
attributes: &[dr_pipeline::Attribute::Tone],
|
||||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||
};
|
||||
|
||||
assert!(!supported(WidgetKind::ColourWheel), "precondition");
|
||||
@@ -5146,15 +5204,15 @@ mod tests {
|
||||
label: LocalizedKey("op.invented_curve"),
|
||||
active: false,
|
||||
presentation: Some(Presentation {
|
||||
widgets: &[WidgetKind::ToneCurve],
|
||||
widgets: vec![WidgetKind::ToneCurve],
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: true,
|
||||
precise_pointing: true,
|
||||
},
|
||||
params: &IDS,
|
||||
params: IDS.to_vec(),
|
||||
}),
|
||||
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");
|
||||
@@ -5407,4 +5465,111 @@ mod tests {
|
||||
session.set_active_tab(99);
|
||||
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));
|
||||
}
|
||||
}
|
||||
+43
-1
@@ -23,6 +23,7 @@ mod activity;
|
||||
mod collections_ui;
|
||||
mod derived_sync;
|
||||
mod develop;
|
||||
mod display_ui;
|
||||
mod export;
|
||||
pub mod faces;
|
||||
mod gradient;
|
||||
@@ -1378,8 +1379,20 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
|
||||
window.set_adjust_rows(rows.clone().into());
|
||||
// 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)));
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Called on every slider change, so it must do no more than run the
|
||||
@@ -1394,10 +1407,26 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let session = session.clone();
|
||||
let viewport = viewport.clone();
|
||||
let drawn_history = drawn_history.clone();
|
||||
let display = display.clone();
|
||||
Rc::new(move |window: &AppWindow, draft: bool| {
|
||||
let mut slot = session.borrow_mut();
|
||||
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
|
||||
// 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:
|
||||
@@ -2571,13 +2600,22 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
|
||||
// Track the canvas size so the adjust pass renders at viewport
|
||||
// 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 viewport = viewport.clone();
|
||||
let redraw = redraw.clone();
|
||||
let display = display.clone();
|
||||
window.on_canvas_resized(move |w_px, h_px| {
|
||||
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 {
|
||||
return;
|
||||
}
|
||||
@@ -2586,6 +2624,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
|
||||
// Slint because a property that both derives from and feeds the layout is
|
||||
// a binding loop.
|
||||
|
||||
+18
-5
@@ -275,6 +275,13 @@ export component AppWindow inherits Window {
|
||||
in property <string> adapter: "detecting…";
|
||||
in property <string> backend: "—";
|
||||
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
|
||||
/// Set from `CARGO_PKG_VERSION`, so the About line cannot disagree with
|
||||
/// the binary it is part of.
|
||||
@@ -1267,6 +1274,9 @@ in property <bool> panel-visible: true;
|
||||
fps: root.fps;
|
||||
layout-class: root.layout-class;
|
||||
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-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
|
||||
// what keeps the image from aliasing.
|
||||
//
|
||||
// This matters even at moderate zoom on a HiDPI display:
|
||||
// the canvas is rendered at *logical* size and Slint scales
|
||||
// it up by the device pixel ratio, so the buffer is
|
||||
// resampled on its way to the screen whatever the pipeline
|
||||
// did.
|
||||
// TRACES: FR-DSP-8
|
||||
// The canvas is rendered at the *physical* pixel size of
|
||||
// the box this image occupies, so `contain` presents it
|
||||
// 1:1 and neither filter is reached at all until the
|
||||
// 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
|
||||
? ImageRendering.pixelated
|
||||
: ImageRendering.smooth;
|
||||
|
||||
@@ -117,6 +117,25 @@ export component SettingsPage inherits Rectangle {
|
||||
in property <string> layout-class;
|
||||
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-unlimited-toggled(bool);
|
||||
callback thumbnail-budget-changed(string);
|
||||
@@ -708,10 +727,46 @@ export component SettingsPage inherits Rectangle {
|
||||
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 {
|
||||
text: "Graphics and frame rate describe this session, "
|
||||
+ "not a setting — they are here so a bug report "
|
||||
+ "can quote them.";
|
||||
text: "Graphics, frame rate and display colour "
|
||||
+ "describe this session, not a setting — they "
|
||||
+ "are here so a bug report can quote them.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user