mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
249cc145e3
commit
5648fdb1c2
7 files changed
+146
-36
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user