diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index a80ae98a9..114f05b4c 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -54,6 +54,17 @@ fn pace_for(prev: f64, lead: i64, target: i64, fps: f64, dt: f64) -> f64 { prev + (raw - prev) * k } +/// How many frames after the current one the pace measures its lead over: twice the prefetch +/// depth `ahead`, but only frames the RAM preview can keep cached together with the current one. +/// `prefetch_playback` requests offsets `0..fit_ahead` from the current frame (the current frame +/// and `fit_ahead - 1` after it), so a lead counted over more frames than that could never fill: +/// a preview that renders every frame at once would still be slowed down to a fraction of its +/// frame rate. 0 when not even one frame after the current one is prefetched: the pace can't +/// measure anything, and the clock runs in real time (holding on frames that aren't cached). +fn pace_horizon(ahead: i64, fit_ahead: i64) -> i64 { + (ahead * 2).max(1).min(fit_ahead - 1).max(0) +} + /// Cached frames (`cached`, sorted) among the next `max` frames `after(1..=max)` plays, and how /// many frames it plays (`after` is `None` past the end). Counted, not the consecutive run: one /// late frame (a GPU warm-up frame, a heavy one) with frames cached beyond it holds the clock @@ -145,7 +156,7 @@ impl EffectcraftApp { if self.advance_audio_clock(&pass, step) { return; } - self.advance_frame_clock(&pass, step); + self.advance_frame_clock(&pass, step, fit_ahead); } /// While paused (scrubbing, editing) the viewer's frame comes first: prefetch only a few @@ -428,7 +439,7 @@ impl EffectcraftApp { /// frame (or holds when it is not cached yet). While frames arrive slower than the frame /// rate it runs at their pace ([`pace_for`]): every frame shows, evenly, as After Effects /// plays a preview it hasn't cached yet; real time again once the frames ahead are cached. - fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64) { + fn advance_frame_clock(&mut self, pass: &PlaybackPass<'_>, step: i64, fit_ahead: i64) { let PlaybackPass { ctx, now, plan, video, frame_rate: fr, cur, series, .. } = *pass; let FrameWindow { last: wb, .. } = pass.window; // The plan carries the rate the preview was started with (and `play_with` copies it to @@ -439,10 +450,16 @@ impl EffectcraftApp { self.playback.paced_at = Some(now); if video { // The lead counts out of twice the prefetch depth: the prefetch keeps that many - // frames coming (or the frames left before the end), so a full lead means rendering keeps up. - let (lead, target) = self.cached_lead(pass, (pass.ahead * 2).max(1)); + // frames coming (or the frames left before the end), so a full lead means rendering + // keeps up. No more than the RAM preview holds ([`pace_horizon`]). + let horizon = pace_horizon(pass.ahead, fit_ahead); let prev = if self.playback.pace > 0.0 { self.playback.pace } else { fps }; - let pace = pace_for(prev, lead, target, fps, dt); + let pace = if horizon > 0 { + let (lead, target) = self.cached_lead(pass, horizon); + pace_for(prev, lead, target, fps, dt) + } else { + fps + }; self.playback.pace = pace; if pace < fps * 0.8 { self.step_down(now, pass.scale); @@ -662,7 +679,26 @@ impl EffectcraftApp { #[cfg(test)] mod pace_tests { - use super::{lead_of, pace_for}; + use super::{lead_of, pace_for, pace_horizon}; + + /// The lead is measured over no more frames than the RAM preview keeps ahead of the current + /// one, so a full cache plays in real time. (It counted out of twice the prefetch depth: with + /// room for 4 frames, at most 2 after the current one were ever cached, and a preview that + /// rendered at once played at a quarter of its frame rate.) + #[test] + fn the_pace_horizon_fits_in_the_ram_preview() { + // A roomy budget: twice the prefetch depth. + assert_eq!(pace_horizon(6, 400), 12); + // Room for 4 frames: the prefetch requests offsets 0..3, so 2 after the current one. + assert_eq!(pace_horizon(6, 3), 2); + let (lead, target) = lead_of(|k| Some(10 + k), &[10, 11, 12], pace_horizon(6, 3)); + assert!((pace_for(30.0, lead, target, 30.0, 10.0) - 30.0).abs() < 1e-6, "a full cache plays in real time"); + // Room for the current frame only (or nothing): no lead to measure. + assert_eq!(pace_horizon(6, 1), 0); + assert_eq!(pace_horizon(6, 0), 0); + // No prefetch depth: still the next frame. + assert_eq!(pace_horizon(0, 400), 1); + } /// The pace follows the cached lead: real time with a full lead, a third of it with a third, /// never under an eighth, unchanged with nothing ahead, and smoothed over time. diff --git a/crates/ui-egui/tests/it/ui_preview_align.rs b/crates/ui-egui/tests/it/ui_preview_align.rs index a9622d458..0baafc55c 100644 --- a/crates/ui-egui/tests/it/ui_preview_align.rs +++ b/crates/ui-egui/tests/it/ui_preview_align.rs @@ -395,3 +395,44 @@ fn playback_that_cannot_keep_up_steps_down_a_resolution() { stall(&mut h, 2.5); assert_eq!(h.state().viewer_scale(1.0, 1.0), 1.0); } + +/// A preview that renders every frame at once plays in real time even when the RAM preview holds +/// only a few frames. (The pace measured its lead over twice the prefetch depth, more frames than +/// a small RAM preview keeps ahead, so such a preview was slowed to a fraction of its frame rate.) +#[test] +fn a_preview_that_keeps_up_plays_in_real_time_with_a_small_ram_preview() { + let mut h = harness(); + let cid = h.state().session.active_comp_id().unwrap(); + let series = h.state().shown_series(cid); + let s = series.scale as f64 / 1000.0; + let frame = (320.0 * s).ceil() * (180.0 * s).ceil() * 4.0; + // Room for 4 frames (90 % of 5): the prefetch keeps the current frame and 2 after it. + h.state().frames.set_budget((frame * 5.0) as usize); + h.state_mut().use_manual_clock(); + h.state_mut().session.set_time(Tick::ZERO); + invoke(&mut h, "playback.toggle", json!({})); + let fr = h.state().session.active_comp().unwrap().frame_rate; + // Rendering keeps up: the frames ahead are rendered before the clock moves on. + let settle = |h: &mut Harness<'_, EffectcraftApp>| { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.step(); + let cur = fr.frame_at(h.state().session.time()); + let cached = h.state().frames.cached_frames(&series); + if (1..=2).all(|k| cached.contains(&((cur + k) % 240))) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + panic!("frames ahead never cached"); + }; + for _ in 0..48 { + settle(&mut h); + h.state_mut().clock_mut().advance(1.0 / 24.0); + h.step(); + } + let pb = &h.state().playback; + assert!(pb.playing); + assert!(pb.pace >= 24.0 * 0.98, "plays at {} fps of 24", pb.pace); + assert!(fr.frame_at(h.state().session.time()) >= 40, "the clock kept real time: {:?}", h.state().session.time()); +} \ No newline at end of file