Mark what is in focus, so a frame can be judged without zooming to 100%
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>
This commit is contained in:
@@ -39,6 +39,7 @@ mod library_ui;
|
||||
mod live_style;
|
||||
mod masks_ui;
|
||||
mod net_runtime;
|
||||
mod peaking;
|
||||
mod presets;
|
||||
mod remote;
|
||||
mod segmentation;
|
||||
@@ -316,6 +317,12 @@ fn reset_view_state(window: &AppWindow) {
|
||||
// beside the next one's filename is a confident, precise lie, and the gap
|
||||
// before the new frame settles is exactly long enough to read it.
|
||||
window.set_histogram(histogram::empty());
|
||||
// TRACES: FR-CULL-3
|
||||
// The marks go down with it, and for the same reason. What is *not* reset
|
||||
// is whether peaking is switched on: that is a way of looking at a folder
|
||||
// rather than a property of one photograph, so it survives to the next
|
||||
// frame — see `chosen_peaking` for the whole of that argument.
|
||||
window.set_focus_overlay_ready(false);
|
||||
// TRACES: FR-DEV-3
|
||||
// The region map belongs to one photograph. Carrying the stack, the
|
||||
// overlay or the crosshair to the next one would offer a selection of
|
||||
@@ -1464,11 +1471,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// is redrawn, and those are very different rates.
|
||||
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// How the photographer wants focus peaking drawn, or `None` for off.
|
||||
//
|
||||
// **Held here rather than on the session, which is the opposite of where
|
||||
// every edit lives.** A session is one photograph; peaking is a way of
|
||||
// *looking* at a folder of them. Someone culling three thousand frames
|
||||
// switches it on once, and a flag that reset with the session would ask
|
||||
// them to switch it on three thousand times — which is why
|
||||
// `reset_view_state` deliberately leaves it alone while emptying the
|
||||
// histogram beside it.
|
||||
let chosen_peaking: Rc<Cell<Option<dr_gpu::FocusPeaking>>> = Rc::new(Cell::new(None));
|
||||
|
||||
let render_now: Render = {
|
||||
let session = session.clone();
|
||||
let viewport = viewport.clone();
|
||||
let drawn_history = drawn_history.clone();
|
||||
let display = display.clone();
|
||||
let chosen_peaking = chosen_peaking.clone();
|
||||
Rc::new(move |window: &AppWindow, draft: bool| {
|
||||
let mut slot = session.borrow_mut();
|
||||
let Some(s) = slot.as_mut() else { return };
|
||||
@@ -1529,6 +1549,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// arrival takes.
|
||||
spots_ui::sync_panel(window, s);
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// The session owns the pass and the interface owns the choice, so
|
||||
// they are joined here — on the one path every frame takes, which
|
||||
// is also what makes a photograph opened with peaking already on
|
||||
// arrive with its marks rather than without them.
|
||||
if s.peaking() != chosen_peaking.get() {
|
||||
s.set_peaking(chosen_peaking.get());
|
||||
}
|
||||
window.set_peaking_available(s.peaking_available());
|
||||
|
||||
let (mut w, mut h) = *viewport.borrow();
|
||||
|
||||
// **Half resolution while the gesture is still moving.**
|
||||
@@ -1591,6 +1621,34 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
.map_or_else(histogram::empty, histogram::view),
|
||||
);
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3 | NFR-P14
|
||||
// **Marked on the settled frame and no other**, and unlike
|
||||
// the histogram beside it the marks are taken *down* in
|
||||
// between rather than left standing.
|
||||
//
|
||||
// The reason is not budget — the dispatch is a fraction of
|
||||
// a millisecond and would fit inside a draft frame
|
||||
// comfortably. It is that peaking measures the top octave
|
||||
// of the frame it is given, and a draft frame is rendered
|
||||
// at half resolution: a defocused edge that spans four
|
||||
// pixels there spans two, which is the signature of a
|
||||
// sharp one. Measuring it would mark the out-of-focus
|
||||
// background of every photograph, briefly, during every
|
||||
// drag. A stale overlay is no better, because a pan moves
|
||||
// the picture out from under it.
|
||||
//
|
||||
// So the marks pause while a control is moving and return
|
||||
// when it stops, which the panel says out loud rather than
|
||||
// leaving to be discovered.
|
||||
let overlay = (!draft).then(|| s.focus_overlay()).flatten();
|
||||
match overlay {
|
||||
Some(image) => {
|
||||
window.set_focus_overlay(image);
|
||||
window.set_focus_overlay_ready(true);
|
||||
}
|
||||
None => window.set_focus_overlay_ready(false),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("render failed: {e}");
|
||||
@@ -1598,6 +1656,11 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// No frame, so nothing to describe. The stale plot would
|
||||
// otherwise sit beside the error message looking current.
|
||||
window.set_histogram(histogram::empty());
|
||||
// TRACES: FR-CULL-3
|
||||
// And nothing to mark. Focus marks over the last frame
|
||||
// that rendered, beside a message saying this one did not,
|
||||
// is the same confident lie in a second instrument.
|
||||
window.set_focus_overlay_ready(false);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2818,6 +2881,70 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// The peaking switch and its two choices.
|
||||
//
|
||||
// All three write `chosen_peaking` and then redraw, because the marks are
|
||||
// produced by a compute pass over the rendered frame: there is nothing the
|
||||
// interface can change about the overlay that does not require the frame
|
||||
// to be measured again. Turning peaking *off* redraws for the same reason
|
||||
// — that render is what drops the overlay textures and clears the flag.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let chosen = chosen_peaking.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_peaking_toggled(move |on| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Built from the chips as they currently stand rather than from a
|
||||
// remembered value: they are what the photographer can see, and an
|
||||
// overlay that came back in a configuration the panel is not
|
||||
// showing would be the panel lying about itself.
|
||||
let next = on.then(|| dr_gpu::FocusPeaking {
|
||||
sensitivity: peaking::sensitivity(w.get_peaking_sensitivity()),
|
||||
colour: peaking::colour(w.get_peaking_colour()),
|
||||
});
|
||||
chosen.set(next);
|
||||
w.set_peaking_on(next.is_some());
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let chosen = chosen_peaking.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_peaking_sensitivity_picked(move |index| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
w.set_peaking_sensitivity(index);
|
||||
// Only reachable while peaking is on — the chips are not drawn
|
||||
// otherwise — but written as a conditional rather than an
|
||||
// `expect`, because a panel is free to change its mind about that
|
||||
// and nothing here should fall over when it does.
|
||||
if let Some(mut current) = chosen.get() {
|
||||
current.sensitivity = peaking::sensitivity(index);
|
||||
chosen.set(Some(current));
|
||||
redraw(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let chosen = chosen_peaking.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_peaking_colour_picked(move |index| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
w.set_peaking_colour(index);
|
||||
if let Some(mut current) = chosen.get() {
|
||||
current.colour = peaking::colour(index);
|
||||
chosen.set(Some(current));
|
||||
redraw(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
// The chips open on whatever the vocabulary calls its default, so the
|
||||
// panel and the pass agree before anything has been pressed.
|
||||
window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default()));
|
||||
window.set_peaking_colour(peaking::colour_index(Default::default()));
|
||||
|
||||
// TRACES: FR-DSP-8 | FR-DSP-6
|
||||
// And which display that canvas is on, from now until the window closes.
|
||||
display_ui::attach(&window, &display, &viewport, redraw.clone());
|
||||
|
||||
Reference in New Issue
Block a user