M4.5: RAM preview: render options in the frame key, least-recently-shown eviction, stale frames freed at once

- `FrameKey` carries a hash of the render options (`frames::opts_hash`): switching Fast
  Previews (Draft / Fast Draft) or Realtime Shadows no longer shows frames rendered the other
  way.
- Eviction drops the least recently shown frame (`Frames::get` touches it), not the oldest
  inserted: a looping preview a little over budget no longer evicts the frame it shows next.
- Frames of earlier project revisions are dropped on every change (`Frames::drop_stale`); they
  could never be shown again but held memory (and GPU textures) until evicted.
- The green cache bars (Timeline, Preview panel) count the frames of exactly what the viewer
  shows — scale, 3D view / region of interest and options (`EffectcraftApp::shown_series`) —
  instead of any frame at that scale.
- Playback loops compute the key's fixed part once (`EffectcraftApp::frame_series`).
- Docs: architecture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 00:43:56 +07:00
1 parent 249cc145e3
commit 5648fdb1c2
7 files changed
+146 -36

No files matched your search

+94 -13
View File
@@ -2,7 +2,10 @@
//!
//! Frames render on a dedicated thread pool from immutable `Arc<Project>` 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<FrameImage> {
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<FrameKey> = 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<i64> {
/// 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<i64> {
let Ok(c) = self.cache.lock() else { return vec![] };
let mut v: Vec<i64> = c.map.keys().filter(|k| k.revision == revision && k.comp == comp && k.scale == scale).map(|k| k.frame).collect();
let mut v: Vec<i64> = 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<i64> {
pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, frames: i64, opts: &RenderOpts) -> Vec<i64> {
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<i64> = self.cached_frames(revision, comp, scale).into_iter().collect();
let ram: HashSet<i64> = 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");
}
}
+35 -11
View File
@@ -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<i64> = (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;
+2 -4
View File
@@ -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));
+5 -5
View File
@@ -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);
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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()));
+7
View File
@@ -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