M4.6: previews: Auto resolution keeps its scale while playing, Cache Before Playback plays what fits, achieved frame rate in the Info panel

- Viewer Resolution Auto rendered at half its scale while a preview played, so frames cached
  while scrubbing were not played and frames cached by the preview were re-rendered once it
  stopped. Auto now follows the zoom alike playing and paused (Fast Previews still lowers it
  while dragging).
- Cache Before Playback waited for every frame of the range: a range larger than the RAM
  preview budget evicted its first frames and never started. The range is now cut to the
  frames that fit (90 % of Settings ▸ Memory & CPU's budget) with a message, then cached and
  played.
- The Info panel shows the frame rate a playing preview achieves and whether it is real time
  (`Playback::achieved_fps` / `real_time`, automation id `info.previewFps`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 00:52:50 +07:00
co-authored by Claude Opus 5.5
parent 5648fdb1c2
commit 6214aecdb9
3 changed files with 106 additions and 9 deletions
+52 -9
View File
@@ -127,6 +127,33 @@ pub struct Playback {
pub restore_max: Option<Option<PanelKind>>,
/// Audio-only previews: the frame under the audible sample (the current time stays put).
pub audio_frame: Option<i64>,
/// When the last frames were shown (seconds, the last second's worth): the achieved rate.
pub shown_at: std::collections::VecDeque<f64>,
}
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<f64> {
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<bool> {
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<i64> = (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
+7
View File
@@ -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);
+47
View File
@@ -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");
}