From 47051fcb82bbd89e316c4ddf2347a59cfdea3d29 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:32:28 +0200 Subject: [PATCH 01/26] #1010: keep a parented camera's or light's Point of Interest in place Parenting a camera or light (layer.setParent, compensate on) now re-expresses its Position and Point of Interest in the new parent's space, like the other 3D layers. The 3D loop in parent_fixes skipped cameras and lights, so their positions stayed in the old space and the camera moved by the null's offset. Orientation is still left as authored; a later commit re-expresses it. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 14 +++++++++++--- crates/engine/src/tests_rig3d.rs | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d4cf1d90d..5396df6b7 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -591,7 +591,8 @@ pub(crate) struct ParentFix { rotation: f64, /// Multiply Scale. scale: [f64; 2], - /// 3D AV layers retain their world-space anchor, including Z. + /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also + /// re-express their Point of Interest. position_3d: Option, /// A uniform relative parent transform can preserve static Orientation and Scale. pose_3d: Option<([f64; 3], f64)>, @@ -626,7 +627,8 @@ impl ParentFix { } } } - if let Some(pr) = tr.get_mut("position") { + // Position and a camera's or light's Point of Interest are both points in the parent's space. + let map_point = |pr: &mut effectcraft_project::Property| { pr.value = map_position(&pr.value); for key in &mut pr.keys { key.value = map_position(&key.value); @@ -646,6 +648,12 @@ impl ParentFix { } } } + }; + if let Some(pr) = tr.get_mut("position") { + map_point(pr); + } + if let Some(pr) = tr.get_mut("poi") { + map_point(pr); } if let Some((orientation, factor)) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { @@ -793,7 +801,7 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op }) }; if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { - for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && !l.is_camera() && !l.is_light() && l.parent != parent) { + for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index d3b965107..2027f4a05 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -207,6 +207,29 @@ fn control_light_with_camera_follows_and_releases() { assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err()); } +/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of +/// Interest are re-expressed in the null's space, so the frame does not move. +#[test] +fn parented_camera_keeps_its_frame_under_an_offset_null() { + let mut s = session(); + let cam = camera(&mut s); + let (eye0, f0) = frame(&s, cam); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, cam); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}"); + // The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp. + let c = layer_in(&s, cid(&s), cam); + assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position")); + assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi")); + // Moving the null carries the camera and keeps its aim. + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap(); + let (eye2, f2) = frame(&s, cam); + assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}"); + assert!((f2 - f0).length() < 1e-6, "{f2:?}"); +} + // ---------------------------------------------------------------- from 3D model struct DiskModels; From bb2153f112ead1c1c10e8c6246d70b663d8adcdf Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:37:05 +0200 Subject: [PATCH 02/26] #1010: recover a static Orientation's Y angle without losing precision near gimbal lock uniform_parent_pose took the Y angle from asin, which loses precision within about 1e-6 degrees of +-90. The Y angle now comes from atan2 on cos(Y), as the X and Z angles already do. The gimbal branch and the principal-range output are unchanged. A new test parents a static Orientation with Y 1e-6 degrees off gimbal lock under a uniform parent. Its largest corner displacement was 3.3e-8 px before this change and 1.5e-14 px after. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 7 +++--- crates/engine/tests/parenting_3d_position.rs | 23 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 5396df6b7..c225612c3 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -751,9 +751,10 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti return Some((orientation, factor)); } let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; - let sy = (-r[2][0]).clamp(-1.0, 1.0); - let y = sy.asin(); - let (x, z) = if (1.0 - sy * sy).sqrt() > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; + // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. + let cy = r[0][0].hypot(r[1][0]); + let y = (-r[2][0]).atan2(cy); + let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) } diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs index 8c06cf8ad..6bf0c4ff1 100644 --- a/crates/engine/tests/parenting_3d_position.rs +++ b/crates/engine/tests/parenting_3d_position.rs @@ -383,6 +383,29 @@ fn parenting_static_orientation_handles_signed_uniform_scale_and_gimbal_order() assert!(max_corners < 1e-8 && max_pixels < 2e-5, "static uniform pose must use the existing Euler order including signed scale and gimbal angles"); } +/// A static Orientation just off gimbal lock (Y within 1e-6 degrees of ±90) keeps its world pose +/// under a uniform parent. Recovering Euler angles from the matrix must not lose precision there. +#[test] +fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { + let mut max_corners = 0.0f64; + for factor in [120.0, -120.0] { + for y in [90.0 - 1e-6, -90.0 + 1e-6] { + let (mut s, p, c) = fixture(true, true); + set(&mut s, p, "transform/orientation", json!([0.0, 0.0, 0.0])); + set(&mut s, p, "transform/rotationX", json!(0.0)); + set(&mut s, p, "transform/rotationY", json!(0.0)); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + set(&mut s, c, "transform/orientation", json!([23.0, y, -37.0])); + set(&mut s, c, "transform/scale", json!([200.0, 160.0, 150.0])); + let before = world(&s, c, Tick::ZERO); + parent(&mut s, c, Some(p), true); + max_corners = max_corners.max(displacement(before, world(&s, c, Tick::ZERO))); + } + } + assert!(max_corners < 1e-9, "a static Orientation near gimbal lock must keep its world pose: corners {max_corners}"); +} + #[test] fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { From 114c4797639eaf657e879dada91738b8cd72e76b Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:40:39 +0200 Subject: [PATCH 03/26] #1010: keep a rotated null's effect on a camera's or light's static Orientation Parenting a camera or light to a rotated uniform null now re-expresses its static Orientation, so a one-node light or camera keeps its direction. A two-node camera or light also keeps its roll: its Orientation is re-expressed against its new aim (aim_correction), so Up does not drift. Keyed Orientation is still left as authored; a later commit re-expresses it. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 116 +++++++++++++++++++++------- crates/engine/src/tests_rig3d.rs | 54 ++++++++++++- 2 files changed, 142 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index c225612c3..0e76b9d0c 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -594,8 +594,17 @@ pub(crate) struct ParentFix { /// 3D AV layers retain their world-space anchor, including Z. Cameras and lights also /// re-express their Point of Interest. position_3d: Option, - /// A uniform relative parent transform can preserve static Orientation and Scale. - pose_3d: Option<([f64; 3], f64)>, + /// A uniform relative parent transform re-expresses static Orientation and Scale. + pose_3d: Option, +} + +/// A uniform relative parent transform, as it applies to a 3D layer's Orientation and Scale. +#[derive(Clone, Copy)] +struct Pose3d { + /// Rotation that Orientation is re-expressed under: `Orientation' = correction * Orientation`. + correction: effectcraft_geom::Mat4, + /// Uniform scale factor, multiplied into Scale (negative for a mirrored parent). + factor: f64, } impl ParentFix { @@ -605,7 +614,7 @@ impl ParentFix { if let Some(m) = self.position_3d { // Spatial Position speed measures distance along its path. Its parent-space // unit changes even when the child's Orientation cannot be re-expressed. - let position_speed = uniform_parent_pose(m, [0.0; 3]).map(|(_, factor)| factor.abs()); + let position_speed = uniform_rotation(m).map(|(_, factor)| factor.abs()); let map_position = |v: &KV| { let q = m.apply(effectcraft_geom::Vec3::from(v.as_vec3())); KV::Vec3([q.x, q.y, q.z]) @@ -655,11 +664,12 @@ impl ParentFix { if let Some(pr) = tr.get_mut("poi") { map_point(pr); } - if let Some((orientation, factor)) = self.pose_3d { + if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(orientation); + pr.value = KV::Vec3(reexpress_static(pose.correction, pr.value.as_vec3())); } if let Some(pr) = tr.get_mut("scale") { + let factor = pose.factor; let scaled = |value: &KV| KV::Vec3(value.as_vec3().map(|v| v * factor)); pr.value = scaled(&pr.value); for key in &mut pr.keys { @@ -722,13 +732,10 @@ impl ParentFix { } } -/// A uniform factor commutes with authored axis Rotation and Scale; prepend its orthogonal -/// rotation to static Orientation using the existing Rz * Ry * Rx order. -fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Option<([f64; 3], f64)> { - use effectcraft_geom::{Mat4, Vec3, vec3}; - if !orientation.iter().all(|v| v.is_finite()) { - return None; - } +/// The rotation and signed scale factor of a uniform parent transform's linear part (negative for a +/// mirrored parent). `None` for a non-uniform or degenerate transform. +fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4, f64)> { + use effectcraft_geom::{Mat4, vec3}; let a = m.0; let columns = [vec3(a[0][0], a[1][0], a[2][0]), vec3(a[0][1], a[1][1], a[2][1]), vec3(a[0][2], a[1][2], a[2][2])]; let magnitude = columns[0].x.hypot(columns[0].y).hypot(columns[0].z); @@ -747,15 +754,47 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti rotation.0[row][col] = a[row][col] / factor; } } - if (0..3).all(|row| (0..3).all(|col| (rotation.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return Some((orientation, factor)); - } - let r = (rotation * Mat4::orientation(Vec3::from(orientation))).0; + Some((rotation, factor)) +} + +/// Euler Orientation (degrees, Rz * Ry * Rx) of `correction * Orientation(orientation)`. +fn euler_of(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { + use effectcraft_geom::{Mat4, Vec3}; + let r = (correction * Mat4::orientation(Vec3::from(orientation))).0; // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); let (x, z) = if cy > 1e-9 { (r[2][1].atan2(r[2][2]), r[1][0].atan2(r[0][0])) } else { ((-r[1][2]).atan2(r[1][1]), 0.0) }; - Some(([x.to_degrees(), y.to_degrees(), z.to_degrees()], factor)) + [x.to_degrees(), y.to_degrees(), z.to_degrees()] +} + +/// Static Orientation re-expressed as `correction * Orientation`. An identity correction keeps the +/// authored values unchanged. +fn reexpress_static(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { + if (0..4).all(|row| (0..4).all(|col| (correction.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { + return orientation; + } + euler_of(correction, orientation) +} + +/// Correction for a two-node rig's Orientation: after a uniform parent change its aim is +/// `rotation * aim`, so the roll is re-expressed against that aim. `None` for a degenerate aim. +fn aim_correction(rotation: effectcraft_geom::Mat4, aim: [f64; 3]) -> Option { + use effectcraft_geom::vec3; + use effectcraft_render::three_d::camera::basis_rotation; + let [x, y, z] = aim; + let length = (x * x + y * y + z * z).sqrt(); + if !length.is_finite() || length <= 1e-9 { + return None; + } + let dir = vec3(x / length, y / length, z / length); + let turned = rotation.apply_vec(dir); + // A view along the up axis has no roll to keep, and its basis is undefined. + if 1.0 - dir.y * dir.y < 1e-12 || 1.0 - turned.y * turned.y < 1e-12 { + return None; + } + let up = vec3(0.0, 1.0, 0.0); + Some(basis_rotation(turned, up).transpose() * rotation * basis_rotation(dir, up)) } /// A parent change retains 2D transforms and the world-space anchor of 3D AV layers. Position is @@ -807,23 +846,46 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { let orientation = tr.get("orientation")?; - let scale = tr.get("scale")?; + // Cameras and lights have no Scale; their Orientation is re-expressed alone. + let scale = tr.get("scale"); // Animated Euler Orientation and auto-orient cannot be re-expressed by // prepending a constant Euler value. Expression-driven values stay authored. + // Auto-orient replaces Orientation, except a two-node rig, whose roll is + // re-expressed against its new aim. + let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); if !orientation.keys.is_empty() || orientation.expr.is_some() - || scale.expr.is_some() - || layer.auto_orient != effectcraft_project::AutoOrient::Off + || scale.is_some_and(|scale| scale.expr.is_some()) + || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { return None; } - let pose = uniform_parent_pose(m, orientation.value.as_vec3())?; - let factor = pose.1; - let values_finite = std::iter::once(&scale.value) - .chain(scale.keys.iter().map(|key| &key.value)) - .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); - let speeds_finite = scale.keys.iter().flat_map(|key| key.in_ease.iter().chain(&key.out_ease)).all(|ease| (ease.speed * factor).is_finite()); - (values_finite && speeds_finite).then_some(pose) + let (rotation, factor) = uniform_rotation(m)?; + let correction = if two_node { + // A mirrored parent flips the aim, which this correction does not model. + if factor <= 0.0 { + return None; + } + let (w, h) = (comp.width as f64, comp.height as f64); + let pos = ectx.v3(layer, tr, "position", [w / 2.0, h / 2.0, -1000.0]); + let poi = ectx.v3(layer, tr, "poi", [w / 2.0, h / 2.0, 0.0]); + aim_correction(rotation, [poi[0] - pos[0], poi[1] - pos[1], poi[2] - pos[2]])? + } else { + rotation + }; + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation.value.as_vec3().iter().all(|v| v.is_finite()) { + return None; + } + if let Some(scale) = scale { + let values_finite = std::iter::once(&scale.value) + .chain(scale.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| (v * factor).is_finite())); + let speeds_finite = scale.keys.iter().all(|key| key.in_ease.iter().chain(&key.out_ease).all(|ease| (ease.speed * factor).is_finite())); + if !values_finite || !speeds_finite { + return None; + } + } + Some(Pose3d { correction, factor }) }); fixes.push(ParentFix { layer: layer.id, m: effectcraft_geom::Mat3::IDENTITY, rotation: 0.0, scale: [1.0; 2], position_3d: Some(m), pose_3d }); } diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 2027f4a05..6a13d8679 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use effectcraft_geom::{Vec3, vec3}; use effectcraft_keyframe::Value as KV; use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project}; -use effectcraft_render::three_d::camera::layer_frame; +use effectcraft_render::three_d::camera::{is_two_node, layer_frame}; use effectcraft_render::{EvalCtx, FootageSource, RenderOpts}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -230,6 +230,58 @@ fn parented_camera_keeps_its_frame_under_an_offset_null() { assert!((f2 - f0).length() < 1e-6, "{f2:?}"); } +/// World (eye, forward, down) of a layer in the active comp at time `t`. +fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { + let c = cid(s); + let comp = s.project.comp(c).unwrap(); + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None }; + layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) +} + +/// Whether two frames agree on eye, forward and down. +fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool { + (a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6 +} + +/// A 3D null off-centre in the comp, with an Orientation and a Z rotation. +fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 { + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap(); + nul +} + +/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is +/// re-expressed against the new aim, so Up does not drift. +#[test] +fn two_node_camera_under_rotated_null_keeps_its_roll() { + let mut s = session(); + let cam = camera(&mut s); + s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap(); + assert!(is_two_node(&layer_in(&s, cid(&s), cam))); + let before = frame_at(&s, cam, Tick::ZERO); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap(); + let after = frame_at(&s, cam, Tick::ZERO); + assert!(frames_match(before, after), "{before:?} {after:?}"); +} + +#[test] +fn parented_point_light_keeps_its_direction_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let (eye0, f0) = frame(&s, light); + let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let (eye1, f1) = frame(&s, light); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); +} + // ---------------------------------------------------------------- from 3D model struct DiskModels; From 707161bc631d68db5da6181c6e78b8ce4250dae7 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 18:43:16 +0200 Subject: [PATCH 04/26] #1010: re-express keyed Orientation per key under a rotated uniform parent Parenting a 3D layer under a rotated uniform parent now re-expresses keyed Orientation at every key, not only the static value. A Z-only correction shifts Z exactly, so the paths between keys stay exact. Other corrections keep each key's authored turns, so the Euler values stay continuous. This changes keyed 3D layers under any rotated uniform parent, not only cameras and lights. The boundary test now sets the non-uniform parent before the animated and static cases, so both keep their authored pose. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/layer.rs | 83 +++++++++++++++----- crates/engine/src/tests_rig3d.rs | 18 +++++ crates/engine/tests/parenting_3d_position.rs | 25 +++++- 3 files changed, 106 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 0e76b9d0c..8222ad34e 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -666,7 +666,7 @@ impl ParentFix { } if let Some(pose) = self.pose_3d { if let Some(pr) = tr.get_mut("orientation") { - pr.value = KV::Vec3(reexpress_static(pose.correction, pr.value.as_vec3())); + reexpress_orientation(pose.correction, pr); } if let Some(pr) = tr.get_mut("scale") { let factor = pose.factor; @@ -757,10 +757,15 @@ fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4 Some((rotation, factor)) } -/// Euler Orientation (degrees, Rz * Ry * Rx) of `correction * Orientation(orientation)`. -fn euler_of(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { - use effectcraft_geom::{Mat4, Vec3}; - let r = (correction * Mat4::orientation(Vec3::from(orientation))).0; +/// The turn (degrees) of a rotation about Z alone, or `None` for any other rotation. +fn z_only_turn(correction: effectcraft_geom::Mat4) -> Option { + let m = correction.0; + let about_z = [m[0][2], m[1][2], m[2][0], m[2][1]].iter().all(|v| v.abs() < 1e-9) && (m[2][2] - 1.0).abs() < 1e-9; + about_z.then(|| m[1][0].atan2(m[0][0]).to_degrees()) +} + +/// ZYX Euler angles in degrees of a row-major rotation (`Rz * Ry * Rx`), safe at gimbal lock. +fn euler_zyx(r: [[f64; 4]; 4]) -> [f64; 3] { // atan2 rather than asin: near ±90 degrees asin loses precision, while cos(y) stays exact. let cy = r[0][0].hypot(r[1][0]); let y = (-r[2][0]).atan2(cy); @@ -768,13 +773,55 @@ fn euler_of(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; [x.to_degrees(), y.to_degrees(), z.to_degrees()] } -/// Static Orientation re-expressed as `correction * Orientation`. An identity correction keeps the -/// authored values unchanged. -fn reexpress_static(correction: effectcraft_geom::Mat4, orientation: [f64; 3]) -> [f64; 3] { - if (0..4).all(|row| (0..4).all(|col| (correction.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { - return orientation; +/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the +/// `(x + 180, 180 - y, z + 180)` form when that is nearer. +fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let near = |v: [f64; 3]| -> [f64; 3] { std::array::from_fn(|i| v[i] + ((reference[i] - v[i]) / 360.0).round() * 360.0) }; + let natural = near(euler); + let flipped = near([euler[0] + 180.0, 180.0 - euler[1], euler[2] + 180.0]); + let distance = |v: [f64; 3]| v.iter().zip(reference.iter()).map(|(a, b)| (a - b).powi(2)).sum::(); + if distance(flipped) < distance(natural) { flipped } else { natural } +} + +/// The Euler Orientation of `correction * Orientation(o)`, in the form nearest `reference`. +fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [f64; 3]) -> [f64; 3] { + let world = correction * effectcraft_geom::Mat4::orientation(effectcraft_geom::Vec3::from(o)); + nearest_euler(euler_zyx(world.0), reference) +} + +/// Re-expresses Orientation (the value and every key) as `correction * Orientation`. A Z-only +/// correction shifts Z exactly, so the keys and the paths between them stay exact. Other +/// corrections keep each key's authored turns, so the Euler values stay continuous. +fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { + if let Some(turn) = z_only_turn(correction) { + // No turn (an identity correction) leaves every authored value as it is. + if turn.abs() <= 1e-12 { + return; + } + let shift = |value: &KV| { + let [x, y, z] = value.as_vec3(); + KV::Vec3([x, y, z + turn]) + }; + pr.value = shift(&pr.value); + for key in &mut pr.keys { + key.value = shift(&key.value); + } + return; + } + let value = pr.value.as_vec3(); + pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &mut pr.keys { + let o = key.value.as_vec3(); + // Each key keeps the authored change from the previous key on top of its converted value. + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + key.value = KV::Vec3(n); + prev = Some((o, n)); } - euler_of(correction, orientation) } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -848,13 +895,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op let orientation = tr.get("orientation")?; // Cameras and lights have no Scale; their Orientation is re-expressed alone. let scale = tr.get("scale"); - // Animated Euler Orientation and auto-orient cannot be re-expressed by - // prepending a constant Euler value. Expression-driven values stay authored. - // Auto-orient replaces Orientation, except a two-node rig, whose roll is - // re-expressed against its new aim. + // Expression-driven values stay authored. Auto-orient replaces Orientation, except + // a two-node rig, whose roll is re-expressed against its new aim. let two_node = (layer.is_camera() || layer.is_light()) && effectcraft_render::three_d::camera::is_two_node(layer); - if !orientation.keys.is_empty() - || orientation.expr.is_some() + if orientation.expr.is_some() || scale.is_some_and(|scale| scale.expr.is_some()) || (layer.auto_orient != effectcraft_project::AutoOrient::Off && !two_node) { @@ -873,7 +917,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op } else { rotation }; - if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation.value.as_vec3().iter().all(|v| v.is_finite()) { + let orientation_finite = std::iter::once(&orientation.value) + .chain(orientation.keys.iter().map(|key| &key.value)) + .all(|value| value.as_vec3().iter().all(|v| v.is_finite())); + if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } if let Some(scale) = scale { diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 6a13d8679..87bb98699 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -282,6 +282,24 @@ fn parented_point_light_keeps_its_direction_under_a_rotated_null() { assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); } +/// Keyed Orientation is re-expressed at each key under a rotated null, so the light keeps its +/// direction at every key. +#[test] +fn keyed_orientation_is_re_expressed_at_each_key_under_a_rotated_null() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let keys = [Tick::ZERO, Tick::from_seconds_f64(1.0)]; + let before = keys.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in keys.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "keyed Orientation keeps the light's direction at each key"); + } +} + // ---------------------------------------------------------------- from 3D model struct DiskModels; diff --git a/crates/engine/tests/parenting_3d_position.rs b/crates/engine/tests/parenting_3d_position.rs index 6bf0c4ff1..3737d16e2 100644 --- a/crates/engine/tests/parenting_3d_position.rs +++ b/crates/engine/tests/parenting_3d_position.rs @@ -410,12 +410,12 @@ fn parenting_static_orientation_is_exact_just_off_gimbal_lock() { fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_boundary() { for animated in [true, false] { let (mut s, p, c) = fixture(true, true); + // A non-uniform parent has no uniform pose to re-express, animated or not. + set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); if animated { for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); } - } else { - set(&mut s, p, "transform/scale", json!([120.0, 100.0, 80.0])); } let layer = s.active_comp().unwrap().layer(LayerId(c)).unwrap(); let orientation = layer.props.prop("transform/orientation").unwrap().clone(); @@ -430,6 +430,27 @@ fn parenting_animated_orientation_and_nonuniform_parent_keep_authored_pose_bound } } +/// Keyed Orientation under a uniform parent is re-expressed at every key, so the child's world pose +/// and pixels stay put at every time (the parent is Z-only, so the paths between keys stay exact too). +#[test] +fn parenting_uniform_parent_re_expresses_keyed_orientation() { + for factor in [120.0, -120.0] { + let (mut s, p, c) = fixture(true, false); + set(&mut s, p, "transform/rotation", json!(42.0)); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); + } + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| world(&s, c, t)); + let images = times.map(|t| cpu(&s, t)); + parent(&mut s, c, Some(p), true); + let error = times.into_iter().zip(before).map(|(t, b)| displacement(b, world(&s, c, t))).fold(0.0, f64::max); + let pixels = times.into_iter().zip(&images).map(|(t, i)| diff(i, &cpu(&s, t))).fold(0.0, f32::max); + assert!(error < 1e-8 && pixels < 2e-5, "keyed Orientation under a uniform parent (factor {factor}): corners {error}, pixels {pixels}"); + } +} + /// Position's temporal speed is measured along its spatial path, in parent-space units. #[test] fn parenting_uniform_parent_preserves_eased_position_at_quarter_times_and_undo() { From 08d024e8866f0b1751fb323772511d1daf1d0006 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:00:52 +0200 Subject: [PATCH 05/26] #1063: find a Levels alpha control on the effect by its name Pro Levels2 keeps its Alpha controls one group down, under Alpha, and a script's effect.property("Alpha Input White") only searched the effect's direct children, so it returned null. The lookup now searches the groups nested below the direct children, up to three levels, after the direct match fails. Direct children keep their priority. A regression test checks the name on the effect and the same control through the Alpha group. Co-Authored-By: Claude Haiku 5.5 --- crates/script/src/model.rs | 49 +++++++++++++++++++++++++------------- crates/script/src/tests.rs | 18 ++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4445fbbf2..dfdf458c9 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -402,6 +402,38 @@ fn alias(k: &str) -> Option<&'static str> { }) } +/// How many groups below a property group a string key may search (#1063: Pro Levels2's Alpha +/// controls sit one group down, under Alpha). +const NESTED_CHILD_DEPTH: u8 = 3; + +/// The child of `g` that a string key names. The direct children are searched first, then the +/// groups nested below them, up to `depth` levels. +fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option<&'a Node> { + let chain_of = |n: &Node| -> String { + let mut ch = if g.uid == l.props.uid { vec![] } else { chain(l, g.uid).unwrap_or_default() }; + ch.push(n); + matchnames::node(&ch) + }; + let lk = loose(k); + g.children + .iter() + .find(|n| chain_of(n) == k) + .or_else(|| g.children.iter().find(|n| n.name() == k)) + .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) + .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) + .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) + .or_else(|| { + if depth == 0 { + return None; + } + g.children.iter().find_map(|n| match n { + Node::Group(sub) => find_child(l, sub, k, depth - 1), + Node::Prop(_) => None, + }) + }) +} + fn child(s: &Session, a: &J) -> R { let (_, c, l) = layer_of(s, a)?; let uid = u(a, "uid").unwrap_or(0); @@ -409,22 +441,7 @@ fn child(s: &Session, a: &J) -> R { let key = a.get("key").cloned().unwrap_or(J::Null); let found = match &key { J::Number(n) => n.as_u64().and_then(|i| g.children.get((i as usize).checked_sub(1)?)), - J::String(k) => { - let chain_of = |n: &Node| -> String { - let mut ch = if uid == 0 || uid == l.props.uid { vec![] } else { chain(l, uid).unwrap_or_default() }; - ch.push(n); - matchnames::node(&ch) - }; - let lk = loose(k); - g.children - .iter() - .find(|n| chain_of(n) == *k) - .or_else(|| g.children.iter().find(|n| n.name() == k)) - .or_else(|| g.children.iter().find(|n| n.match_id() == k)) - .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) - .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) - .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) - } + J::String(k) => find_child(l, g, k, NESTED_CHILD_DEPTH), _ => None, }; match found { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 303bd820a..27812b825 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1010,3 +1010,21 @@ fn scripted_text_range_selector_resolves_and_is_the_only_selector() { ); assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"])); } + +/// Pro Levels2's Alpha controls sit one group down (under Alpha), so a script finds them by their +/// name on the effect as well as through the group (#1063). +#[test] +fn scripted_levels_alpha_controls_resolve_from_the_effect() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Levels", 320, 180, 1, 3, 30); + var l = c.layers.addSolid([1, 0, 0], "S", 320, 180, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Pro Levels2"); + fx.property("Alpha Input White").setValue(0.5); + [fx.property("Alpha").property("Alpha Input White").value, fx.property("Alpha Input White").value] + "#, + ); + assert_eq!(o.result, json!([0.5, 0.5])); +} From 55ce6017107557ab0c8cc2a26e070a70e9636648 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 20:48:16 +0200 Subject: [PATCH 06/26] #1063: return the text animator property a script asked for addProperty("ADBE Text Tracking Amount") returned the first property added to the animator, which is the Tracking Type companion, so the script changed the wrong value. The engine now reports the requested property's position in the Properties group, and the script returns that property. An animator's Properties group also finds its properties by their After Effects names, such as ADBE Text Tracking Amount, which the name table already had in the other direction. A regression test checks the property returned, its value, and the lookup by name. Not covered: the Advanced and Wiggly selector children and the Expression amount, which still keep their own names. Co-Authored-By: Claude Haiku 5.5 --- crates/engine/src/commands/text_anim.rs | 6 +++++- crates/script/src/model.rs | 28 ++++++++++++++++++------- crates/script/src/prelude.js | 5 ++--- crates/script/src/tests.rs | 20 ++++++++++++++++++ 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 228d48424..fff32c2ff 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -139,6 +139,7 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { } let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or_else(|| bad(C, "no layer"))?; let auid = animator_uid(s, &layer, p, C)?; + let mut index = 0; s.edit("Add Text Animator Property", None, |proj, _| { let mut next = proj.next_id; let mut ids = Ids(&mut next); @@ -148,10 +149,13 @@ fn add_animator_prop(s: &mut Session, p: &Value) -> Result { for k in &kinds { add_props(&mut ids, anim, k, three); } + // The requested property's 1-based position in the Properties group. A companion (such as + // Tracking Type) is added before it, so the first new child is not always the one asked for. + index = kinds.first().and_then(|k| anim.sub("properties")?.children.iter().position(|n| n.match_id() == k.as_str())).map_or(0, |i| i + 1); proj.next_id = next; Ok(()) })?; - Ok(json!({"animator": auid})) + Ok(json!({"animator": auid, "index": index})) } pub(crate) fn add_selector(s: &mut Session, p: &Value) -> Result { diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index dfdf458c9..9df540b86 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -415,11 +415,14 @@ fn find_child<'a>(l: &'a Layer, g: &'a PropGroup, k: &str, depth: u8) -> Option< matchnames::node(&ch) }; let lk = loose(k); + // An animator's Properties group also answers to the After Effects names of its properties. + let animator_props = g.match_id == "properties"; g.children .iter() .find(|n| chain_of(n) == k) .or_else(|| g.children.iter().find(|n| n.name() == k)) .or_else(|| g.children.iter().find(|n| n.match_id() == k)) + .or_else(|| g.children.iter().find(|n| animator_props && animator_property_match_name(n.match_id()) == Some(k))) .or_else(|| g.children.iter().find(|n| n.name().eq_ignore_ascii_case(k))) .or_else(|| g.children.iter().find(|n| loose(n.name()) == lk || loose(n.match_id()) == lk)) .or_else(|| alias(&lk).and_then(|m| g.children.iter().find(|n| n.match_id() == m))) @@ -671,10 +674,9 @@ fn resolve_add(s: &Session, a: &J) -> R { }) } -/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match -/// name or a display name. -fn text_animator_property(name: &str) -> Option<&'static str> { - const AE: &[(&str, &str)] = &[ +/// The After Effects match name and key of each text animator property. +fn animator_property_table() -> &'static [(&'static str, &'static str)] { + &[ ("ADBE Text Anchor Point 3D", "anchor"), ("ADBE Text Position 3D", "position"), ("ADBE Text Scale 3D", "scale"), @@ -698,12 +700,24 @@ fn text_animator_property(name: &str) -> Option<&'static str> { ("ADBE Text Character Offset", "characterOffset"), ("ADBE Text Character Replace", "characterValue"), ("ADBE Text Blur", "blur"), - ]; - if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { + ] +} + +/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match +/// name or a display name. +fn text_animator_property(name: &str) -> Option<&'static str> { + let table = animator_property_table(); + if let Some((_, k)) = table.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) { return Some(k); } let n = loose(name); - AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) + table.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking")) +} + +/// The After Effects match name of a text animator property key (`tracking` is +/// `ADBE Text Tracking Amount`), for scripts that look a property up by that name (#1063). +fn animator_property_match_name(key: &str) -> Option<&'static str> { + animator_property_table().iter().find(|(_, k)| *k == key).map(|(m, _)| *m) } /// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 9daac6ab7..dc159b878 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -718,9 +718,8 @@ PropertyGroup.prototype.addProperty = function (name) { } else if (r.kind === "animator") { uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator; } else if (r.kind === "animatorProperty") { - var before = this.numProperties; - __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); - return this.property(before + 1); + var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property }); + return this.property(added.index); } else if (r.kind === "selector") { var n0 = this.numProperties; __call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector }); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 27812b825..7170652bf 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1028,3 +1028,23 @@ fn scripted_levels_alpha_controls_resolve_from_the_effect() { ); assert_eq!(o.result, json!([0.5, 0.5])); } + +/// A text animator property added by its After Effects name is the one asked for, not its Tracking +/// Type companion, and it can be found again by that name (#1063). +#[test] +fn scripted_text_animator_property_is_the_one_asked_for() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator"); + var props = a.property("ADBE Text Animator Properties"); + var added = props.addProperty("ADBE Text Tracking Amount"); + added.setValue(20); + [added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name] + "#, + ); + assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount"])); +} From 95f2f3ec5528fd2d51716cd48fd043480c4a245f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:52:08 +0200 Subject: [PATCH 07/26] #1010: leave keyed Orientation authored under a tilted parent Euler angles are blended between keys, so re-expressing only the key values cannot keep the motion between them under a tilted parent. Keyed Orientation is now re-expressed only for a Z-only correction, which shifts Z exactly at every time; static Orientation is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/commands/layer.rs | 23 ++++++++------------- crates/engine/src/tests_rig3d.rs | 32 +++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 8222ad34e..7208c1ff1 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -789,9 +789,10 @@ fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [ nearest_euler(euler_zyx(world.0), reference) } -/// Re-expresses Orientation (the value and every key) as `correction * Orientation`. A Z-only -/// correction shifts Z exactly, so the keys and the paths between them stay exact. Other -/// corrections keep each key's authored turns, so the Euler values stay continuous. +/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the +/// value and every key, so the paths between keys stay exact too. Any other correction is applied +/// only to a static value (`parent_fixes` leaves keyed Orientation alone then): Euler angles are +/// blended between keys, which no re-expression of the keys can reproduce. fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { if let Some(turn) = z_only_turn(correction) { // No turn (an identity correction) leaves every authored value as it is. @@ -810,18 +811,6 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf } let value = pr.value.as_vec3(); pr.value = KV::Vec3(reexpress_euler(correction, value, value)); - let mut prev: Option<([f64; 3], [f64; 3])> = None; - for key in &mut pr.keys { - let o = key.value.as_vec3(); - // Each key keeps the authored change from the previous key on top of its converted value. - let reference = match prev { - Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], - None => o, - }; - let n = reexpress_euler(correction, o, reference); - key.value = KV::Vec3(n); - prev = Some((o, n)); - } } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -923,6 +912,10 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } + // Keys can only follow a Z-only correction exactly; otherwise they stay authored. + if !orientation.keys.is_empty() && z_only_turn(correction).is_none() { + return None; + } if let Some(scale) = scale { let values_finite = std::iter::once(&scale.value) .chain(scale.keys.iter().map(|key| &key.value)) diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index 87bb98699..ecae51cb1 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -282,22 +282,38 @@ fn parented_point_light_keeps_its_direction_under_a_rotated_null() { assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}"); } -/// Keyed Orientation is re-expressed at each key under a rotated null, so the light keeps its -/// direction at every key. +/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at +/// every time, between the keys too. #[test] -fn keyed_orientation_is_re_expressed_at_each_key_under_a_rotated_null() { +fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { let mut s = session(); let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); } - let keys = [Tick::ZERO, Tick::from_seconds_f64(1.0)]; - let before = keys.map(|t| frame_at(&s, light, t)); + let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction"); + } +} + +/// Under a tilted null the keys cannot follow exactly (Euler angles are blended between them), so +/// keyed Orientation stays as authored rather than changing the motion between the keys. +#[test] +fn keyed_orientation_under_a_tilted_null_stays_authored() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let keys_before = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); - for (t, b) in keys.into_iter().zip(before) { - assert!(frames_match(b, frame_at(&s, light, t)), "keyed Orientation keeps the light's direction at each key"); - } + let keys_after = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); + assert_eq!(keys_before, keys_after); } // ---------------------------------------------------------------- from 3D model From e78f115d71ca5ab4ec9fc4e073836cb3cb445ccc Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Tue, 13 Oct 2026 02:57:25 +0200 Subject: [PATCH 08/26] #1010: add keys so keyed Orientation follows a tilted parent between the keys Under a tilted parent, the converted keys alone give a different path between them, since Euler angles are blended. Keys are now added wherever the blend drifts more than 0.05 degrees from the exact rotation (halving each segment, at most 10 times and 4096 keys). The segments become straight blends with the original easing baked in; hold segments are kept. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/commands/layer.rs | 95 ++++++++++++++++++++++++++--- crates/engine/src/tests_rig3d.rs | 31 ++++++---- 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 7208c1ff1..c112a4912 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -789,10 +789,48 @@ fn reexpress_euler(correction: effectcraft_geom::Mat4, o: [f64; 3], reference: [ nearest_euler(euler_zyx(world.0), reference) } +/// Largest angle (degrees) a baked Orientation may drift from the exact rotation between its keys. +const BAKE_TOLERANCE_DEGREES: f64 = 0.05; +/// How many times a segment may be halved while baking, and the most keys a property may end up with. +const BAKE_MAX_DEPTH: u32 = 10; +const BAKE_MAX_KEYS: usize = 4096; + +/// The angle (degrees) between two rotations. +fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 { + let trace: f64 = (0..3).flat_map(|i| (0..3).map(move |j| (i, j))).map(|(i, j)| a.0[i][j] * b.0[i][j]).sum(); + ((trace - 1.0) / 2.0).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend +/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. +fn bake_between( + correction: effectcraft_geom::Mat4, + authored: &effectcraft_project::Property, + (ta, ea): (Tick, [f64; 3]), + (tb, eb): (Tick, [f64; 3]), + depth: u32, + out: &mut Vec, +) { + use effectcraft_geom::{Mat4, Vec3}; + if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= BAKE_MAX_KEYS { + return; + } + let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); + let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; + let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); + if rotation_angle(Mat4::orientation(Vec3::from(blend)), exact) <= BAKE_TOLERANCE_DEGREES { + return; + } + let em = nearest_euler(euler_zyx(exact.0), blend); + bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, out); + out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em))); + bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, out); +} + /// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the -/// value and every key, so the paths between keys stay exact too. Any other correction is applied -/// only to a static value (`parent_fixes` leaves keyed Orientation alone then): Euler angles are -/// blended between keys, which no re-expression of the keys can reproduce. +/// value and every key. For any other correction a static value is converted directly; keys are +/// converted, and keys are added between them wherever the Euler blend between two keys would drift +/// from the exact rotation (the segments become linear, with the original easing baked in). fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraft_project::Property) { if let Some(turn) = z_only_turn(correction) { // No turn (an identity correction) leaves every authored value as it is. @@ -811,6 +849,53 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf } let value = pr.value.as_vec3(); pr.value = KV::Vec3(reexpress_euler(correction, value, value)); + if pr.keys.is_empty() { + return; + } + let authored = pr.clone(); + // Each key keeps the authored change from the previous key on top of its converted value. + let mut converted: Vec<[f64; 3]> = vec![]; + let mut prev: Option<([f64; 3], [f64; 3])> = None; + for key in &authored.keys { + let o = key.value.as_vec3(); + let reference = match prev { + Some((before, after)) => [after[0] + o[0] - before[0], after[1] + o[1] - before[1], after[2] + o[2] - before[2]], + None => o, + }; + let n = reexpress_euler(correction, o, reference); + converted.push(n); + prev = Some((o, n)); + } + use effectcraft_keyframe::Interp; + let mut keys: Vec = vec![]; + let mut after_baked_segment = false; + for (i, key) in authored.keys.iter().enumerate() { + let mut k = key.clone(); + k.value = KV::Vec3(converted[i]); + if after_baked_segment { + k.in_interp = Interp::Linear; + k.in_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + } + after_baked_segment = false; + let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold); + let mut between = vec![]; + if let Some(next) = segment { + bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, &mut between); + // An eased segment, or one that gained keys, becomes a run of straight blends. + if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { + k.out_interp = Interp::Linear; + k.out_ease.clear(); + k.auto_bezier = false; + k.continuous = false; + after_baked_segment = true; + } + } + keys.push(k); + keys.extend(between); + } + pr.keys = keys; } /// Correction for a two-node rig's Orientation: after a uniform parent change its aim is @@ -912,10 +997,6 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if !correction.0.iter().flatten().all(|v| v.is_finite()) || !orientation_finite { return None; } - // Keys can only follow a Z-only correction exactly; otherwise they stay authored. - if !orientation.keys.is_empty() && z_only_turn(correction).is_none() { - return None; - } if let Some(scale) = scale { let values_finite = std::iter::once(&scale.value) .chain(scale.keys.iter().map(|key| &key.value)) diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index ecae51cb1..d867337ac 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -300,20 +300,27 @@ fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { } } -/// Under a tilted null the keys cannot follow exactly (Euler angles are blended between them), so -/// keyed Orientation stays as authored rather than changing the motion between the keys. +/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are +/// added between them: the light keeps its direction within a small angle at every time. #[test] -fn keyed_orientation_under_a_tilted_null_stays_authored() { - let mut s = session(); - let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); - for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] { - s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); +fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { + { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64); + let before = times.map(|t| frame_at(&s, light, t)); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + // 0.05 degrees is about 9e-4 of a radian on a unit vector. + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } + assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two"); } - let keys_before = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); - let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); - s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); - let keys_after = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.iter().map(|k| k.value.as_vec3()).collect::>(); - assert_eq!(keys_before, keys_after); } // ---------------------------------------------------------------- from 3D model From 912f828aa3e66cbc3ec3ad41996bb3dec800ea9d Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:14:47 +0300 Subject: [PATCH 09/26] project: output_size never asks for sides past the comp limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 30 000 px comp (the repaired MAX_SIZE on load) at resolution 4.0 produced 120 000 px sides, which overflow u32 in the exporters' w * h * 4 (debug panic, release wrap then slice panic) after already requesting a ~57 GB frame. The resize path in the same file clamps to 30 000; output_size now does too. Regression test: output_size_never_exceeds_the_comp_limit — on main it returns (120000, 120000); with the clamp (30000, 30000), through both RenderSettings and the OutputModule wrapper. --- crates/project/src/render_queue.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index 2d3ab741b..e49c5d1cc 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -444,7 +444,11 @@ impl RenderSettings { } pub fn output_size(&self, comp: &Comp) -> (u32, u32) { let s = self.resolution.clamp(0.01, 4.0); - (((comp.width as f64 * s).round() as u32).max(1), ((comp.height as f64 * s).round() as u32).max(1)) + // A 30 000 px comp at 4x would otherwise request 120 000 px sides, overflowing + // `w * h * 4` in the exporters. The resize path clamps to the same limit. + let w = ((comp.width as f64 * s).round() as u32).max(1).min(30_000); + let h = ((comp.height as f64 * s).round() as u32).max(1).min(30_000); + (w, h) } pub fn resolution_label(&self) -> &'static str { match self.resolution { @@ -1663,6 +1667,16 @@ mod tests { assert_eq!(m.output_size(&c, &half), (400, 200)); } + #[test] + fn output_size_never_exceeds_the_comp_limit() { + // 30 000 px at 4x must not request 120 000 px sides (exporter `w * h * 4`). + let c = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + let rs = RenderSettings { resolution: 4.0, ..Default::default() }; + assert_eq!(rs.output_size(&c), (30_000, 30_000)); + let m = OutputModule::for_format(OutputFormat::PngSequence); + assert_eq!(m.output_size(&c, &rs), (30_000, 30_000)); + } + #[test] fn uncompressed_quicktime_format() { let f = OutputFormat::from_name("uncompressed").unwrap(); From 30e02b06caef9ca3d6bfc672bbd4110204d86db4 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:46:24 +0300 Subject: [PATCH 10/26] project: output_size clamps the scale, not the sides, and every renderer uses it Clamping each side on its own broke the aspect ratio (a 30000x15000 comp at 4x came out 30000x30000). The effective scale min(s, MAX_SIZE/w, MAX_SIZE/h) keeps both sides within the comp limit, and the export pipeline and the locked-aspect width derivation render at that same scale, so nothing requests a frame larger than output_size reports. --- crates/engine/src/commands/render_queue.rs | 2 +- crates/export/src/pipeline.rs | 4 +-- crates/project/src/render_queue.rs | 37 ++++++++++++++++------ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index 09a7c7d2f..39af29c42 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/render_queue.rs @@ -601,7 +601,7 @@ fn set_output_module(s: &mut Session, p: &Value) -> Result { let (w, h) = item.settings.output_size(comp); let mut crop_only = om.clone(); crop_only.resize.enabled = false; - let (cw, ch) = crop_only.frame_size(w, h, item.settings.resolution.clamp(0.01, 4.0)); + let (cw, ch) = crop_only.frame_size(w, h, item.settings.effective_scale(comp)); om.resize.width = ((om.resize.height as f64 * cw as f64 / ch as f64).round() as u32).clamp(1, 30_000); } let post = match str_p(p, "postRenderAction") { diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index a0093f4c7..6877a09bf 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -147,7 +147,7 @@ impl<'a> Cx<'a> { // compositors differ slightly, so a per-frame choice made renders differ run to run. let backend = if self.project.settings.gpu_acceleration { Backend::Gpu } else { Backend::Cpu }; let opts = RenderOpts { - scale: s.resolution.clamp(0.01, 4.0), + scale: self.comp().map(|c| s.effective_scale(c)).unwrap_or_else(|| s.resolution.clamp(0.01, 4.0)), motion_blur: s.motion_blur_override() != SwitchOverride::OffForAll, guides: s.guide_layers == CurrentOrOff::Current, draft: s.quality == RenderQuality::Draft, @@ -231,7 +231,7 @@ impl<'a> Cx<'a> { /// Output Module ▸ Crop, then Resize. fn crop_resize(&self, img: Image) -> Image { let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.comp().map(|c| self.job.settings.effective_scale(c)).unwrap_or_else(|| self.job.settings.resolution.clamp(0.01, 4.0)); let (t, l, b, r) = om.crop.edges(img.width, img.height, scale); let img = if (t, l, b, r) == (0, 0, 0, 0) { img } else { crop(&img, t, l, b, r) }; if !om.resize.enabled { diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index e49c5d1cc..44a7be5a4 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -442,12 +442,17 @@ impl RenderSettings { pub fn frame_time(&self, comp: &Comp, i: u64) -> Tick { self.rate(comp).tick_of(self.first_frame(comp) + i as i64) } - pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + pub fn effective_scale(&self, comp: &Comp) -> f64 { let s = self.resolution.clamp(0.01, 4.0); - // A 30 000 px comp at 4x would otherwise request 120 000 px sides, overflowing - // `w * h * 4` in the exporters. The resize path clamps to the same limit. - let w = ((comp.width as f64 * s).round() as u32).max(1).min(30_000); - let h = ((comp.height as f64 * s).round() as u32).max(1).min(30_000); + let max = f64::from(crate::limits::MAX_SIZE); + s.min(max / f64::from(comp.width.max(1))).min(max / f64::from(comp.height.max(1))) + } + pub fn output_size(&self, comp: &Comp) -> (u32, u32) { + let s = self.effective_scale(comp); + // The scale is clamped so neither side exceeds the comp limit, keeping the aspect + // ratio: a 30 000 px side at 4x renders at 1x instead of cropping to a corner. + let w = ((comp.width as f64 * s).round() as u32).max(1); + let h = ((comp.height as f64 * s).round() as u32).max(1); (w, h) } pub fn resolution_label(&self) -> &'static str { @@ -1120,7 +1125,7 @@ impl OutputModule { /// rounding). pub fn output_size(&self, comp: &Comp, settings: &RenderSettings) -> (u32, u32) { let (w, h) = settings.output_size(comp); - let (w, h) = self.frame_size(w, h, settings.resolution.clamp(0.01, 4.0)); + let (w, h) = self.frame_size(w, h, settings.effective_scale(comp)); self.format.coded_size(w, h) } /// Every setting as `(label, value)` lines (render logs). @@ -1669,12 +1674,24 @@ mod tests { #[test] fn output_size_never_exceeds_the_comp_limit() { - // 30 000 px at 4x must not request 120 000 px sides (exporter `w * h * 4`). - let c = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + // The effective scale is clamped so neither side exceeds the limit (exporter + // `w * h * 4` would overflow at 120 000 px sides); the pipeline renders at the + // same scale, so it never requests a frame larger than this reports. let rs = RenderSettings { resolution: 4.0, ..Default::default() }; - assert_eq!(rs.output_size(&c), (30_000, 30_000)); let m = OutputModule::for_format(OutputFormat::PngSequence); - assert_eq!(m.output_size(&c, &rs), (30_000, 30_000)); + let square = Comp::new(30_000, 30_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&square), 1.0); + assert_eq!(rs.output_size(&square), (30_000, 30_000)); + assert_eq!(m.output_size(&square, &rs), (30_000, 30_000)); + // Wide comps keep their aspect ratio instead of clamping each side on its own. + let wide = Comp::new(30_000, 15_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(rs.effective_scale(&wide), 1.0); + assert_eq!(rs.output_size(&wide), (30_000, 15_000)); + assert_eq!(m.output_size(&wide, &rs), (30_000, 15_000)); + // A mid-size comp renders at the largest scale that still fits. + let mid = Comp::new(20_000, 10_000, FrameRate::new(30, 1), Tick::from_seconds_f64(1.0)); + assert_eq!(mid.width as f64 * rs.effective_scale(&mid), 30_000.0); + assert_eq!(rs.output_size(&mid), (30_000, 15_000)); } #[test] From f64cd5ce381e3d7e1863eef2ce3599f3a6e262a0 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Sat, 17 Oct 2026 20:44:28 +0000 Subject: [PATCH 11/26] 1010: preserve easing while baking parented Orientation --- crates/engine/src/commands/layer.rs | 10 +++++++++- crates/engine/src/tests_rig3d.rs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 123a81471..e26f0279b 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -818,7 +818,15 @@ fn bake_between( let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); let blend = [(ea[0] + eb[0]) / 2.0, (ea[1] + eb[1]) / 2.0, (ea[2] + eb[2]) / 2.0]; let exact = correction * Mat4::orientation(Vec3::from(authored.value_at(tm).as_vec3())); - if rotation_angle(Mat4::orientation(Vec3::from(blend)), exact) <= BAKE_TOLERANCE_DEGREES { + // Symmetric easing crosses the straight blend at its midpoint. Check both sides too before + // accepting a segment whose original easing will be replaced with a linear blend. + let quarter = (tb.0 - ta.0) / 4; + if [Tick(ta.0 + quarter), tm, Tick(tb.0 - quarter)].into_iter().all(|t| { + let u = (t.0 - ta.0) as f64 / (tb.0 - ta.0) as f64; + let linear = std::array::from_fn(|i| ea[i] + (eb[i] - ea[i]) * u); + let target = correction * Mat4::orientation(Vec3::from(authored.value_at(t).as_vec3())); + rotation_angle(Mat4::orientation(Vec3::from(linear)), target) <= BAKE_TOLERANCE_DEGREES + }) { return; } let em = nearest_euler(euler_zyx(exact.0), blend); diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs index d867337ac..595d3d7c5 100644 --- a/crates/engine/src/tests_rig3d.rs +++ b/crates/engine/src/tests_rig3d.rs @@ -325,6 +325,27 @@ fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { // ---------------------------------------------------------------- from 3D model +/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points. +#[test] +fn eased_orientation_under_x_parent_keeps_motion_between_keys() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] { + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect(); + let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect(); + let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap(); + for (t, b) in times.into_iter().zip(before) { + let a = frame_at(&s, light, t); + assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1); + } +} + struct DiskModels; impl FootageSource for DiskModels { From bccbc666a235ab4eba2ad0ad0b4c22b88e60bd30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:36:16 +0000 Subject: [PATCH 12/26] #1417: WAV markers show on the audio layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WAV's markers were never read, so its audio layer showed none. The importer now reads the file's cue points (`cue ` chunk named by `LIST/adtl` `labl` and `note`, sized by `ltxt`) and Adobe's XMP markers (`_PMX` chunk, `xmpDM:markers` entries as elements or attributes, in frames of the track's `xmpDM:frameRate`). A marker present in both encodings is listed once. The reader is bounds-checked and caps the marker count and XMP size. - `Footage.markers` (optional in the file format) holds them at footage time; a layer made from the footage starts with them. - Layer ▸ Markers ▸ Update Markers From Source also works on footage layers. - Markers from a file have no label colour. Tests: reader unit tests (cue, note, ltxt, both XMP forms, both encodings, truncated and hostile files), a probe test, and an end-to-end test through the fully wired session. Not checked against a WAV written by Adobe's tools. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + crates/engine/src/commands/markers.rs | 15 +- crates/host/tests/wav_markers.rs | 75 ++++++ crates/media/Cargo.toml | 1 + crates/media/src/lib.rs | 1 + crates/media/src/probe.rs | 3 + crates/media/src/wav_markers.rs | 350 ++++++++++++++++++++++++++ crates/project/src/build.rs | 10 +- crates/project/src/lib.rs | 5 + 9 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 crates/host/tests/wav_markers.rs create mode 100644 crates/media/src/wav_markers.rs diff --git a/Cargo.lock b/Cargo.lock index 412fb6672..243837d45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1443,6 +1443,7 @@ name = "effectcraft-media" version = "0.12.0" dependencies = [ "craft-docpath", + "effectcraft-color", "effectcraft-model", "effectcraft-pdf", "effectcraft-project", diff --git a/crates/engine/src/commands/markers.rs b/crates/engine/src/commands/markers.rs index 4c979619e..35ab7123f 100644 --- a/crates/engine/src/commands/markers.rs +++ b/crates/engine/src/commands/markers.rs @@ -296,8 +296,9 @@ fn convert(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers onto the -/// layer (layer time is the nested comp's time), skipping ones already there. +/// Layer ▸ Markers ▸ Update Markers From Source: copy a precomp's composition markers, or the +/// markers a footage file carries (a WAV's cue points), onto the layer (layer time is the source's +/// time), skipping ones already there. fn update_from_source(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let src: Vec<(LayerId, Vec)> = { @@ -307,13 +308,17 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { let l = comp.layer(*id)?; match &l.source { LayerSource::Comp { item } => Some((*id, s.project.comp(*item)?.markers.clone())), + LayerSource::Footage { item } => match &s.project.item(*item)?.kind { + effectcraft_project::ItemKind::Footage(f) => Some((*id, f.markers.clone())), + _ => None, + }, _ => None, } }) .collect() }; if src.is_empty() { - return Err(bad("layer.updateMarkersFromSource", "select a precomposition layer")); + return Err(bad("layer.updateMarkersFromSource", "select a precomposition or footage layer")); } let n = s.edit("Update Markers From Source", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; @@ -339,10 +344,10 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { fn has_precomp_layer(s: &Session) -> std::result::Result<(), String> { has_layers(s)?; let c = s.active_comp().ok_or("no composition")?; - if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. })) { + if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. })) { Ok(()) } else { - Err("select a precomposition layer".into()) + Err("select a precomposition or footage layer".into()) } } diff --git a/crates/host/tests/wav_markers.rs b/crates/host/tests/wav_markers.rs new file mode 100644 index 000000000..6e3943915 --- /dev/null +++ b/crates/host/tests/wav_markers.rs @@ -0,0 +1,75 @@ +//! Markers a WAV file carries, through the fully wired session (media import included): the +//! layer made from the file starts with them, and Update Markers From Source copies them again. + +use effectcraft_project::LayerSource; +use serde_json::json; + +/// A one-second mono 8 kHz WAV with two named cue points (a `cue ` chunk and a `LIST` `adtl`). +fn marker_wav() -> Vec { + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + let mut fmt = Vec::new(); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(1u16.to_le_bytes()); + fmt.extend(8000u32.to_le_bytes()); + fmt.extend(16000u32.to_le_bytes()); + fmt.extend(2u16.to_le_bytes()); + fmt.extend(16u16.to_le_bytes()); + let points = [(1u32, 0u32, "Intro"), (2, 4000, "Middle")]; + let mut cue = (points.len() as u32).to_le_bytes().to_vec(); + let mut adtl = b"adtl".to_vec(); + for (id, at, name) in points { + for v in [id, 0] { + cue.extend(v.to_le_bytes()); + } + cue.extend(b"data"); + for v in [0, 0, at] { + cue.extend(u32::to_le_bytes(v)); + } + let mut labl = id.to_le_bytes().to_vec(); + labl.extend(name.as_bytes()); + labl.push(0); + adtl.extend(chunk(b"labl", &labl)); + } + let mut body = b"WAVE".to_vec(); + for c in [chunk(b"fmt ", &fmt), chunk(b"data", &[0; 16000]), chunk(b"cue ", &cue), chunk(b"LIST", &adtl)] { + body.extend(c); + } + let mut file = b"RIFF".to_vec(); + file.extend((body.len() as u32).to_le_bytes()); + file.extend(body); + file +} + +/// A WAV's cue points become markers on the layer made from it, named as in the file, and +/// Update Markers From Source brings back one that was deleted (through the fully wired session, +/// media import included). +#[test] +fn a_wav_layer_starts_with_the_markers_in_its_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav()).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0; + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + let marks: Vec<(String, f64)> = + list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); + assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); + s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 1); + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 0ef01d831..52a375fef 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] craft-docpath = { workspace = true } effectcraft-time = { workspace = true } +effectcraft-color = { workspace = true } effectcraft-project = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-render = { workspace = true } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 04a9d95e3..18609ecf5 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -25,6 +25,7 @@ pub use layered::vector_doc; mod pool; mod probe; mod stream; +mod wav_markers; mod webp_anim; pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats}; diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 2793be251..12ca2d207 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -98,8 +98,11 @@ pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result { let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let turn = video_turn(&bytes); let rate = video_rate(&bytes); + // A WAV's cue points and XMP markers become the footage's markers. + let markers = crate::wav_markers::read(&bytes); let src = filmcraft_codecs::open_bytes(&name, bytes)?; let mut f = footage_from_info(path, src.info()); + f.markers = markers; turn_footage(&mut f, turn); correct_rate(&mut f, rate); Ok(f) diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs new file mode 100644 index 000000000..f7c1bb84d --- /dev/null +++ b/crates/media/src/wav_markers.rs @@ -0,0 +1,350 @@ +//! Markers a WAV file carries, read from its RIFF chunks. +//! +//! Two encodings are read and merged: +//! - **Cue points** (the standard): a `cue ` chunk with one point per marker, named by `labl` (or +//! `note`) sub-chunks of a `LIST` `adtl` chunk, and given a length by `ltxt`. +//! - **XMP** (Adobe's tools): an `_PMX` chunk holding an XMP packet whose `xmpDM:markers` list has +//! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames +//! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). +//! +//! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and +//! the number of markers and the size of the XMP packet are capped. + +use effectcraft_color::Label; +use effectcraft_project::Marker; +use effectcraft_time::Tick; + +/// Most markers read from one file. +pub const MAX_MARKERS: usize = 10_000; +/// Largest XMP packet read (bytes). +const MAX_XMP: usize = 4 << 20; + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes(s.try_into().ok()?)) +} + +/// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. +fn label(b: &[u8]) -> String { + let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim().to_string() +} + +fn secs(frames: f64, rate: f64) -> Tick { + if rate > 0.0 && frames.is_finite() { Tick::from_seconds_f64((frames / rate).max(0.0)) } else { Tick::ZERO } +} + +/// Whether `bytes` start a RIFF/WAVE file. +pub fn is_wav(bytes: &[u8]) -> bool { + matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") +} + +/// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. +fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let mut out = Vec::new(); + let mut at = 12usize; + while let Some(head) = bytes.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = bytes.get(start..end) else { break }; + out.push((id, body)); + // Chunks are padded to an even size. + at = end.saturating_add(end & 1); + } + out +} + +/// The markers `bytes` carry, in time order (empty for anything that isn't a WAV with markers). +pub fn read(bytes: &[u8]) -> Vec { + if !is_wav(bytes) { + return Vec::new(); + } + let chunks = chunks(bytes); + let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); + let mut out = cue_markers(&chunks, rate); + if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + for m in xmp_markers(xmp, rate) { + // Tools that write both encodings repeat each marker. + if !out.iter().any(|o| o.comment == m.comment && o.time == m.time) { + out.push(m); + } + } + } + out.truncate(MAX_MARKERS); + out.sort_by_key(|m| m.time); + out +} + +/// Cue points named and sized by the `adtl` list. +fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { + let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; + let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); + // Cue id -> (name, length in samples). + let mut names: Vec<(u32, String)> = Vec::new(); + let mut lengths: Vec<(u32, u32)> = Vec::new(); + for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + let mut at = 4usize; + while let Some(head) = list.get(at..at.saturating_add(8)) { + let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let start = at.saturating_add(8); + let Some(end) = start.checked_add(size as usize) else { break }; + let Some(body) = list.get(start..end) else { break }; + if let Some(cue_id) = u32_at(body, 0) { + match &id { + b"labl" | b"note" => { + let text = label(body.get(4..).unwrap_or_default()); + // A `labl` is the name; a `note` only when there is none. + let known = names.iter().any(|(c, _)| *c == cue_id); + if &id == b"labl" || !known { + names.retain(|(c, _)| *c != cue_id); + names.push((cue_id, text)); + } + } + b"ltxt" => lengths.push((cue_id, u32_at(body, 4).unwrap_or(0))), + _ => {} + } + } + at = end.saturating_add(end & 1); + } + } + let mut out = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + let comment = names.iter().find(|(c, _)| *c == id).map(|(_, t)| t.clone()).unwrap_or_default(); + let length = lengths.iter().find(|(c, _)| *c == id).map_or(0, |(_, l)| *l); + out.push(Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() }); + } + out +} + +/// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element +/// (`v`). +fn xmp_value(entry: &str, name: &str) -> Option { + let attr = format!("{name}=\""); + if let Some(i) = entry.find(&attr) { + let rest = entry.get(i + attr.len()..)?; + return Some(unescape(rest.get(..rest.find('"')?)?)); + } + let open = format!("<{name}>"); + let i = entry.find(&open)?; + let rest = entry.get(i + open.len()..)?; + Some(unescape(rest.get(..rest.find('<')?)?)) +} + +fn unescape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let tail = rest.get(i..).unwrap_or_default(); + let Some(semi) = tail.find(';').filter(|&n| n <= 10) else { + out.push('&'); + rest = tail.get(1..).unwrap_or_default(); + continue; + }; + let ent = tail.get(1..semi).unwrap_or_default(); + match ent { + "amp" => out.push('&'), + "lt" => out.push('<'), + "gt" => out.push('>'), + "quot" => out.push('"'), + "apos" => out.push('\''), + _ => { + let code = ent.strip_prefix("#x").and_then(|h| u32::from_str_radix(h, 16).ok()).or_else(|| ent.strip_prefix('#').and_then(|d| d.parse().ok())); + match code.and_then(char::from_u32) { + Some(c) => out.push(c), + None => out.push_str(tail.get(..=semi).unwrap_or_default()), + } + } + } + rest = tail.get(semi + 1..).unwrap_or_default(); + } + out.push_str(rest); + out.trim().to_string() +} + +/// A frame rate as XMP writes it: `f44100`, or `f30000s1001` (rate over scale). +fn xmp_rate(text: &str) -> Option { + let t = text.trim().strip_prefix('f')?; + let (num, den) = t.split_once('s').unwrap_or((t, "1")); + let (n, d): (f64, f64) = (num.parse().ok()?, den.parse().ok()?); + (n > 0.0 && d > 0.0).then_some(n / d) +} + +/// The markers of an XMP packet's `xmpDM:markers` entries. +fn xmp_markers(packet: &[u8], file_rate: f64) -> Vec { + let text = String::from_utf8_lossy(packet.get(..packet.len().min(MAX_XMP)).unwrap_or_default()); + let mut out = Vec::new(); + // The frame rate belongs to the track an entry follows. + let mut rate = file_rate; + for entry in text.split("().ok()) else { continue }; + let length = xmp_value(entry, "xmpDM:duration").and_then(|v| v.trim().parse::().ok()).unwrap_or(0.0); + let comment = xmp_value(entry, "xmpDM:name").unwrap_or_default(); + out.push(Marker { time: secs(start, rate), duration: secs(length, rate), comment, label: Label::None, ..Default::default() }); + if out.len() >= MAX_MARKERS { + break; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend((body.len() as u32).to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + fn fmt(rate: u32) -> Vec { + let mut b = Vec::new(); + b.extend(1u16.to_le_bytes()); // PCM + b.extend(1u16.to_le_bytes()); // mono + b.extend(rate.to_le_bytes()); + b.extend((rate * 2).to_le_bytes()); + b.extend(2u16.to_le_bytes()); + b.extend(16u16.to_le_bytes()); + chunk(b"fmt ", &b) + } + + fn wav(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RIFF".to_vec(); + v.extend((body.len() as u32 + 4).to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + fn cue(points: &[(u32, u32)]) -> Vec { + let mut b = (points.len() as u32).to_le_bytes().to_vec(); + for (id, offset) in points { + b.extend(id.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(b"data"); + b.extend(0u32.to_le_bytes()); + b.extend(0u32.to_le_bytes()); + b.extend(offset.to_le_bytes()); + } + chunk(b"cue ", &b) + } + + fn labl(id: &[u8; 4], cue_id: u32, text: &str) -> Vec { + let mut b = cue_id.to_le_bytes().to_vec(); + b.extend(text.as_bytes()); + b.push(0); + chunk(id, &b) + } + + fn adtl(subs: &[Vec]) -> Vec { + let mut b = b"adtl".to_vec(); + b.extend(subs.iter().flatten()); + chunk(b"LIST", &b) + } + + #[test] + fn cue_points_are_named_by_the_adtl_list_and_timed_in_samples() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 8]), + cue(&[(2, 88200), (1, 22050)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 2); + assert_eq!((m[0].comment.as_str(), m[0].time), ("SEQ01_Prologue", Tick::from_seconds_f64(0.5))); + assert_eq!((m[1].comment.as_str(), m[1].time), ("SEQ02_Vallee_P1_Agriculture", Tick::from_seconds_f64(2.0))); + } + + #[test] + fn a_note_names_a_cue_only_when_it_has_no_label_and_ltxt_gives_a_length() { + let mut ltxt = 1u32.to_le_bytes().to_vec(); + ltxt.extend(44100u32.to_le_bytes()); + ltxt.extend([0; 16]); + let file = wav(&[ + fmt(44100), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"note", 1, "from note"), chunk(b"ltxt", <xt), labl(b"labl", 2, "named"), labl(b"note", 2, "ignored")]), + ]); + let m = read(&file); + assert_eq!((m[0].comment.as_str(), m[0].duration), ("from note", Tick::from_seconds_f64(1.0))); + assert_eq!(m[1].comment, "named"); + } + + const XMP_ELEMENTS: &str = r#"MarkersCuef44100441000One & Two13230022050Three"#; + + #[test] + fn xmp_markers_are_read_as_elements_in_frames_of_the_track_rate() { + let file = wav(&[fmt(48000), chunk(b"_PMX", XMP_ELEMENTS.as_bytes())]); + let m = read(&file); + assert_eq!(m.len(), 2); + // 44100 frames at f44100 is one second, whatever the file's own rate. + assert_eq!((m[0].comment.as_str(), m[0].time), ("One & Two", Tick::from_seconds_f64(1.0))); + assert_eq!((m[1].comment.as_str(), m[1].time, m[1].duration), ("Three", Tick::from_seconds_f64(3.0), Tick::from_seconds_f64(0.5))); + } + + #[test] + fn xmp_markers_are_read_as_attributes_and_use_the_file_rate_without_a_track_rate() { + let xmp = r#""#; + let m = read(&wav(&[fmt(48000), chunk(b"_PMX", xmp.as_bytes())])); + assert_eq!((m[0].comment.as_str(), m[0].time), ("Attr é", Tick::from_seconds_f64(0.5))); + } + + #[test] + fn a_marker_in_both_encodings_is_listed_once() { + let xmp = r#""#; + let file = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Same")]), chunk(b"_PMX", xmp.as_bytes())]); + let names: Vec = read(&file).into_iter().map(|m| m.comment).collect(); + assert_eq!(names, ["Same", "Only XMP"]); + } + + #[test] + fn importing_a_wav_puts_its_markers_on_the_footage() { + let file = wav(&[ + fmt(44100), + chunk(b"data", &[0; 4 * 44100]), + cue(&[(1, 0), (2, 44100)]), + adtl(&[labl(b"labl", 1, "SEQ01_Prologue"), labl(b"labl", 2, "SEQ02_Vallee_P1_Agriculture")]), + ]); + let f = crate::probe::probe_bytes("markers.wav", file.into()).expect("probe"); + assert!(f.has_audio); + let names: Vec<&str> = f.markers.iter().map(|m| m.comment.as_str()).collect(); + assert_eq!(names, ["SEQ01_Prologue", "SEQ02_Vallee_P1_Agriculture"]); + assert_eq!(f.markers[1].time, Tick::from_seconds_f64(1.0)); + } + + #[test] + fn damaged_or_hostile_files_read_as_far_as_they_go_without_panicking() { + assert!(read(b"").is_empty()); + assert!(read(b"RIFF").is_empty()); + assert!(read(b"not a wav file at all").is_empty()); + let good = wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + // Every truncation is safe. + for n in 0..good.len() { + let _ = read(&good[..n]); + } + // A cue count far beyond the chunk, and chunk sizes past the end of the file. + let mut huge = 0xFFFF_FFFFu32.to_le_bytes().to_vec(); + huge.extend([0; 24]); + let m = read(&wav(&[fmt(44100), chunk(b"cue ", &huge)])); + assert!(m.len() <= 1); + let mut lying = good.clone(); + lying.extend(b"LIST\xff\xff\xff\xffadtl"); + assert_eq!(read(&lying).len(), 1); + // No sample rate: times are zero, not a division by zero. + let m = read(&wav(&[cue(&[(1, 44100)])])); + assert_eq!(m[0].time, Tick::ZERO); + } +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 935c03b0e..1b2f12c81 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -854,6 +854,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource _ => {} } } + // A footage layer starts with the markers its file carries (layer time is footage time). + let footage_markers = match &source { + LayerSource::Footage { item } => match project.item(*item).map(|i| &i.kind) { + Some(crate::ItemKind::Footage(f)) => f.markers.clone(), + _ => vec![], + }, + _ => vec![], + }; let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio)) || matches!(source, LayerSource::Comp { .. }); if has_audio { @@ -883,7 +891,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource preserve_transparency: false, track_matte: None, parent: None, - markers: vec![], + markers: footage_markers, markers_locked: false, environment: false, environment_background: false, diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index a4bffbe0c..4cdb3ac24 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1172,6 +1172,10 @@ pub struct Footage { /// timecode isn't read). It only relabels source time, as in After Effects. #[serde(default, skip_serializing_if = "Option::is_none")] pub start_timecode: Option, + /// Markers the file carries (a WAV's cue points and XMP markers), at footage time. A layer + /// made from the footage starts with them, and Update Markers From Source copies new ones. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub markers: Vec, } fn is_default(v: &T) -> bool { @@ -1207,6 +1211,7 @@ impl Default for Footage { page: 0, alphabetical: false, start_timecode: None, + markers: vec![], } } } From 620d340d1577c6470c0685c1cec6607259f69184 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 19 Oct 2026 00:56:32 +0200 Subject: [PATCH 13/26] WAV markers: read RF64 files and bound the adtl list Two findings from the review of #1423. RF64 (WAVs over 4 GB): a chunk too big for 32 bits has a size field of 0xFFFFFFFF and its real size in the file's `ds64` chunk. The reader took the field literally, so it stopped at the `data` chunk and never reached the `cue ` and `_PMX` chunks that follow it. It now resolves such sizes through `ds64` (the `data` size, and a table of other chunks, capped at 1,024 entries), with checked arithmetic, and leaves the field as it was when there is no usable `ds64`. The `adtl` list: every `labl` record scanned the growing list of names, which is quadratic in the number of records, and nothing bounded how many were kept. The cue points are now read first, names and lengths are kept only for those ids in hash maps, and at most 40,000 `adtl` sub-chunks are looked at. The duplicate check between the cue and XMP markers uses a set instead of a scan. What a file yields is unchanged for any file within the bounds: a `labl` still wins over a `note`, and the first `ltxt` still gives the length. Tests: an RF64 file whose markers follow a sentinel-sized `data` chunk, sizes taken from the `ds64` table (and not taken when the table has no entry), hostile `ds64` sizes and tables and every truncation of an RF64 file, and a 100,000-record `adtl` list that is read in linear time, with the cap applied. The RF64 tests fail with the previous reader. Co-Authored-By: Claude Sonnet 5.5 --- crates/media/src/wav_markers.rs | 223 ++++++++++++++++++++++++++++---- 1 file changed, 195 insertions(+), 28 deletions(-) diff --git a/crates/media/src/wav_markers.rs b/crates/media/src/wav_markers.rs index f7c1bb84d..ca40824b6 100644 --- a/crates/media/src/wav_markers.rs +++ b/crates/media/src/wav_markers.rs @@ -7,8 +7,13 @@ //! one entry per marker (`xmpDM:startTime`, `xmpDM:duration`, `xmpDM:name`), counted in frames //! of `xmpDM:frameRate` (`f44100` for samples at 44.1 kHz). //! +//! RF64 files (WAVs over 4 GB) carry a `0xFFFFFFFF` size for a chunk too big for 32 bits and give its real size in a +//! `ds64` chunk; those sizes are resolved so the chunks after the audio are still found. +//! //! The file is untrusted input: every read is bounds-checked, sizes use checked arithmetic, and -//! the number of markers and the size of the XMP packet are capped. +//! the number of markers, of `adtl` sub-chunks looked at and the size of the XMP packet are capped. + +use std::collections::{HashMap, HashSet}; use effectcraft_color::Label; use effectcraft_project::Marker; @@ -18,12 +23,21 @@ use effectcraft_time::Tick; pub const MAX_MARKERS: usize = 10_000; /// Largest XMP packet read (bytes). const MAX_XMP: usize = 4 << 20; +/// Most `adtl` sub-chunks (`labl`, `note`, `ltxt`) looked at in one file: a few per cue point is plenty. +const MAX_ADTL_RECORDS: usize = 4 * MAX_MARKERS; +/// Most entries read from an RF64 file's `ds64` size table. +const MAX_DS64_TABLE: usize = 1_024; fn u32_at(b: &[u8], at: usize) -> Option { let s = b.get(at..at.checked_add(4)?)?; Some(u32::from_le_bytes(s.try_into().ok()?)) } +fn u64_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + Some(u64::from_le_bytes(s.try_into().ok()?)) +} + /// The text of a NUL-terminated label (the rest of the chunk), lossily decoded. fn label(b: &[u8]) -> String { let end = b.iter().position(|&c| c == 0).unwrap_or(b.len()); @@ -39,14 +53,55 @@ pub fn is_wav(bytes: &[u8]) -> bool { matches!(bytes.get(0..4), Some(b"RIFF" | b"RF64")) && bytes.get(8..12) == Some(b"WAVE") } +/// The sizes an RF64 file's `ds64` chunk gives for chunks too big for the 32-bit size field. +struct Ds64 { + /// The `data` chunk's size. + data: u64, + /// Other chunks: `(id, size)`. + table: Vec<([u8; 4], u64)>, +} + +impl Ds64 { + /// The real size of chunk `id`, when the file says. + fn size_of(&self, id: &[u8; 4]) -> Option { + if id == b"data" { Some(self.data) } else { self.table.iter().find(|(t, _)| t == id).map(|(_, size)| *size) } + } +} + +/// The `ds64` chunk of an RF64 file (`RF64`, size, `WAVE`, then `ds64`); `None` for any other file or one whose +/// `ds64` doesn't fit. +fn ds64(bytes: &[u8]) -> Option { + if bytes.get(0..4) != Some(b"RF64") || bytes.get(12..16) != Some(b"ds64") { + return None; + } + let size = usize::try_from(u32_at(bytes, 16)?).ok()?; + let body = bytes.get(20..20usize.checked_add(size)?)?; + // riff size (8), data size (8), sample count (8), table length (4), then `(id, size)` entries of 12 bytes + let data = u64_at(body, 8)?; + let entries = usize::try_from(u32_at(body, 24)?).ok()?; + let mut table = Vec::new(); + for i in 0..entries.min(MAX_DS64_TABLE) { + let at = 28usize.checked_add(i.checked_mul(12)?)?; + let id = <[u8; 4]>::try_from(body.get(at..at.checked_add(4)?)?).ok()?; + table.push((id, u64_at(body, at + 4)?)); + } + Some(Ds64 { data, table }) +} + /// The chunks of a WAVE file as `(id, body)`, stopping at the first one that doesn't fit. fn chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + let big = ds64(bytes); let mut out = Vec::new(); let mut at = 12usize; while let Some(head) = bytes.get(at..at.saturating_add(8)) { - let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + let (Ok(id), Some(field)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; + // In an RF64 file a size field of 0xFFFFFFFF stands for the size `ds64` gives. + let size = match &big { + Some(d) if field == u32::MAX => d.size_of(&id).unwrap_or(u64::from(field)), + _ => u64::from(field), + }; let start = at.saturating_add(8); - let Some(end) = start.checked_add(size as usize) else { break }; + let Some(end) = usize::try_from(size).ok().and_then(|n| start.checked_add(n)) else { break }; let Some(body) = bytes.get(start..end) else { break }; out.push((id, body)); // Chunks are padded to an even size. @@ -64,9 +119,10 @@ pub fn read(bytes: &[u8]) -> Vec { let rate = chunks.iter().find(|(id, _)| id == b"fmt ").and_then(|(_, b)| u32_at(b, 4)).map_or(0.0, f64::from); let mut out = cue_markers(&chunks, rate); if let Some((_, xmp)) = chunks.iter().find(|(id, _)| id == b"_PMX") { + // Tools that write both encodings repeat each marker. + let mut seen: HashSet<(String, Tick)> = out.iter().map(|o| (o.comment.clone(), o.time)).collect(); for m in xmp_markers(xmp, rate) { - // Tools that write both encodings repeat each marker. - if !out.iter().any(|o| o.comment == m.comment && o.time == m.time) { + if seen.insert((m.comment.clone(), m.time)) { out.push(m); } } @@ -80,43 +136,57 @@ pub fn read(bytes: &[u8]) -> Vec { fn cue_markers(chunks: &[([u8; 4], &[u8])], rate: f64) -> Vec { let Some((_, cue)) = chunks.iter().find(|(id, _)| id == b"cue ") else { return Vec::new() }; let count = (u32_at(cue, 0).unwrap_or(0) as usize).min(MAX_MARKERS); - // Cue id -> (name, length in samples). - let mut names: Vec<(u32, String)> = Vec::new(); - let mut lengths: Vec<(u32, u32)> = Vec::new(); - for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { + // The cue points first: `(id, offset in samples)`. + let mut points = Vec::new(); + for i in 0..count { + let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; + let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; + points.push((id, offset)); + } + // Only the names and lengths of those ids are kept, so a hostile `adtl` list cannot grow them past the cue count, + // and each record is a lookup, not a scan. + let wanted: HashSet = points.iter().map(|(id, _)| *id).collect(); + let mut names: HashMap = HashMap::new(); + let mut lengths: HashMap = HashMap::new(); + let mut records = 0usize; + 'lists: for (_, list) in chunks.iter().filter(|(id, b)| id == b"LIST" && b.get(0..4) == Some(b"adtl")) { let mut at = 4usize; while let Some(head) = list.get(at..at.saturating_add(8)) { let (Ok(id), Some(size)) = (<[u8; 4]>::try_from(&head[..4]), u32_at(head, 4)) else { break }; let start = at.saturating_add(8); let Some(end) = start.checked_add(size as usize) else { break }; let Some(body) = list.get(start..end) else { break }; - if let Some(cue_id) = u32_at(body, 0) { + records += 1; + if records > MAX_ADTL_RECORDS { + break 'lists; + } + if let Some(cue_id) = u32_at(body, 0).filter(|c| wanted.contains(c)) { match &id { - b"labl" | b"note" => { - let text = label(body.get(4..).unwrap_or_default()); - // A `labl` is the name; a `note` only when there is none. - let known = names.iter().any(|(c, _)| *c == cue_id); - if &id == b"labl" || !known { - names.retain(|(c, _)| *c != cue_id); - names.push((cue_id, text)); - } + // A `labl` is the name; a `note` only when there is none. + b"labl" => { + names.insert(cue_id, label(body.get(4..).unwrap_or_default())); + } + b"note" => { + names.entry(cue_id).or_insert_with(|| label(body.get(4..).unwrap_or_default())); + } + // The first `ltxt` of a cue point gives its length. + b"ltxt" => { + lengths.entry(cue_id).or_insert_with(|| u32_at(body, 4).unwrap_or(0)); } - b"ltxt" => lengths.push((cue_id, u32_at(body, 4).unwrap_or(0))), _ => {} } } at = end.saturating_add(end & 1); } } - let mut out = Vec::new(); - for i in 0..count { - let Some(point) = cue.get(4usize.saturating_add(i.saturating_mul(24))..) else { break }; - let (Some(id), Some(offset)) = (u32_at(point, 0), u32_at(point, 20)) else { break }; - let comment = names.iter().find(|(c, _)| *c == id).map(|(_, t)| t.clone()).unwrap_or_default(); - let length = lengths.iter().find(|(c, _)| *c == id).map_or(0, |(_, l)| *l); - out.push(Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() }); - } - out + points + .into_iter() + .map(|(id, offset)| { + let comment = names.get(&id).cloned().unwrap_or_default(); + let length = lengths.get(&id).copied().unwrap_or(0); + Marker { time: secs(f64::from(offset), rate), duration: secs(f64::from(length), rate), comment, label: Label::None, ..Default::default() } + }) + .collect() } /// The text of `name` in an XMP entry, as an attribute (`name="v"`) or an element @@ -347,4 +417,101 @@ mod tests { let m = read(&wav(&[cue(&[(1, 44100)])])); assert_eq!(m[0].time, Tick::ZERO); } + + /// A chunk whose size field says `field` (an RF64 file's `0xFFFFFFFF`) while its body is `body`. + fn chunk_with_size_field(id: &[u8; 4], field: u32, body: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend(field.to_le_bytes()); + v.extend(body); + if body.len() % 2 == 1 { + v.push(0); + } + v + } + + /// An RF64 `ds64` chunk: the `data` chunk's size and a table of other chunks' sizes. + fn ds64_chunk(data_size: u64, table: &[([u8; 4], u64)]) -> Vec { + let mut b = 0u64.to_le_bytes().to_vec(); // riff size (not used) + b.extend(data_size.to_le_bytes()); + b.extend(0u64.to_le_bytes()); // sample count + b.extend((table.len() as u32).to_le_bytes()); + for (id, size) in table { + b.extend(id); + b.extend(size.to_le_bytes()); + } + chunk(b"ds64", &b) + } + + fn rf64(chunks: &[Vec]) -> Vec { + let body: Vec = chunks.iter().flatten().copied().collect(); + let mut v = b"RF64".to_vec(); + v.extend(u32::MAX.to_le_bytes()); + v.extend(b"WAVE"); + v.extend(body); + v + } + + #[test] + fn rf64_markers_after_a_data_chunk_of_sentinel_size_are_read() { + let audio = [0u8; 8]; + let file = rf64(&[ + ds64_chunk(audio.len() as u64, &[]), + fmt(44100), + chunk_with_size_field(b"data", u32::MAX, &audio), + cue(&[(1, 44100)]), + adtl(&[labl(b"labl", 1, "After the audio")]), + ]); + let m = read(&file); + assert_eq!(m.len(), 1); + assert_eq!((m[0].comment.as_str(), m[0].time), ("After the audio", Tick::from_seconds_f64(1.0))); + } + + #[test] + fn rf64_sizes_of_other_chunks_come_from_the_ds64_table() { + let junk = [7u8; 6]; + let tail = || [cue(&[(1, 8000)]), adtl(&[labl(b"labl", 1, "Found")])].concat(); + let with_entry = rf64(&[ds64_chunk(0, &[(*b"junk", junk.len() as u64)]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert_eq!(read(&with_entry)[0].comment, "Found"); + // Without the table entry the sentinel is all there is, and what follows is not reached. + let without = rf64(&[ds64_chunk(0, &[]), fmt(8000), chunk_with_size_field(b"junk", u32::MAX, &junk), tail()]); + assert!(read(&without).is_empty()); + } + + #[test] + fn hostile_rf64_size_tables_read_as_far_as_they_go_without_panicking() { + // sizes that overflow when added to an offset + for data in [u64::MAX, u64::MAX - 7, 1 << 63] { + let file = rf64(&[ds64_chunk(data, &[(*b"junk", u64::MAX)]), fmt(8000), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 0)])]); + assert!(read(&file).is_empty()); + } + // a table that claims far more entries than its chunk holds + let mut liar = ds64_chunk(0, &[]); + liar[8 + 24..8 + 28].copy_from_slice(&u32::MAX.to_le_bytes()); + let _ = read(&rf64(&[liar, fmt(8000), cue(&[(1, 0)])])); + // every truncation of a good RF64 file is safe + let good = + rf64(&[ds64_chunk(4, &[]), fmt(44100), chunk_with_size_field(b"data", u32::MAX, &[0; 4]), cue(&[(1, 44100)]), adtl(&[labl(b"labl", 1, "Kept")])]); + assert_eq!(read(&good).len(), 1); + for n in 0..good.len() { + let _ = read(&good[..n]); + } + } + + #[test] + fn a_huge_adtl_list_is_read_in_linear_time_and_only_for_the_cue_points_that_exist() { + // Records for ids that have no cue point are skipped, not stored, and the number looked at is capped. + let junk = |n: u32| (1000..1000 + n).map(|id| labl(b"labl", id, "n")).collect::>(); + let started = std::time::Instant::now(); + // the real label comes first: read + let mut subs = vec![labl(b"labl", 1, "Real")]; + subs.extend(junk(100_000)); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!(m[0].comment, "Real"); + // it comes after more records than are looked at: not read, and the marker is still there + let mut subs = junk(MAX_ADTL_RECORDS as u32 + 10); + subs.push(labl(b"labl", 1, "Too late")); + let m = read(&wav(&[fmt(44100), cue(&[(1, 44100)]), adtl(&subs)])); + assert_eq!((m.len(), m[0].comment.as_str()), (1, "")); + assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); + } } From 0c70ee026b0f960e17ce0fa16d19f59763f1d99c Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 19 Oct 2026 00:56:32 +0200 Subject: [PATCH 14/26] WAV markers: the host test covers the bound layers parameter and undo/redo The end-to-end test called Update Markers From Source only through the selection. It now also passes the layer as the bound `layers` parameter (what a script or the menu binding supplies), checks the markers by name, and checks that the update is one undo step: undo removes the restored marker again and redo brings it back. Co-Authored-By: Claude Sonnet 5.5 --- crates/host/tests/wav_markers.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/host/tests/wav_markers.rs b/crates/host/tests/wav_markers.rs index 6e3943915..6f88b9c9c 100644 --- a/crates/host/tests/wav_markers.rs +++ b/crates/host/tests/wav_markers.rs @@ -1,9 +1,17 @@ //! Markers a WAV file carries, through the fully wired session (media import included): the -//! layer made from the file starts with them, and Update Markers From Source copies them again. +//! layer made from the file starts with them, and Update Markers From Source copies them again, +//! through its bound `layers` parameter as well as the selection, as one undo step. +use effectcraft_engine::Session; use effectcraft_project::LayerSource; use serde_json::json; +/// The comments of a layer's markers, in time order. +fn names(s: &mut Session, layer: u64) -> Vec { + let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); + list.as_array().unwrap().iter().map(|m| m["comment"].as_str().unwrap().to_string()).collect() +} + /// A one-second mono 8 kHz WAV with two named cue points (a `cue ` chunk and a `LIST` `adtl`). fn marker_wav() -> Vec { fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { @@ -67,9 +75,18 @@ fn a_wav_layer_starts_with_the_markers_in_its_file() { list.as_array().unwrap().iter().map(|m| (m["comment"].as_str().unwrap().to_string(), m["time"].as_f64().unwrap())).collect(); assert_eq!(marks, [("Intro".to_string(), 0.0), ("Middle".to_string(), 0.5)]); s.execute_checked("markers.delete", json!({"layer": layer, "index": 0})).unwrap(); + assert_eq!(names(&mut s, layer), ["Middle"]); s.execute_checked("layer.select", json!({"layers": [layer]})).unwrap(); - assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 1); - let list = s.execute_checked("markers.list", json!({"layer": layer})).unwrap(); - assert_eq!(list.as_array().unwrap().len(), 2); + // The bound `layers` parameter (what a script or the menu's own binding passes) brings the marker back... + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // ...as one undo step: undo removes it again, redo restores it. + assert!(s.undo()); + assert_eq!(names(&mut s, layer), ["Middle"]); + assert!(s.redo()); + assert_eq!(names(&mut s, layer), ["Intro", "Middle"]); + // The selection route (no parameter) finds nothing more to add. + assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0); let _ = std::fs::remove_dir_all(&dir); } From 53399c2831a255022ffad5be1676b3d4a9cdd3b9 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 18:47:51 +0200 Subject: [PATCH 15/26] Expressions: cubic-Bezier easing helper (bezier(...)) (#1884) Add bezier(t, tMin, tMax, v1, v2, [x1, y1, x2, y2]) and the short bezier(t, v1, v2, curve) form: a CSS cubic-bezier easing curve solved with Newton-Raphson and a bisection fallback. Scalars and arrays work like linear/ease; x1/x2 clamp to 0..1, y1/y2 may overshoot, t clamps to tMin..tMax, and a curve that is not four finite numbers falls back to linear instead of throwing. Tests cover known curves (ease is [1/3, 0, 2/3, 1]), clamps, arrays, the short form, invalid curves and overshoot, plus two fuzz seeds. Closes #1884. --- crates/expr/src/lib.rs | 3 ++- crates/expr/src/prelude.js | 50 +++++++++++++++++++++++++++++++++++ crates/expr/src/tests.rs | 35 ++++++++++++++++++++++++ crates/expr/src/tests/fuzz.rs | 2 ++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index c77af5b37..0e1a92b03 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -17,7 +17,8 @@ //! `sourceRectAtTime`), property methods (`valueAtTime`, `velocityAtTime`, `speedAtTime`, `key`, //! `nearestKey`, `numKeys`, `wiggle`, `temporalWiggle`, `smooth`, `loopIn/Out[Duration]`) and the //! global helpers (`linear`/`ease`/`easeIn`/`easeOut`, `random`/`gaussRandom`/`seedRandom`/ -//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`). +//! `noise`, vector maths, `clamp`, `lookAt`, time and colour conversions, `posterizeTime`), +//! plus `bezier`, a CSS-style cubic-Bezier easing helper that After Effects does not have. //! * **Determinism**: `wiggle` and `random` are seeded by layer index, property uid (and time / //! `seedRandom`), so frames render identically on every thread and platform. //! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 6611d36aa..c69cd9a67 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -207,6 +207,56 @@ function ease() { return __interp(__ease, arguments); } function easeIn() { return __interp(__easeIn, arguments); } function easeOut() { return __interp(__easeOut, arguments); } +// Cubic-Bezier easing (the CSS cubic-bezier() shape): [x1, y1, x2, y2], with x1 / x2 in 0..1 +// and y1 / y2 free (overshoot included). Solves x(u) = x by Newton–Raphson, then by bisection +// when Newton leaves 0..1 or the derivative vanishes, and returns y(u). A curve that is not four +// finite numbers is linear, never an error. +function __cubicBezier(curve) { + var x1, y1, x2, y2; + if (Array.isArray(curve) && curve.length >= 4) { + x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3]; + } + if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' || + !isFinite(x1) || !isFinite(y1) || !isFinite(x2) || !isFinite(y2)) { + return __lin; + } + x1 = Math.max(0, Math.min(1, x1)); + x2 = Math.max(0, Math.min(1, x2)); + function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; } + function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); } + function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; } + return function (x) { + if (x <= 0) return 0; + if (x >= 1) return 1; + var u = x; + for (var i = 0; i < 8; i++) { + var f = bx(u) - x; + if (Math.abs(f) < 1e-9) return by(u); + var d = dbx(u); + if (Math.abs(d) < 1e-12) break; + var n = u - f / d; + if (!(n >= 0 && n <= 1)) break; + u = n; + } + var lo = 0, hi = 1; + for (var j = 0; j < 60; j++) { + var mid = (lo + hi) / 2; + if (bx(mid) < x) lo = mid; else hi = mid; + } + return by((lo + hi) / 2); + }; +} + +// bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is +// the last argument; everything before it follows linear / ease. +function bezier() { + var curve = arguments[arguments.length - 1]; + var shape = __cubicBezier(curve); + var args = []; + for (var i = 0; i < arguments.length - 1; i++) args.push(arguments[i]); + return __interp(shape, args); +} + // --------------------------------------------------------------------------------------------- // Random numbers and noise. diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 7d2790255..27b374078 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -313,6 +313,41 @@ fn interpolation_with_arrays() { assert_eq!(f.pos("ease(time, 0, 1, [0, 0], [100, 50])", 1.0), [100.0, 50.0, 0.0]); } +#[test] +fn cubic_bezier_curves() { + let f = fx(); + // ease(t, …) is cubic-bezier(1/3, 0, 2/3, 1): x(u) = u and y(u) = 3u² - 2u³. + assert_close!(f.num("bezier(0.25, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), f.num("ease(0.25, 0, 1, 0, 100)", 0.0)); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.3333333333333333, 0, 0.6666666666666666, 1])", 0.0), 50.0); + // Symmetric ease-in-out: slower at both ends, faster in the middle. + let early = f.num("bezier(0.25, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + let late = f.num("bezier(0.75, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0); + assert!(early < 25.0 && late > 75.0, "ease-in-out: {early}, {late}"); + // Control points on the diagonal are linear. + assert_close!(f.num("bezier(0.3, 0, 1, 0, 100, [0, 0, 1, 1])", 0.0), 30.0); + // t is clamped to tMin..tMax; tMin == tMax behaves like the other helpers. + assert_close!(f.num("bezier(-1, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 0.0); + assert_close!(f.num("bezier(2, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); + assert_close!(f.num("bezier(5, 2, 2, 0, 100, [0.42, 0, 0.58, 1])", 0.0), 100.0); +} + +#[test] +fn cubic_bezier_short_form_arrays_and_fallbacks() { + let f = fx(); + // Short form: no tMin / tMax. + assert_close!(f.num("bezier(0.25, 10, 20, [0, 0, 1, 1])", 0.0), 12.5); + assert_eq!(f.pos("bezier(time, 0, 1, [0, 0], [100, 50], [0.42, 0, 0.58, 1])", 1.0), [100.0, 50.0, 0.0]); + // y outside 0..1 overshoots, as CSS allows. + let over = f.num("bezier(0.5, 0, 1, 0, 100, [0.5, 2, 0.5, 2])", 0.0); + assert!(over > 100.0, "overshoot: {over}"); + // A curve that is not four finite numbers falls back to linear instead of throwing. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0); + // x control points outside 0..1 are clamped, as CSS requires. + let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0); + assert!(v.is_finite(), "clamped curve: {v}"); +} + // ---- object model ----------------------------------------------------------------------------- #[test] diff --git a/crates/expr/src/tests/fuzz.rs b/crates/expr/src/tests/fuzz.rs index 1bfaf8c16..37b28c415 100644 --- a/crates/expr/src/tests/fuzz.rs +++ b/crates/expr/src/tests/fuzz.rs @@ -93,6 +93,8 @@ const SEEDS: &[&str] = &[ "linear(time, 0, 1, 0, 100)", "ease(time, 0, 2, [0, 0], [100, 50])", "easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)", + "bezier(time, 0, 1, 0, 100, [0.42, 0, 0.58, 1])", + "bezier(time, [0, 0], [100, 50], [0.25, 0.1, 0.25, 1])", "clamp(value * 2, 0, 100)", "length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]", "normalize([3, 4])[0] * degreesToRadians(180)", From 06bdd7e2b653f3f122eb6f2a67647a3ee41d54cb Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 18:57:04 +0200 Subject: [PATCH 16/26] Expressions: offer bezier in the language menu and auto-complete (#1884) Add the helper to the expression editor's completions (syntax highlighting and auto-complete) and to the Expression Language menu's Interpolation group, so accepting it inserts the call parentheses like linear/ease. --- crates/engine/src/cmd/expr_tools.rs | 2 ++ crates/ui-egui/src/panels/expr_editor.rs | 1 + 2 files changed, 3 insertions(+) diff --git a/crates/engine/src/cmd/expr_tools.rs b/crates/engine/src/cmd/expr_tools.rs index fae74a5b5..cb39077af 100644 --- a/crates/engine/src/cmd/expr_tools.rs +++ b/crates/engine/src/cmd/expr_tools.rs @@ -163,6 +163,8 @@ pub fn language_menu() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> ("easeIn(t, tMin, tMax, value1, value2)", "easeIn(t, tMin, tMax, value1, value2)"), ("easeOut(t, value1, value2)", "easeOut(t, value1, value2)"), ("easeOut(t, tMin, tMax, value1, value2)", "easeOut(t, tMin, tMax, value1, value2)"), + ("bezier(t, value1, value2, [x1, y1, x2, y2])", "bezier(t, value1, value2, [x1, y1, x2, y2])"), + ("bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])", "bezier(t, tMin, tMax, value1, value2, [x1, y1, x2, y2])"), ], ), ( diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs index 157288b94..6d742dc4e 100644 --- a/crates/ui-egui/src/panels/expr_editor.rs +++ b/crates/ui-egui/src/panels/expr_editor.rs @@ -111,6 +111,7 @@ pub const COMPLETIONS: &[&str] = &[ "ease", "easeIn", "easeOut", + "bezier", "clamp", "random", "gaussRandom", From efcdad04af771dbf1a9189abef9a2cfdaf820743 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Mon, 19 Oct 2026 20:56:23 +0200 Subject: [PATCH 17/26] Expressions: reject malformed bezier curves and bound the y error - accept only four-element arrays for bezier(): extra values make the curve malformed, so it falls back to linear like the other invalid forms (#1884); - stop Newton on the estimated y error (f * dy/dx) instead of a residual in x alone: near the endpoints dy/dx is in the thousands, so a 1e-9 residual in x could still move the returned value; - tests: a five-element curve is linear, and three steep curves match a bisection reference within 1e-6. --- crates/expr/src/prelude.js | 7 +++++-- crates/expr/src/tests.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index c69cd9a67..589dde55c 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -213,7 +213,7 @@ function easeOut() { return __interp(__easeOut, arguments); } // finite numbers is linear, never an error. function __cubicBezier(curve) { var x1, y1, x2, y2; - if (Array.isArray(curve) && curve.length >= 4) { + if (Array.isArray(curve) && curve.length === 4) { x1 = curve[0]; y1 = curve[1]; x2 = curve[2]; y2 = curve[3]; } if (typeof x1 !== 'number' || typeof y1 !== 'number' || typeof x2 !== 'number' || typeof y2 !== 'number' || @@ -225,15 +225,18 @@ function __cubicBezier(curve) { function bx(u) { var v = 1 - u; return 3 * v * v * u * x1 + 3 * v * u * u * x2 + u * u * u; } function dbx(u) { var v = 1 - u; return 3 * v * v * x1 + 6 * v * u * (x2 - x1) + 3 * u * u * (1 - x2); } function by(u) { var v = 1 - u; return 3 * v * v * u * y1 + 3 * v * u * u * y2 + u * u * u; } + function dby(u) { var v = 1 - u; return 3 * v * v * y1 + 6 * v * u * (y2 - y1) + 3 * u * u * (1 - y2); } return function (x) { if (x <= 0) return 0; if (x >= 1) return 1; var u = x; for (var i = 0; i < 8; i++) { var f = bx(u) - x; - if (Math.abs(f) < 1e-9) return by(u); var d = dbx(u); if (Math.abs(d) < 1e-12) break; + // Stop when the *value* is accurate: near the endpoints dy/dx is steep, so a residual in x + // alone can still mean a visible error in y (f * dy/dx estimates it). + if (Math.abs(f * dby(u) / d) < 1e-9) return by(u); var n = u - f / d; if (!(n >= 0 && n <= 1)) break; u = n; diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 27b374078..b651802f8 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -343,13 +343,42 @@ fn cubic_bezier_short_form_arrays_and_fallbacks() { // A curve that is not four finite numbers falls back to linear instead of throwing. assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0); assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0); + // Exactly four values are a curve; extra ones mean the curve is malformed, so linear. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0); // x control points outside 0..1 are clamped, as CSS requires. let v = f.num("bezier(0.5, 0, 1, 0, 100, [-1, 0, 2, 1])", 0.0); assert!(v.is_finite(), "clamped curve: {v}"); } -// ---- object model ----------------------------------------------------------------------------- +#[test] +fn cubic_bezier_is_accurate_near_the_endpoints() { + /// The value the curve reaches at `x`, by bisection on its monotone x(u). + fn reference(x1: f64, y1: f64, x2: f64, y2: f64, x: f64) -> f64 { + let bx = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * x1 + 3.0 * (1.0 - u) * u * u * x2 + u * u * u; + let by = |u: f64| 3.0 * (1.0 - u) * (1.0 - u) * u * y1 + 3.0 * (1.0 - u) * u * u * y2 + u * u * u; + let (mut lo, mut hi) = (0.0f64, 1.0f64); + for _ in 0..100 { + let mid = 0.5 * (lo + hi); + if bx(mid) < x { + lo = mid; + } else { + hi = mid; + } + } + by(0.5 * (lo + hi)) + } + // Steep curves: a residual in x is not a residual in y near the endpoints (dy/dx is in the + // thousands), so the solver stops on the estimated y error instead. + let f = fx(); + for (c, x) in [([0.0, 1000.0, 1.0, 1.0], 1e-6), ([0.0, 100.0, 1.0, 0.0], 1e-9), ([0.8, -500.0, 1.0, 1.0], 0.999999)] { + let got = f.num(&format!("bezier({x}, 0, 1, [{}, {}, {}, {}])", c[0], c[1], c[2], c[3]), 0.0); + let want = reference(c[0], c[1], c[2], c[3], x); + assert!((got - want).abs() < 1e-6, "curve {c:?} at {x}: {got} vs {want}"); + } +} +// ---- object model ----------------------------------------------------------------------------- #[test] fn comp_attributes() { let f = fx(); From 7038f4f0ee17227f21c3272af4cc4f8d3eac4d7f Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 17:30:03 +0200 Subject: [PATCH 18/26] Expressions: read bezier curve by position (#1884) A missing or unusable curve no longer consumes the last value. The long form reads argument 5 and the short form argument 3, with the other arities falling back to linear interpolation. Adds the missing-curve and four-element-array cases reported in review. --- crates/expr/src/prelude.js | 13 +++++++------ crates/expr/src/tests.rs | 6 ++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 589dde55c..62d7f1b3a 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -251,13 +251,14 @@ function __cubicBezier(curve) { } // bezier(t, tMin, tMax, value1, value2, curve), or bezier(t, value1, value2, curve): the curve is -// the last argument; everything before it follows linear / ease. +// the sixth / fourth argument. Read it by position, not from the end: when it is missing or +// unusable, the interpolation falls back to linear without consuming the last value. function bezier() { - var curve = arguments[arguments.length - 1]; - var shape = __cubicBezier(curve); - var args = []; - for (var i = 0; i < arguments.length - 1; i++) args.push(arguments[i]); - return __interp(shape, args); + var a = arguments, n = a.length; + if (n >= 6) return __interp(__cubicBezier(a[5]), [a[0], a[1], a[2], a[3], a[4]]); + if (n === 5) return __interp(__lin, [a[0], a[1], a[2], a[3], a[4]]); + if (n === 4) return __interp(__cubicBezier(a[3]), [a[0], a[1], a[2]]); + return __interp(__lin, [a[0], a[1], a[2]]); } // --------------------------------------------------------------------------------------------- diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index b651802f8..4a13ebd8a 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -343,6 +343,12 @@ fn cubic_bezier_short_form_arrays_and_fallbacks() { // A curve that is not four finite numbers falls back to linear instead of throwing. assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [1, 0])", 0.0), 50.0); assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, 'ease')", 0.0), 50.0); + // A missing or unusable curve must not consume the last value: read the curve by position. + assert_close!(f.num("bezier(0.5, 0, 1, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100)", 0.0), 50.0); + assert_close!(f.num("bezier(0.5, 0, 100, [1, 0])", 0.0), 50.0); + let v = f.ev(f.shape, "contents/#1/contents/fill/color", "bezier(0.5, 0, 1, [0, 0, 0, 1], [1, 1, 1, 1])", 0.0).unwrap(); + assert_eq!(v, Value::Color([0.5, 0.5, 0.5, 1.0])); // Exactly four values are a curve; extra ones mean the curve is malformed, so linear. assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 0])", 0.0), 50.0); assert_close!(f.num("bezier(0.5, 0, 1, 0, 100, [0.42, 0, 0.58, 1, 2, 3])", 0.0), 50.0); From 942b47847295ffb06ffdefdeb15c51b11ed8ba66 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:22:49 +0000 Subject: [PATCH 19/26] 1063: name the companion properties of text animators (Track Type, Skew Axis, X / Y Rotation) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/script/src/matchnames.rs | 25 ++++++++++++++++++++++++- crates/script/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 15c1201ea..3dd717aa8 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -118,6 +118,16 @@ pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[ ("blur", "ADBE Text Blur"), ]; +/// Companion properties that exist below an animator but are not kinds `addProperty` accepts: +/// they come with Tracking (Track Type), Skew (Skew Axis) and per-character 3D Rotation +/// (X and Y Rotation). Lookup and `.matchName` know them; `addProperty` does not. +pub const TEXT_ANIMATOR_COMPANIONS: &[(&str, &str)] = &[ + ("trackingType", "ADBE Text Track Type"), + ("skewAxis", "ADBE Text Skew Axis"), + ("rotationX", "ADBE Text Rotation X"), + ("rotationY", "ADBE Text Rotation Y"), +]; + const TEXT_SELECTORS: &[(&str, &str)] = &[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")]; @@ -516,7 +526,7 @@ pub fn node(chain: &[&Node]) -> String { "text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own), "text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own), "text" if pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => { - find(TEXT_ANIMATOR_PROPERTIES, id).map(str::to_string).unwrap_or_else(own) + find(TEXT_ANIMATOR_PROPERTIES, id).or_else(|| find(TEXT_ANIMATOR_COMPANIONS, id)).map(str::to_string).unwrap_or_else(own) } "text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own), "cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own), @@ -579,6 +589,19 @@ mod tests { } } + /// #1063: the companions an animator property brings (Track Type, Skew Axis, X / Y Rotation) + /// have their After Effects names too, though `addProperty` does not accept them. + #[test] + fn text_animator_companions_have_after_effects_names() { + let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); + let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties")); + for (id, name) in TEXT_ANIMATOR_COMPANIONS { + let prop = n(id); + assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name); + assert!(!TEXT_ANIMATOR_PROPERTIES.iter().any(|(k, m)| k == id || m == name), "{id} is not an addProperty kind"); + } + } + #[test] fn text_range_selector_names() { let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m)); diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 786dec561..875885c11 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -1740,6 +1740,29 @@ fn scripted_text_animator_property_is_the_one_asked_for() { assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2])); } +/// The companion properties added with Tracking and Skew are found by their After Effects names, +/// but cannot be added on their own (#1063). +#[test] +fn text_animator_companion_properties_resolve_by_name() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30); + var t = c.layers.addText("MOVARA"); + var props = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator").property("ADBE Text Animator Properties"); + props.addProperty("ADBE Text Tracking Amount"); + props.addProperty("ADBE Text Skew"); + var type = props.property("ADBE Text Track Type"); + var axis = props.property("ADBE Text Skew Axis"); + var added = ""; + try { props.addProperty("ADBE Text Track Type"); added = "added"; } catch (e) { added = "refused"; } + [type ? type.matchName : null, axis ? axis.matchName : null, added] + "#, + ); + assert_eq!(o.result, json!(["ADBE Text Track Type", "ADBE Text Skew Axis", "refused"])); +} + /// #1936: an array whose length doesn't match the property's dimension is an error in After /// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal. #[test] From b5b77f446e3f86fa3cb5d24eb996820b59416511 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:41:27 +0000 Subject: [PATCH 20/26] #1417: Reload Footage re-reads a file's markers; index marker times when merging them Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/engine/src/cmd/markers.rs | 5 ++- crates/engine/src/footage/footage_reload.rs | 2 + crates/host/tests/it/wav_markers.rs | 44 ++++++++++++++++++--- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/markers.rs b/crates/engine/src/cmd/markers.rs index 35ab7123f..3530eaa4b 100644 --- a/crates/engine/src/cmd/markers.rs +++ b/crates/engine/src/cmd/markers.rs @@ -328,8 +328,11 @@ fn update_from_source(s: &mut Session, p: &Value) -> Result { if l.markers_locked { continue; } + // One set of the times already on the layer: a file with thousands of markers must not + // scan the layer's list for each of them. + let mut times: std::collections::HashSet<_> = l.markers.iter().map(|x| x.time).collect(); for m in ms { - if !l.markers.iter().any(|x| x.time == m.time) { + if times.insert(m.time) { l.markers.push(m.clone()); n += 1; } diff --git a/crates/engine/src/footage/footage_reload.rs b/crates/engine/src/footage/footage_reload.rs index 75fd0f936..267985e37 100644 --- a/crates/engine/src/footage/footage_reload.rs +++ b/crates/engine/src/footage/footage_reload.rs @@ -81,6 +81,8 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { nf.codec = probed.codec; nf.has_video = probed.has_video; nf.has_audio = probed.has_audio; + // The markers the file carries now (a WAV's cue points), for Update Markers From Source. + nf.markers = probed.markers; if matches!(f.kind, FootageKind::Video | FootageKind::Audio) { nf.duration = probed.duration; // Interpret Footage ▸ Conform to frame rate stays; the file's own rate is refreshed. diff --git a/crates/host/tests/it/wav_markers.rs b/crates/host/tests/it/wav_markers.rs index 6f88b9c9c..98d2685dc 100644 --- a/crates/host/tests/it/wav_markers.rs +++ b/crates/host/tests/it/wav_markers.rs @@ -12,8 +12,9 @@ fn names(s: &mut Session, layer: u64) -> Vec { list.as_array().unwrap().iter().map(|m| m["comment"].as_str().unwrap().to_string()).collect() } -/// A one-second mono 8 kHz WAV with two named cue points (a `cue ` chunk and a `LIST` `adtl`). -fn marker_wav() -> Vec { +/// A one-second mono 8 kHz WAV with the named cue points `(id, sample, name)` (a `cue ` chunk and a +/// `LIST` `adtl`). +fn marker_wav(points: &[(u32, u32, &str)]) -> Vec { fn chunk(id: &[u8; 4], body: &[u8]) -> Vec { let mut v = id.to_vec(); v.extend((body.len() as u32).to_le_bytes()); @@ -30,10 +31,9 @@ fn marker_wav() -> Vec { fmt.extend(16000u32.to_le_bytes()); fmt.extend(2u16.to_le_bytes()); fmt.extend(16u16.to_le_bytes()); - let points = [(1u32, 0u32, "Intro"), (2, 4000, "Middle")]; let mut cue = (points.len() as u32).to_le_bytes().to_vec(); let mut adtl = b"adtl".to_vec(); - for (id, at, name) in points { + for &(id, at, name) in points { for v in [id, 0] { cue.extend(v.to_le_bytes()); } @@ -66,7 +66,7 @@ fn a_wav_layer_starts_with_the_markers_in_its_file() { let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-markers-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("cues.wav"); - std::fs::write(&path, marker_wav()).unwrap(); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Middle")])).unwrap(); let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); assert_eq!(r["errors"], json!([]), "{r}"); let layer = s.active_comp().unwrap().layers.iter().find(|l| matches!(l.source, LayerSource::Footage { .. })).unwrap().id.0; @@ -90,3 +90,37 @@ fn a_wav_layer_starts_with_the_markers_in_its_file() { assert_eq!(s.execute_checked("layer.updateMarkersFromSource", json!({})).unwrap()["added"], 0); let _ = std::fs::remove_dir_all(&dir); } + +/// Reload Footage re-reads the markers the file carries: a cue point added to the file after the +/// import is available to Update Markers From Source (and a layer already made keeps its own). +#[test] +fn reloading_a_wav_picks_up_cue_points_added_to_the_file() { + let mut s = effectcraft_host::session(); + s.execute_checked("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 30, "duration": 2})).unwrap(); + let dir = std::env::temp_dir().join(format!("effectcraft-host-wav-reload-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cues.wav"); + std::fs::write(&path, marker_wav(&[(1, 0, "Intro")])).unwrap(); + let r = s.execute_checked("file.import", json!({"paths": [path.to_string_lossy()], "addToComp": true})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let (layer, item) = s + .active_comp() + .unwrap() + .layers + .iter() + .find_map(|l| match l.source { + LayerSource::Footage { item } => Some((l.id.0, item.0)), + _ => None, + }) + .unwrap(); + assert_eq!(names(&mut s, layer), ["Intro"]); + // The file gains a cue point; the layer does not change until asked. + std::fs::write(&path, marker_wav(&[(1, 0, "Intro"), (2, 4000, "Added later")])).unwrap(); + let r = s.execute_checked("file.reloadFootage", json!({"items": [item]})).unwrap(); + assert_eq!(r["reloaded"], 1, "{r}"); + assert_eq!(names(&mut s, layer), ["Intro"]); + let r = s.execute_checked("layer.updateMarkersFromSource", json!({"layers": [layer]})).unwrap(); + assert_eq!(r["added"], 1); + assert_eq!(names(&mut s, layer), ["Intro", "Added later"]); + let _ = std::fs::remove_dir_all(&dir); +} From b0be02fff41049603e422b82493525a039daaf1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:52:23 +0000 Subject: [PATCH 21/26] 1010: cap baked Orientation keys per property; leave one-node rigs under a mirrored parent as authored Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/engine/src/cmd/layer/parent.rs | 25 +++++++++++++---- crates/engine/src/tests/rig3d.rs | 39 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index 64195d646..dd4c842a2 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -220,17 +220,19 @@ fn rotation_angle(a: effectcraft_geom::Mat4, b: effectcraft_geom::Mat4) -> f64 { } /// Adds keys between `(ta, ea)` and `(tb, eb)` (already converted) wherever the straight Euler blend -/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. +/// between them drifts from `correction * Orientation(authored)` by more than the tolerance. `out` +/// never grows past `budget` keys: what the whole property may still add, not what one segment may. fn bake_between( correction: effectcraft_geom::Mat4, authored: &effectcraft_project::Property, (ta, ea): (Tick, [f64; 3]), (tb, eb): (Tick, [f64; 3]), depth: u32, + budget: usize, out: &mut Vec, ) { use effectcraft_geom::{Mat4, Vec3}; - if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= BAKE_MAX_KEYS { + if depth == 0 || tb.0 - ta.0 < 2 || out.len() >= budget { return; } let tm = Tick(ta.0 + (tb.0 - ta.0) / 2); @@ -248,9 +250,12 @@ fn bake_between( return; } let em = nearest_euler(euler_zyx(exact.0), blend); - bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, out); + bake_between(correction, authored, (ta, ea), (tm, em), depth - 1, budget, out); + if out.len() >= budget { + return; + } out.push(effectcraft_keyframe::Keyframe::new(tm, KV::Vec3(em))); - bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, out); + bake_between(correction, authored, (tm, em), (tb, eb), depth - 1, budget, out); } /// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the @@ -308,7 +313,10 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf let segment = authored.keys.get(i + 1).filter(|_| key.out_interp != Interp::Hold); let mut between = vec![]; if let Some(next) = segment { - bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, &mut between); + // What the whole property may still add: the keys it will have already (the ones written + // and the authored ones still to come) count against the cap. + let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i); + bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between); // An eased segment, or one that gained keys, becomes a run of straight blends. if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { k.out_interp = Interp::Linear; @@ -391,6 +399,13 @@ pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Op if let Some(inv) = space_3d(parent).inverse().filter(|m| m.0.iter().flatten().all(|v| v.is_finite())) { for layer in comp.layers.iter().filter(|l| ids.contains(&l.id) && l.is_3d() && l.parent != parent) { let m = inv * space_3d(layer.parent); + // A one-node camera or light has no Scale to carry a mirror, and its frame (forward and + // down) would come out reversed: compensate none of it, so Position and Orientation stay + // as authored instead of half-following the parent. + let rig = layer.is_camera() || layer.is_light(); + if rig && !effectcraft_render::three_d::camera::is_two_node(layer) && uniform_rotation(m).is_some_and(|(_, factor)| factor <= 0.0) { + continue; + } if m.0.iter().flatten().all(|v| v.is_finite()) { let pose_3d = layer.transform().and_then(|tr| { let orientation = tr.get("orientation")?; diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index 059e74fbc..0fef2434c 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -333,6 +333,45 @@ fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { } } +/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and +/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is +/// compensated rather than reversing its frame (a half-compensated light would point backwards). +#[test] +fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap(); + let before = layer_in(&s, cid(&s), light); + let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0); + s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap(); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let after = layer_in(&s, cid(&s), light); + assert_eq!(after.parent, Some(LayerId(nul))); + assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position")); + assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation")); +} + +/// The cap on keys added by baking is for the whole Orientation property, not for each segment: +/// many strongly eased segments under a tilted parent stay within it. +#[test] +fn baked_orientation_keys_are_capped_across_the_whole_property() { + let mut s = session(); + let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap(); + let count = 120; + for i in 0..count { + let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) }; + s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap(); + } + let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid; + let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0); + s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); + let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len(); + assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment"); +} + // ---------------------------------------------------------------- from 3D model /// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points. From 77050532a4ed0b49d48ba9e90a64f2c007068fb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:50:56 +0000 Subject: [PATCH 22/26] 1010: keep authored easing on segments the baking cap leaves unchecked Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U --- crates/engine/src/cmd/layer/parent.rs | 6 +++++- crates/engine/src/tests/rig3d.rs | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index dd4c842a2..8b345a95f 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -317,8 +317,12 @@ fn reexpress_orientation(correction: effectcraft_geom::Mat4, pr: &mut effectcraf // and the authored ones still to come) count against the cap. let budget = BAKE_MAX_KEYS.saturating_sub(keys.len() + authored.keys.len() - i); bake_between(correction, &authored, (key.time, converted[i]), (next.time, converted[i + 1]), BAKE_MAX_DEPTH, budget, &mut between); + // With the cap reached the segment can't be checked or subdivided: it keeps its authored easing between + // the converted keys, rather than becoming a straight blend that was never checked against the original. + if between.len() >= budget { + between.clear(); // An eased segment, or one that gained keys, becomes a run of straight blends. - if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { + } else if !between.is_empty() || key.out_interp != Interp::Linear || next.in_interp != Interp::Linear { k.out_interp = Interp::Linear; k.out_ease.clear(); k.auto_bezier = false; diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index 0fef2434c..4c7c4edab 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -370,6 +370,11 @@ fn baked_orientation_keys_are_capped_across_the_whole_property() { s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap(); let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len(); assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment"); + // the segments the cap left unchecked keep their authored easing instead of turning into straight blends + let orientation = layer_in(&s, cid(&s), light); + let keys = &orientation.props.prop("transform/orientation").unwrap().keys; + let last = keys.iter().rev().nth(1).expect("the penultimate key"); + assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing"); } // ---------------------------------------------------------------- from 3D model From df0a88b7957783885794b88b0800c6ca6a822765 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 16:42:59 +0200 Subject: [PATCH 23/26] Fix collapsed precomp Essential Property overrides (#1747) Carry property, Font and Media Replacement overrides into the collapsed nested EvalCtx so nested layers render beyond the precomp frame instead of falling back to a clipped, flattened image. Key the layer cache on instance overrides to keep distinct collapsed instances isolated. --- crates/engine/src/analysis/tracking.rs | 2 +- crates/engine/src/cmd/effect.rs | 55 +++++++- crates/engine/src/cmd/layer/parent.rs | 12 +- crates/engine/src/cmd/layer_time.rs | 11 +- crates/engine/src/cmd/path_nulls.rs | 23 ++- crates/engine/src/cmd/prop.rs | 12 +- crates/engine/src/cmd/puppet.rs | 3 +- crates/engine/src/cmd/rig3d.rs | 11 +- crates/engine/src/cmd/three_d.rs | 6 +- crates/engine/src/tests/crypto_pick.rs | 11 +- crates/engine/src/tests/essential.rs | 122 ++++++++++++++++ crates/engine/src/tests/essential_more.rs | 31 ++++ crates/engine/src/tests/rig3d.rs | 2 +- crates/expr/src/host.rs | 12 +- crates/expr/src/lib.rs | 11 +- crates/expr/src/tests.rs | 11 +- crates/gpu/src/tests/adv3d.rs | 22 ++- crates/gpu/src/walk.rs | 15 +- crates/host/src/tests.rs | 23 ++- crates/render/src/accel.rs | 9 +- crates/render/src/cache.rs | 22 ++- crates/render/src/cache/dependencies.rs | 7 + crates/render/src/eval.rs | 74 ++++++++-- crates/render/src/frames.rs | 4 +- crates/render/src/fx_host.rs | 5 +- crates/render/src/layers.rs | 4 +- crates/render/src/lib.rs | 1 + crates/render/src/pipeline.rs | 133 +++++++++++++++--- crates/render/src/tests/collapse.rs | 62 +++++++- crates/render/src/tests/time.rs | 11 +- crates/render/src/three_d/adv/scene.rs | 8 +- crates/render/src/three_d/adv/tests.rs | 44 +++++- crates/render/src/three_d/compose.rs | 29 ++-- crates/script/src/tests/ui_more.rs | 11 +- crates/ui-egui/src/menus/invoke.rs | 11 +- .../src/panels/effect_controls/panel.rs | 11 +- crates/ui-egui/src/panels/layer_panel.rs | 11 +- crates/ui-egui/src/panels/properties.rs | 11 +- crates/ui-egui/src/panels/roto_tool.rs | 11 +- crates/ui-egui/src/panels/text_panels.rs | 11 +- crates/ui-egui/src/panels/timeline.rs | 11 +- crates/ui-egui/src/panels/viewer.rs | 11 +- crates/ui-egui/src/panels/viewer/shapes.rs | 2 + crates/ui-egui/src/panels/viewer/view.rs | 11 +- docs/architecture.md | 11 +- 45 files changed, 820 insertions(+), 111 deletions(-) diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index 434cdd8af..fe04f882b 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -207,7 +207,7 @@ pub(crate) fn source_frame( t: Tick, expr: Option<&dyn ExprHost>, ) -> Option<(Arc, [f64; 2])> { - let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None }; + let ctx = EvalCtx { project, comp_id: cid, comp, time: t, expr, footage: None, property_overrides: &[], media_overrides: &[] }; if let effectcraft_project::LayerSource::Footage { item } = &layer.source && let Some(effectcraft_project::ItemKind::Footage(f)) = project.item(*item).map(|i| &i.kind) && f.has_video diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 0a1354fd6..2d05085cf 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -136,7 +136,16 @@ fn warning(s: &mut Session, p: &Value) -> Result { let layer = comp.layer(lid).ok_or(EngineError::NoComp)?; let g = layer.effects().and_then(|fx| fx.groups().find(|g| g.uid == uid)).ok_or_else(|| bad(cmd, "no such effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; Ok(json!({"effect": uid, "warning": effectcraft_effects::warning(g, &mut |pr| ctx.value(layer, pr))})) } @@ -160,7 +169,16 @@ fn pick_color(s: &mut Session, p: &Value) -> Result { .filter(|pr| pr.ui == effectcraft_project::ParamUi::Color) .ok_or_else(|| bad(cmd, "not a colour parameter of this effect"))?; let t = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: t, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = effectcraft_render::Renderer::new(&s.project, &*s.footage, effectcraft_render::RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); @@ -288,7 +306,16 @@ impl CryptoProbe { let comp = r.project.comp(cid).ok_or("no composition")?; let layer = comp.layer(lid).ok_or("no such layer")?; let g = layer.effects().and_then(|fx| fx.groups().nth(pos)).ok_or("no such effect")?; - let ctx = effectcraft_render::EvalCtx { project: r.project, comp_id: cid, comp, time, expr: r.expr, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: r.project, + comp_id: cid, + comp, + time, + expr: r.expr, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let params = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)); let want = effectcraft_effects::CRYPTO_LAYERS.get(params.e("layer") as usize).copied().unwrap_or("CryptoAsset"); let aux = r.layer_aux(&ctx, layer).ok_or(NO_CRYPTO)?; @@ -351,7 +378,16 @@ fn probe_key<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(ProbeKey, Tick let g = fx.groups().nth(pos).ok_or_else(|| bad(cmd, "no such effect"))?; let prop = g.prop("selection").filter(|_| g.match_id == "ec.3d.cryptomatte").ok_or_else(|| bad(cmd, "not a Cryptomatte effect"))?; let time = super::time_p(s, p, Some(comp)); - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let frame = probe_frame(&ctx, layer, pos); let o = s.render_opts(Default::default()); let prefs = (o.nested_switches, o.draft_shadows); @@ -503,7 +539,16 @@ fn crypto_hit(s: &Session, p: &Value, cmd: &str) -> Result { let Some(hash) = b.aux.index_at(ax, ay).and_then(|i| b.aux.crypto_id_at(&b.layer, i)) else { return Ok(Hit::Nothing) }; let (cid, lid, ..) = key.at; let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time, expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time, + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selection = effectcraft_effects::flatten_params(g, &mut |pr| ctx.value(layer, pr)).s("selection").to_string(); let driven = prop.expr.as_ref().is_some_and(|e| e.enabled); let (aux, layer_name) = (b.aux.clone(), b.layer.clone()); diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index fbaec14e4..88eaf92f7 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -196,8 +196,16 @@ fn uniform_parent_pose(m: effectcraft_geom::Mat4, orientation: [f64; 3]) -> Opti /// time. pub(crate) fn parent_fixes(s: &Session, cid: ItemId, ids: &[LayerId], parent: Option) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ectx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let space = |id: Option| -> effectcraft_geom::Mat3 { id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) }; diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index d98bfdea7..368e280c6 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -185,7 +185,16 @@ fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer")); } // What each layer shows now (honouring an existing remap) — evaluated before the edit. - let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ectx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: cti, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; let now: Vec<(effectcraft_project::LayerId, f64)> = comp .layers .iter() diff --git a/crates/engine/src/cmd/path_nulls.rs b/crates/engine/src/cmd/path_nulls.rs index bb74ee77c..93d71cd67 100644 --- a/crates/engine/src/cmd/path_nulls.rs +++ b/crates/engine/src/cmd/path_nulls.rs @@ -78,7 +78,16 @@ fn info(s: &Session, cid: ItemId, lid: LayerId, uid: Uid, cmd: &str) -> Result

