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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-06 00:16:04 +07:00
1 parent 75640044b8
commit 1b8780f6da
2 files changed
+66 -1

No files matched your search

+22 -1
View File
@@ -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<f64> {
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.
+44
View File
@@ -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");
}