Merge PR #4 from storybored379

Control: screenshots and ui.elements wait for the UI to settle
This commit is contained in:
Brandon Thomas 2026-10-04 02:51:55 -04:00 committed by GitHub
commit 0fb2ba99ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 80 additions and 3 deletions

View File

@ -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())
}

View File

@ -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<String>, Option<[f32; 4]>, Sender<Value>, 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<Sender<Value>>,
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);
}

View File

@ -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);

View File

@ -200,6 +200,13 @@ pub struct TimelineView {
pub track_lanes: std::collections::BTreeMap<u64, String>,
}
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 {

View File

@ -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");
}
}