Merge pull request #54 from storytold/m4-content-keyed-frames

M4.14: content-keyed RAM preview frames (M4 done); proxies in the disk cache key
This commit is contained in:
BFlat 2026-10-05 14:39:33 +07:00 committed by GitHub
commit 6a5abad753
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 321 additions and 52 deletions

View File

@ -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).

View File

@ -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);
}

View File

@ -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()

View File

@ -1,11 +1,11 @@
//! Background frame rendering and the RAM preview cache.
//!
//! 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, 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<H: std::hash::Hasher>(&self, h: &mut H) {
self.id().hash(h);
}
}
struct Cache {
map: HashMap<FrameKey, FrameImage>,
order: VecDeque<FrameKey>,
@ -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<u64>) {
let keys: Vec<FrameKey> = 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<ItemId> = 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<Mutex<f64>>,
content_keys: ContentKeys,
/// [`comp_content`] identities by (revision, comp).
identities: Mutex<HashMap<(u64, u64), u64>>,
/// 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<u64, (u64, Arc<Project>)>)>,
/// Where frames render instead of the UI thread (wasm32), and how many it is rendering.
remote: Option<Arc<dyn RemoteFrames>>,
remote_busy: Arc<Mutex<usize>>,
@ -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<FrameKey> = 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<Project>, 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<u64> = 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<u64> = 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<u64> = 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<i64> {
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);
}
}

View File

@ -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;

View File

@ -530,7 +530,7 @@ mod tests {
assert_eq!(format_ago(now, now - 3 * 86_400), "3 days ago");
assert_eq!(format_ago(now, now - 30 * 86_400), "2026-08-22");
assert_eq!(format_ago(now, now + 100), "Just now");
assert_eq!((format_size(900), format_size(12_800), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
assert_eq!((format_size(900), format_size(13_000), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project"));
}

View File

@ -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 {

View File

@ -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 }));
}

View File

@ -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();

View File

@ -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());

View File

@ -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.