diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 10051b42..bcc6a910 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -569,6 +569,7 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result { let disk = matches!(what.as_str(), "all" | "memoryAndDisk" | "disk"); if memory { s.layer_cache.clear(); + s.footage.purge(); } let mut disk_entries = 0; if disk && let Some(dc) = &s.disk_cache { @@ -581,7 +582,11 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result { if matches!(what.as_str(), "all" | "image" | "memory" | "memoryAndDisk") { effectcraft_effects::roto::purge(); } - s.events.push(crate::Event::PurgeCaches); + // The viewers' RAM preview goes with the memory purges (and the 3D one, whose renders it + // holds); purging the disk cache or the snapshot leaves it. + if memory || what == "3d" { + s.events.push(crate::Event::PurgeCaches); + } s.toast(format!("Purged {what} cache")); Ok(json!({"purged": what, "diskEntries": disk_entries})) } diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 85e12674..ec6e09b6 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -263,9 +263,35 @@ fn label_group_purge_and_edit_original() { s.execute("layer.select", json!({"layers": [a]})).unwrap(); s.execute("edit.selectLabelGroup", json!({})).unwrap(); assert_eq!(s.state.selected_layers.len(), 2); + // A footage source that counts purges (the media pool drops its decoded frames). + struct Counting(std::sync::atomic::AtomicUsize); + impl crate::render::FootageSource for Counting { + fn frame( + &self, + _: effectcraft_project::ItemId, + _: &effectcraft_project::Footage, + _: effectcraft_time::Tick, + ) -> Option> { + None + } + fn purge(&self) { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + let src = std::sync::Arc::new(Counting(Default::default())); + s.footage = src.clone(); s.drain_events(); s.execute("edit.purge", json!({"what": "memory"})).unwrap(); assert!(s.drain_events().contains(&Event::PurgeCaches)); + assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1, "decoded footage frames are dropped"); + // The disk cache and the snapshot purge alone: the RAM preview stays. + for what in ["disk", "snapshot"] { + s.execute("edit.purge", json!({"what": what})).unwrap(); + assert!(!s.drain_events().contains(&Event::PurgeCaches), "{what}"); + } + assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1); + s.execute("edit.purge", json!({"what": "3d"})).unwrap(); + assert!(s.drain_events().contains(&Event::PurgeCaches)); assert!(!s.is_enabled("edit.editOriginal")); } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 3aa7fca7..e5be90a6 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -233,12 +233,16 @@ impl MediaPool { } } - /// Drop every cached frame and decoder. - pub fn clear(&self) { + /// Drop every cached frame (decoders stay open). + pub fn clear_frames(&self) { let mut c = lock(&self.inner.cache); c.lru = Lru::default(); c.last.clear(); - drop(c); + } + + /// Drop every cached frame and decoder. + pub fn clear(&self) { + self.clear_frames(); lock(&self.inner.sources).clear(); } @@ -602,6 +606,9 @@ impl FootageSource for MediaPool { fn set_cache_budget(&self, bytes: usize) { self.set_budget(bytes); } + fn purge(&self) { + self.clear_frames(); + } fn cache_budget(&self) -> Option { Some(self.budget()) } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4b6c7f3e..13d8fc4b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -56,6 +56,9 @@ pub trait FootageSource: Send + Sync { /// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without /// a cache ignore it. fn set_cache_budget(&self, _bytes: usize) {} + /// Drop the decoded frames it keeps (Edit ▸ Purge ▸ All Memory / Image Cache Memory): the + /// next reads decode again. Sources without a cache ignore it. + fn purge(&self) {} /// Settings ▸ Disk ▸ Conformed Audio Folder: where decoded audio is kept between reads /// (`None` = off); sources that don't decode audio ignore it. fn set_conform_folder(&self, _folder: Option) {} diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 19839189..53be6a38 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -2,7 +2,10 @@ //! //! Frames render on a dedicated thread pool from immutable `Arc` snapshots, so the UI //! never waits for the compositor. Results land in a memory-budgeted cache keyed by (project -//! revision, comp, frame, scale); the timeline draws the cached range as the green cache bar. +//! revision, comp, frame, scale, view, render options); the timeline draws the cached range as +//! the green cache bar. When the budget is full the least recently shown frames go first, and +//! frames of an older revision are dropped as soon as the project changes (they can never be +//! shown again). //! //! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] hands them //! to a [`RemoteFrames`] (the browser's frame worker, a second engine instance fed with project @@ -63,6 +66,15 @@ pub struct FrameKey { pub scale: u32, /// Hash of the 3D view camera (0 = the comp's active camera). pub view: u64, + /// Hash of the other render options (Draft / Fast Previews, shadows, nested switches…). + pub opts: u64, +} + +impl FrameKey { + /// Whether `self` is a frame of the same comp, revision, scale, view and options as `base`. + pub fn same_series(&self, base: &FrameKey) -> bool { + FrameKey { frame: base.frame, ..*self } == *base + } } struct Cache { @@ -109,6 +121,14 @@ impl Cache { self.forget(&i); } } + /// Shown again: last in line for eviction. + fn touch(&mut self, k: &FrameKey) { + if let Some(i) = self.order.iter().rposition(|o| o == k) + && let Some(k) = self.order.remove(i) + { + self.order.push_back(k); + } + } fn evict_to_budget(&mut self) { while self.bytes > self.budget { let Some(old) = self.order.pop_front() else { break }; @@ -153,7 +173,8 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) k } -fn opts_hash(o: &RenderOpts) -> u64 { +/// Hash of render options (part of [`FrameKey`] and of the disk key). +pub fn opts_hash(o: &RenderOpts) -> u64 { let mut h = disk_cache::Hash128::default(); h.write(format!("{o:?}").as_bytes()); h.finish() as u64 @@ -288,8 +309,24 @@ impl Frames { self.ctx.clone() } + /// The cached frame, which becomes the most recently used. pub fn get(&self, k: &FrameKey) -> Option { - self.cache.lock().ok()?.map.get(k).cloned() + let mut c = self.cache.lock().ok()?; + let img = c.map.get(k).cloned()?; + c.touch(k); + Some(img) + } + + /// Drop frames of project revisions before `revision` (and their queued jobs): an edit + /// makes them unreachable, so they only take memory from frames that can still be shown. + pub fn drop_stale(&self, revision: u64) { + if let Ok(mut c) = self.cache.lock() { + let stale: Vec = c.map.keys().filter(|k| k.revision < revision).copied().collect(); + for k in &stale { + c.remove(k); + } + c.order.retain(|k| k.revision >= revision); + } } pub fn is_cached(&self, k: &FrameKey) -> bool { @@ -300,17 +337,18 @@ impl Frames { self.inflight.lock().map(|s| s.len()).unwrap_or(0) } - /// Cached frame numbers for (revision, comp, scale) — for the cache bar. - pub fn cached_frames(&self, revision: u64, comp: u64, scale: u32) -> Vec { + /// Cached frame numbers of the series of `base` (its comp, revision, scale, view and render + /// options) — for the cache bar. + pub fn cached_frames(&self, base: &FrameKey) -> Vec { let Ok(c) = self.cache.lock() else { return vec![] }; - let mut v: Vec = c.map.keys().filter(|k| k.revision == revision && k.comp == comp && k.scale == scale).map(|k| k.frame).collect(); + let mut v: Vec = c.map.keys().filter(|k| k.same_series(base)).map(|k| k.frame).collect(); v.sort_unstable(); v } - /// Frames of (revision, comp, scale) in the disk cache but not in RAM — the blue cache bar. + /// Frames of the series of `base` in the disk cache but not in RAM — the blue cache bar. /// `opts` are the viewer's render options (part of the disk key). - pub fn disk_frames(&self, src: &RenderSource, revision: u64, comp: u64, scale: u32, view: u64, frames: i64, opts: &RenderOpts) -> Vec { + pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, frames: i64, opts: &RenderOpts) -> Vec { let remote = self.remote.as_ref().filter(|r| r.disk()); let contains = |k: u128| match (&src.disk, remote) { (Some(dc), _) => dc.contains(disk_cache::Kind::Frame, k), @@ -320,14 +358,11 @@ impl Frames { if src.disk.is_none() && remote.is_none() { return vec![]; } - let ram: HashSet = self.cached_frames(revision, comp, scale).into_iter().collect(); + let ram: HashSet = self.cached_frames(base).into_iter().collect(); let oh = opts_hash(opts); (0..frames.clamp(0, 100_000)) .filter(|f| !ram.contains(f)) - .filter(|f| { - let key = FrameKey { revision, comp, frame: *f, scale, view }; - contains(frame_disk_key(&self.content_keys, &src.project, &key, oh)) - }) + .filter(|f| contains(frame_disk_key(&self.content_keys, &src.project, &FrameKey { frame: *f, ..*base }, oh))) .collect() } @@ -692,3 +727,49 @@ impl Queue { jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn key(revision: u64, frame: i64, opts: u64) -> FrameKey { + FrameKey { revision, comp: 1, frame, scale: 1000, view: 0, opts } + } + + fn img() -> FrameImage { + FrameImage::Cpu(Arc::new(egui::ColorImage::new([2, 2], vec![egui::Color32::BLACK; 4]))) + } + + fn insert(f: &Frames, k: FrameKey) { + f.cache.lock().unwrap().insert(k, img()); + } + + #[test] + fn least_recently_shown_frames_go_first() { + let f = Frames::default(); + f.set_budget(3 * 16); + for i in 0..3 { + insert(&f, key(1, i, 0)); + } + // Frame 0 is shown again, so frame 1 is now the oldest. + assert!(f.get(&key(1, 0, 0)).is_some()); + insert(&f, key(1, 3, 0)); + assert!(f.is_cached(&key(1, 0, 0)) && !f.is_cached(&key(1, 1, 0))); + assert!(f.is_cached(&key(1, 2, 0)) && f.is_cached(&key(1, 3, 0))); + } + + #[test] + fn stale_revisions_are_dropped_and_series_are_kept_apart() { + let f = Frames::default(); + insert(&f, key(1, 0, 0)); + insert(&f, key(2, 0, 0)); + insert(&f, key(2, 1, 7)); + // Other render options (Draft, shadows…) are another series. + assert_eq!(f.cached_frames(&key(2, 0, 0)), vec![0]); + assert_eq!(f.cached_frames(&key(2, 0, 7)), vec![1]); + f.drop_stale(2); + assert!(!f.is_cached(&key(1, 0, 0)) && f.is_cached(&key(2, 0, 0))); + let c = f.cache.lock().unwrap(); + assert_eq!((c.order.len(), c.bytes), (2, 2 * 16), "the memory is given back"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8d6ff74e..1682ce2b 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 { @@ -152,6 +179,9 @@ pub struct EffectcraftApp { pending_screenshots: Vec<(u64, Option, Option<[f32; 4]>, Sender, f64)>, next_token: u64, pub(crate) last_ui_time: f64, + /// 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), 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)>, @@ -226,6 +256,7 @@ impl EffectcraftApp { pending_screenshots: vec![], next_token: 1, last_ui_time: 0.0, + last_activity: (0.0, 0), toast: None, viewer_tex: None, viewer_native: None, @@ -462,12 +493,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); @@ -477,8 +504,32 @@ impl EffectcraftApp { full } + /// The cache key of viewer frame `frame` of `comp` at `scale` (see [`Self::frame_series`]). pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey { - FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32, view: self.view_hash(comp) } + FrameKey { frame, ..self.frame_series(comp, scale) } + } + + /// The key of frame 0 of `comp`'s viewer frames at `scale` (the revision, view and render + /// options are the same for every frame: loops compute this once). + pub fn frame_series(&self, comp: ItemId, scale: f64) -> FrameKey { + FrameKey { + revision: self.session.revision, + comp: comp.0, + frame: 0, + scale: (scale * 1000.0).round() as u32, + view: self.view_hash(comp), + opts: frames::opts_hash(&self.frame_opts(comp, scale)), + } + } + + /// The frame series the viewer of `comp` shows now (its scale, view and options, at the + /// current revision): what the cache bars count. + pub fn shown_series(&self, comp: ItemId) -> FrameKey { + match self.viewer_shown.as_ref().map(|(_, k)| *k) { + Some(k) if k.comp == comp.0 && k.revision == self.session.revision => FrameKey { frame: 0, ..k }, + Some(k) => self.frame_series(comp, k.scale as f64 / 1000.0), + None => self.frame_series(comp, 1.0), + } } /// Hash of the comp viewer's 3D view camera (0 for the active camera view). @@ -587,6 +638,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)); @@ -666,9 +718,10 @@ impl EffectcraftApp { fn play_cached(&mut self, now: f64) -> bool { let (Some(mut pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false }; let scale = self.viewer_shown.as_ref().map(|(_, k)| k.scale as f64 / 1000.0).unwrap_or(1.0); + let series = self.frame_series(cid, scale); let mut last = None; let mut f = pl.first; - while f <= pl.end && self.frames.is_cached(&self.frame_key(cid, f, scale)) { + while f <= pl.end && self.frames.is_cached(&FrameKey { frame: f, ..series }) { last = Some(f); f += pl.step; } @@ -722,16 +775,27 @@ impl EffectcraftApp { (self.frames_parallelism() / 2).max(2) as i64 }; let mut queued = self.frames.inflight(); + let series = self.frame_series(cid, scale); 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 { - let key = self.frame_key(cid, f, scale); - if self.frames.is_cached(&key) { + if self.frames.is_cached(&FrameKey { frame: f, ..series }) { continue; } all = false; @@ -769,8 +833,7 @@ impl EffectcraftApp { f } }; - let key = self.frame_key(cid, f, scale); - if !self.frames.is_cached(&key) { + if !self.frames.is_cached(&FrameKey { frame: f, ..series }) { self.request_frame(cid, f, scale); queued += 1; } @@ -783,6 +846,7 @@ impl EffectcraftApp { ctx.request_repaint(); } if !self.playback.playing { + self.cache_when_idle(ctx, &c, &series, now, (wa, wb)); return; } let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; @@ -802,10 +866,10 @@ impl EffectcraftApp { if !video { self.playback.audio_frame = Some(target); } else { - self.playback.waiting = !self.frames.is_cached(&self.frame_key(cid, target, scale)); + 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(); @@ -841,18 +905,17 @@ impl EffectcraftApp { ctx.request_repaint(); return; } - let key = self.frame_key(cid, target, scale); - if self.frames.is_cached(&key) { + if self.frames.is_cached(&FrameKey { frame: target, ..series }) { 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). self.playback.waiting = true; let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 }); - if self.frames.is_cached(&self.frame_key(cid, next, scale)) { + if self.frames.is_cached(&FrameKey { frame: next, ..series }) { self.session.set_time(fr.tick_of(next)); } self.playback.start_wall = now; @@ -861,6 +924,54 @@ impl EffectcraftApp { ctx.request_repaint(); } + /// 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. + fn cache_when_idle(&mut self, ctx: &egui::Context, comp: &effectcraft_engine::project::Comp, series: &FrameKey, now: f64, (wa, wb): (i64, i64)) { + let active = ctx.input(|i| !i.events.is_empty() || i.pointer.any_down() || i.pointer.is_moving()); + if active || self.last_activity.1 != self.session.revision { + self.last_activity = (now, self.session.revision); + } + if !self.ui.cache_when_idle || self.frames.urgent_pending() { + return; + } + let quiet = now - self.last_activity.0; + if quiet < 1.0 { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(1.0 - quiet)); + return; + } + let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb); + let n = wb - wa + 1; + let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n); + let slots = (self.frames_parallelism() / 2).max(1); + let mut queued = self.frames.inflight(); + let scale = series.scale as f64 / 1000.0; + let mut pending = false; + for k in 0..fit { + let f = wa + (cur - wa + k).rem_euclid(n); + if self.frames.is_cached(&FrameKey { frame: f, ..*series }) { + continue; + } + pending = true; + if queued >= slots { + break; + } + self.request_frame(ItemId(series.comp), f, scale); + queued += 1; + } + if pending { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + } + + /// 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 @@ -1011,7 +1122,8 @@ impl EffectcraftApp { } effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), - effectcraft_engine::Event::ProjectChanged { .. } => {} + // Frames of earlier revisions can't be shown again. + effectcraft_engine::Event::ProjectChanged { revision } => self.frames.drop_stale(revision), effectcraft_engine::Event::Frontend { command, params } => { if let Err(e) = crate::menus::frontend(self, ctx, &command, params) { self.ui.status = e; diff --git a/crates/ui-egui/src/panels/info.rs b/crates/ui-egui/src/panels/info.rs index 9044047e..92df6f8c 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/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 6ff3e95e..87229014 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -215,14 +215,12 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } // Cache bar over the preview range. - if let Some(c) = app.session.active_comp_arc() { - let cid = app.session.active_comp_id().map(|i| i.0).unwrap_or(0); - let scale = app.viewer_shown.as_ref().map(|(_, k)| k.scale).unwrap_or(1000); + if let (Some(c), Some(cid)) = (app.session.active_comp_arc(), app.session.active_comp_id()) { let pl = match app.playback.plan.filter(|_| app.playback.playing) { Some(pl) => pl, None => effectcraft_engine::preview::plan(&o, &c, app.session.time()), }; - let cached_set = app.frames.cached_frames(app.session.revision, cid, scale); + let cached_set = app.frames.cached_frames(&app.shown_series(cid)); let total = pl.frames().count(); let cached = pl.frames().filter(|f| cached_set.binary_search(f).is_ok()).count(); let bar = Rect::from_min_size(pos2(rect.min.x + 12.0, yy), vec2(rect.width() - 24.0, 6.0)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 090cadbe..28b10af3 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -932,9 +932,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("comp.workArea", json!({key: secs, "merge": "wa-drag"})); } } - // Cache bar (green: cached frames at the viewer's resolution). - let scale_key = app.viewer_shown.as_ref().map(|(_, k)| k.scale).unwrap_or(1000); - let cached = app.frames.cached_frames(app.session.revision, cid.0, scale_key); + // Cache bar (green: cached frames of what the viewer shows: its resolution, view and options). + let series = app.shown_series(cid); + let cached = app.frames.cached_frames(&series); let fd = comp.frame_duration().seconds(); let cy0 = wa_row.max.y - 4.0; let mut run: Option<(i64, i64)> = None; @@ -959,8 +959,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Disk-cached frames not in RAM (blue), like After Effects. if app.session.disk_cache.is_some() || app.frames.remote_disk() { let frames = comp.frame_rate.frame_at(comp.duration); - let opts = app.frame_opts(cid, scale_key as f64 / 1000.0); - let disk = app.frames.disk_frames(&app.render_source(), app.session.revision, cid.0, scale_key, app.view_hash(cid), frames, &opts); + let opts = app.frame_opts(cid, series.scale as f64 / 1000.0); + let disk = app.frames.disk_frames(&app.render_source(), &series, frames, &opts); let draw_blue = |a: i64, b: i64| { let x0 = tm.x(a as f64 * fd); let x1 = tm.x((b + 1) as f64 * fd); diff --git a/crates/ui-egui/tests/ui_preview_align.rs b/crates/ui-egui/tests/ui_preview_align.rs index 3629a02c..ad02c328 100644 --- a/crates/ui-egui/tests/ui_preview_align.rs +++ b/crates/ui-egui/tests/ui_preview_align.rs @@ -184,3 +184,76 @@ 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"); +} + +#[test] +fn cache_frames_when_idle_fills_the_work_area() { + let mut h = harness(); + let cid = h.state().session.active_comp_id().unwrap(); + let cached = |h: &Harness<'_, EffectcraftApp>| h.state().frames.cached_frames(&h.state().shown_series(cid)).len(); + // Off: only a few frames ahead of the current time are prefetched (the work area is the + // whole 240 frames). + for _ in 0..120 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let before = cached(&h); + assert!(before < 240, "{before}"); + invoke(&mut h, "playback.cacheWhenIdle", json!({"value": true})); + let mut n = 0; + for _ in 0..4000 { + h.step(); + n = cached(&h); + if n >= 240 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert_eq!(n, 240, "the work area is cached while idle"); +} diff --git a/crates/ui-egui/tests/ui_web_gpu_storage.rs b/crates/ui-egui/tests/ui_web_gpu_storage.rs index f322fac1..80c0a593 100644 --- a/crates/ui-egui/tests/ui_web_gpu_storage.rs +++ b/crates/ui-egui/tests/ui_web_gpu_storage.rs @@ -137,7 +137,7 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() { s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap(); let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Gpu, ..Default::default() }; let t = effectcraft_engine::time::Tick::ZERO; - let key = |comp: effectcraft_engine::project::ItemId| FrameKey { revision: s.revision, comp: comp.0, frame: 0, scale: 1000, view: 0 }; + let key = |comp: effectcraft_engine::project::ItemId| FrameKey { revision: s.revision, comp: comp.0, frame: 0, scale: 1000, view: 0, opts: 0 }; let disk = Arc::new(Mutex::new(HashMap::new())); let worker = Arc::new(GpuWorker::new(&s, worker_gpu.clone(), disk.clone())); let mut frames = Frames::default(); @@ -162,7 +162,7 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() { let mut frames2 = Frames::default(); frames2.set_remote(Some(worker2.clone())); assert!(frames2.remote_disk()); - let bar = frames2.disk_frames(&source(&s, None), s.revision, fx.0, 1000, 0, 30, &opts); + let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts); assert_eq!(bar, vec![0], "the blue cache bar shows the stored frame"); frames2.request_urgent(&source(&s, Some(page_gpu)), key(fx), fx, t, opts); frames2.dispatch_remote(); diff --git a/crates/ui-egui/tests/ui_web_offload.rs b/crates/ui-egui/tests/ui_web_offload.rs index b766c73c..86706e4b 100644 --- a/crates/ui-egui/tests/ui_web_offload.rs +++ b/crates/ui-egui/tests/ui_web_offload.rs @@ -105,7 +105,7 @@ fn viewer_frames_render_remotely_from_project_diffs() { let comp = s.active_comp_id().unwrap(); let c = s.project.comp(comp).unwrap().clone(); let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() }; - let key = |rev: u64, f: i64| FrameKey { revision: rev, comp: comp.0, frame: f, scale: 250, view: 0 }; + let key = |rev: u64, f: i64| FrameKey { revision: rev, comp: comp.0, frame: f, scale: 250, view: 0, opts: 0 }; let worker = Arc::new(FakeWorker::new(&s, 2)); let mut frames = Frames::default(); frames.set_remote(Some(worker.clone())); diff --git a/docs/architecture.md b/docs/architecture.md index 492c1a29..6343ea64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,6 +129,13 @@ its evaluated inputs, excluding the transform. Static and transform-only layers editing one layer re-renders only that layer. Effects that read the clock directly are declared in `effects::TIME_DEPENDENT`, and a test checks every registered effect against that list. +The viewer's **RAM preview** (`ui-egui/src/frames.rs`) keeps finished frames keyed by project +revision, comp, frame, scale, 3D view / region of interest and a hash of the other render +options (Draft / Fast Previews, shadows, nested switches), so frames rendered under other +options are never shown. When the budget (Settings ▸ Memory & CPU) is full the least recently +shown frames go first, and frames of an older revision are dropped as soon as the project +changes. The timeline's green bar counts the frames of exactly what the viewer shows. + A persistent **disk cache** (`render::disk_cache`, Settings ▸ Media & Disk Cache) backs both the layer cache and the viewer's RAM preview: layer buffers that were slow to render and every preview frame are written (LZ4, atomic rename, checksummed) under 128-bit content keys — the diff --git a/docs/parity.md b/docs/parity.md index f3a26b9a..8b131007 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -383,6 +383,23 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. +## Update: M4.5–M4.8 cached playback + +- **RAM preview keys** carry the render options (Fast Previews Draft / Fast Draft, Realtime + Shadows), so a mode switch never shows frames rendered the other way; eviction drops the least + recently shown frame; frames of an earlier revision are freed as soon as the project changes; + the green bars count exactly what the viewer shows (scale, 3D view, region of interest). +- **Playback**: viewer Resolution Auto keeps its scale while playing (it halved it, so cached + frames weren't reused); Cache Before Playback cuts a range larger than the RAM preview budget + to the frames that fit and plays them (it waited forever); the Info panel shows the achieved + frame rate and whether it is real time. +- **Cache Frames When Idle** renders the work area in the background after a second of quiet. +- **Edit ▸ Purge**: disk and snapshot purges keep the RAM preview; memory purges also drop + decoded footage frames. + +Not yet: content-keyed RAM frames (an edit drops every comp's RAM frames; the disk cache keeps +content keys), audio scrubbing, four snapshot slots. + ## Update: M3.14 project files Project files name the version that wrote them (`savedBy`); opening one that a newer EffectCraft diff --git a/docs/preferences.md b/docs/preferences.md index 93da1311..315e0ea4 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -47,7 +47,9 @@ Commands (CLI, MCP, control channel): - `autoSave.saveOnRenderStart`: Save When Starting Render Queue - `composition.showRenderingProgress`: Show Rendering Progress in Info Panel and Flowchart - `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit -- `previews.cacheFramesWhenIdle`: Cache Frames When Idle +- `previews.cacheFramesWhenIdle`: Cache Frames When Idle (Composition ▸ Preview): after a second + without input or edits, the viewer renders the work area into the RAM preview in the + background, from the current time on, until it is cached or the budget is full - `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects) - `appearance.theme`: Theme - `appearance.brightness`: Brightness