Compare commits

...
2 Commits
Author SHA1 Message Date
dtourolle d5c93ae795 Step to a library photograph without flashing frames in between
Benchmarks / Frame budget (on demand) (push) Canceled after 0s
Benchmarks / CPU and I/O (per commit) (push) Canceled after 5s
Traceability / Requirement traces (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Successful in 1h21m7s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 47m42s
Build and test / Windows (x86_64, cross) (push) Successful in 54m42s
Build and test / Publish the release (push) Successful in 52s
A step along the roll showed up to four pictures: the grid thumbnail,
the previous photograph again (the thumbnail was dropped when the bytes
landed, while the canvas still held the last texture through the decode
and first render), the new one at its defaults when a cached original
beat the sidecar, and then its edit.

The thumbnail now stays up until render_now draws the new photograph's
first frame, and that first frame waits up to 400 ms for the stored edit
before drawing at the defaults. A later arrival still redraws.
2026-10-02 20:46:05 -04:00
dtourolle 379dd1afcc Keep a late sidecar off the next photograph
A stored edit that arrived after the view had stepped on was applied to
whatever session was open by then — the next photograph's. The wait now
stops once the open it belongs to is no longer the current one.
2026-10-02 20:44:43 -04:00
2 changed files with 104 additions and 56 deletions
File diff suppressed because one or more lines are too long
+67 -19
View File
@@ -794,29 +794,36 @@ pub(crate) fn refresh_export_label(window: &AppWindow) {
/// dragged, because each move event destroys the thing that would deliver /// dragged, because each move event destroys the thing that would deliver
/// the next one. /// the next one.
/// TRACES: FR-CAT-8 /// TRACES: FR-CAT-8
/// Apply a fetched sidecar to the open session, now or as soon as it arrives. /// Apply a fetched sidecar to the open session, then draw its first frame.
/// ///
/// The sidecar fetch is started beside the image fetch and is three orders of /// The sidecar fetch is started beside the image fetch and is three orders of
/// magnitude smaller, so it has almost always landed by the time there is a /// magnitude smaller, so it has usually landed by the time there is a session
/// session to apply it to — and this takes it straight from the channel. The /// to apply it to — and this takes it straight from the channel. An original
/// timer covers the case where it has not, which is why this is not simply a /// read from the cache can still beat it, and drawing then showed the
/// blocking receive: a slow or stalled sidecar request must not freeze the /// photograph at its defaults and changed it a moment later. So the first
/// window with the photograph already decoded and on screen. /// frame waits up to `SIDECAR_GRACE` for the edit, with the grid's thumbnail
/// still standing in; past that it is drawn at its defaults, and a later
/// arrival redraws. Not a blocking receive: a stalled request must not freeze
/// the window.
/// ///
/// A late arrival redraws, so the image is correct either way; the only /// `still_current` is false once the view has moved to another photograph,
/// difference is whether it was ever briefly shown at its defaults. /// whose session this sidecar must not be applied to.
fn apply_when_ready( fn apply_when_ready(
window: &AppWindow, window: &AppWindow,
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>, rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
session: &Rc<RefCell<Option<DevelopSession>>>, session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<slint::VecModel<ParamRow>>, rows: &Rc<slint::VecModel<ParamRow>>,
redraw: &Rc<dyn Fn(&AppWindow)>, redraw: &Rc<dyn Fn(&AppWindow)>,
still_current: impl Fn() -> bool + 'static,
) { ) {
// Already here — the overwhelmingly common case. const SIDECAR_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
// Already here — the common case.
if let Ok(got) = rx.try_recv() { if let Ok(got) = rx.try_recv() {
if let Some(sidecar) = got { if let Some(sidecar) = got {
presets::apply_stored_edit(window, &sidecar, session, rows); presets::apply_stored_edit(window, &sidecar, session, rows);
} }
redraw(window);
return; return;
} }
@@ -824,17 +831,37 @@ fn apply_when_ready(
let session = session.clone(); let session = session.clone();
let rows = rows.clone(); let rows = rows.clone();
let redraw = redraw.clone(); let redraw = redraw.clone();
let deadline = std::time::Instant::now() + SIDECAR_GRACE;
let drawn = Cell::new(false);
let timer = Rc::new(slint::Timer::default()); let timer = Rc::new(slint::Timer::default());
let held = timer.clone(); let held = timer.clone();
timer.start( timer.start(
slint::TimerMode::Repeated, slint::TimerMode::Repeated,
std::time::Duration::from_millis(50), std::time::Duration::from_millis(50),
move || { move || {
let Ok(got) = rx.try_recv() else { return }; let Some(w) = weak.upgrade() else {
held.stop();
return;
};
if !still_current() {
held.stop();
return;
}
let Ok(got) = rx.try_recv() else {
// Waited long enough: draw at the defaults and keep
// listening, so a late edit still lands.
if !drawn.get() && std::time::Instant::now() >= deadline {
drawn.set(true);
redraw(&w);
}
return;
};
held.stop(); held.stop();
let Some(w) = weak.upgrade() else { return }; let applied = match got {
let Some(sidecar) = got else { return }; Some(sidecar) => presets::apply_stored_edit(&w, &sidecar, &session, &rows),
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) { None => false,
};
if applied || !drawn.get() {
redraw(&w); redraw(&w);
} }
}, },
@@ -2244,6 +2271,13 @@ fn build_render_now(
window.set_canvas_draft(draft); window.set_canvas_draft(draft);
window.global::<Levels>().set_provisional(draft); window.global::<Levels>().set_provisional(draft);
window.set_load_error("".into()); window.set_load_error("".into());
// TRACES: FR-NC-6a
// The library path keeps the grid's thumbnail up until
// here, the first frame of the new photograph, because
// the canvas still holds the last photograph's texture
// until this line replaces it.
window.set_load_pending(false);
window.set_has_load_preview(false);
// The readout and the "Fit" button follow the session // The readout and the "Fit" button follow the session
// rather than the gesture, so a clamped zoom shows the // rather than the gesture, so a clamped zoom shows the
// value that was actually applied. // value that was actually applied.
@@ -2779,13 +2813,18 @@ fn wire_remote_open(
log::debug!("{name}: landed after the view moved on"); log::debug!("{name}: landed after the view moved on");
return; return;
} }
w.set_load_pending(false); // The bar goes, and the thumbnail goes back to full
// strength; the thumbnail itself stays until the first
// frame of this photograph replaces it in `render_now`.
// Dropping it here showed the last photograph's texture,
// still in the canvas, for the length of the decode.
w.set_load_waiting("".into()); w.set_load_waiting("".into());
w.set_has_load_preview(false);
let bytes = match got { let bytes = match got {
Ok(b) => b, Ok(b) => b,
Err(e) => { Err(e) => {
w.set_load_pending(false);
w.set_has_load_preview(false);
job.fail(e.message.clone()); job.fail(e.message.clone());
log::warn!("{name}: {e}"); log::warn!("{name}: {e}");
// Offline needs its own words. "network error: // Offline needs its own words. "network error:
@@ -2854,16 +2893,19 @@ fn wire_remote_open(
// TRACES: FR-CAT-8 // TRACES: FR-CAT-8
// The stored edit, if it has landed. It // The stored edit, if it has landed. It
// was started before the download of a // was started before the download of a
// file thousands of times its size, so in // file thousands of times its size, but
// practice it has; `apply_when_ready` // an original read from the cache can
// covers the case where it has not rather // beat it; `apply_when_ready` holds the
// first frame back a moment for it rather
// than blocking the UI thread on a socket. // than blocking the UI thread on a socket.
let still = current.clone();
apply_when_ready( apply_when_ready(
&w, &w,
sidecar_rx.clone(), sidecar_rx.clone(),
&session, &session,
&rows, &rows,
&redraw, &redraw,
move || still.get() == mine,
); );
// TRACES: FR-UI-4 // TRACES: FR-UI-4
// Under the same magnifier as the last // Under the same magnifier as the last
@@ -2876,12 +2918,16 @@ fn wire_remote_open(
// moves the point rather than losing it. // moves the point rather than losing it.
resume_inspection(&session, &viewport, &inspection); resume_inspection(&session, &viewport, &inspection);
sync_rows(&w, &rows, &session); sync_rows(&w, &rows, &session);
redraw(&w); // No redraw here: `apply_when_ready` makes
// the first one, once the edit is applied
// or has been waited for long enough.
} }
None => { None => {
*session.borrow_mut() = None; *session.borrow_mut() = None;
rows.set_vec(Vec::<ParamRow>::new()); rows.set_vec(Vec::<ParamRow>::new());
w.global::<Develop>().set_enabled(false); w.global::<Develop>().set_enabled(false);
w.set_load_pending(false);
w.set_has_load_preview(false);
if let Some(image) = l.fallback { if let Some(image) = l.fallback {
w.set_canvas(image); w.set_canvas(image);
} }
@@ -2893,6 +2939,8 @@ fn wire_remote_open(
log::warn!("{name}: {e}"); log::warn!("{name}: {e}");
*session.borrow_mut() = None; *session.borrow_mut() = None;
w.global::<Develop>().set_enabled(false); w.global::<Develop>().set_enabled(false);
w.set_load_pending(false);
w.set_has_load_preview(false);
w.set_load_error(e.into()); w.set_load_error(e.into());
} }
} }