FR-CULL-3's focus peaking. One compute dispatch measures local contrast in WGSL and writes an overlay texture; on desktop it reaches Slint through the same zero-copy wgpu import the canvas uses, so nothing per-pixel touches the CPU on the frame path. With peaking off the cost is zero and structurally so: focus_overlay opens with `let settings = self.peaking?;` before the frame is touched, and clearing drops both overlay textures, so no VRAM is held either. NFR-P14 is met by construction rather than by measurement -- one dispatch, no second render, no pipeline compile after session open, and a test asserting allocations stay at 2 over eight frames. The budget test asserts 50ms at 4K rather than a tight bound, deliberately: a tight bound fails on a loaded machine and gets deleted, which is worse than a loose one that still catches the regression that matters. TD-1 is amended rather than joined by a TD-6: on Android the overlay rides the readback that already exists there, roughly doubling that transfer while peaking is on, and TD-1's own "Done when" removes both because both are the same missing capability. Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings green, which also compiles peaking.slint through dr-ui's build.rs; 11 focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass. Not verified: the cfg(target_os = "android") arm, which the host-target clippy never compiled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
6.5 KiB
Rust
161 lines
6.5 KiB
Rust
//! TRACES: FR-CULL-3
|
|
//! The focus-peaking vocabulary, as the indices a chip row can carry.
|
|
//!
|
|
//! `dr_gpu` decides what peaking *is* — the measure, the thresholds, the
|
|
//! marks. This decides how a menu of three sensitivities and four colours
|
|
//! crosses the boundary into Slint, which has no notion of a Rust enum and
|
|
//! carries the choice as an `int` into an array of labels.
|
|
//!
|
|
//! That translation is small and it is the kind of small that goes wrong
|
|
//! silently. An index the interface sends that Rust reads as a different
|
|
//! variant produces a control that changes something other than what it says,
|
|
//! which nobody notices as a bug — they notice it as peaking behaving oddly.
|
|
//! So the order lives in one place here, both directions are asserted to round
|
|
//! trip, and a test checks that the labels in `ui/peaking.slint` still number
|
|
//! the same as the vocabularies they claim to name.
|
|
//!
|
|
//! Free-standing functions over plain integers, deliberately, for the reason
|
|
//! `crate::histogram` gives: none of this needs a GPU, a window or a
|
|
//! photograph to be checked, and all of it is invisible when wrong.
|
|
|
|
use dr_gpu::{PeakColour, PeakSensitivity};
|
|
|
|
/// The sensitivities, in the order the chip row shows them.
|
|
///
|
|
/// Least sensitive first, so the row reads left to right as "mark less" to
|
|
/// "mark more" — the axis the photographer is actually moving along.
|
|
pub(crate) const SENSITIVITIES: [PeakSensitivity; 3] = [
|
|
PeakSensitivity::Low,
|
|
PeakSensitivity::Medium,
|
|
PeakSensitivity::High,
|
|
];
|
|
|
|
/// The mark colours, in the order the chip row shows them.
|
|
pub(crate) const COLOURS: [PeakColour; 4] = [
|
|
PeakColour::Red,
|
|
PeakColour::Yellow,
|
|
PeakColour::Cyan,
|
|
PeakColour::Magenta,
|
|
];
|
|
|
|
/// The sensitivity an index names.
|
|
///
|
|
/// Out of range falls back to the default rather than panicking. The index
|
|
/// arrives from the interface, and the interface is the half of this that can
|
|
/// be recompiled without recompiling the other — a chip row that grew an entry
|
|
/// should degrade to a sane setting, not take the application down mid-cull.
|
|
pub(crate) fn sensitivity(index: i32) -> PeakSensitivity {
|
|
usize::try_from(index)
|
|
.ok()
|
|
.and_then(|i| SENSITIVITIES.get(i).copied())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// The colour an index names, on the same terms.
|
|
pub(crate) fn colour(index: i32) -> PeakColour {
|
|
usize::try_from(index)
|
|
.ok()
|
|
.and_then(|i| COLOURS.get(i).copied())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Which chip is lit for this sensitivity.
|
|
pub(crate) fn sensitivity_index(value: PeakSensitivity) -> i32 {
|
|
SENSITIVITIES.iter().position(|s| *s == value).unwrap_or(0) as i32
|
|
}
|
|
|
|
/// Which chip is lit for this colour.
|
|
pub(crate) fn colour_index(value: PeakColour) -> i32 {
|
|
COLOURS.iter().position(|c| *c == value).unwrap_or(0) as i32
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn every_variant_appears_exactly_once_in_its_row() {
|
|
// A variant missing from the row is a setting the photographer cannot
|
|
// reach; one listed twice is two chips that do the same thing, of
|
|
// which only the first can ever look selected. Both are invisible in
|
|
// the running application until somebody presses the wrong chip.
|
|
for s in SENSITIVITIES {
|
|
assert_eq!(
|
|
SENSITIVITIES.iter().filter(|x| **x == s).count(),
|
|
1,
|
|
"{s:?} is listed more than once"
|
|
);
|
|
}
|
|
for c in COLOURS {
|
|
assert_eq!(COLOURS.iter().filter(|x| **x == c).count(), 1);
|
|
}
|
|
// Named rather than counted, so adding a variant to `dr_gpu` without
|
|
// adding it here fails to compile instead of passing quietly.
|
|
assert!(SENSITIVITIES.contains(&PeakSensitivity::Low));
|
|
assert!(SENSITIVITIES.contains(&PeakSensitivity::Medium));
|
|
assert!(SENSITIVITIES.contains(&PeakSensitivity::High));
|
|
assert!(COLOURS.contains(&PeakColour::Red));
|
|
assert!(COLOURS.contains(&PeakColour::Yellow));
|
|
assert!(COLOURS.contains(&PeakColour::Cyan));
|
|
assert!(COLOURS.contains(&PeakColour::Magenta));
|
|
}
|
|
|
|
#[test]
|
|
fn an_index_and_its_variant_agree_in_both_directions() {
|
|
// The failure this catches is a chip that lights up under the pointer
|
|
// while a different setting takes effect — the two directions drifting
|
|
// apart is exactly what one shared array is here to prevent, and the
|
|
// only way to see it is to go round.
|
|
for (i, s) in SENSITIVITIES.iter().enumerate() {
|
|
assert_eq!(sensitivity(i as i32), *s);
|
|
assert_eq!(sensitivity_index(*s), i as i32);
|
|
}
|
|
for (i, c) in COLOURS.iter().enumerate() {
|
|
assert_eq!(colour(i as i32), *c);
|
|
assert_eq!(colour_index(*c), i as i32);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_index_from_nowhere_lands_on_the_default_rather_than_panicking() {
|
|
// Slint has no bound on the `int` it sends and Rust has no way to
|
|
// refuse one. A panic here would be an application that closes because
|
|
// a chip row was edited.
|
|
assert_eq!(sensitivity(-1), PeakSensitivity::default());
|
|
assert_eq!(sensitivity(99), PeakSensitivity::default());
|
|
assert_eq!(colour(-1), PeakColour::default());
|
|
assert_eq!(colour(99), PeakColour::default());
|
|
}
|
|
|
|
#[test]
|
|
fn the_panel_offers_exactly_the_choices_this_module_knows_about() {
|
|
// **The one seam neither compiler checks.** The labels live in
|
|
// `ui/peaking.slint` and the meanings live here, joined only by an
|
|
// integer; a fifth colour added to the chip row would send index 4 to
|
|
// `colour`, which would quietly answer Red. Reading the file is
|
|
// clumsier than a derive, and it is what there is.
|
|
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/peaking.slint"))
|
|
.expect("the panel this module serves");
|
|
|
|
let listed = |line_start: &str| -> usize {
|
|
let line = src
|
|
.lines()
|
|
.map(str::trim)
|
|
.find(|l| l.starts_with(line_start))
|
|
.unwrap_or_else(|| panic!("no `{line_start}` row in peaking.slint"));
|
|
line.matches('"').count() / 2
|
|
};
|
|
|
|
assert_eq!(
|
|
listed("options: [\"Low\""),
|
|
SENSITIVITIES.len(),
|
|
"the sensitivity chips and `SENSITIVITIES` disagree"
|
|
);
|
|
assert_eq!(
|
|
listed("options: [\"Red\""),
|
|
COLOURS.len(),
|
|
"the colour chips and `COLOURS` disagree"
|
|
);
|
|
}
|
|
}
|