Result Res let fd = comp.frame_duration(); let mut t = layer.in_point; while t < layer.out_point { - let ctx = - effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp: &comp, time: t, expr: s.expr.as_deref(), footage: Some(s.footage.as_ref()) }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp: &comp, + time: t, + expr: s.expr.as_deref(), + footage: Some(s.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + }; keys.push(Keyframe::new(layer.layer_time(t), ctx.value(&layer, &pr))); t += fd; } diff --git a/crates/engine/src/cmd/puppet.rs b/crates/engine/src/cmd/puppet.rs index 320e1d415..1d080e773 100644 --- a/crates/engine/src/cmd/puppet.rs +++ b/crates/engine/src/cmd/puppet.rs @@ -38,7 +38,8 @@ pub fn puppet_eval(s: &Session, cid: ItemId, lid: LayerId, fx_uid: Uid, t: Tick) let fx = layer.effects()?; let index = fx.groups().position(|g| g.uid == fx_uid)?; let g = fx.groups().nth(index)?; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut r = Renderer::new(&s.project, &*s.footage, RenderOpts::default()); r.expr = s.expr.as_deref(); r.cache = Some(&s.layer_cache); diff --git a/crates/engine/src/cmd/rig3d.rs b/crates/engine/src/cmd/rig3d.rs index f034fa641..5b17fcffe 100644 --- a/crates/engine/src/cmd/rig3d.rs +++ b/crates/engine/src/cmd/rig3d.rs @@ -48,7 +48,16 @@ impl Env { Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() } } fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> { - EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) } + EvalCtx { + project: proj, + comp_id: cid, + comp, + time: self.time, + expr: self.expr.as_deref(), + footage: Some(self.footage.as_ref()), + property_overrides: &[], + media_overrides: &[], + } } } diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index e143690d3..1cf470ebe 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -413,7 +413,8 @@ fn get_3d(s: &mut Session, p: &Value) -> Result { let (w, h) = (comp.width as f64, comp.height as f64); let vs = s.state.views3d.get(&cid).cloned().unwrap_or_default(); let t = s.state.times.get(&cid).copied().unwrap_or(Tick::ZERO); - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let cam = vs.override_camera(w, h).unwrap_or_else(|| camera::active_camera(&ctx)); let f = cam.forward(); let lights: Vec = comp @@ -555,7 +556,8 @@ fn auto_orient(s: &mut Session, p: &Value) -> Result { /// through each layer's world matrix; cameras and lights contribute their position). fn layer_points(s: &Session, cid: ItemId, ids: &[LayerId], t: Tick) -> Vec { let Some(comp) = s.project.comp(cid) else { return vec![] }; - let ctx = EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None }; + let ctx = + EvalCtx { project: &s.project, comp_id: cid, comp, time: t, expr: s.expr.as_deref(), footage: None, property_overrides: &[], media_overrides: &[] }; let mut out = vec![]; for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { let m = ctx.world_matrix(l); diff --git a/crates/engine/src/tests/crypto_pick.rs b/crates/engine/src/tests/crypto_pick.rs index eacae55af..2e61f57a1 100644 --- a/crates/engine/src/tests/crypto_pick.rs +++ b/crates/engine/src/tests/crypto_pick.rs @@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap(); let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap()); let layer = &comp.layers[0]; - let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = effectcraft_render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap(); let (px, py) = buf.to_px([x, y]); let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0]; diff --git a/crates/engine/src/tests/essential.rs b/crates/engine/src/tests/essential.rs index 191416b9d..db5bf0751 100644 --- a/crates/engine/src/tests/essential.rs +++ b/crates/engine/src/tests/essential.rs @@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() { assert!(center(&s, main)[0] > 0.99); } +/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested +/// layers past the precomp's frame instead of falling back to a flattened, clipped frame. +#[test] +fn collapsed_precomp_applies_property_overrides_without_clipping() { + struct AddY; + impl effectcraft_render::ExprHost for AddY { + fn eval( + &self, + _: &effectcraft_render::EvalCtx, + _: &effectcraft_project::Layer, + _: &effectcraft_project::Property, + value: &effectcraft_keyframe::Value, + ) -> Result { + Ok(match value { + effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]), + _ => value.clone(), + }) + } + } + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + // The source property's expression still runs after the instance override. + s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap(); + s.expr = Some(std::sync::Arc::new(AddY)); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [other]})).unwrap(); + s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + // The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to + // outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above + // the instance's frame (150..449), where a flattened precomp would stop. The second maps to + // 470 and spans 420..519, past the bottom. + assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100)); + assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds"); + assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500)); + assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances"); +} + +/// #1747: two collapsed instances of the same comp keep their own source-affecting property +/// overrides in the layer cache (the nested layers' ids and static values are identical). +#[test] +fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + let card = s.active_comp_id().unwrap(); + let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap(); + let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let main = s.active_comp_id().unwrap(); + let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap(); + s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap(); + s.execute("layer.select", json!({"layers": [a]})).unwrap(); + s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap(); + s.execute("layer.select", json!({"layers": [b]})).unwrap(); + s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + let img = s.render(main, Tick::ZERO, RenderOpts::default()); + let left = img.get(60, 50); + let right = img.get(140, 50); + assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}"); + assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}"); +} + +/// #1747 with Media Replacement: the replacement footage and the position override both apply +/// while the precomp stays collapsed, so the box is blue and still draws above the frame. +#[test] +fn collapsed_precomp_applies_media_replacement_without_clipping() { + struct Colors; + impl crate::render::FootageSource for Colors { + fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option> { + let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] }; + Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c))) + } + } + let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() }; + let footage = |path: &str| effectcraft_project::Footage { + path: path.into(), + kind: effectcraft_project::FootageKind::Still, + width: 100, + height: 100, + has_video: true, + ..Default::default() + }; + let (red, blue) = s + .edit("test", None, |proj, _| { + let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png"))); + let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png"))); + Ok((red, blue)) + }) + .unwrap(); + s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap(); + let inner = s.active_comp_id().unwrap(); + let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap(); + let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap(); + let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap(); + let outer = s.active_comp_id().unwrap(); + let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap(); + s.execute("layer.select", json!({"layers": [instance]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap(); + s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap(); + let img = s.render(outer, Tick::ZERO, RenderOpts::default()); + let above = img.get(200, 100); + assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}"); + assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame"); +} + /// Responsive Design — Time: stretching a precomp keeps its protected regions at their /// original speed; the unprotected parts absorb the stretch. #[test] diff --git a/crates/engine/src/tests/essential_more.rs b/crates/engine/src/tests/essential_more.rs index a8d8dc808..ea4aa42e5 100644 --- a/crates/engine/src/tests/essential_more.rs +++ b/crates/engine/src/tests/essential_more.rs @@ -2,6 +2,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::essential::{self, ControlType}; +use effectcraft_time::Tick; use serde_json::json; use crate::{Event, Session}; @@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() { ); assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A"); } + +/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source +/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides` +/// does on the flattened path, instead of one entry replacing the other. +#[test] +fn collapsed_instance_merges_text_and_font_overrides() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let title = s.active_comp_id().unwrap(); + let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap(); + let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap(); + let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap(); + let main = s.active_comp_id().unwrap(); + let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap(); + s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap(); + // Evaluate the nested text in the collapsed instance's context, as the renderer does. + let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default()); + let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO); + let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap(); + let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses"); + let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context"); + let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap(); + match nctx.value(src, src.props.prop("text/sourceText").unwrap()) { + KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)), + o => panic!("{o:?}"), + } +} diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index d801a743a..29618fc02 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { fn frame(s: &Session, id: u64) -> (Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()); (e, f) } diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index a25fbc4f0..db38ba805 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -299,7 +299,17 @@ impl<'a> Resolver<'a> { } fn ctx_at(&self, c: u64, t: f64) -> Option> { let comp = self.comp(c)?; - Some(EvalCtx { project: self.ctx.project, comp_id: ItemId(c), comp, time: Tick::from_seconds_f64(t), expr: self.ctx.expr, footage: self.ctx.footage }) + let same_comp = c == self.ctx.comp_id.0; + Some(EvalCtx { + project: self.ctx.project, + comp_id: ItemId(c), + comp, + time: Tick::from_seconds_f64(t), + expr: self.ctx.expr, + footage: self.ctx.footage, + property_overrides: if same_comp { self.ctx.property_overrides } else { &[] }, + media_overrides: if same_comp { self.ctx.media_overrides } else { &[] }, + }) } fn prop(&self, c: u64, l: u64, path: &str) -> Option<(&'a Layer, &'a Property)> { let layer = self.layer(c, l)?; diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index c77af5b37..58e6e90ec 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -162,7 +162,16 @@ fn evaluate_out( return Err("Error: expressions reference each other too deeply (circular reference?)".into()); } budget_left()?; - let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage }; + let ctx = EvalCtx { + project: ctx.project, + comp_id: ctx.comp_id, + comp: ctx.comp, + time: ctx.time, + expr: Some(host), + footage: ctx.footage, + property_overrides: ctx.property_overrides, + media_overrides: ctx.media_overrides, + }; let comp = ctx.comp_id.0; let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid)); let resolver = Resolver { ctx, own: Own { comp, layer, prop, path: path.clone() } }; diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 7d2790255..a08fd231d 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -1114,7 +1114,16 @@ fn text_expression_selector_values() { let sel_of = |p: &Project| { let c = p.comp(cid).unwrap(); let l = c.layer(lid).unwrap(); - let ctx = EvalCtx { project: p, comp_id: cid, comp: c, time: Tick::ZERO, expr: Some(&Expressions), footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let doc = effectcraft_render::text::source_text(&ctx, l).unwrap(); let lay = effectcraft_text::layout_doc(&doc); let anim = l.props.group("text/animators/#1").unwrap(); diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs index 21422aa6b..f09798119 100644 --- a/crates/gpu/src/tests/adv3d.rs +++ b/crates/gpu/src/tests/adv3d.rs @@ -96,7 +96,16 @@ pub(crate) fn scene_project() -> (Project, ItemId) { pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene { let r = Renderer::new(p, &NoFootage, RenderOpts::default()); - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height)) } @@ -277,7 +286,16 @@ pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) { } pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) { - let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = EvalCtx { + project: p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect(); (ctx, run) } diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 78a6857fb..d4d7a62a1 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -89,7 +89,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut i = j; } else { match r.collapsed(ctx, visible[i]) { - Some(item) => draw_collapsed(e, r, ctx, visible[i], item, canvas)?, + Some(collapsed) => draw_collapsed(e, r, ctx, visible[i], &collapsed, canvas)?, None => draw_layer(e, r, ctx, visible[i], canvas)?, } i += 1; @@ -187,19 +187,26 @@ fn draw_split(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, split: &effectcraft_rend Some(cv) } -fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> { +fn draw_collapsed<'a>( + e: &mut Enc, + r: &Renderer<'a>, + ctx: &EvalCtx<'a>, + layer: &'a Layer, + collapsed: &effectcraft_render::Collapsed, + canvas: &mut GpuImage, +) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); if opacity <= 0.0 { return Some(()); } let matte = r.track_matte(ctx, layer).is_some(); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, opacity) { draw_comp(e, &sub, &nctx, canvas)?; } return Some(()); } - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, 1.0) else { return Some(()) }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, 1.0) else { return Some(()) }; let mut iso = e.zeros(canvas.width, canvas.height); draw_comp(e, &sub, &nctx, &mut iso)?; composite_iso(e, r, ctx, layer, iso, canvas, opacity) diff --git a/crates/host/src/tests.rs b/crates/host/src/tests.rs index a73f15937..3114c2ddb 100644 --- a/crates/host/src/tests.rs +++ b/crates/host/src/tests.rs @@ -269,7 +269,16 @@ fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_eng let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()) } @@ -523,8 +532,16 @@ fn stereo_rig_expressions_follow_the_master_camera() { let v3 = |s: &effectcraft_engine::Session, path: &str| { let comp = s.project.comp(left).unwrap(); let layer = &comp.layers[0]; - let ctx = - effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: left, comp, time: Default::default(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: left, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3() }; let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6); diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index accc1fc54..4f93e41d7 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -184,8 +184,9 @@ impl<'a> Renderer<'a> { pub fn layer_aux(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { match &layer.source { LayerSource::Footage { item } => { - let ItemKind::Footage(f) = &self.project.item(*item)?.kind else { return None }; - let a = self.footage.aux(*item, f, ctx.source_time(layer))?; + let item = ctx.media_item(layer, *item); + let ItemKind::Footage(f) = &self.project.item(item)?.kind else { return None }; + let a = self.footage.aux(item, f, ctx.source_time(layer))?; // Aux pixels per layer pixel (the layer is the footage at its nominal size). The // planes are only copied when that changes the scale. let scale = a.width as f64 / f.width as f64; @@ -332,8 +333,8 @@ impl<'a> Renderer<'a> { } /// Draw a collapsed precomp layer into `canvas` on the CPU. - pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) { - self.draw_collapsed(ctx, layer, item, canvas, false); + pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, canvas: &mut Image) { + self.draw_collapsed(ctx, layer, collapsed, canvas, false); } /// The layer's finished buffers (styles included) in the blending space. diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 21f745c0e..56c655828 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -479,6 +479,19 @@ fn group_has(g: &PropGroup, match_id: &str) -> bool { /// Feed every property of `g` that can change over time (keyframes or expression) as its value /// at the context time. The static structure is hashed separately. +/// Mix the collapsed-instance overrides that target `id` into a cache key. They replace values +/// without editing the layer, so a key without them would reuse another instance's buffer. +fn hash_overrides(h: &mut KeyHasher, ctx: &EvalCtx, id: effectcraft_project::LayerId) { + for o in ctx.property_overrides.iter().filter(|o| o.layer == id) { + o.prop.hash(h); + o.font.hash(h); + hash_debug(h, &o.value); + } + for m in ctx.media_overrides.iter().filter(|m| m.layer == id) { + m.item.hash(h); + } +} + fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) { for c in &g.children { match c { @@ -592,8 +605,9 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo hash_debug(&mut h, s); } LayerSource::Text | LayerSource::Shape => {} - LayerSource::Footage { item } if footage || still_footage(ctx, *item) => { - let it = ctx.project.item(*item)?; + LayerSource::Footage { item } if footage || still_footage(ctx, ctx.media_item(layer, *item)) => { + let item = ctx.media_item(layer, *item); + let it = ctx.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; hash_debug(&mut h, f); // A proxy (and its Use Proxy switch) changes the pixels. @@ -616,6 +630,10 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo scale.to_bits().hash(&mut h); draft.hash(&mut h); layer.id.hash(&mut h); + // Collapsed-instance overrides replace values without editing the nested layer, so mix them + // into the key: a Font override, effect parameter or any other source-affecting override + // must not reuse another instance's processed buffer. + hash_overrides(&mut h, ctx, layer.id); hash_debug(&mut h, &layer.source); // (Audio, Lock and Shy don't change pixels.) hash_debug(&mut h, &layer.switches.pixels()); diff --git a/crates/render/src/cache/dependencies.rs b/crates/render/src/cache/dependencies.rs index 483b19da3..70348a5ea 100644 --- a/crates/render/src/cache/dependencies.rs +++ b/crates/render/src/cache/dependencies.rs @@ -76,6 +76,9 @@ impl Dependencies<'_> { // Include transforms/parents as well as pixels: selected-layer anchors and // continuously rasterized sources can depend on them. hash_debug(self.h, layer); + // A referenced layer's static values can be overridden by the collapsed instance without + // editing it (`hash_values` only sees animated ones), so the owner's key needs them too. + super::hash_overrides(self.h, ctx, layer.id); hash_values(self.h, ctx, layer, &layer.props); hash_debug(self.h, &ctx.world_matrix(layer)); // A cached owner never runs this layer's effects, so the comp camera and lights its @@ -88,6 +91,10 @@ impl Dependencies<'_> { } match layer.source { LayerSource::Solid { item } | LayerSource::Footage { item } | LayerSource::Comp { item } => { + let item = match layer.source { + LayerSource::Footage { .. } => ctx.media_item(layer, item), + _ => item, + }; let source = ctx.project.item(item); hash_debug(self.h, &source); if let Some(source) = source { diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 8a193b87a..e3cf182f8 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -39,17 +39,33 @@ pub struct EvalCtx<'a> { /// Footage frames for expressions that read rendered pixels (`sampleImage`): the renderer's /// own source while rendering; `None` renders footage as transparent. pub footage: Option<&'a dyn crate::FootageSource>, + /// Property values overridden by the Essential Properties of a collapsed precomp instance. + pub property_overrides: &'a [crate::PropertyOverride], + /// Media Replacement values overridden by the Essential Properties of a collapsed precomp + /// instance. + pub media_overrides: &'a [crate::MediaOverride], } impl<'a> EvalCtx<'a> { pub fn new(project: &'a Project, comp_id: ItemId, comp: &'a Comp, time: Tick) -> EvalCtx<'a> { - EvalCtx { project, comp_id, comp, time, expr: None, footage: None } + EvalCtx { project, comp_id, comp, time, expr: None, footage: None, property_overrides: &[], media_overrides: &[] } } pub fn at(&self, time: Tick) -> EvalCtx<'a> { EvalCtx { time, ..*self } } /// Value of a property at the context time (keyframes, then expression). pub fn value(&self, layer: &Layer, prop: &Property) -> Value { + // A collapsed precomp instance's Essential Property override replaces the base value; + // its expression (if any) still runs, and sees the overrides of this context. + if let Some(o) = self.property_overrides.iter().rev().find(|o| o.layer == layer.id && o.prop == prop.uid) { + let v = o.value(layer, prop, self.time); + if prop.has_expression() + && let Some(h) = self.expr + { + return h.eval(self, layer, prop, &v).unwrap_or(v); + } + return v; + } // Separated Position reads as the combination of X/Y/Z Position. if prop.match_id == "position" && let Some(tr) = layer.transform() @@ -139,13 +155,15 @@ impl<'a> EvalCtx<'a> { /// Source pixel aspect relative to the comp's: non-square footage (or solids, or precomps /// with another pixel aspect) is stretched horizontally by this factor in the comp. pub fn par_ratio(&self, layer: &Layer) -> f64 { - let par = match &layer.source { - LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match self.project.item(*item).map(|i| &i.kind) { - Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, - Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, - Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, - _ => 1.0, - }, + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Solid { item } | LayerSource::Comp { item } => Some(*item), + _ => None, + }; + let par = match item.and_then(|item| self.project.item(item)).map(|i| &i.kind) { + Some(effectcraft_project::ItemKind::Footage(f)) => f.pixel_aspect, + Some(effectcraft_project::ItemKind::Solid(s)) => s.pixel_aspect, + Some(effectcraft_project::ItemKind::Comp(c)) => c.pixel_aspect, _ => 1.0, }; let r = par / if self.comp.pixel_aspect > 0.0 { self.comp.pixel_aspect } else { 1.0 }; @@ -231,9 +249,27 @@ impl<'a> EvalCtx<'a> { (Mat3([[m[0][0], m[0][1], m[0][3]], [m[1][0], m[1][1], m[1][3]], [0.0, 0.0, 1.0]]), 0.0) } } + /// The footage item a layer uses, honouring this instance's Media Replacement override. + pub fn media_item(&self, layer: &Layer, item: ItemId) -> ItemId { + self.media_overrides.iter().rev().find(|m| m.layer == layer.id).map_or(item, |m| m.item) + } + + /// Size of a layer's source in layer pixels, honouring Media Replacement. + pub fn source_size(&self, layer: &Layer) -> (u32, u32) { + let item = match &layer.source { + LayerSource::Footage { item } => Some(self.media_item(layer, *item)), + LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item), + _ => None, + }; + match layer.source { + LayerSource::Null => (100, 100), + _ => item.and_then(|item| self.project.item(item)).and_then(|i| i.dimensions()).unwrap_or((0, 0)), + } + } + /// [`effect_bounds`] of a layer of this comp. pub fn effect_bounds(&self, layer: &Layer) -> ([f64; 2], [f64; 2]) { - effect_bounds(self.project, self.comp, layer) + effect_bounds_of(self.comp, self.source_size(layer)) } /// Effect space (see [`effect_bounds`]) → comp matrix. pub fn effect_to_comp(&self, layer: &Layer) -> Mat3 { @@ -242,6 +278,20 @@ impl<'a> EvalCtx<'a> { } } +impl crate::PropertyOverride { + fn value(&self, layer: &Layer, prop: &Property, time: Tick) -> Value { + if self.font { + // A Font control merges only the font into the target text, as + // `essential::with_overrides` does. + let mut value = prop.value_at(layer.layer_time(time)); + effectcraft_project::essential::merge_font(&mut value, &self.value); + value + } else { + self.value.clone() + } + } +} + /// The protected regions of a comp (Responsive Design — Time markers), merged, sorted and /// clamped to the comp, in seconds. pub fn protected_regions(comp: &Comp) -> Vec<(f64, f64)> { @@ -315,7 +365,11 @@ pub fn responsive_source_time(comp: &Comp, stretch: f64, elapsed: f64) -> Option /// surrounds. Effects work in **effect space**, measured from that corner (After Effects' layer /// space for effect points: a shape layer's default effect point is its bounds' centre). pub fn effect_bounds(project: &Project, comp: &Comp, layer: &Layer) -> ([f64; 2], [f64; 2]) { - match source_size(project, layer) { + effect_bounds_of(comp, source_size(project, layer)) +} + +fn effect_bounds_of(comp: &Comp, size: (u32, u32)) -> ([f64; 2], [f64; 2]) { + match size { (0, _) => { let (w, h) = (comp.width as f64, comp.height as f64); ([w, h], [-w / 2.0, -h / 2.0]) diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 7dd0fc0de..3024dfc52 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,6 +1,6 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text}; +use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. /// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / @@ -132,7 +132,7 @@ pub fn content_bounds(ctx: &EvalCtx, layer: &effectcraft_project::Layer) -> Opti LayerSource::Shape => layer.props.sub("contents").and_then(|c| shapes::content_bounds(ctx, layer, c)).map(|r| [r.x0, r.y0, r.x1, r.y1]), LayerSource::Camera | LayerSource::Light { .. } => None, _ => { - let (w, h) = source_size(ctx.project, layer); + let (w, h) = ctx.source_size(layer); (w > 0 && h > 0).then_some([0.0, 0.0, w as f64, h as f64]) } } diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index 8366cf7c0..ccc8a3c7f 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -90,12 +90,13 @@ impl EffectHost for FxHost<'_, '_, '_> { fn audio(&self, id: u64, start: f64, frames: usize, rate: u32) -> Option> { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; let LayerSource::Footage { item } = &other.source else { return None }; - let ItemKind::Footage(f) = &self.r.project.item(*item)?.kind else { return None }; + let item = self.ctx.media_item(other, *item); + let ItemKind::Footage(f) = &self.r.project.item(item)?.kind else { return None }; if !f.has_audio { return None; } let st = other.layer_time(Tick::from_seconds_f64(start)); - self.r.footage.audio(*item, f, st, frames, rate) + self.r.footage.audio(item, f, st, frames, rate) } fn self_at(&self, layer_time: f64, effects: usize) -> Option { diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index cac2add43..ce0a2dcde 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -2,7 +2,7 @@ use super::frames::adaptive_samples; use super::opts::{LayerTiming, Through}; use super::{ Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3, - MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2, + MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, styles, vec2, }; /// Blend policy for placing a layer, including optional output-depth quantization. @@ -52,7 +52,7 @@ impl<'a> Renderer<'a> { } } let t0 = web_time::Instant::now(); - let size = source_size(self.project, layer); + let size = ctx.source_size(layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; let st = styles::render(ctx, layer, &content, layer_size); if let Some(t) = timing { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0552a44d4..0c2f11e4c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -60,6 +60,7 @@ pub use layers::{Placement, StyledLayer}; pub(crate) use opts::Collapse3d; pub use opts::{LayerTiming, RenderOpts}; use opts::{MAX_THROUGH, Through}; +pub use pipeline::{Collapsed, MediaOverride, PropertyOverride}; pub struct Renderer<'a> { pub project: &'a Project, diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index bc1879369..fd29d9bea 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -7,6 +7,8 @@ use super::{ Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, }; +use effectcraft_keyframe::Value; +use effectcraft_project::LayerId; /// Coordinate space and stack range shared by CPU and accelerated effect evaluation. #[derive(Clone, Copy)] @@ -16,6 +18,88 @@ pub(crate) struct EffectStack { pub origin: [f64; 2], } +/// A property value an Essential Properties override gives a layer of a collapsed precomp. +#[derive(Clone, Debug)] +pub struct PropertyOverride { + pub(crate) layer: LayerId, + pub(crate) prop: u64, + pub(crate) value: Value, + /// A Font control changes only the font of the target text, not its text. + pub(crate) font: bool, +} + +/// A Media Replacement an Essential Properties override gives a footage layer of a collapsed +/// precomp. +#[derive(Clone, Copy, Debug)] +pub struct MediaOverride { + pub(crate) layer: LayerId, + pub(crate) item: ItemId, +} + +impl MediaOverride { + /// A Media Replacement of `layer`'s footage by `item`. + pub fn new(layer: LayerId, item: ItemId) -> Self { + MediaOverride { layer, item } + } +} + +/// A collapsed precomp layer: the nested comp and the instance overrides that apply to it. +pub struct Collapsed { + pub item: ItemId, + pub(crate) property_overrides: Vec, + pub(crate) media_overrides: Vec, +} + +/// The property and media overrides of a collapsed precomp layer. `None` when its Essential +/// Properties group or the source comp's definitions are missing (the flattened path then +/// preserves the old behaviour). +fn collapsed_instance(ctx: &EvalCtx, layer: &Layer, item: ItemId) -> Option<(Vec, Vec)> { + use effectcraft_project::essential::{self, ControlType, EgKind}; + let over = essential::overridden(layer); + if over.is_empty() { + return Some((vec![], vec![])); + } + let g = essential::group(layer)?; + let eg = ctx.project.comp(item)?.essential.as_ref()?; + let mut property = vec![]; + let mut fonts: Vec = vec![]; + let mut media = vec![]; + g.walk("", &mut |_, p| { + if !over.contains(&p.uid) { + return; + } + let Some(control) = essential::control_of(&p.match_id) else { return }; + let Some(ctl) = eg.resolve(control) else { return }; + let value = ctx.value(layer, p); + match &ctl.kind { + EgKind::Property { .. } => { + let font = ctl.as_type == Some(ControlType::Font); + for (layer, prop) in ctl.kind.targets() { + let o = PropertyOverride { layer, prop, value: value.clone(), font }; + if font { fonts.push(o) } else { property.push(o) } + } + } + EgKind::Media { layer: target } => { + let Value::Scalar(id) = value else { return }; + let item = ItemId(id.max(0.0) as u64); + if matches!(ctx.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { + media.push(MediaOverride { layer: *target, item }); + } + } + _ => {} + } + }); + // A Font control merges into the text its instance shows, after a whole-value (Text) override + // of the same property, as `essential::with_overrides` does: keep one entry per property. + for f in fonts { + match property.iter_mut().rev().find(|o| o.layer == f.layer && o.prop == f.prop) { + Some(o) => effectcraft_project::essential::merge_font(&mut o.value, &f.value), + None => property.push(f), + } + } + Some((property, media)) +} + impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { Renderer { @@ -122,8 +206,10 @@ impl<'a> Renderer<'a> { /// The nested comp of a precomp layer whose transformations collapse into this comp: the /// Collapse Transformations switch is on and the layer has no masks, effects or layer - /// styles (those force a flattened render of the precomp, as in After Effects). - pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + /// styles (those force a flattened render of the precomp, as in After Effects). Essential + /// Properties overrides travel with it and are applied as the nested layers are evaluated, + /// so the nested comp is not rasterised at its frame. + pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 { return None; } @@ -140,24 +226,37 @@ impl<'a> Renderer<'a> { if styles::active(ctx, layer) { return None; } - // Instance overrides (Essential Properties) need the nested comp rendered on its own. - if !effectcraft_project::essential::overridden(layer).is_empty() { + // A composition proxy or frame blending is drawn by the flattened path (`precomp_source`); + // collapsing renders the nested comp directly and would ignore both. + if self.proxy_for(*item).is_some() || frame_blend_mode(ctx, layer) != FrameBlend::Off { return None; } - Some(*item) + let (property_overrides, media_overrides) = collapsed_instance(ctx, layer, *item)?; + Some(Collapsed { item: *item, property_overrides, media_overrides }) } /// A renderer and context that draw a collapsed precomp's layers straight into this comp: /// their transforms are concatenated with the precomp layer's (one resample, no /// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their /// 3D layers use this comp's camera and lights. - pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { + pub fn collapse_into<'p>(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &'p Collapsed, opacity: f32) -> Option<(Renderer<'p>, EvalCtx<'p>)> + where + 'a: 'p, + { + let item = collapsed.item; let nc = self.project.comp(item)?; let t = ctx.nested_time(layer); if !nc.covers(t) { return None; } - let nctx = EvalCtx { comp_id: item, comp: nc, time: t, ..*ctx }; + let nctx = EvalCtx { + comp_id: item, + comp: nc, + time: t, + property_overrides: &collapsed.property_overrides, + media_overrides: &collapsed.media_overrides, + ..*ctx + }; let (l2c, _) = ctx.layer_to_comp(layer); let outer = Some(self.outer.map_or(l2c, |o| o * l2c)); let world = ctx.world_matrix(layer); @@ -189,19 +288,19 @@ impl<'a> Renderer<'a> { /// that then takes the precomp layer's track matte, Preserve Transparency and blend mode. /// Returns the region left to re-quantise (nested layers drawn straight into `canvas` /// quantise their own footprints). - pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image, blank: bool) -> Region { + pub(crate) fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &Collapsed, canvas: &mut Image, blank: bool) -> Region { let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; if opacity <= 0.0 { return Region::Empty; } let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { - if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) { + if let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, opacity) { sub.draw_comp(&nctx, canvas, blank); } return Region::Empty; } - let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return Region::Empty }; + let Some((sub, nctx)) = self.collapse_into(ctx, layer, collapsed, 1.0) else { return Region::Empty }; let mut iso = Image::new(canvas.width, canvas.height); sub.draw_comp(&nctx, &mut iso, true); self.composite_iso(ctx, layer, iso, canvas, opacity); @@ -230,6 +329,7 @@ impl<'a> Renderer<'a> { let continuous = match layer.source { LayerSource::Text | LayerSource::Shape => true, LayerSource::Footage { item } => { + let item = ctx.media_item(layer, item); layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) } _ => false, @@ -267,7 +367,7 @@ impl<'a> Renderer<'a> { } pub(crate) fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { - EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage) } + EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr, footage: Some(self.footage), property_overrides: &[], media_overrides: &[] } } /// This renderer with another accelerator (or none): the passes of @@ -406,7 +506,7 @@ impl<'a> Renderer<'a> { blank = false; } else { let changed = match self.collapsed(ctx, visible[i]) { - Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas, blank), + Some(collapsed) => self.draw_collapsed(ctx, visible[i], &collapsed, canvas, blank), None => self.draw_layer(ctx, visible[i], canvas, blank), }; i += 1; @@ -635,7 +735,8 @@ impl<'a> Renderer<'a> { Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: bs }) } LayerSource::Footage { item } => { - let it = self.project.item(*item)?; + let item = ctx.media_item(layer, *item); + let it = self.project.item(item)?; let ItemKind::Footage(f) = &it.kind else { return None }; if !f.has_video { return None; @@ -643,7 +744,7 @@ impl<'a> Renderer<'a> { // Continuously rasterised vector footage: drawn at the on-screen scale. if layer.switches.collapse && is_vector_footage(f) { let k = self.raster_scale(ctx, layer); - if let Some(img) = self.footage.vector_frame(*item, f, k) { + if let Some(img) = self.footage.vector_frame(item, f, k) { let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); @@ -653,10 +754,10 @@ impl<'a> Renderer<'a> { } let lt = ctx.source_time(layer); // The proxy is decoded instead, at its own size, and fills the footage's frame. - let proxy = self.proxy_for(*item); + let proxy = self.proxy_for(item); let pf = proxy.unwrap_or(f); let size = self.reduced_size(f.width, f.height).filter(|_| proxy.is_none()); - let img = self.footage_frame(ctx, layer, *item, pf, lt, size)?; + let img = self.footage_frame(ctx, layer, item, pf, lt, size)?; Some(self.footage_buf(&img, pf, f.width, f.height)) } LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), diff --git a/crates/render/src/tests/collapse.rs b/crates/render/src/tests/collapse.rs index 797960033..eb340a4ca 100644 --- a/crates/render/src/tests/collapse.rs +++ b/crates/render/src/tests/collapse.rs @@ -3,7 +3,7 @@ use effectcraft_color::{BlendMode, Label}; use effectcraft_keyframe::{TextDoc, Value}; use effectcraft_project::build::{self, Ids}; -use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; +use effectcraft_project::{BitDepth, Comp, FrameBlend, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; use effectcraft_time::{FrameRate, Tick}; use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame}; @@ -110,6 +110,66 @@ fn masks_on_a_collapsed_layer_force_a_flattened_render() { assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b)); } +/// #1747: the flattened path honours a composition proxy and frame blending; a collapsed layer +/// renders the nested comp directly and ignores both, so such a layer must not collapse. +#[test] +fn proxy_and_frame_blending_keep_a_precomp_on_the_flattened_path() { + let (mut p, cid) = scene(true, false); + let id = p.comp(cid).unwrap().layers[0].id; + let collapses = |p: &Project| { + let r = Renderer::new(p, &NoFootage, RenderOpts::default()); + let ctx = EvalCtx::new(p, cid, p.comp(cid).unwrap(), Tick::ZERO); + r.collapsed(&ctx, ctx.comp.layer(id).unwrap()).is_some() + }; + assert!(collapses(&p), "a plain precomp collapses"); + // Frame blending needs both the comp's switch and the layer's. + p.comp_mut(cid).unwrap().enable_frame_blending = true; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::FrameMix; + assert!(!collapses(&p), "frame blending keeps the flattened path"); + p.comp_mut(cid).unwrap().enable_frame_blending = false; + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off; + // A composition proxy stands in for the precomp's pixels. + let LayerSource::Comp { item } = p.comp(cid).unwrap().layers[0].source else { panic!("a precomp layer") }; + let proxy = + effectcraft_project::Proxy { footage: effectcraft_project::Footage { has_video: true, width: 32, height: 32, ..Default::default() }, enabled: true }; + p.item_mut(item).unwrap().proxy = Some(Box::new(proxy)); + assert!(!collapses(&p), "a proxy keeps the flattened path"); +} + +/// #1747: an effect on B that reads A must not reuse its cached buffer when two collapsed +/// instances give A different static overrides (`hash_values` only sees animated values). +#[test] +fn referenced_layer_overrides_change_the_owner_cache_key() { + let mut p = project(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let a = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [30.0, 50.0, 0.0]); + let aid = a.id; + let mut b = solid(&mut p, &comp, [1.0, 1.0, 1.0], (100, 100), [50.0, 50.0, 0.0]); + let bid = b.id; + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let spec = effectcraft_effects::find("ec.channel.setmatte").unwrap(); + let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [100.0, 100.0]); + fx.prop_mut("takeMatteFromLayer").unwrap().value = Value::Layer(Some(a.id.0)); + p.next_id = next; + b.props.sub_mut("effects").unwrap().children.push(fx.into()); + let mut c = comp; + c.layers = vec![b, a]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + let comp = p.comp(cid).unwrap(); + let b = comp.layer(bid).unwrap(); + let opacity = comp.layer(aid).unwrap().props.prop("transform/opacity").unwrap().uid; + let key = |value: f64| { + let overrides = [crate::PropertyOverride { layer: aid, prop: opacity, value: Value::Scalar(value), font: false }]; + let ctx = + EvalCtx { project: &p, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &overrides, media_overrides: &[] }; + crate::cache::layer_key(&ctx, b, 1.0, false, false) + }; + let (opaque, half) = (key(100.0), key(50.0)); + assert!(opaque.is_some(), "the owner is cacheable"); + assert_ne!(opaque, half, "A's static override changes B's key"); +} + #[test] fn collapsed_3d_layers_join_the_parent_scene() { // Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a diff --git a/crates/render/src/tests/time.rs b/crates/render/src/tests/time.rs index 8739140de..e9599f70b 100644 --- a/crates/render/src/tests/time.rs +++ b/crates/render/src/tests/time.rs @@ -134,7 +134,16 @@ fn input_keys_ignore_later_effects() { } let key = |p: &Project| { let comp = p.comp(cid).unwrap(); - let ctx = crate::EvalCtx { project: p, comp_id: cid, comp, time: Tick::from_seconds_f64(0.5), expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: p, + comp_id: cid, + comp, + time: Tick::from_seconds_f64(0.5), + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &comp.layers[0]; (crate::cache::input_key(&ctx, l, 1.0, false, false, 1), crate::cache::input_key(&ctx, l, 1.0, false, false, 2)) }; diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index a97272434..2162439ec 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -857,8 +857,8 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { return; } - if let Some(item) = r.collapsed(ctx, layer) { - collapsed_layer(r, ctx, layer, item, b); + if let Some(collapsed) = r.collapsed(ctx, layer) { + collapsed_layer(r, ctx, layer, &collapsed, b); } else if layer.source.is_model() { model_layer(r, ctx, layer, b); } else if matches!(layer.source, LayerSource::Text | LayerSource::Shape) @@ -874,9 +874,9 @@ fn emit_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, b: &mut Bu /// (meshes, extrusions, cards) placed by the precomp layer's world transform, so they /// intersect, occlude and shadow the parent's layers. Nested 2D layers lie on the precomp /// layer's plane; nested lights and cameras are ignored (the parent's light the scene). -fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: effectcraft_project::ItemId, b: &mut Builder) { +fn collapsed_layer<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, collapsed: &crate::Collapsed, b: &mut Builder) { let op = ctx.opacity(layer) as f32 * r.opacity_mul(); - let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return }; + let Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return }; let saved = b.outer; b.outer = saved * ctx.world_matrix(layer); let t = nctx.time; diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index f9a0f2066..e3a42e623 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -370,7 +370,16 @@ fn extruded_text_renders_as_a_mesh() { set(l, "geometryOptions/bevelStyle", Value::Enum(1)); set(l, "transform/rotationY", Value::Scalar(40.0)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -409,7 +418,16 @@ fn shape_surface_items_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0)); set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0])); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -445,7 +463,16 @@ fn text_animators_colour_the_extruded_surfaces() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); @@ -643,7 +670,16 @@ fn extruded_strokes_render_as_bevelled_meshes() { set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0)); set(l, "geometryOptions/bevelStyle", Value::Enum(1)); }); - let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None }; + let ctx = crate::EvalCtx { + project: &p, + comp_id: cid, + comp: p.comp(cid).unwrap(), + time: Tick::ZERO, + expr: None, + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let l = &ctx.comp.layers[0]; let params = scene::extrusion_params(&ctx, l).unwrap(); let meshes = scene::extruded_meshes(&ctx, l, ¶ms); diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 8e932819a..32b0778e4 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -195,13 +195,13 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { } /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. -pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { +pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { let buf = r.blend_layer_buf(ctx, layer)?; prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY }) } /// Prepare a layer as planes: one per character for per-character 3D text, else one. -pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { +pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Vec> { if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() @@ -218,7 +218,7 @@ struct PlaneInput { } /// Prepare the plane's geometry, motion samples and material. -fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { +fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { let PlaneInput { mut buf, local } = plane; if buf.img.is_empty() { return None; @@ -449,8 +449,8 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, .flat_map_iter(|(i, l)| { // Stack order with room for a collapsed precomp's layers in between. let i = i * 1024; - if let Some(item) = r.collapsed(ctx, l) { - return collapsed_items(r, ctx, l, item, i, out); + if let Some(collapsed) = r.collapsed(ctx, l) { + return collapsed_items(r, ctx, l, &collapsed, i, out); } let mut its = prepare_all(r, ctx, l, i, out, true); if !its.is_empty() { @@ -483,20 +483,13 @@ fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, /// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they /// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack /// order. (Nested adjustment layers are skipped here.) -fn collapsed_items<'a>( - r: &Renderer<'a>, - ctx: &EvalCtx<'a>, - layer: &'a Layer, - item: effectcraft_project::ItemId, - order: usize, - out: (u32, u32), -) -> Vec> { +fn collapsed_items<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &'a Layer, collapsed: &crate::Collapsed, order: usize, out: (u32, u32)) -> Vec> { 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 Some((sub, nctx)) = r.collapse_into(ctx, layer, collapsed, op) else { return vec![] }; let t = nctx.time; - let any_solo = nctx.comp.any_solo(); - let nested: Vec<&'a Layer> = nctx - .comp + let Some(nc) = r.project.comp(collapsed.item) else { return vec![] }; + let any_solo = nc.any_solo(); + let nested: Vec<&'a Layer> = nc .layers .iter() .rev() @@ -850,7 +843,7 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh if nv[2] > 0.0 { nv = nv.map(|v| -v); } - let (sw, sh) = crate::source_size(r.project, it.layer); + let (sw, sh) = ctx.source_size(it.layer); let size = if sw == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [sw as f64, sh as f64] }; (number(it.layer), source_id(it.layer), crypto_hash(&it.layer.name), crypto_hash(&source_name(it.layer)), nv, size) }) diff --git a/crates/script/src/tests/ui_more.rs b/crates/script/src/tests/ui_more.rs index 228a58016..483d686ef 100644 --- a/crates/script/src/tests/ui_more.rs +++ b/crates/script/src/tests/ui_more.rs @@ -222,7 +222,16 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] { let cid = s.active_comp_id().unwrap(); let comp = s.project.comp(cid).unwrap(); let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap(); - let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components(); [v[0], v[1]] } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index b266623f3..6c4863033 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -151,7 +151,16 @@ fn vertex_delta(app: &EffectcraftApp, layer: effectcraft_engine::project::LayerI let s = &app.session; let (Some(cid), Some(comp)) = (s.active_comp_id(), s.active_comp()) else { return d }; let Some(l) = comp.layer(layer) else { return d }; - let ectx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None }; + let ectx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: cid, + comp, + time: s.time(), + expr: s.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let (l2c, _) = crate::panels::viewer::l2c(&ectx, l); l2c.inverse().map_or(d, |inv| { let v = inv.apply_vec(vec2(d[0], d[1])); diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 39cdee76c..12f3e0057 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -166,7 +166,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: app.session.time(), + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0)); p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim); p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator)); diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs index 5fb4d703c..fe0932b34 100644 --- a/crates/ui-egui/src/panels/layer_panel.rs +++ b/crates/ui-egui/src/panels/layer_panel.rs @@ -104,7 +104,16 @@ fn render_tex( match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c35108937..2e102622a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -70,7 +70,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let project = app.session.project.clone(); let expr = app.session.expr.clone(); let now = app.session.time(); - let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &project, + comp_id: cid, + comp: &comp, + time: now, + expr: expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let x0 = rect.min.x + PAD; let w = rect.width() - 2.0 * PAD; let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect); diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs index 0d9eefe45..ca3c56ac5 100644 --- a/crates/ui-egui/src/panels/roto_tool.rs +++ b/crates/ui-egui/src/panels/roto_tool.rs @@ -69,7 +69,16 @@ pub fn draw_overlay( let ov = match cached { Some(c) if c.key == key => Some(c), _ => { - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time, + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() }); r.expr = app.session.expr.as_deref(); r.cache = Some(&app.session.layer_cache); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8a8e604e2..cc46eba78 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -57,7 +57,16 @@ pub fn text_target(app: &EffectcraftApp) -> Option { .iter() .filter_map(|id| comp.layer(*id)) .find(|l| matches!(l.source, effectcraft_engine::project::LayerSource::Text))?; - let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time: app.session.time(), expr: app.session.expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &app.session.project, + comp_id: cid, + comp, + time: app.session.time(), + expr: app.session.expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; effectcraft_engine::render::text::source_text(&ectx, layer).map(|d| TextTarget { layer: layer.id.0, doc: d, range: None }) } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index a45d8e708..308180392 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1828,7 +1828,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { pick_whip_edge_scroll(app, g); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: g.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let mut rw = begin_rows(app, ui, tl); let g = &mut rw; draw_rows(app, ui, g, &ectx); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bcae99bc5..8551a705c 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -178,7 +178,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { phase_frame(app, &mut v); let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp: &comp, + time: v.time, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; phase_guides(app, &ectx, &mut v); phase_layers(app, ui, &ectx, &mut v); phase_scene(app, &ectx, &mut v); diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs index 395219a05..3c4b3627b 100644 --- a/crates/ui-egui/src/panels/viewer/shapes.rs +++ b/crates/ui-egui/src/panels/viewer/shapes.rs @@ -202,6 +202,8 @@ pub(super) fn create_shape(app: &mut EffectcraftApp, tool: Tool, a: [f64; 2], b: time: app.session.time(), expr: None, footage: None, + property_overrides: &[], + media_overrides: &[], }; let inv = l2c(&ectx, &l).0.inverse().unwrap_or(Mat3::IDENTITY); let p0 = inv.apply(gv2(cx - w / 2.0, cy - h / 2.0)); diff --git a/crates/ui-egui/src/panels/viewer/view.rs b/crates/ui-egui/src/panels/viewer/view.rs index 30b3f4d00..59e3e513d 100644 --- a/crates/ui-egui/src/panels/viewer/view.rs +++ b/crates/ui-egui/src/panels/viewer/view.rs @@ -44,7 +44,16 @@ pub(super) fn aux_views( // layout shows them), evaluated on a snapshot of the project. let snap_project = app.session.project.clone(); let snap_expr = app.session.expr.clone(); - let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp, time: t, expr: snap_expr.as_deref(), footage: None }; + let ectx = EvalCtx { + project: &snap_project, + comp_id: cid, + comp, + time: t, + expr: snap_expr.as_deref(), + footage: None, + property_overrides: &[], + media_overrides: &[], + }; let selected = app.session.state.selected_layers.clone(); let mut picked: Option<(usize, View3D)> = None; for (i, (r, v)) in aux.into_iter().enumerate() { diff --git a/docs/architecture.md b/docs/architecture.md index d3dd4db69..713a1f901 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -391,7 +391,10 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, its nested layers straight into the parent with concatenated transforms (one resample), so nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's -3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at +3D run. Essential Properties overrides (properties, Font and Media Replacement) travel with the +collapsed context and are applied while the nested layers are evaluated, so they do not force a +flattened render or clip at the precomp's frame. Masks, effects or styles force a flattened +render. Text layers are always rasterised at their on-screen scale, as text is vector in After Effects; on shape layers and vector footage the switch is Continuously Rasterize and does the same. Quality: Draft samples nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, @@ -458,8 +461,10 @@ effects crate, hence the `effects → track` edge. `essential` field lists its exposed controls (properties, Media Replacement, groups, comments). Every precomp layer of such a comp gets an Essential Properties group (`GroupKind::Essential`, children `eg`), kept in step by `Session::edit`; editing a child overrides it for -that instance. The renderer draws an instance with overrides from a copy of the project with the -overridden source properties set (`essential::with_overrides`). Templates are `.ectemplate` +that instance. A collapsed instance carries its property, Font and Media Replacement overrides +into the nested render context (`Collapsed`, `EvalCtx::{property_overrides, media_overrides}`); +other instances are drawn from a copy of the project with the overridden source properties set +(`essential::with_overrides`). Templates are `.ectemplate` ZIPs: `manifest.json` (controls), `project.ecproj` (the comp and its dependencies), `media/…` and `poster.png`; importing offsets every id past the project's (`essential::offset_ids`). **Responsive Design — Time**: a time-stretched precomp maps time piecewise so its protected From a2f8913eb32f5e67d5f618f50e5b7781b27a8f00 Mon Sep 17 00:00:00 2001 From: Camille Moine Date: Tue, 20 Oct 2026 17:08:03 +0200 Subject: [PATCH 24/26] Expressions: follow Media Replacement source dimensions (#1747) LayerInfo, sourceRectAtTime and Layer.source now resolve the effective footage item/size through the collapsed instance EvalCtx, matching the rendered pixels. Adds a regression test with different original and replacement footage sizes. --- crates/expr/src/host.rs | 13 ++++++++---- crates/expr/src/tests.rs | 43 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index db38ba805..fa2993f97 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -10,7 +10,7 @@ use effectcraft_geom::{Mat3, Mat4}; use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerId, LayerSource, Node, PropGroup, Property}; -use effectcraft_render::{EvalCtx, source_size}; +use effectcraft_render::EvalCtx; use effectcraft_time::Tick; /// A name-or-index argument (`layer("A")`, `layer(2)`, `effect(1)`). @@ -382,7 +382,7 @@ impl<'a> Resolver<'a> { Req::LayerInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let (mut w, mut h) = source_size(project, l); + let (mut w, mut h) = self.ctx_at(*comp, self.ctx.time.seconds())?.source_size(l); if w == 0 || h == 0 { (w, h) = (c.width, c.height); } @@ -477,7 +477,7 @@ impl<'a> Resolver<'a> { } } _ => { - let (w, h) = source_size(project, l); + let (w, h) = ctx.source_size(l); [0.0, 0.0, w as f64, h as f64] } }; @@ -511,7 +511,12 @@ impl<'a> Resolver<'a> { Req::SourceInfo { comp, layer } => { let c = self.comp(*comp)?; let l = self.layer(*comp, *layer)?; - let it = project.item(l.source.item()?)?; + let ctx = self.ctx_at(*comp, self.ctx.time.seconds())?; + let item = match &l.source { + LayerSource::Footage { item } => ctx.media_item(l, *item), + _ => l.source.item()?, + }; + let it = project.item(item)?; let (kind, aspect, frame, duration) = match &it.kind { ItemKind::Comp(nc) => ("comp", nc.pixel_aspect, nc.frame_duration().seconds(), nc.duration.seconds()), ItemKind::Footage(f) => ("footage", f.pixel_aspect, f.frame_rate.frame_duration().seconds(), f.duration.seconds()), diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index a08fd231d..4e7cea0ee 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -907,6 +907,49 @@ fn source_rect_at_time() { assert!((s[0] + 31.0).abs() < 2.0 && (s[1] + 16.0).abs() < 2.0 && (s[2] - 62.0).abs() < 3.0, "{s:?}"); } +#[test] +fn source_dimensions_follow_media_replacement() { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Main", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let footage = |p: &mut Project, name: &str, width, height| { + p.add_item( + name, + Label::SeaFoam, + None, + ItemKind::Footage(effectcraft_project::Footage { + path: name.into(), + kind: effectcraft_project::FootageKind::Still, + width, + height, + has_video: true, + ..Default::default() + }), + ) + }; + let original = footage(&mut p, "original.png", 100, 80); + let replacement = footage(&mut p, "replacement.png", 30, 40); + let mut l = build::layer(&mut p, &comp, "Clip", LayerSource::Footage { item: original }, (100, 80), None); + l.props.prop_mut(ROT).unwrap().expr = + Some(Expression { text: "thisLayer.width + thisLayer.height + thisLayer.source.width + sourceRectAtTime().width".into(), enabled: true }); + let lid = l.id; + p.comp_mut(cid).unwrap().layers.push(l); + let c = p.comp(cid).unwrap(); + let l = c.layer(lid).unwrap(); + let media = effectcraft_render::MediaOverride::new(lid, replacement); + let ctx = EvalCtx { + project: &p, + comp_id: cid, + comp: c, + time: Tick::ZERO, + expr: Some(&Expressions), + footage: None, + property_overrides: &[], + media_overrides: &[media], + }; + assert_eq!(ctx.value(l, l.props.prop(ROT).unwrap()), Value::Scalar(130.0)); +} + // ---- colour ----------------------------------------------------------------------------------- #[test] From 3217320c1c813f36b39b93a2674accb19e2d7aa9 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:48:55 +0300 Subject: [PATCH 25/26] export: the yuv420 fast path checks the effective scale too --- crates/export/src/pipeline.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 28bd3ecc7..66c2177be 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -389,7 +389,7 @@ impl<'a> Cx<'a> { return None; } let om = self.job.output; - let scale = self.job.settings.resolution.clamp(0.01, 4.0); + let scale = self.job.settings.effective_scale(comp); let size = |n: u32| ((n as f64 * scale).round() as u32).max(1); if om.resize.enabled || (size(comp.width), size(comp.height)) != (w, h) || om.crop.edges(w, h, scale) != (0, 0, 0, 0) { return None; From 971e75d06ae9c020eaf1e8b82c0d518a857606ed Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 05:08:15 +0700 Subject: [PATCH 26/26] Merge main; keep the batch-tested versions of crates/engine/src/tests/rig3d.rs --- crates/engine/src/tests/rig3d.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index b4e9800f2..cf2518447 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -244,7 +244,7 @@ fn parented_camera_keeps_its_frame_under_an_offset_null() { fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) { let c = cid(s); let comp = s.project.comp(c).unwrap(); - let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None }; + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] }; layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()) }