From 1b8780f6dab3b2a192fb21d4f85402cfa13900e2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 6 Oct 2026 00:16:04 +0700 Subject: [PATCH] Fix #65: the viewer keeps up while a layer is dragged While paused, the app prefetched the frames around the current one as soon as the viewer's frame finished. During a drag every step changes the project, so those frames were stale at the next step, and they held the CPU and GPU the next viewer frame needed: on the demo project a drag step took 102 ms on average to show (up to 413 ms) though a frame renders in 20 ms. Paused prefetch now waits until edits pause for 0.3 s: 16 ms a step (11 ms render). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 23 +++++++++++++- crates/ui-egui/tests/ui_ram_preview.rs | 44 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3704dfbe..63d1db37 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -39,6 +39,9 @@ use state::UiState; use theme::Tokens; const SCREENSHOT_TIMEOUT_S: f64 = 4.0; +/// Seconds without a project change before paused prefetch resumes (see +/// [`EffectcraftApp::editing`]). +const EDIT_QUIET: f64 = 0.3; /// Modal dialogs. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -184,6 +187,8 @@ pub struct EffectcraftApp { /// When the user last did something (input or an edit): Cache Frames When Idle waits for /// a second of quiet. (time, project revision then) last_activity: (f64, u64), + /// When the project last changed, and its revision then (see [`Self::editing`]). + last_edit: (f64, u64), pub(crate) toast: Option<(String, f64)>, /// Texture of the last CPU frame shown in the viewer and the key it came from. pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey)>, @@ -263,6 +268,7 @@ impl EffectcraftApp { next_token: 1, last_ui_time: 0.0, last_activity: (0.0, 0), + last_edit: (0.0, 0), toast: None, viewer_tex: None, passive_tex: std::collections::HashMap::new(), @@ -797,11 +803,16 @@ impl EffectcraftApp { // Prefetch ahead. let cur = fr.frame_at(self.session.time()); // While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few - // frames, and only once it is done, so prefetch never delays what is on screen. + // frames, only once it is done, and not while edits keep coming (a layer dragged in the + // viewer): those frames would be stale at the next step and would hold the CPU and GPU + // the next viewer frame needs. let ahead = if self.playback.playing { (self.frames_parallelism() * 2).max(4) as i64 } else if self.frames.urgent_pending() { 0 + } else if let Some(wait) = self.editing(now) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + 0 } else { (self.frames_parallelism() / 2).max(2) as i64 }; @@ -963,6 +974,16 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// While the project keeps changing (an edit within [`EDIT_QUIET`] seconds), how long until + /// it counts as settled. + fn editing(&mut self, now: f64) -> Option { + if self.last_edit.1 != self.session.revision { + self.last_edit = (now, self.session.revision); + } + let left = EDIT_QUIET - (now - self.last_edit.0); + (left > 0.0).then_some(left) + } + /// Composition ▸ Preview ▸ Cache Frames When Idle: after a second without input or edits, /// render the work area's frames (from the current time on, then from its start) in the /// background, a few at a time, until it is cached or the RAM preview budget is full. diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/ui_ram_preview.rs index c384607a..df62c028 100644 --- a/crates/ui-egui/tests/ui_ram_preview.rs +++ b/crates/ui-egui/tests/ui_ram_preview.rs @@ -51,3 +51,47 @@ fn an_edit_in_another_comp_keeps_the_cached_frames_and_undo_finds_them_again() { // B's frame is still there too. assert!(h.state().frames.is_cached(&effectcraft_ui_egui::frames::FrameKey { frame: 0, ..b_frame })); } + +/// Issue #65: while edits keep coming (a layer dragged in the viewer), only the viewer's frame +/// renders; prefetching the frames around it waits until the edits pause (they would be stale at +/// the next step, and would hold the CPU and GPU the next viewer frame needs), then resumes. +#[test] +fn prefetch_waits_while_edits_keep_coming() { + let mut s = Session::default(); + let c = s.execute("comp.new", json!({"name": "Drag", "width": 64, "height": 36, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + let solid = s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap(); + // Short steps: input time advances 50 ms per frame. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.05).build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + let frame = |h: &Harness<'_, EffectcraftApp>| { + let app = h.state(); + let comp = app.session.project.comp(ItemId(c)).unwrap(); + comp.frame_rate.frame_at(app.session.time()) + }; + for i in 1..=8 { + let v = json!([20.0 + 2.0 * i as f64, 18.0, 0.0]); + h.state_mut().session.execute("prop.set", json!({"layer": solid, "path": "transform/position", "value": v, "merge": "drag"})).unwrap(); + h.step(); + // The viewer's frame renders... + for _ in 0..500 { + if !h.state().frames.urgent_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + h.step(); + // ...and nothing else is queued or cached for this state of the comp. + let series = h.state().shown_series(ItemId(c)); + assert_eq!(h.state().frames.cached_frames(&series), vec![frame(&h)], "edit {i}"); + assert_eq!(h.state().frames.inflight(), 0, "edit {i}"); + } + // The edits pause: the frames around the current one fill in. + settle(&mut h); + for _ in 0..20 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + settle(&mut h); + let series = h.state().shown_series(ItemId(c)); + assert!(h.state().frames.cached_frames(&series).len() > 1, "prefetch resumed"); +}