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:
commit
6a5abad753
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue