From 4baeee63e6331d74a44a661c3f6352f7173d8906 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 31 Oct 2026 12:47:37 -0400 Subject: [PATCH 1/5] 3D: tests for keyed model clips starting at their activation time (#2707) --- crates/render/src/tests.rs | 186 +++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 38beb2130..4595ac2a0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1541,3 +1541,189 @@ fn an_environment_light_makes_the_copy_uncached() { q.comp_mut(cid).unwrap().layers[0].switches.video = false; assert!(key_at(&q).is_some(), "the light off, the read is cached again"); } + +// ------------------------------------------------------------------- keyed model animation clips + +/// [`TRIANGLE`] with one 1 s clip: its node slides from x = -1 to x = +1 (linear). +const SLIDING_TRIANGLE: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0]}], + "nodes": [{"mesh": 0}], + "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}], + "materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}], + "animations": [{"samplers": [{"input": 2, "output": 3, "interpolation": "LINEAR"}], "channels": [{"sampler": 0, "target": {"node": 0, "path": "translation"}}]}], + "buffers": [{"byteLength": 104, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgD8AAIC/AAAAAAAAAAAAAIA/AAAAAAAAAAA="}], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36}, + {"buffer": 0, "byteOffset": 72, "byteLength": 8}, + {"buffer": 0, "byteOffset": 80, "byteLength": 24} + ], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]}, + {"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3}, + {"bufferView": 2, "componentType": 5126, "type": "SCALAR", "count": 2, "min": [0.0], "max": [1.0]}, + {"bufferView": 3, "componentType": 5126, "type": "VEC3", "count": 2} + ] +}"#; + +struct SlidingModels; + +impl crate::FootageSource for SlidingModels { + fn frame(&self, _: ItemId, _: &effectcraft_project::Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &effectcraft_project::Footage) -> Option> { + effectcraft_model::load(&f.path, SLIDING_TRIANGLE.as_bytes(), &|_| None).ok().map(std::sync::Arc::new) + } +} + +fn clip_comp() -> Comp { + let mut c = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(6.0)); + c.renderer = effectcraft_project::Renderer::Advanced3D; + c +} + +/// The sliding triangle at 20 px a model unit. `keys` key Geometry Options ▸ Animation as +/// (layer seconds, popup value); with none, clip 1 is selected statically. +fn sliding_model(p: &mut Project, comp: &Comp, keys: &[(f64, u32)], looping: bool) -> effectcraft_project::Layer { + let footage = effectcraft_project::Footage { + path: "Sliding.gltf".into(), + kind: effectcraft_project::FootageKind::Model, + frame_rate: FrameRate::FPS_30, + codec: "glTF".into(), + ..Default::default() + }; + let mid = p.add_item("Sliding", Label::Red, None, ItemKind::Footage(footage)); + let mut l = build::layer(p, comp, "Sliding", LayerSource::Model { item: mid }, (200, 100), None); + l.props.prop_mut("geometryOptions/unitScale").unwrap().value = Value::Scalar(20.0); + l.props.prop_mut("geometryOptions/loopAnimation").unwrap().value = Value::Bool(looping); + let clip = l.props.prop_mut("geometryOptions/animation").unwrap(); + clip.value = Value::Enum(1); + clip.keys = keys.iter().map(|&(t, v)| Keyframe::new(Tick::from_seconds_f64(t), Value::Enum(v)).hold()).collect(); + l +} + +fn clip_scene(keys: &[(f64, u32)], looping: bool, stretch: f64, start: f64) -> (Project, ItemId) { + let (mut p, mut comp) = (Project::default(), clip_comp()); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let mut l = sliding_model(&mut p, &comp, keys, looping); + l.stretch = stretch; + l.start_time = Tick::from_seconds_f64(start); + comp.layers = vec![l].into(); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + (p, cid) +} + +fn clip_frame(p: &Project, cid: ItemId, t: Tick) -> crate::Image { + crate::Renderer::new(p, &SlidingModels, crate::RenderOpts::default()).comp_frame(cid, t) +} + +/// The pose of the clip `elapsed` seconds in: clip 1 selected statically, played on the layer +/// clock. +fn clip_pose(looping: bool, elapsed: f64) -> crate::Image { + let (p, cid) = clip_scene(&[], looping, 100.0, 0.0); + clip_frame(&p, cid, Tick::from_seconds_f64(elapsed)) +} + +fn assert_poses(p: &Project, cid: ItemId, looping: bool, cases: &[(f64, f64)]) { + for &(t, elapsed) in cases { + let at = clip_frame(p, cid, Tick::from_seconds_f64(t)); + assert_same(&at, &clip_pose(looping, elapsed), &format!("at {t}s the clip is {elapsed}s in")); + } +} + +#[test] +fn a_static_clip_plays_on_the_layer_clock() { + let xs: Vec = [0.0, 0.25, 0.75, 1.0].iter().map(|&e| cover_x(&clip_pose(false, e))).collect(); + assert!(xs.windows(2).all(|w| w[1] > w[0] + 5.0), "the clip slides the triangle right: {xs:?}"); + assert_same(&clip_pose(true, 1.25), &clip_pose(true, 0.25), "a looping clip wraps on the layer clock"); +} + +#[test] +fn a_clip_keyed_on_later_starts_from_its_first_frame() { + let (p, cid) = clip_scene(&[(0.0, 0), (3.0, 1)], false, 100.0, 0.0); + assert_poses(&p, cid, false, &[(3.0, 0.0), (3.25, 0.25), (3.75, 0.75), (4.0, 1.0), (5.0, 1.0)]); +} + +#[test] +fn a_looping_clip_keyed_on_later_loops_from_its_activation() { + let (p, cid) = clip_scene(&[(0.0, 0), (2.5, 1)], true, 100.0, 0.0); + assert_poses(&p, cid, true, &[(2.75, 0.25), (3.75, 0.25)]); +} + +#[test] +fn a_clip_reselected_after_none_restarts() { + let (p, cid) = clip_scene(&[(0.0, 1), (1.5, 0), (3.0, 1)], false, 100.0, 0.0); + assert_poses(&p, cid, false, &[(0.5, 0.5), (3.25, 0.25)]); +} + +#[test] +fn a_repeated_key_of_the_same_clip_does_not_restart_it() { + let (p, cid) = clip_scene(&[(0.0, 0), (2.0, 1), (3.0, 1)], false, 100.0, 0.0); + assert_poses(&p, cid, false, &[(2.5, 0.5), (3.25, 1.25)]); +} + +#[test] +fn seeking_a_keyed_clip_poses_it_the_same_in_any_order() { + let (p, cid) = clip_scene(&[(0.0, 0), (3.0, 1)], false, 100.0, 0.0); + let cache = crate::LayerCache::default(); + let mut r = crate::Renderer::new(&p, &SlidingModels, crate::RenderOpts::default()); + r.cache = Some(&cache); + for (t, elapsed) in [(4.0, 1.0), (3.25, 0.25), (3.0, 0.0), (3.75, 0.75), (3.25, 0.25), (4.0, 1.0)] { + let at = r.comp_frame(cid, Tick::from_seconds_f64(t)); + assert_same(&at, &clip_pose(false, elapsed), &format!("seek to {t}s: the clip is {elapsed}s in")); + } +} + +#[test] +fn a_stretched_layer_times_its_clip_from_activation_in_layer_time() { + let (p, cid) = clip_scene(&[(0.0, 0), (1.5, 1)], false, 200.0, 0.0); + let t = p.comp(cid).unwrap().layers[0].comp_time(Tick::from_seconds_f64(1.75)); + assert_same(&clip_frame(&p, cid, t), &clip_pose(false, 0.25), "layer time 1.75 s: the clip is 0.25 s in"); +} + +#[test] +fn a_reversed_layer_times_its_clip_from_activation_in_layer_time() { + let (p, cid) = clip_scene(&[(0.0, 0), (3.0, 1)], false, -100.0, 5.0); + let l = &p.comp(cid).unwrap().layers[0]; + let t = l.comp_time(Tick::from_seconds_f64(3.25)); + assert!(l.is_active_at(t), "the reversed layer is on at {t:?}"); + assert_same(&clip_frame(&p, cid, t), &clip_pose(false, 0.25), "layer time 3.25 s: the clip is 0.25 s in"); +} + +#[test] +fn an_expression_driven_clip_keeps_the_layer_clock() { + let (mut p, cid) = clip_scene(&[(0.0, 0), (2.5, 1)], true, 100.0, 0.0); + let clip = p.comp_mut(cid).unwrap().layers[0].props.prop_mut("geometryOptions/animation").unwrap(); + clip.expr = Some(effectcraft_project::Expression { text: "value".into(), enabled: true }); + assert_poses(&p, cid, true, &[(3.25, 3.25)]); + assert!((cover_x(&clip_pose(true, 3.25)) - cover_x(&clip_pose(true, 0.75))).abs() > 5.0, "the layer clock and the activation clock disagree"); +} + +#[test] +fn an_essential_property_override_of_the_clip_keeps_the_layer_clock() { + use effectcraft_project::essential::{self, EgControl, EgKind, EssentialGraphics}; + let mut p = Project::default(); + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; + let (mut inner, mut outer) = (clip_comp(), clip_comp()); + let model = sliding_model(&mut p, &inner, &[(0.0, 0), (3.0, 1)], false); + let prop = model.props.prop("geometryOptions/animation").unwrap().uid; + let kind = EgKind::Property { layer: model.id, prop, links: vec![] }; + inner.essential = Some(EssentialGraphics { controls: vec![EgControl { id: 1, name: "Clip".into(), kind, as_type: None }], ..Default::default() }); + inner.layers = vec![model].into(); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 100), None); + pre.switches.three_d = true; + pre.switches.collapse = true; + let mut ids = Ids(&mut p.next_id); + let control = ids.prop(&essential::match_id(1), "Clip", Value::Enum(1)); + let mut group = ids.group(essential::GROUP, "Essential Properties"); + group.kind = effectcraft_project::GroupKind::Essential { overridden: vec![control.uid] }; + group.children.push(control.into()); + pre.props.children.push(group.into()); + outer.layers = vec![pre].into(); + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + assert_poses(&p, cid, false, &[(1.0, 1.0), (3.25, 3.25)]); + assert!((cover_x(&clip_pose(false, 3.25)) - cover_x(&clip_pose(false, 0.25))).abs() > 5.0, "the layer clock and the activation clock disagree"); +} From a1ea20a52a036a62851cbd732e81b8be42cf9ae1 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 31 Oct 2026 12:47:37 -0400 Subject: [PATCH 2/5] 3D: a model clip keyed on later starts at its activation time (#2707) --- crates/render/src/three_d/adv/scene.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index d66d8db71..22507069e 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -18,7 +18,8 @@ use std::sync::Arc; use effectcraft_effects::Buf; use effectcraft_geom::{Mat4, Vec3, vec3}; use effectcraft_model::{self as model, Primitive}; -use effectcraft_project::{ItemKind, Layer, LayerSource, LightKind, PrimitiveKind}; +use effectcraft_project::{ItemKind, Layer, LayerSource, LightKind, PrimitiveKind, Property}; +use effectcraft_time::Tick; use super::shade::srgb_to_linear; use crate::three_d::camera::{CameraState, NEAR}; @@ -457,6 +458,25 @@ pub fn model_to_layer(unit_scale: f64) -> Mat4 { Mat4::scale(vec3(unit_scale, -unit_scale, -unit_scale)) } +/// Layer time the selected clip started at: the last key that changed the selection, so a clip +/// keyed on later plays from its first frame. Before any change, the layer clock; likewise under +/// an expression or an Essential Property override, as the keys don't say when those switch. +fn clip_start(ctx: &EvalCtx, layer: &Layer, prop: &Property, lt: Tick) -> Tick { + if prop.has_expression() || ctx.property_overrides.iter().any(|o| o.layer == layer.id && o.prop == prop.uid) { + return Tick::ZERO; + } + let mut keys = prop.keys.iter().filter(|k| !k.muted && k.time <= lt); + let Some(first) = keys.next() else { return Tick::ZERO }; + let (mut clip, mut start) = (first.value.as_enum(), Tick::ZERO); + for k in keys { + let v = k.value.as_enum(); + if v != clip { + (clip, start) = (v, k.time); + } + } + start +} + fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let lin = b.s.linear_io; let (opts, prim_mat) = model_options(ctx, layer, lin); @@ -472,7 +492,9 @@ fn model_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { let clip = geo.map_or(0, |g| ctx.e(layer, g, "animation")) as usize; let clip = clip.checked_sub(1).filter(|c| *c < m.animations.len()); let speed = geo.map_or(100.0, |g| ctx.f(layer, g, "animationSpeed", 100.0)) / 100.0; - let mut t = layer.layer_time(ctx.time).seconds() * speed; + let lt = layer.layer_time(ctx.time); + let start = geo.and_then(|g| g.get("animation")).map_or(Tick::ZERO, |p| clip_start(ctx, layer, p, lt)); + let mut t = (lt - start).seconds() * speed; if let Some(c) = clip { let d = m.animations[c].duration(); let looping = geo.is_none_or(|g| ctx.b(layer, g, "loopAnimation")); From 5b27de98e2c5810aa05736a11bd012a6ea6fc9df Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 31 Oct 2026 17:47:02 -0400 Subject: [PATCH 3/5] 3D: test that a copy of an overridden model clip isn't served the keyed clip's pixels (#2707) --- crates/render/src/tests.rs | 50 +++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 4595ac2a0..865a4f59f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1701,8 +1701,9 @@ fn an_expression_driven_clip_keeps_the_layer_clock() { assert!((cover_x(&clip_pose(true, 3.25)) - cover_x(&clip_pose(true, 0.75))).abs() > 5.0, "the layer clock and the activation clock disagree"); } -#[test] -fn an_essential_property_override_of_the_clip_keeps_the_layer_clock() { +/// The sliding model keyed None, then clip 1 at 3 s, in a collapsed 3D precomp; `overridden` +/// sets the clip through its Essential Property, and `copy` adds a 3D Layer Instance of it. +fn collapsed_clip_scene(overridden: bool, copy: bool) -> (Project, ItemId) { use effectcraft_project::essential::{self, EgControl, EgKind, EssentialGraphics}; let mut p = Project::default(); p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; @@ -1711,19 +1712,50 @@ fn an_essential_property_override_of_the_clip_keeps_the_layer_clock() { let prop = model.props.prop("geometryOptions/animation").unwrap().uid; let kind = EgKind::Property { layer: model.id, prop, links: vec![] }; inner.essential = Some(EssentialGraphics { controls: vec![EgControl { id: 1, name: "Clip".into(), kind, as_type: None }], ..Default::default() }); - inner.layers = vec![model].into(); + let mut layers = vec![]; + if copy { + let mut owner = solid(&mut p, &inner, [1.0, 1.0, 1.0], 200, 100); + add_effect_200(&mut p, &mut owner, "ec.3d.instance3d", &[("layer", Value::Layer(Some(model.id.0)))]); + layers.push(owner); + } + layers.push(model); + inner.layers = layers.into(); let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 100), None); pre.switches.three_d = true; pre.switches.collapse = true; - let mut ids = Ids(&mut p.next_id); - let control = ids.prop(&essential::match_id(1), "Clip", Value::Enum(1)); - let mut group = ids.group(essential::GROUP, "Essential Properties"); - group.kind = effectcraft_project::GroupKind::Essential { overridden: vec![control.uid] }; - group.children.push(control.into()); - pre.props.children.push(group.into()); + if overridden { + let mut ids = Ids(&mut p.next_id); + let control = ids.prop(&essential::match_id(1), "Clip", Value::Enum(1)); + let mut group = ids.group(essential::GROUP, "Essential Properties"); + group.kind = effectcraft_project::GroupKind::Essential { overridden: vec![control.uid] }; + group.children.push(control.into()); + pre.props.children.push(group.into()); + } outer.layers = vec![pre].into(); let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.into())); + (p, cid) +} + +#[test] +fn an_essential_property_override_of_the_clip_keeps_the_layer_clock() { + let (p, cid) = collapsed_clip_scene(true, false); assert_poses(&p, cid, false, &[(1.0, 1.0), (3.25, 3.25)]); assert!((cover_x(&clip_pose(false, 3.25)) - cover_x(&clip_pose(false, 0.25))).abs() > 5.0, "the layer clock and the activation clock disagree"); } + +/// The keyed clip and its override select the same clip at 3.25 s but pose it 0.25 s and +/// 3.25 s in: a 3D Layer Instance of one is not served the other's pixels. +#[test] +fn a_copy_of_an_overridden_clip_is_not_served_the_keyed_clips_pixels() { + let t = Tick::from_seconds_f64(3.25); + let cache = crate::LayerCache::default(); + let render = |(p, cid): &(Project, ItemId), cache: Option<&crate::LayerCache>| { + let mut r = crate::Renderer::new(p, &SlidingModels, crate::RenderOpts::default()); + r.cache = cache; + r.comp_frame(*cid, t) + }; + let (keyed, overridden) = (collapsed_clip_scene(false, true), collapsed_clip_scene(true, true)); + assert_same(&render(&keyed, Some(&cache)), &render(&keyed, None), "the keyed clip's copy"); + assert_same(&render(&overridden, Some(&cache)), &render(&overridden, None), "the overridden clip's copy"); +} From 474e73b85c726259941fcd707e52eaef1a822fa2 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 31 Oct 2026 17:47:02 -0400 Subject: [PATCH 4/5] 3D: a model copy's cache key covers the model's Essential Property overrides (#2707) --- crates/render/src/cache/dependencies.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 2019d858a..db1465c07 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -83,6 +83,9 @@ impl Dependencies<'_> { return None; } hash_debug(self.h, layer); + // An overridden clip plays on the layer clock and a keyed one from its key: the same + // selection at the same time can pose differently. + super::hash_overrides(self.h, ctx, layer.id); hash_values(self.h, ctx, layer, &layer.props); hash_debug(self.h, &ctx.world_matrix(layer)); if let Some(item) = layer.source.item() { From f12154cf4cac1979d7548c1cc2a03f5155d95307 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 31 Oct 2026 17:47:02 -0400 Subject: [PATCH 5/5] Cache: bump the renderer version so frames from the old clip clock aren't reused (#2707) --- crates/render/src/disk_cache.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs index 70fd99a85..9cf5694f0 100644 --- a/crates/render/src/disk_cache.rs +++ b/crates/render/src/disk_cache.rs @@ -841,7 +841,7 @@ fn decode_layer(p: &[u8]) -> Option { // ---------------------------------------------------------------- content keys /// Bump when rendering changes in a way that makes old disk entries wrong. -const RENDER_VERSION: &str = "effectcraft-render-4"; +const RENDER_VERSION: &str = "effectcraft-render-5"; fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) { h.write(format!("{v:?}").as_bytes()); @@ -1091,7 +1091,7 @@ mod tests { let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)); let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into())); let key = frame_key(comp_content_key(&project, id), 0, 1000, 0); - let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-3"), 0, 1000, 0); + let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-4"), 0, 1000, 0); let dir = tmpdir("previous-renderer"); { let cache = DiskCache::open(&dir, 1 << 30).unwrap();