diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index 198391891..53be6a381 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 8d6ff74e8..8dae555ea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -477,8 +477,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). @@ -666,9 +690,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,6 +747,7 @@ 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 { @@ -730,8 +756,7 @@ impl EffectcraftApp { let n = (p.end - p.start) / p.step + 1; 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 +794,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; } @@ -802,7 +826,7 @@ 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; @@ -841,8 +865,7 @@ 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)); @@ -852,7 +875,7 @@ impl EffectcraftApp { // 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; @@ -1011,7 +1034,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/misc.rs b/crates/ui-egui/src/panels/misc.rs index 6ff3e95ef..872290141 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 090cadbe0..28b10af37 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_web_gpu_storage.rs b/crates/ui-egui/tests/ui_web_gpu_storage.rs index f322fac1b..80c0a5932 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 b766c73c2..86706e4bc 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 492c1a295..6343ea64d 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