Merge upstream/main into fix-1083-beat-marker-audio-pause

Conflicts in project/src/lib.rs and ui-egui/src/frames.rs: Comp::same_settings
takes with_markers and is shared by same_pixels_with and changed_span (#1641);
frames identity uses same_pixels_with/reads_markers and drops
same_pixels_ignoring_markers and project_has_expressions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-25 10:33:46 +01:00
co-authored by Claude Sonnet 5.5
5 changed files with 255 additions and 16 deletions
+62 -4
View File
@@ -876,7 +876,12 @@ impl Comp {
/// (even during playback) leaves every rendered frame valid. Protected time markers with a
/// duration set the timing (Responsive Design), so those always count.
pub fn same_pixels_with(&self, o: &Comp, with_markers: bool) -> bool {
// Every field (no `..`: a new field must be considered here); the layers last.
self.same_settings(o, with_markers) && self.layers.len() == o.layers.len() && self.layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels_with(b, with_markers))
}
/// [`Self::same_pixels_with`] but for the layers.
fn same_settings(&self, o: &Comp, with_markers: bool) -> bool {
// Every field (no `..`: a new field must be considered here).
let Comp {
width,
height,
@@ -886,7 +891,7 @@ impl Comp {
display_start,
background,
work_area,
layers,
layers: _,
markers,
shutter_angle,
shutter_phase,
@@ -928,8 +933,61 @@ 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_with(b, with_markers))
}
/// The comp-time span (`in_point..out_point` of every layer changed, added or removed,
/// widened by the shutter) 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, Solo turning on or off, 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),
})
}
// Solo hides every other layer at every frame.
if !self.same_settings(o, true) || self.any_solo() != o.any_solo() {
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;
}
// A frame at `t` samples cameras, lights and transforms over its shutter,
// `t + (phase + angle * 0..=1) / 360` frames, and draws the layers active at `t` itself.
let (a, p) = (self.shutter_angle / 360.0, self.shutter_phase / 360.0);
let fd = self.frame_duration().seconds();
let (before, after) = (Tick::from_seconds_f64(p.max(p + a).max(0.0) * fd), Tick::from_seconds_f64(-p.min(p + a).min(0.0) * fd));
span.map(|(s, e)| (s - before, e + after))
}
/// Some layer is soloed: only the soloed layers draw.
pub fn any_solo(&self) -> bool {
self.layers.iter().any(|l| l.switches.solo && l.source.is_av())
}
/// The comp with those switches and the Resolution at their defaults (borrowed when they
+1 -1
View File
@@ -2135,7 +2135,7 @@ impl<'a> Renderer<'a> {
/// applied).
pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> {
let (comp, t) = (ctx.comp, ctx.time);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
let any_solo = comp.any_solo();
comp.layers
.iter()
.rev()
+1 -1
View File
@@ -880,7 +880,7 @@ fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item:
let saved = b.outer;
b.outer = saved * ctx.world_matrix(layer);
let t = nctx.time;
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
let any_solo = nctx.comp.any_solo();
for l in nctx.comp.layers.iter().rev() {
if !l.is_active_at(t)
|| l.is_light()
+1 -1
View File
@@ -497,7 +497,7 @@ fn collapsed_items<'a>(
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] };
let t = nctx.time;
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av());
let any_solo = nctx.comp.any_solo();
let nested: Vec<&'a Layer> = nctx
.comp
.layers
+190 -9
View File
@@ -238,6 +238,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 };
@@ -301,11 +314,12 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64,
/// 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 {
comp_content_with(project, comp, |_, c| Arc::as_ptr(c))
comp_content_with(project, comp, project, |_, c| c)
}
/// [`comp_content`] with each comp's address given by `addr`.
fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc<Comp>) -> *const Comp) -> u64 {
/// [`comp_content`] with each comp's version selected for both its sources and its address.
/// Sources removed by undo are read from `base`.
fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project, version: impl Fn(ItemId, &'a Arc<Comp>) -> &'a Arc<Comp>) -> u64 {
use std::hash::{Hash, Hasher};
fn has_expression(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
@@ -315,6 +329,7 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc
}
let mut h = std::collections::hash_map::DefaultHasher::new();
format!("{:?}", project.settings).hash(&mut h);
let item = |id| project.item(id).or_else(|| base.item(id));
let mut seen = BTreeSet::new();
let mut stack = vec![comp];
let mut expressions = false;
@@ -322,7 +337,8 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc
if !seen.insert(id) {
continue;
}
let Some(ItemKind::Comp(c)) = project.item(id).map(|i| &i.kind) else { continue };
let Some(ItemKind::Comp(c)) = item(id).map(|i| &i.kind) else { continue };
let c = version(id, c);
for l in &c.layers {
if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source {
stack.push(item);
@@ -332,14 +348,14 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc
}
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 };
let Some(it) = 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) => (addr(id, c) as usize).hash(&mut h),
ItemKind::Comp(c) => (Arc::as_ptr(version(id, c)) as usize).hash(&mut h),
k => format!("{k:?}").hash(&mut h),
}
format!("{:?}", it.proxy).hash(&mut h);
@@ -733,13 +749,28 @@ impl Frames {
let markers = project.reads_markers();
if let Some((base_id, base)) = bases.get(&comp.0)
&& *base_id != plain
&& comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => Arc::as_ptr(b),
_ => Arc::as_ptr(c),
&& comp_content_with(project, comp, project, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b,
_ => c,
}) == *base_id
{
return (*base_id, Some(base.clone()));
}
// Only layers of this comp changed: its frames outside their span (and a frame either
// side for rounding) 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, base, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || b.same_pixels_with(c, markers) => b,
_ => 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();
}
@@ -2168,6 +2199,156 @@ 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");
}
/// Every frame an edit keeps renders as a fresh render of the edited project does: Solo
/// hides the other layers at every frame, and a camera reaches the frames whose shutter
/// samples it (#1641 review).
#[test]
fn the_frames_an_edit_keeps_match_a_fresh_render() {
use effectcraft_engine::Session;
use effectcraft_engine::render::NoFootage;
use serde_json::json;
// Cache every frame of `comp`, run `edit`, check the frames kept against fresh renders.
fn kept(s: &mut Session, comp: ItemId, edit: serde_json::Value) -> Vec<i64> {
let (fr, n) = s.project.comp(comp).map(|c| (c.frame_rate, c.frame_rate.frame_at(c.duration))).unwrap();
let f = Frames::default();
let before = f.content_of(&s.project, s.revision, comp);
(0..n).for_each(|i| insert(&f, key(before, i, 0)));
let old = s.project.clone();
s.execute(edit["cmd"].as_str().unwrap(), edit["params"].clone()).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
let kept: Vec<i64> = (0..n).filter(|&i| f.is_cached(&key(after, i, 0))).collect();
let render = |p, i| Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(comp, fr.tick_of(i));
for &i in &kept {
assert_eq!(render(&old, i), render(&s.project, i), "kept frame {i} draws other pixels now");
}
kept
}
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let mut s = Session::default();
// Solo on a 2–3 s layer hides the 10 s underlay everywhere; turning it off shows it again.
let comp = ItemId(id(s.execute("comp.new", json!({"name": "Solo", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp"));
s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap();
let short = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [short], "in": 2, "out": 3})).unwrap();
let solo = |on: bool| json!({"cmd": "layer.setSwitch", "params": {"layers": [short], "switch": "solo", "value": on}});
assert_eq!(kept(&mut s, comp, solo(true)), Vec::<i64>::new());
assert_eq!(kept(&mut s, comp, solo(false)), Vec::<i64>::new());
// A 2–3 s camera over a 4 s motion-blurred 3D layer, shutter 720° at phase 360°: a frame
// samples 1 to 3 frames ahead, so moving the camera reaches the 3 frames before it.
let comp = ItemId(id(
s.execute("comp.new", json!({"name": "Cam", "width": 16, "height": 16, "duration": 4, "frameRate": 30, "shutterAngle": 720, "shutterPhase": 360}))
.unwrap(),
"comp",
));
let plane = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer");
for sw in ["threeD", "motionBlur"] {
s.execute("layer.setSwitch", json!({"layers": [plane], "switch": sw, "value": true})).unwrap();
}
let cam = id(s.execute("layer.newCamera", json!({"comp": comp.0})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [cam], "in": 2, "out": 3})).unwrap();
let moved =
kept(&mut s, comp, json!({"cmd": "prop.set", "params": {"comp": comp.0, "layer": cam, "path": "transform/position", "value": [40, 8, -30]}}));
assert!(moved.contains(&0) && moved.contains(&100), "frames far from the camera stay: {moved:?}");
assert!(!(56..=91).any(|f| moved.contains(&f)), "frames whose shutter samples the camera render again: {moved:?}");
}
#[test]
fn short_solid_additions_and_removals_keep_unaffected_frames() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let comp = ItemId(s.execute("comp.new", json!({"width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap());
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let f = Frames::default();
let cache_all = |s: &Session| {
let content = f.content_of(&s.project, s.revision, comp);
(0..600).for_each(|n| insert(&f, key(content, n, 0)));
};
let cached = |s: &Session| f.cached_frames(&key(f.content_of(&s.project, s.revision, comp), 0, 0));
// 2–3 s, a quarter frame of the default shutter and a frame of rounding either side.
let kept: Vec<i64> = (0..=57).chain(92..600).collect();
cache_all(&s);
let solid = s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap();
assert_eq!(cached(&s), kept, "adding a source invalidates only frames 58..=91");
cache_all(&s);
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(cached(&s), (0..600).collect::<Vec<_>>(), "undoing the addition restores every frame");
s.execute("edit.redo", json!({})).unwrap();
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(cached(&s), (0..600).collect::<Vec<_>>(), "redoing the addition restores every frame");
s.execute("edit.clear", json!({"layers": [solid]})).unwrap();
assert_eq!(cached(&s), kept, "removing its layer invalidates the same span");
}
#[test]
fn adding_a_source_does_not_hide_changes_to_existing_dependencies() {
use effectcraft_engine::Session;
use serde_json::json;
for (command, params) in [
("layer.settings", json!({"comp": "Outer", "layer": "Underlay", "color": "#0000ff"})),
("prop.set", json!({"comp": "Inner", "layer": "Inner solid", "path": "transform/opacity", "value": 40})),
] {
let mut s = Session::default();
let comp = ItemId(
s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap(),
);
s.execute("layer.newSolid", json!({"name": "Underlay", "color": "#ff0000"})).unwrap();
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 20})).unwrap();
s.execute("layer.newSolid", json!({"name": "Inner solid", "color": "#00ff00"})).unwrap();
s.execute("comp.open", json!({"comp": "Outer"})).unwrap();
s.execute("layer.addItem", json!({"item": "Inner"})).unwrap();
let f = Frames::default();
let before = f.content_of(&s.project, s.revision, comp);
(0..600).for_each(|n| insert(&f, key(before, n, 0)));
s.execute(command, params).unwrap();
let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
assert!(f.cached_frames(&key(after, 0, 0)).is_empty(), "{command} changes pixels outside the added layer");
}
}
/// Adding, moving or deleting a marker (Num* while a preview plays) keeps the cached frames:
/// the sound would otherwise stop at the next frame that has to be rendered again (#1083).
/// Only expressions that read markers make them part of the pixels.