diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8dae555ea..6c0f4ee23 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -127,6 +127,33 @@ pub struct Playback { pub restore_max: Option>, /// Audio-only previews: the frame under the audible sample (the current time stays put). pub audio_frame: Option, + /// When the last frames were shown (seconds, the last second's worth): the achieved rate. + pub shown_at: std::collections::VecDeque, +} + +impl Playback { + /// A new frame went on screen at `now`. + pub fn frame_shown(&mut self, now: f64) { + self.shown += 1; + self.shown_at.push_back(now); + while self.shown_at.front().is_some_and(|t| now - t > 1.0) { + self.shown_at.pop_front(); + } + } + + /// Frames per second shown over the last second while the preview plays (`None` before two + /// frames were shown, while caching, or stopped). + pub fn achieved_fps(&self) -> Option { + let (a, b) = (self.shown_at.front()?, self.shown_at.back()?); + (self.playing && !self.caching && self.shown_at.len() > 1 && b > a).then(|| (self.shown_at.len() - 1) as f64 / (b - a)) + } + + /// The preview keeps up with its frame rate (within 5 %; previews that skip frames count + /// the frames they mean to show). + pub fn real_time(&self) -> Option { + let step = self.plan.map_or(1, |p| p.step).max(1) as f64; + self.achieved_fps().map(|f| f * step >= self.fps * 0.95) + } } pub struct EffectcraftApp { @@ -462,12 +489,8 @@ impl EffectcraftApp { }; return r.scale(zoom, ppp); } - if self.playback.playing && self.ui.viewer.res == state::Resolution::Auto { - // Keep previews snappy: half the displayed size, but no lower than Settings ▸ - // Previews ▸ Adaptive Resolution Limit. - let full = self.ui.viewer.res.scale(zoom, ppp); - return full.min((full * 0.5).max(self.session.prefs.adaptive_limit())); - } + // Auto follows the zoom alike while playing and paused, so frames cached while scrubbing + // play back, and frames cached by a preview stay on screen when it stops. let full = self.ui.viewer.res.scale(zoom, ppp); // Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging. let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting); @@ -611,6 +634,7 @@ impl EffectcraftApp { layer_controls: preset.include_layer_controls, restore_max, audio_frame: (!plan.video).then_some(plan.first), + shown_at: Default::default(), }; if !self.playback.caching { self.start_audio(fr.tick_of(plan.first)); @@ -751,9 +775,20 @@ impl EffectcraftApp { if self.playback.caching && let Some(p) = plan { - // Cache Before Playback: the whole range in play order, then start the clock. + // Cache Before Playback: the range in play order, then start the clock. A range + // larger than the RAM preview budget is cut to the frames that fit (caching more + // would evict the first ones and never finish). let mut all = true; let n = (p.end - p.start) / p.step + 1; + let fit = self.frames_that_fit(&c, scale); + if n > fit { + let mut cut = p; + (cut.start, cut.end) = (p.first, (p.first + (fit - 1) * p.step).min(p.end)); + self.playback.plan = Some(cut); + self.toast = Some((format!("Preview cut to {fit} of {n} frames: no more fit in the RAM preview (Settings ▸ Memory & CPU)"), now)); + ctx.request_repaint(); + return; + } let order: Vec = (0..n).filter_map(|k| p.frame_after(p.first, k)).collect(); for f in order { if self.frames.is_cached(&FrameKey { frame: f, ..series }) { @@ -829,7 +864,7 @@ impl EffectcraftApp { self.playback.waiting = !self.frames.is_cached(&FrameKey { frame: target, ..series }); if target != cur { self.session.set_time(fr.tick_of(target)); - self.playback.shown += 1; + self.playback.frame_shown(now); } } ctx.request_repaint(); @@ -869,7 +904,7 @@ impl EffectcraftApp { self.playback.waiting = false; if target != cur { self.session.set_time(fr.tick_of(target)); - self.playback.shown += 1; + self.playback.frame_shown(now); } } else { // Not cached yet: hold the clock at the current frame (cache first, then play). @@ -884,6 +919,14 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// How many viewer frames of `comp` at `scale` the RAM preview budget holds (90 % of it, + /// at least one). + fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 { + let side = |n: u32| (n as f64 * scale).ceil().max(1.0); + let frame = side(comp.width) * side(comp.height) * 4.0; + ((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1) + } + fn frames_parallelism(&self) -> usize { if cfg!(target_arch = "wasm32") { // frame workers render one frame each; without them frames render one at a time diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 9044047e2..92df6f8c3 100644 --- a/crates/ui-egui/src/panels/info.rs +++ b/crates/ui-egui/src/panels/info.rs @@ -87,6 +87,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ly += 18.0; } } + // While a preview plays: the frame rate it achieves and whether that is real time. + if let (Some(fps), Some(rt)) = (app.playback.achieved_fps(), app.playback.real_time()) { + let note = if rt { "real-time" } else { "not real-time" }; + p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("{fps:.2} fps ({note})"), Tokens::ui(11.5), if rt { t.text_dim } else { t.warning }); + app.auto.add("info.previewFps", Rect::from_min_size(pos2(x0, ly - 8.0), vec2(200.0, 16.0)), &format!("{fps:.2} fps ({note})")); + ly += 18.0; + } // Settings ▸ Composition ▸ Show Rendering Progress in Info Panel. if app.session.prefs.composition.show_rendering_progress { let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0); diff --git a/crates/ui-egui/tests/ui_preview_align.rs b/crates/ui-egui/tests/ui_preview_align.rs index 3629a02cc..9468ce70a 100644 --- a/crates/ui-egui/tests/ui_preview_align.rs +++ b/crates/ui-egui/tests/ui_preview_align.rs @@ -184,3 +184,50 @@ fn align_panel_runs_align_and_distribute() { click(&mut h, "distribute.hcenter"); assert_eq!(pos(&h), p); } + +#[test] +fn cache_before_playback_plays_what_fits_in_ram() { + let mut h = harness(); + set(&mut h, json!({"shortcut": "spacebar", "cacheBeforePlayback": true, "range": "entireDuration", "playFrom": "rangeStart"})); + h.run_steps(2); + // Room for about 12 full-size frames: the 240-frame range can't all be cached. + h.state().frames.set_budget(320 * 180 * 4 * 12); + invoke(&mut h, "playback.toggle", json!({})); + assert!(h.state().playback.caching); + for _ in 0..600 { + h.step(); + if !h.state().playback.caching { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let st = &h.state().playback; + assert!(st.playing && !st.caching, "caching finished and the preview plays"); + let pl = st.plan.unwrap(); + assert!(pl.start == 0 && pl.end < 239 && pl.end >= 9, "cut to what fits: {}..{}", pl.start, pl.end); +} + +#[test] +fn auto_resolution_is_the_same_playing_and_paused() { + let mut h = harness(); + let paused = h.state().viewer_scale(0.5, 2.0); + h.state_mut().playback.playing = true; + assert_eq!(h.state().viewer_scale(0.5, 2.0), paused); +} + +#[test] +fn achieved_frame_rate_and_real_time() { + let mut p = effectcraft_ui_egui::Playback { playing: true, fps: 24.0, ..Default::default() }; + assert_eq!(p.achieved_fps(), None); + for i in 0..25 { + p.frame_shown(i as f64 / 24.0); + } + assert!((p.achieved_fps().unwrap() - 24.0).abs() < 1e-9); + assert_eq!(p.real_time(), Some(true)); + let mut slow = effectcraft_ui_egui::Playback { playing: true, fps: 24.0, ..Default::default() }; + for i in 0..13 { + slow.frame_shown(i as f64 / 12.0); + } + assert_eq!(slow.real_time(), Some(false)); + assert_eq!(slow.shown_at.len(), 13, "only the last second counts"); +}