diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d770df9c..547cdeb4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -101,6 +101,10 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) "ui.inspect" => ok(inspect(app, ctx)), "perf.stats" => ok(crate::perf::stats(app)), "ui.elements" => { + if app.timeline_still < 2 { + // rects of timeline elements are still moving (e.g. the fit after opening a sequence) + return Outcome::Retry("the timeline is still zooming".into()); + } let prefix = s("prefix").unwrap_or(""); ok(serde_json::to_value(app.auto.query(prefix)).unwrap_or_default()) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5091cd74..2076ae12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -129,6 +129,9 @@ pub struct Playback { /// How long `ui.screenshot` waits for the window to present the frame. const SCREENSHOT_TIMEOUT_S: f64 = 10.0; +/// How long `ui.screenshot` waits for the UI to settle (monitors showing their exact frames, the +/// timeline zoom animation finished) before capturing what is there. +const SCREENSHOT_SETTLE_MAX_S: f64 = 5.0; pub struct FilmcraftApp { pub session: Session, @@ -155,6 +158,12 @@ pub struct FilmcraftApp { /// Screenshots waiting for their frame: (token, path, crop, reply, give-up time). pending_screenshots: Vec<(u64, Option, Option<[f32; 4]>, Sender, f64)>, queued_screenshots: Vec<(u64, f64, u32)>, + /// A paused monitor drew a stand-in (nearest cached) picture last frame: its exact frame is + /// still decoding. + pub(crate) monitor_inexact: bool, + /// Consecutive frames the timeline zoom / scroll has been at rest. `ui.elements` answers from + /// the frame before the last one, so its timeline rects are final from 2 on. + pub(crate) timeline_still: u32, input_waiters: Vec>, next_token: u64, styled: bool, @@ -336,6 +345,8 @@ impl FilmcraftApp { status_seen: (String::new(), 0.0), pending_screenshots: Vec::new(), queued_screenshots: Vec::new(), + monitor_inexact: false, + timeline_still: 0, input_waiters: Vec::new(), next_token: 1, styled: false, @@ -929,13 +940,16 @@ impl FilmcraftApp { self.control_rx = Some(rx); } - fn issue_screenshots(&mut self, ctx: &egui::Context) { + /// Capture once the UI shows the current state (`settled`): after a seek the monitor shows the + /// nearest cached picture until the exact frame is decoded, and an agent must not be handed + /// the stand-in. + fn issue_screenshots(&mut self, ctx: &egui::Context, settled: bool) { let now = ctx.input(|i| i.time); let mut any = false; self.queued_screenshots.retain_mut(|(token, at, frames)| { *frames += 1; any = true; - if now >= *at && *frames >= 3 { + if now >= *at && *frames >= 3 && (settled || now >= *at + SCREENSHOT_SETTLE_MAX_S) { ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); false } else { @@ -1234,6 +1248,7 @@ impl eframe::App for FilmcraftApp { // Nothing is shown while the window is hidden: not a dropped frame. self.playback.hidden = true; } + self.timeline_still = if self.ui.timeline.animating() { 0 } else { self.timeline_still.saturating_add(1) }; let had_synthetic = !self.synthetic.is_empty(); self.drain_control(ctx); if !self.synthetic.is_empty() && !had_synthetic { @@ -1248,7 +1263,8 @@ impl eframe::App for FilmcraftApp { let _ = w.send(json!({"ok": true, "result": null})); } } - self.issue_screenshots(ctx); + let settled = !std::mem::take(&mut self.monitor_inexact) && self.timeline_still > 0; + self.issue_screenshots(ctx, settled); self.collect_screenshots(ctx); } diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 2aed8ac5..9bb98721 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -183,6 +183,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } else { cpu_texture(app, &ctx, &tex_name, key, display) }; + if !playing && !exact { + app.monitor_inexact = true; + } if playing { if std::mem::take(&mut app.playback.hidden) { app.playback.meter.resync(frame, exact); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 401e6cc0..7a5f001a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -200,6 +200,13 @@ pub struct TimelineView { pub track_lanes: std::collections::BTreeMap, } +impl TimelineView { + /// The zoom / scroll animation is still running (element rects are moving). + pub fn animating(&self) -> bool { + self.pps != self.target_pps || self.scroll != self.target_scroll + } +} + impl Default for TimelineView { fn default() -> Self { Self { diff --git a/crates/ui-egui/tests/view_ui.rs b/crates/ui-egui/tests/view_ui.rs index 1e063cf3..2b73bd05 100644 --- a/crates/ui-egui/tests/view_ui.rs +++ b/crates/ui-egui/tests/view_ui.rs @@ -313,3 +313,50 @@ fn snap_in_program_monitor_and_graphics_menu() { assert_eq!(shortcut, "Cmd+Shift+]"); d.shot("graphics-menu"); } + +#[test] +fn elements_wait_for_the_timeline_zoom_to_settle() { + let mut d = Driver::demo(); + let project = d.exec("project.inspect", json!({})); + let item = first_media_item(&project).expect("a movie in the demo project"); + // opening a sequence fits the timeline with an animated zoom: rects read right away must + // already be the final ones + d.exec("file.newSequence", json!({"name": "Zoom", "fromItem": item})); + let clip = d.exec("sequence.inspect", json!({}))["video"][0]["items"][0]["clip"].as_u64().expect("clip"); + let id = format!("timeline.clip.{clip}"); + let first = d.rect(&id); + d.frames(120); + let settled = d.rect(&id); + for k in 0..4 { + assert!((first[k] - settled[k]).abs() < 0.5, "rect read while zooming {first:?}, settled {settled:?}"); + } +} + +/// Frames until `ui.elements` answers (it retries while the timeline is still zooming). +fn elements_latency(d: &mut Driver) -> usize { + let (req, reply) = ControlRequest::new("ui.elements", json!({"prefix": "timeline."})); + d.tx.send(req).unwrap(); + for n in 1..=600 { + d.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], true, "{v}"); + return n; + } + } + panic!("ui.elements never answered"); +} + +#[test] +fn elements_answer_promptly_once_the_timeline_is_at_rest() { + let mut d = Driver::demo(); + d.frames(120); + // a timeline at rest must not keep `ui.elements` waiting (the animation snaps to its target) + assert!(elements_latency(&mut d) <= 3); + for pps in [40.0, 400.0, 7.5] { + d.ok("ui.set", json!({"timeline": {"pps": pps}})); + d.ok("ui.set", json!({"timeline": {"scroll": 1.5}})); + d.frames(120); + let n = elements_latency(&mut d); + assert!(n <= 3, "ui.elements took {n} frames at rest after zooming to {pps} px/s"); + } +}