From 26c714db7fee2e670ed91638dcc06c3203311c6b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:39:17 +0700 Subject: [PATCH] M4.14: content-keyed RAM preview frames; proxies in the disk cache's content key - FrameKey's identity is the comp's content (frames::comp_content), not the project revision: an edit keeps the cached frames of comps it doesn't touch and undo finds the frames of the state it returns to. The identity hashes the addresses of the comps a comp draws (~0.1 ms on the 1500-layer test project; the full content hash took ~117 ms), by value the footage, solids, proxies and project settings; with expressions, every item. Frames::content_of keeps a project snapshot per identity with frames (or in flight), so Arc::make_mut always copies an edited comp and its address can't be reused meanwhile; past 64 identities the least recently used go with their frames (long drags). - drop_stale is gone (frames of other contents are evicted least-recently-shown first). - disk_cache::comp_content_key covers items' proxies and Use Proxy (it served stale frames). - Tests: frames (content keys, edits elsewhere and undo, bounded drags, in-place edit safety), ui_ram_preview (end to end), the disk cache proxy key. Docs: roadmap (M4 done), parity. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/engine/src/tests_disk_cache.rs | 9 + crates/render/src/disk_cache.rs | 9 +- crates/ui-egui/src/frames.rs | 226 ++++++++++++++++++--- crates/ui-egui/src/lib.rs | 17 +- crates/ui-egui/src/panels/viewer.rs | 3 + crates/ui-egui/tests/ui_ram_preview.rs | 53 +++++ crates/ui-egui/tests/ui_web_gpu_storage.rs | 10 +- crates/ui-egui/tests/ui_web_offload.rs | 28 ++- docs/parity.md | 12 +- 10 files changed, 320 insertions(+), 51 deletions(-) create mode 100644 crates/ui-egui/tests/ui_ram_preview.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8147cc2b..5936aa9a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ once. | M1 | Keyframes: temporal and spatial interpolation, Easy Ease, roving, velocity | Done | | M2 | Compositing: 38 blend modes, track mattes, parenting, adjustment layers | Done | | M3 | Project operations: settings dialogs, layer commands, `.ecproj`, undo, After Effects menu bar | Done (unsaved-changes prompts, Solid / Layer Settings, lock-aware layer commands, safe project files; View ▸ New Viewer still opens only the one locked viewer) | -| M4 | Preview: precomps, motion blur, cached playback | Mostly done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content; left: content-keyed RAM preview frames) | +| M4 | Preview: precomps, motion blur, cached playback | Done (Preserve frame rate / resolution when nested, one motion blur gate, RAM preview fixes, Cache Frames When Idle, motion blur of collapsed precomps and of animated shape / text content, content-keyed RAM preview frames) | | M5 | Timeline depth: graph editor, keyframe clipboard and dialogs, time remapping, pick-whips, expression editor | Done | | M6 | Shapes, masks and footage: shape operators, masks and the pen tool, video and image import | Done | | M7 | 3D: 3D layers, cameras, lights, shadows, depth of field, 3D views and camera tools | In review | @@ -33,6 +33,6 @@ Measured feature by feature in [docs/parity.md](docs/parity.md) (4 October 2026) - **≈ 8.5 agent-hours of work remain**: about **2–3 hours of wall-clock time** with five Claude Opus 5.5 agents in parallel, ≈ 1 hour without learned models for Roto Brush and face tracking. - What is left: GPU kernels for the remaining visual effects, and learned-model quality for Roto - Brush and face tracking; in previews, content-keyed RAM preview frames ([parity.md](docs/parity.md#highest-value-gaps-in-order)). + Brush and face tracking ([parity.md](docs/parity.md#highest-value-gaps-in-order)). Come tell us what matters most to you on [Discord](https://discord.gg/artcraft). diff --git a/crates/engine/src/tests_disk_cache.rs b/crates/engine/src/tests_disk_cache.rs index beaeeada..bdefe6b6 100644 --- a/crates/engine/src/tests_disk_cache.rs +++ b/crates/engine/src/tests_disk_cache.rs @@ -119,4 +119,13 @@ fn footage_changes_change_the_salt() { // Unrelated comps do not change it. s.execute("comp.new", json!({"name": "Other", "width": 10, "height": 10, "frameRate": 30, "duration": 1})).unwrap(); assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k2); + // A proxy, and its Use Proxy switch, change the pixels. + let set_proxy = |s: &mut Session, enabled: bool| { + let it = std::sync::Arc::make_mut(&mut s.project).items.get_mut(&cid).unwrap(); + it.proxy = Some(Box::new(effectcraft_project::Proxy { footage: effectcraft_project::Footage::default(), enabled })); + effectcraft_render::disk_cache::comp_content_key(&s.project, cid) + }; + let k3 = set_proxy(&mut s, true); + assert_ne!(k3, k2); + assert_ne!(set_proxy(&mut s, false), k3); } diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 6204da45..b35a3aa7 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -778,8 +778,9 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 { } /// Content key of a composition's pixels: project render settings, the comp and everything it -/// uses (nested comps, footage, solids, footage files' size and time). With expressions in play -/// (which can read any comp) the whole project counts. +/// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy +/// files' size and time). With expressions in play (which can read any comp) the whole project +/// counts. pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 { use effectcraft_project::{ItemKind, LayerSource}; let mut h = Hash128::default(); @@ -813,6 +814,10 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf if let ItemKind::Footage(f) = &it.kind { footage_stamp(&mut h, f); } + hash_debug(&mut h, &it.proxy); + if let Some(px) = &it.proxy { + footage_stamp(&mut h, &px.footage); + } } h.write_u64(comp.0); h.finish() diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index be6761f3..f506e62d 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -1,11 +1,11 @@ //! Background frame rendering and the RAM preview cache. //! //! 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, 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). +//! never waits for the compositor. Results land in a memory-budgeted cache keyed by the comp's +//! *content* ([`comp_content`]: the comp and everything it uses), frame, scale, view and render +//! options; the timeline draws the cached range as the green cache bar. An edit keeps the frames +//! of every comp it doesn't touch, and undo finds the frames of the state it returns to, as in +//! After Effects. When the budget is full the least recently shown frames go first. //! //! 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 @@ -22,16 +22,19 @@ //! registers with egui-wgpu and draws directly; pixels are read back only when something needs //! them (Info panel, eyedroppers, histograms). -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::sync::{Arc, Mutex}; -use effectcraft_engine::project::{ItemId, Project}; +use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Node, Project, PropGroup}; use effectcraft_engine::render::disk_cache::{self, DiskCache}; use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer}; use effectcraft_engine::time::Tick; use effectcraft_gpu::{DisplayFrame, Gpu}; use rayon::prelude::*; +/// Comp identities (and the project snapshots behind them) the RAM preview keeps frames of. +const MAX_KEEPERS: usize = 64; + /// GPU frames kept for RAM preview (bytes of video memory). const GPU_BUDGET: usize = 1 << 30; @@ -57,9 +60,14 @@ impl FrameImage { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +/// A viewer frame: its comp's content, the frame, scale, view and render options. `revision` is +/// the project revision it was asked for at (job bookkeeping); it is not part of the identity, so +/// a later revision with the same content finds the frame. +#[derive(Clone, Copy, Debug)] pub struct FrameKey { pub revision: u64, + /// The comp's content key ([`Frames::content_of`]). + pub content: u64, pub comp: u64, pub frame: i64, /// Render scale × 1000. @@ -71,12 +79,31 @@ pub struct FrameKey { } impl FrameKey { - /// Whether `self` is a frame of the same comp, revision, scale, view and options as `base`. + /// Everything but the revision. + fn id(&self) -> (u64, u64, i64, u32, u64, u64) { + (self.content, self.comp, self.frame, self.scale, self.view, self.opts) + } + + /// Whether `self` is a frame of the same comp content, scale, view and options as `base`. pub fn same_series(&self, base: &FrameKey) -> bool { FrameKey { frame: base.frame, ..*self } == *base } } +impl PartialEq for FrameKey { + fn eq(&self, o: &FrameKey) -> bool { + self.id() == o.id() + } +} + +impl Eq for FrameKey {} + +impl std::hash::Hash for FrameKey { + fn hash(&self, h: &mut H) { + self.id().hash(h); + } +} + struct Cache { map: HashMap, order: VecDeque, @@ -129,6 +156,14 @@ impl Cache { self.order.push_back(k); } } + /// Drop every frame of the identities in `gone`. + fn drop_contents(&mut self, gone: &HashSet) { + let keys: Vec = self.map.keys().filter(|k| gone.contains(&k.content)).copied().collect(); + for k in &keys { + self.remove(k); + } + self.order.retain(|k| !gone.contains(&k.content)); + } fn evict_to_budget(&mut self) { while self.bytes > self.budget { let Some(old) = self.order.pop_front() else { break }; @@ -173,6 +208,54 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) k } +/// The identity of a comp's pixels in `project`, cheap enough to take at every revision: the +/// addresses of the comps it draws (an edit replaces a comp's `Arc`: `Arc::make_mut` copies it +/// while another snapshot holds it, and [`Frames::content_of`] keeps one for every identity it +/// hands out), and by value the project settings and the footage, solids and proxies it uses. +/// With expressions in its comps (which can read anything) every item counts. +pub fn comp_content(project: &Project, comp: ItemId) -> u64 { + use std::hash::{Hash, Hasher}; + fn has_expression(g: &PropGroup) -> bool { + g.children.iter().any(|c| match c { + Node::Prop(p) => p.expr.is_some(), + Node::Group(sub) => has_expression(sub), + }) + } + let mut h = std::collections::hash_map::DefaultHasher::new(); + format!("{:?}", project.settings).hash(&mut h); + let mut seen = BTreeSet::new(); + let mut stack = vec![comp]; + let mut expressions = false; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + let Some(ItemKind::Comp(c)) = project.item(id).map(|i| &i.kind) else { continue }; + for l in &c.layers { + if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source { + stack.push(item); + } + expressions = expressions || has_expression(&l.props); + } + } + let ids: Vec = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() }; + for id in ids { + let Some(it) = project.item(id) else { continue }; + id.0.hash(&mut h); + if expressions { + // (`comp("Name")`, `footage("Name")`) + it.name.hash(&mut h); + } + match &it.kind { + ItemKind::Comp(c) => (Arc::as_ptr(c) as usize).hash(&mut h), + k => format!("{k:?}").hash(&mut h), + } + format!("{:?}", it.proxy).hash(&mut h); + } + comp.0.hash(&mut h); + h.finish() +} + /// 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(); @@ -259,6 +342,11 @@ pub struct Frames { /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. pub last_ms: Arc>, content_keys: ContentKeys, + /// [`comp_content`] identities by (revision, comp). + identities: Mutex>, + /// A project snapshot for each identity handed out, with when it was last asked for (see + /// [`Frames::content_of`]); the counter. + keepers: Mutex<(u64, HashMap)>)>, /// Where frames render instead of the UI thread (wasm32), and how many it is rendering. remote: Option>, remote_busy: Arc>, @@ -284,6 +372,8 @@ impl Default for Frames { ctx: None, last_ms: Arc::new(Mutex::new(0.0)), content_keys: Arc::default(), + identities: Mutex::default(), + keepers: Mutex::default(), remote: None, remote_busy: Arc::default(), } @@ -325,16 +415,44 @@ impl Frames { 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); + /// The [`comp_content`] identity of `comp` in `project` at `revision` (taken once per + /// revision). The project is kept while frames of that identity are cached or rendering, so + /// the comp addresses it hashes can't be reused by other comps meanwhile. + pub fn content_of(&self, project: &Arc, revision: u64, comp: ItemId) -> u64 { + let known = self.identities.lock().ok().and_then(|m| m.get(&(revision, comp.0)).copied()); + let id = known.unwrap_or_else(|| { + let id = comp_content(project, comp); + if let Ok(mut m) = self.identities.lock() { + if m.len() > 256 { + m.clear(); + } + m.insert((revision, comp.0), id); + } + id + }); + if let Ok(mut guard) = self.keepers.lock() { + let (clock, k) = &mut *guard; + *clock += 1; + let now = *clock; + k.entry(id).or_insert_with(|| (now, project.clone())).0 = now; + if k.len() > MAX_KEEPERS { + let inflight: HashSet = self.inflight.lock().map(|s| s.iter().map(|f| f.content).collect()).unwrap_or_default(); + if let Ok(mut c) = self.cache.lock() { + // Snapshots of identities without frames go; past the limit the least + // recently asked for go too, with their frames (a long drag leaves many). + let cached: HashSet = c.map.keys().map(|f| f.content).collect(); + k.retain(|i, _| *i == id || cached.contains(i) || inflight.contains(i)); + if k.len() > MAX_KEEPERS { + let mut ages: Vec<(u64, u64)> = k.iter().filter(|(i, _)| **i != id && !inflight.contains(i)).map(|(i, (t, _))| (*t, *i)).collect(); + ages.sort_unstable(); + let gone: HashSet = ages.into_iter().take(k.len().saturating_sub(MAX_KEEPERS / 2)).map(|(_, i)| i).collect(); + k.retain(|i, _| !gone.contains(i)); + c.drop_contents(&gone); + } + } } - c.order.retain(|k| k.revision >= revision); } + id } pub fn is_cached(&self, k: &FrameKey) -> bool { @@ -345,7 +463,7 @@ impl Frames { self.inflight.lock().map(|s| s.len()).unwrap_or(0) } - /// Cached frame numbers of the series of `base` (its comp, revision, scale, view and render + /// Cached frame numbers of the series of `base` (its comp content, 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![] }; @@ -744,8 +862,8 @@ impl Queue { mod tests { use super::*; - fn key(revision: u64, frame: i64, opts: u64) -> FrameKey { - FrameKey { revision, comp: 1, frame, scale: 1000, view: 0, opts } + fn key(content: u64, frame: i64, opts: u64) -> FrameKey { + FrameKey { revision: content, content, comp: 1, frame, scale: 1000, view: 0, opts } } fn img() -> FrameImage { @@ -771,17 +889,73 @@ mod tests { } #[test] - fn stale_revisions_are_dropped_and_series_are_kept_apart() { + fn frames_are_keyed_by_content_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. + // Other content or 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"); + // A later revision with the same content finds the frame (an edit elsewhere, an undo). + assert!(f.is_cached(&FrameKey { revision: 99, ..key(1, 0, 0) })); + assert!(!f.is_cached(&key(3, 0, 0))); + } + + #[test] + fn an_edit_keeps_the_frames_of_comps_it_does_not_touch() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + let b = ItemId(s.execute("comp.new", json!({"name": "B", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + let f = Frames::default(); + let at = |s: &Session, c: ItemId| f.content_of(&s.project, s.revision, c); + let (a0, b0) = (at(&s, a), at(&s, b)); + // Edit B: A's content (and frames) stay, B's change. + s.execute("layer.newSolid", json!({"comp": b.0, "color": "#ff0000"})).unwrap(); + assert_eq!(at(&s, a), a0); + assert_ne!(at(&s, b), b0); + // Undo: B is back to the content its old frames were rendered for. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(at(&s, b), b0); + } + + #[test] + fn a_long_drag_keeps_a_bounded_number_of_states() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + let f = Frames::default(); + let mut first = None; + let mut last = None; + for i in 0..200 { + s.execute("comp.settings", json!({"width": 8 + i, "merge": "drag"})).unwrap(); + let k = FrameKey { content: f.content_of(&s.project, s.revision, a), ..key(0, 0, 0) }; + insert(&f, k); + first.get_or_insert(k); + last = Some(k); + } + assert!(f.keepers.lock().unwrap().1.len() <= MAX_KEEPERS); + assert!(f.is_cached(&last.unwrap()), "the current state's frame stays"); + assert!(!f.is_cached(&first.unwrap()), "the oldest states' frames went with their snapshots"); + } + + #[test] + fn an_identity_keeps_its_comps_so_an_edit_in_place_still_changes_it() { + use effectcraft_engine::Session; + use serde_json::json; + let mut s = Session::default(); + let a = ItemId(s.execute("comp.new", json!({"name": "A", "width": 8, "height": 8, "duration": 1})).unwrap()["comp"].as_u64().unwrap()); + // A project nothing else holds (no undo history): make_mut would edit the comp in place, + // at the same address, if the identity hadn't kept a snapshot holding it. + let mut p = Arc::new((*s.project).clone()); + drop(s); + let f = Frames::default(); + let before = f.content_of(&p, 1, a); + let comp = Arc::make_mut(&mut p).comp_mut(a).unwrap(); + comp.width = 16; + assert_ne!(f.content_of(&p, 2, a), before); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index beaa5846..aec2d8dc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -511,11 +511,12 @@ impl EffectcraftApp { FrameKey { frame, ..self.frame_series(comp, scale) } } - /// The key of frame 0 of `comp`'s viewer frames at `scale` (the revision, view and render + /// The key of frame 0 of `comp`'s viewer frames at `scale` (the content, 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, + content: self.frames.content_of(&self.session.project, self.session.revision, comp), comp: comp.0, frame: 0, scale: (scale * 1000.0).round() as u32, @@ -524,14 +525,11 @@ impl EffectcraftApp { } } - /// The frame series the viewer of `comp` shows now (its scale, view and options, at the - /// current revision): what the cache bars count. + /// The frame series the viewer of `comp` shows now (its scale, view and options, for the + /// comp's current content): 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), - } + let scale = self.viewer_shown.as_ref().filter(|(_, k)| k.comp == comp.0).map_or(1000, |(_, k)| k.scale); + self.frame_series(comp, scale as f64 / 1000.0) } /// Hash of the comp viewer's 3D view camera (0 for the active camera view). @@ -1132,7 +1130,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)), // Frames of earlier revisions can't be shown again. - effectcraft_engine::Event::ProjectChanged { revision } => self.frames.drop_stale(revision), + // Frames are keyed by content: an edit keeps those of comps it doesn't touch. + effectcraft_engine::Event::ProjectChanged { .. } => {} 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/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 8e9197c0..c1d90dd2 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -520,6 +520,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key); if stale { show_frame(app, &ctx, key, img); + } else if let Some((_, k)) = app.viewer_shown.as_mut() { + // The same frame at a later revision (an edit elsewhere, an undo): it shows that one. + k.revision = key.revision; } } if app.ui.viewer.transparency_grid { diff --git a/crates/ui-egui/tests/ui_ram_preview.rs b/crates/ui-egui/tests/ui_ram_preview.rs new file mode 100644 index 00000000..c384607a --- /dev/null +++ b/crates/ui-egui/tests/ui_ram_preview.rs @@ -0,0 +1,53 @@ +//! RAM preview frames are keyed by content: editing one comp keeps the cached frames of another, +//! and undo finds the frames of the state it returns to. + +use effectcraft_engine::Session; +use effectcraft_engine::project::ItemId; +use effectcraft_ui_egui::EffectcraftApp; +use egui_kittest::Harness; +use serde_json::json; + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + h.run_steps(2); +} + +fn shown_cached(h: &Harness<'_, EffectcraftApp>, comp: u64) -> bool { + let app = h.state(); + let key = effectcraft_ui_egui::frames::FrameKey { frame: 0, ..app.shown_series(ItemId(comp)) }; + app.frames.is_cached(&key) +} + +#[test] +fn an_edit_in_another_comp_keeps_the_cached_frames_and_undo_finds_them_again() { + let mut s = Session::default(); + let b = s.execute("comp.new", json!({"name": "B", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + let a = s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "duration": 1})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + settle(&mut h); + assert!(shown_cached(&h, a), "A's frame rendered"); + // Edit B (open it, add a layer), then come back to A: its frame is still in RAM. + h.state_mut().session.execute("comp.open", json!({"comp": b})).unwrap(); + h.state_mut().session.execute("layer.newSolid", json!({"color": "#ff8030"})).unwrap(); + settle(&mut h); + let b_frame = h.state().shown_series(ItemId(b)); + h.state_mut().session.execute("comp.open", json!({"comp": a})).unwrap(); + assert!(shown_cached(&h, a), "A's frame survived the edit in B"); + // Edit A, then undo: the frame of the state undo returns to is found again. + let before = h.state().shown_series(ItemId(a)); + h.state_mut().session.execute("layer.newSolid", json!({"color": "#20c040"})).unwrap(); + settle(&mut h); + assert_ne!(h.state().shown_series(ItemId(a)), before, "A's content changed"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().shown_series(ItemId(a)), before); + assert!(shown_cached(&h, a), "undo finds the earlier frame"); + // B's frame is still there too. + assert!(h.state().frames.is_cached(&effectcraft_ui_egui::frames::FrameKey { frame: 0, ..b_frame })); +} diff --git a/crates/ui-egui/tests/ui_web_gpu_storage.rs b/crates/ui-egui/tests/ui_web_gpu_storage.rs index 80c0a593..fa97b571 100644 --- a/crates/ui-egui/tests/ui_web_gpu_storage.rs +++ b/crates/ui-egui/tests/ui_web_gpu_storage.rs @@ -137,7 +137,15 @@ 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, opts: 0 }; + let key = |comp: effectcraft_engine::project::ItemId| FrameKey { + revision: s.revision, + content: effectcraft_ui_egui::frames::comp_content(&s.project, comp), + 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(); diff --git a/crates/ui-egui/tests/ui_web_offload.rs b/crates/ui-egui/tests/ui_web_offload.rs index 86706e4b..cc49325f 100644 --- a/crates/ui-egui/tests/ui_web_offload.rs +++ b/crates/ui-egui/tests/ui_web_offload.rs @@ -105,16 +105,24 @@ 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, opts: 0 }; + let key = |s: &Session, f: i64| FrameKey { + revision: s.revision, + content: effectcraft_ui_egui::frames::comp_content(&s.project, comp), + 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())); assert!(frames.remote_active()); // Three prefetch frames, then the viewer's frame. for f in 1..=3 { - frames.request(&source(&s), key(s.revision, f), comp, c.frame_rate.tick_of(f), opts); + frames.request(&source(&s), key(&s, f), comp, c.frame_rate.tick_of(f), opts); } - frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts); + frames.request_urgent(&source(&s), key(&s, 10), comp, c.frame_rate.tick_of(10), opts); assert_eq!(frames.inflight(), 4); // Nothing renders until dispatched (no frame threads with a remote renderer); the viewer's // frame goes first, then prefetch in request order. @@ -126,15 +134,15 @@ fn viewer_frames_render_remotely_from_project_diffs() { // Frames arrive: the cache holds exactly what the page would have rendered. worker.deliver(); assert_eq!(frames.remote_busy(), 0); - let img = match frames.get(&key(s.revision, 10)).expect("viewer frame cached") { + let img = match frames.get(&key(&s, 10)).expect("viewer frame cached") { FrameImage::Cpu(c) => c, FrameImage::Gpu(_) => unreachable!(), }; let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts)); assert_eq!(img.size, local.size); assert_eq!(img.pixels, local.pixels); - assert!(frames.is_cached(&key(s.revision, 1))); - assert!(!frames.is_cached(&key(s.revision, 2))); + assert!(frames.is_cached(&key(&s, 1))); + assert!(!frames.is_cached(&key(&s, 2))); // The rest; an edit later travels as a patch. frames.dispatch_remote(); worker.deliver(); @@ -142,19 +150,19 @@ fn viewer_frames_render_remotely_from_project_diffs() { worker.deliver(); assert_eq!(frames.inflight(), 0); s.execute("layer.newSolid", json!({"color": "#ff8800"})).unwrap(); - frames.request_urgent(&source(&s), key(s.revision, 10), comp, c.frame_rate.tick_of(10), opts); + frames.request_urgent(&source(&s), key(&s, 10), comp, c.frame_rate.tick_of(10), opts); frames.dispatch_remote(); worker.deliver(); assert_eq!(worker.syncs.lock().unwrap().last(), Some(&"patch")); - let FrameImage::Cpu(img) = frames.get(&key(s.revision, 10)).unwrap() else { unreachable!() }; + let FrameImage::Cpu(img) = frames.get(&key(&s, 10)).unwrap() else { unreachable!() }; let local = effectcraft_ui_egui::frames::to_color_image(&s.render(comp, c.frame_rate.tick_of(10), opts)); assert_eq!(img.pixels, local.pixels); // A lost frame is released (requested again later), not cached. - frames.request(&source(&s), key(s.revision, 20), comp, c.frame_rate.tick_of(20), opts); + frames.request(&source(&s), key(&s, 20), comp, c.frame_rate.tick_of(20), opts); frames.dispatch_remote(); worker.lose(); assert_eq!(frames.inflight(), 0); - assert!(!frames.is_cached(&key(s.revision, 20))); + assert!(!frames.is_cached(&key(&s, 20))); // Without slots the remote renderer is unusable (frames render here again). frames.set_remote(Some(Arc::new(FakeWorker::new(&s, 0)))); assert!(!frames.remote_active()); diff --git a/docs/parity.md b/docs/parity.md index 57fd16c7..8193b0e8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -422,6 +422,15 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph Editor keys are small squares whatever their interpolation, as in After Effects. +Since (M4.14): RAM preview frames are keyed by their comp's content, not the project revision. An +edit keeps the cached frames (the green bar) of every comp it doesn't touch, and undo finds the +frames of the state it returns to, as in After Effects. The identity costs about 0.1 ms on the +1500-layer test project: it hashes the addresses of the comps a comp draws, and the RAM cache +keeps a project snapshot for every identity with frames, so those addresses can't be reused +meanwhile. Footage, solids, proxies and the project settings are hashed by value. At most 64 +states are kept, so a long drag doesn't hold on to every intermediate state. The disk cache's +content key now also covers proxies and Use Proxy. + Since (M13.30–M13.31): the Tools bar and About dialog carry the ArtCraft mark. The Home screen is laid out like After Effects' and covers the whole workspace. A left rail holds New Project / Open Project, the Home, Templates and Learn pages and, at its foot, the community @@ -732,7 +741,8 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r 15. ~~Preferences and a shortcut editor that can rebind; real Wiggler, Smoother and Motion Sketch; the marker dialog.~~ (second and third waves) 16. ~~Motion blur of collapsed precomps and of animated content inside a layer (shape paths, text animators).~~ (M4.13) -17. Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch. +17. ~~Content-keyed RAM preview frames, so an edit keeps the frames of comps it doesn't touch.~~ + (M4.14) 18. View ▸ New Viewer (several unlocked Composition viewers), nested comp markers on the precomp layer bar, audio scrubbing.