Preview: an edit keeps the cached frames outside the layers it changes (#1641)

The RAM preview keyed every frame by one identity of the whole comp, so any
edit (a cut, a trim, a nudge) made every cached frame a miss and the whole
comp recached. When only some of a comp's layers change, frames outside their
In-Out span now move to the new identity. Settings changes, reorders,
expressions, and edits that reach other layers (parents, track mattes, effect
layer pickers) still recache everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
arnav-hiwarkar
2026-10-16 16:43:57 +05:30
co-authored by Claude Opus 5.5
parent 7c733f4cc9
commit 17edbbffff
2 changed files with 116 additions and 4 deletions
+51 -4
View File
@@ -839,7 +839,12 @@ impl Comp {
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches, Hide
/// Shy Layers and the viewer's Resolution.
pub fn same_pixels(&self, o: &Comp) -> bool {
// Every field (no `..`: a new field must be considered here); the layers last.
self.same_settings(o) && self.layers.len() == o.layers.len() && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b))
}
/// [`Self::same_pixels`] but for the layers.
fn same_settings(&self, o: &Comp) -> bool {
// Every field (no `..`: a new field must be considered here).
let Comp {
width,
height,
@@ -849,7 +854,7 @@ impl Comp {
display_start,
background,
work_area,
layers,
layers: _,
markers,
shutter_angle,
shutter_phase,
@@ -891,8 +896,50 @@ impl Comp {
&& *global_light == o.global_light
&& *guides == o.guides
&& *essential == o.essential
&& layers.len() == o.layers.len()
&& layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b))
}
/// The comp-time span (`in_point..out_point` of every layer changed, added or removed)
/// outside which `self` draws the same pixels as `o`, so cached frames there stay valid
/// (#1641). `None` when the edit can reach any frame: a comp setting, a layer reorder, an
/// expression (it reads anything), or a changed layer another layer parents to, mattes
/// with or picks in an effect.
pub fn changed_span(&self, o: &Comp) -> Option<(Tick, Tick)> {
fn reads(g: &PropGroup, changed: &[LayerId]) -> bool {
g.children.iter().any(|c| match c {
Node::Prop(p) => {
p.expr.is_some()
|| std::iter::once(&p.value)
.chain(p.keys.iter().map(|k| &k.value))
.any(|v| matches!(v, Value::Layer(Some(id)) if changed.contains(&LayerId(*id))))
}
Node::Group(g) => reads(g, changed),
})
}
if !self.same_settings(o) {
return None;
}
let common = |a: &Comp, b: &Comp| a.layers.iter().map(|l| l.id).filter(|id| b.layer(*id).is_some()).collect::<Vec<_>>();
if common(self, o) != common(o, self) {
return None;
}
let mut changed = Vec::new();
let mut span: Option<(Tick, Tick)> = None;
// Each changed layer comes up from both sides: its old and its new span.
for (l, other) in self.layers.iter().map(|l| (l, o.layer(l.id))).chain(o.layers.iter().map(|l| (l, self.layer(l.id)))) {
if other.is_some_and(|b| l.same_pixels(b)) {
continue;
}
changed.push(l.id);
let (s, e) = (l.in_point.min(l.out_point), l.in_point.max(l.out_point));
span = Some(span.map_or((s, e), |(a, b)| (a.min(s), b.max(e))));
}
let reaches = |l: &Layer| {
l.parent.is_some_and(|p| changed.contains(&p)) || l.track_matte.as_ref().is_some_and(|m| changed.contains(&m.layer)) || reads(&l.props, &changed)
};
if self.layers.iter().chain(&o.layers).any(reaches) {
return None;
}
span
}
/// Draws the same pixels as `o` once plain markers are ignored: they change no pixels. Protected
+65
View File
@@ -227,6 +227,19 @@ impl Cache {
}
self.order.retain(|k| !gone.contains(&k.content));
}
/// Move the frames of identity `from` that `keep` accepts to identity `to` (in place in the
/// eviction order), unless `to` already has them.
fn carry(&mut self, from: u64, to: u64, keep: impl Fn(i64) -> bool) {
for k in self.order.iter_mut().filter(|k| k.content == from && keep(k.frame)) {
let moved = FrameKey { content: to, ..*k };
if !self.map.contains_key(&moved)
&& let Some(img) = self.map.remove(k)
{
self.map.insert(moved, img);
*k = moved;
}
}
}
fn evict_to_budget(&mut self) {
while self.bytes > self.budget {
let Some(old) = self.victim().and_then(|i| self.order.remove(i)) else { break };
@@ -731,6 +744,21 @@ impl Frames {
{
return (*base_id, Some(base.clone()));
}
// Only layers of this comp changed: its frames outside their span (a frame either side
// for motion blur) keep drawing the same pixels, so a cut or trim recaches only the
// layers it touched (#1641).
if let Some((base_id, base)) = bases.get(&comp.0)
&& let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp))
&& comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => Arc::as_ptr(b),
_ => Arc::as_ptr(c),
}) == *base_id
&& let Some((s, e)) = new.changed_span(old)
&& let Ok(mut cache) = self.cache.lock()
{
let (first, last) = (new.frame_rate.frame_at(s).saturating_sub(1), new.frame_rate.frame_at(e).saturating_add(1));
cache.carry(*base_id, plain, |f| f < first || f > last);
}
if bases.len() > MAX_KEEPERS {
bases.clear();
}
@@ -1990,6 +2018,43 @@ mod tests {
assert_ne!(at(&s), o1);
}
/// A cut or trim recaches only the frames of the layers it touched (#1641), and none are
/// kept when the change reaches other layers (here a child of the trimmed layer).
#[test]
fn an_edit_keeps_the_frames_outside_the_layers_it_changed() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let comp = ItemId(id(s.execute("comp.new", json!({"name": "Edit", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp"));
let a = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer");
let b = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [a], "in": 0, "out": 4})).unwrap();
s.execute("layer.timing", json!({"layers": [b], "in": 4, "out": 10})).unwrap();
let (fr, out) = s.project.comp(comp).map(|c| (c.frame_rate, c.layers.iter().find(|l| l.id.0 == a).unwrap().out_point)).unwrap();
let frames: Vec<i64> = (0..fr.frame_at(Tick::from_seconds_f64(10.0))).collect();
let f = Frames::default();
let cache_all = |s: &Session| {
let c = f.content_of(&s.project, s.revision, comp);
frames.iter().for_each(|&n| insert(&f, key(c, n, 0)));
c
};
let cached = |c: u64| frames.iter().filter(|&&n| f.is_cached(&key(c, n, 0))).copied().collect::<Vec<_>>();
let before = cache_all(&s);
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
assert_ne!(after, before);
let kept: Vec<i64> = (fr.frame_at(out) + 2..frames.len() as i64).collect();
assert_eq!(cached(after), kept, "the cut layer's frames (and one either side) render again; the rest stay");
s.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap();
let parented = cache_all(&s);
s.execute("layer.timing", json!({"layers": [a], "out": 1})).unwrap();
assert_eq!(cached(f.content_of(&s.project, s.revision, comp)), Vec::<i64>::new(), "the trimmed layer's child moves everywhere");
assert_eq!(cached(parented).len(), frames.len(), "the earlier frames stay for undo");
}
#[test]
fn markers_keep_the_frames() {
use effectcraft_engine::Session;