diff --git a/crates/effects/src/time_fx.rs b/crates/effects/src/time_fx.rs index cf7624d92..f8886d58c 100644 --- a/crates/effects/src/time_fx.rs +++ b/crates/effects/src/time_fx.rs @@ -4,8 +4,10 @@ //! These read the layer at *other* layer times through [`EffectHost::self_at`]: its source with //! its masks and the effects applied before them, as After Effects renders them (SceneYard: //! Echo after CC Particle Systems II echoes the fireworks, not the black solid under them; -//! Posterize Time after Turbulent Displace steps the displacement). Without a host (tests, -//! thumbnails) they pass their input through. +//! Posterize Time after Turbulent Displace steps the displacement). Timewarp is the exception: +//! it retimes the layer's source with its masks and ignores the effects above it (After +//! Effects 2026: a red solid with a Gradient Ramp and then Timewarp shows the red solid). +//! Without a host (tests, thumbnails) they pass their input through. //! //! Timewarp's Pixel Motion estimates block-matching optical flow between neighbouring frames //! ([`effectcraft_raster::flow`]); Pixel Motion Blur averages sub-frame samples. @@ -163,15 +165,21 @@ struct Frames { imgs: Vec, } -/// Fetch the layer (source + masks) at layer times `times`. `None` without a host. +/// Fetch the layer (source + masks + the effects before this one) at layer times `times`. +/// `None` without a host. fn fetch(ctx: &EffectCtx, times: &[f64]) -> Option { + fetch_with(ctx, times, usize::MAX) +} + +/// [`fetch`] with the first `effects` effects of the stack only (0: source and masks). +fn fetch_with(ctx: &EffectCtx, times: &[f64], effects: usize) -> Option { let host = ctx.env.host?; // Dedupe times (sub-microsecond differences are the same frame). let mut uniq: Vec = times.iter().map(|t| (t * 1e6).round() as i64).collect(); uniq.sort_unstable(); uniq.dedup(); // Every effect before this one (the host bounds the count to them). - let bufs: Vec<(i64, Option)> = uniq.iter().map(|&k| (k, host.self_at(k as f64 / 1e6, usize::MAX))).collect(); + let bufs: Vec<(i64, Option)> = uniq.iter().map(|&k| (k, host.self_at(k as f64 / 1e6, effects))).collect(); let first = bufs.iter().find_map(|(_, b)| b.as_ref())?; let scale = first.scale; // Union of every frame's extent in layer pixels (offset = where layer (0,0) sits). @@ -851,7 +859,8 @@ pub fn timewarp_plan(ctx: &EffectCtx) -> Option { let (instants, times, fps) = timewarp_instants(ctx); let method = ctx.params.e("method"); let one = method > 0 && ctx.params.b("tuning/buildFromOneImage"); - let fr = fetch(ctx, ×)?; + // The source with its masks, without the effects above Timewarp. + let fr = fetch_with(ctx, ×, 0)?; let pm = PixelMotion::from(ctx, fr.grid.scale); let mattes = fetch_layer(ctx, "matteLayer", ×, &fr.grid); let warps = fetch_layer(ctx, "warpLayer", ×, &fr.grid); @@ -1082,8 +1091,8 @@ mod tests { None } fn self_at(&self, t: f64, effects: usize) -> Option { - // Time effects see every effect before them. - assert_eq!(effects, usize::MAX); + // Time effects see every effect before them; Timewarp only the source and masks. + assert!(effects == usize::MAX || effects == 0, "{effects}"); let v = if self.static_layer { 0.4 } else { t as f32 }; Some(Buf { img: Image::filled(6, 4, [v, v, v, 1.0]), offset: [0.0; 2], scale: 1.0 }) } @@ -1094,6 +1103,32 @@ mod tests { run_fx(id, vals, Image::filled(6, 4, [0.9, 0.0, 0.0, 1.0]), t, env) } + /// After Effects 2026: Timewarp retimes the layer's source (with masks) and ignores the + /// effects above it; Echo keeps them. + #[test] + fn timewarp_reads_the_source_without_the_effects_above_it() { + struct Asked(std::sync::Mutex>); + impl EffectHost for Asked { + fn layer(&self, _: u64, _: bool) -> Option { + None + } + fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option> { + None + } + fn self_at(&self, _: f64, effects: usize) -> Option { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(effects); + // The source (effects 0) is red, the layer with its effects green. + let c = if effects == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 1.0, 0.0, 1.0] }; + Some(Buf { img: Image::filled(6, 4, c), offset: [0.0; 2], scale: 1.0 }) + } + } + let host = Asked(std::sync::Mutex::new(vec![])); + let env = EffectEnv { host: Some(&host), frame_rate: 10.0, comp_time: 1.0, ..Default::default() }; + let out = run_fx("ec.time.timewarp", &[("speed", num(25.0))], Image::filled(6, 4, [0.0, 1.0, 0.0, 1.0]), 1.0, env); + assert_eq!(out.img.get(2, 2), [1.0, 0.0, 0.0, 1.0]); + assert!(host.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).iter().all(|&e| e == 0)); + } + #[test] fn posterize_time_holds_frames() { let h = Clock { static_layer: false }; diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index 870d22f5f..3611ef047 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -175,23 +175,7 @@ fn uniform_rotation(m: effectcraft_geom::Mat4) -> Option<(effectcraft_geom::Mat4 Some((rotation, factor)) } -/// 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); - 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) }; - [x.to_degrees(), y.to_degrees(), z.to_degrees()] -} - -/// The equivalent ZYX Euler triple nearest `reference`: each axis moved by whole turns, or the +/// The equivalent XYZ 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) }; @@ -204,136 +188,30 @@ fn nearest_euler(euler: [f64; 3], reference: [f64; 3]) -> [f64; 3] { /// 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) + let angles = world.orientation_angles(); + nearest_euler([angles.x, angles.y, angles.z], 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. `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() >= budget { - 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())); - // 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); - 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, budget, out); -} - -/// Re-expresses Orientation as `correction * Orientation`. A Z-only correction shifts Z exactly in the -/// 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). +/// Re-express Orientation as `correction * Orientation`, retaining authored keys and easing. +/// Orientation interpolates along quaternion arcs: a constant left rotation preserves those arcs +/// and their angular speeds, so converting the endpoints preserves the entire eased animation. 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); - } + if (0..3).all(|row| (0..3).all(|col| (correction.0[row][col] - if row == col { 1.0 } else { 0.0 }).abs() < 1e-12)) { return; } 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 { + for key in &mut pr.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); + key.value = KV::Vec3(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 { - // 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); - // 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. - } 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; - 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 diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs index f7b1c3e08..009a33b2e 100644 --- a/crates/engine/src/cmd/three_d.rs +++ b/crates/engine/src/cmd/three_d.rs @@ -492,7 +492,7 @@ fn camera_tool(s: &mut Session, p: &Value, op: ToolOp, cmd: &str, label: &str) - let poi = if two { Vec3::from(get_at(l, "transform/poi", t).map(|v| v.as_vec3()).unwrap_or([w / 2.0, h / 2.0, 0.0])) } else { - let fwd = (Mat4::orientation(Vec3::from(o)) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx)).apply_vec(vec3(0.0, 0.0, 1.0)); + let fwd = (Mat4::orientation(Vec3::from(o)) * Mat4::rotation_xyz(rx, ry, 0.0)).apply_vec(vec3(0.0, 0.0, 1.0)); pos + fwd * zoom }; let mut rig = Rig { eye: pos, poi, zoom, ortho: false }; diff --git a/crates/engine/src/cmd/vr_editor.rs b/crates/engine/src/cmd/vr_editor.rs index e62281128..12ebcda5b 100644 --- a/crates/engine/src/cmd/vr_editor.rs +++ b/crates/engine/src/cmd/vr_editor.rs @@ -79,12 +79,10 @@ fn norm(a: f64) -> f64 { (r * 1e6).round() / 1e6 } -/// Orientation angles (X, Y, Z degrees, applied X then Y then Z) of a rotation matrix. +/// Orientation angles (X, Y, Z degrees, [`Mat4::orientation`] order) of a rotation matrix. pub fn euler(m: Mat4) -> [f64; 3] { - let m = m.0; - let y = (-m[2][0]).clamp(-1.0, 1.0).asin(); - let (x, z) = if y.cos().abs() > 1e-6 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) }; - [norm(x.to_degrees()), norm(y.to_degrees()), norm(z.to_degrees())] + let o = m.orientation_angles(); + [norm(o.x), norm(o.y), norm(o.z)] } /// The current view orientation of an environment (from its Front camera). diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index cf2518447..db64a9066 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -292,7 +292,7 @@ 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 under a Z-only turn is shifted exactly, so the light keeps its direction at +/// Keyed Orientation under a Z-only turn is re-expressed exactly, so the light keeps its direction at /// every time, between the keys too. #[test] fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { @@ -310,8 +310,8 @@ fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() { } } -/// 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. +/// Under a tilted null the re-expressed endpoints preserve the quaternion arc exactly, +/// without adding keys between them. #[test] fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { { @@ -326,10 +326,9 @@ fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() { 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!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-8, "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"); + assert_eq!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len(), 2, "quaternion arcs need no extra keys"); } } @@ -351,10 +350,9 @@ fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() { 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. +/// Many strongly eased Orientation segments keep their authored key count and easing. #[test] -fn baked_orientation_keys_are_capped_across_the_whole_property() { +fn reexpressed_orientation_preserves_key_count_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; @@ -369,8 +367,8 @@ fn baked_orientation_keys_are_capped_across_the_whole_property() { 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"); - // the segments the cap left unchecked keep their authored easing instead of turning into straight blends + assert_eq!(n, count, "re-expressing quaternion arcs must not add keys"); + // Re-expressed endpoints retain their authored easing. 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"); diff --git a/crates/engine/tests/it/parenting_3d_position.rs b/crates/engine/tests/it/parenting_3d_position.rs index 61440e273..de2bc5dad 100644 --- a/crates/engine/tests/it/parenting_3d_position.rs +++ b/crates/engine/tests/it/parenting_3d_position.rs @@ -431,7 +431,7 @@ 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). +/// and pixels stay put at every time along the quaternion arcs between keys. #[test] fn parenting_uniform_parent_re_expresses_keyed_orientation() { for factor in [120.0, -120.0] { @@ -544,3 +544,49 @@ fn parenting_camera_to_default_null_preserves_framing() { assert_eq!(authored(&s, "transform/position"), pos0, "unparenting restores position"); assert_eq!(authored(&s, "transform/poi"), poi0, "unparenting restores point of interest"); } + +/// A constant parent rotation preserves the full eased quaternion path, including mixed axes. +#[test] +fn parenting_mixed_axis_eased_orientation_preserves_arcs_and_key_metadata() { + use effectcraft_engine::keyframe::{Ease, Interp}; + for factor in [120.0, -120.0] { + let (mut s, p, c) = fixture(true, true); + set(&mut s, p, "transform/scale", json!([factor, factor, factor])); + for (time, value) in [(0.0, json!([-35.0, 48.0, 150.0])), (0.7, json!([62.0, -27.0, -145.0])), (1.6, json!([-12.0, 58.0, 70.0]))] { + command(&mut s, "prop.addKey", json!({"layer":c,"path":"transform/orientation","time":time,"value":value})); + } + let cid = s.active_comp_id().unwrap(); + let orientation = Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(c)).unwrap().props.prop_mut("transform/orientation").unwrap(); + for key in &mut orientation.keys { + key.in_interp = Interp::Bezier; + key.out_interp = Interp::Bezier; + key.in_ease = vec![Ease { speed: 17.0, influence: 0.3 }]; + key.out_ease = vec![Ease { speed: 11.0, influence: 0.4 }]; + } + orientation.keys[1].auto_bezier = true; + let original = orientation.keys.clone(); + let times: Vec<_> = (0..=32).map(|i| Tick::from_seconds_f64(i as f64 * 0.05)).collect(); + let before: Vec<_> = times.iter().map(|&t| world(&s, c, t)).collect(); + let old_project = s.project.clone(); + parent(&mut s, c, Some(p), true); + let converted = &s.active_comp().unwrap().layer(LayerId(c)).unwrap().props.prop("transform/orientation").unwrap().keys; + assert_eq!(converted.len(), original.len()); + for (new, old) in converted.iter().zip(&original) { + let mut metadata = new.clone(); + metadata.value = old.value.clone(); + assert_eq!(&metadata, old, "all authored key metadata must survive"); + } + // Rotation matrices represent both q and -q identically, so compare transformed geometry + // rather than Euler representatives or quaternion signs. + for (&t, &matrix) in times.iter().zip(&before) { + assert!(displacement(matrix, world(&s, c, t)) < 1e-8, "pose changed at {t:?}, factor {factor}"); + } + parent(&mut s, c, None, true); + for (&t, &matrix) in times.iter().zip(&before) { + assert!(displacement(matrix, world(&s, c, t)) < 1e-8, "unparent changed pose at {t:?}"); + } + assert!(s.undo()); + assert!(s.undo()); + assert!(Arc::ptr_eq(&old_project, &s.project)); + } +} diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index eefacc876..cc0405251 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -167,12 +167,28 @@ function normalize(a) { return l === 0 ? a.map(function () { return 0; }) : div(a, l); } +// Orientation that points the layer's z axis at `at` with its x axis kept level, in the +// Orientation order (Rx · Ry · Rz) and wrapped to [0, 360), as After Effects returns it. function lookAt(from, at) { var d = normalize(sub(__v(at), __v(from))); var x = d[0], y = d[1], z = d[2] || 0; - var rx = -Math.asin(Math.max(-1, Math.min(1, y))) * 180 / Math.PI; - var ry = Math.atan2(x, z) * 180 / Math.PI; - return [rx, ry, 0]; + var p = Math.asin(Math.max(-1, Math.min(1, -y))); + var w = Math.atan2(x, z); + var cw = Math.cos(w), sw = Math.sin(w), cp = Math.cos(p), sp = Math.sin(p); + // The rotation Ry(w) · Rx(p), written as Orientation angles. + var oy = Math.asin(Math.max(-1, Math.min(1, sw * cp))); + var ox, oz; + if (Math.sqrt(cw * cw + sw * sw * sp * sp) > 1e-9) { + ox = Math.atan2(sp, cw * cp); + oz = Math.atan2(-sw * sp, cw); + } else { + ox = Math.atan2(cw * sp, cp); + oz = 0; + } + return [ox, oy, oz].map(function (r) { + var a = ((r * 180 / Math.PI) % 360 + 360) % 360; + return a >= 360 - 1e-9 || Math.abs(a) < 1e-9 ? 0 : a; + }); } function degreesToRadians(d) { return d * Math.PI / 180; } diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 48f656e52..a7d2489c7 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -586,6 +586,12 @@ fn angles_and_look_at() { assert_close!(f.num("degreesToRadians(90)", 0.0), std::f64::consts::FRAC_PI_2); let o = f.ev(f.a, "transform/orientation", "lookAt([0, 0, 0], [100, 0, 100])", 0.0).unwrap().as_vec3(); assert!(close(o[1], 45.0) && close(o[0], 0.0), "{o:?}"); + // After Effects 2026 read-backs of lookAt(position, …) on a layer at [960, 540, 0]. + for (at, want) in [([1500.0, 200.0, 300.0], [48.5763, 49.9802, 319.0442]), ([500.0, 900.0, -400.0], [221.9872, 319.4766, 210.3184])] { + let e = format!("lookAt([960, 540, 0], [{}, {}, {}])", at[0], at[1], at[2]); + let o = f.ev(f.a, "transform/orientation", &e, 0.0).unwrap().as_vec3(); + assert!((0..3).all(|i| (o[i] - want[i]).abs() < 1e-3), "{e}: {o:?}"); + } } // ---- interpolation ---------------------------------------------------------------------------- diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 32121ffcb..794761f6c 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -3,7 +3,8 @@ //! //! Conventions follow After Effects' public documentation: y grows downwards, angles are degrees //! clockwise on screen, 3D uses a left-handed system with +z pointing into the screen, and a 3D -//! layer's rotation is Orientation (X, Y, Z) followed by X Rotation, Y Rotation, Z Rotation. +//! layer's rotation is Orientation then X/Y/Z Rotation, each composed as `Rx · Ry · Rz` (Z turns +//! the vector first; measured in After Effects). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -321,17 +322,32 @@ impl Mat4 { let (s, c) = deg.to_radians().sin_cos(); Mat4([[c, -s, 0.0, 0.0], [s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]) } - /// Orientation (degrees, X then Y then Z applied to the vector in that order). + /// Orientation (degrees): `Rx(x) · Ry(y) · Rz(z)`, i.e. Z is applied to the vector first, + /// then Y, then X. Measured in After Effects 2026 (Orientation [10,20,30] and X/Y/Z Rotation + /// 10/20/30 on a 3D solid match this order to the anti-aliased edge; the reverse order is + /// 5 % of the frame off). pub fn orientation(o: Vec3) -> Mat4 { - Mat4::rotate_z(o.z) * Mat4::rotate_y(o.y) * Mat4::rotate_x(o.x) + Mat4::rotate_x(o.x) * Mat4::rotate_y(o.y) * Mat4::rotate_z(o.z) } - /// The 3D layer transform: layer space → parent space. + /// X, Y and Z Rotation of a 3D layer (degrees), composed like [`Mat4::orientation`]. + pub fn rotation_xyz(x: f64, y: f64, z: f64) -> Mat4 { + Mat4::rotate_x(x) * Mat4::rotate_y(y) * Mat4::rotate_z(z) + } + /// The Orientation angles (degrees) of this matrix's rotation part: the inverse of + /// [`Mat4::orientation`] (Y in [-90, 90], X and Z in (-180, 180]; Z = 0 at gimbal lock). + pub fn orientation_angles(&self) -> Vec3 { + let m = &self.0; + let sy = m[0][2].clamp(-1.0, 1.0); + let cy = m[0][0].hypot(m[0][1]); + let y = sy.atan2(cy); + let (x, z) = if cy > 1e-9 { ((-m[1][2]).atan2(m[2][2]), (-m[0][1]).atan2(m[0][0])) } else { (m[2][1].atan2(m[1][1]), 0.0) }; + vec3(x.to_degrees(), y.to_degrees(), z.to_degrees()) + } + /// The 3D layer transform: layer space → parent space (Orientation, then X/Y/Z Rotation). pub fn layer_3d(anchor: Vec3, position: Vec3, scale_pct: Vec3, orientation: Vec3, rotation: Vec3) -> Mat4 { Mat4::translate(position) * Mat4::orientation(orientation) - * Mat4::rotate_z(rotation.z) - * Mat4::rotate_y(rotation.y) - * Mat4::rotate_x(rotation.x) + * Mat4::rotation_xyz(rotation.x, rotation.y, rotation.z) * Mat4::scale(scale_pct / 100.0) * Mat4::translate(-anchor) } @@ -448,6 +464,76 @@ pub fn look_at(pos: Vec3, poi: Vec3, extra: Mat4) -> Mat4 { extra * rot * Mat4::translate(-pos) } +/// Unit quaternion `[w, x, y, z]` of [`Mat4::orientation`]`(o)`. +fn orientation_quat(o: Vec3) -> [f64; 4] { + let half = |d: f64| (d.to_radians() * 0.5).sin_cos(); + let (sx, cx) = half(o.x); + let (sy, cy) = half(o.y); + let (sz, cz) = half(o.z); + // qx · qy · qz (Hamilton product), matching Rx · Ry · Rz. + let (w, x, y, z) = (cx * cy, sx * cy, cx * sy, sx * sy); + [w * cz - z * sz, x * cz + y * sz, y * cz - x * sz, z * cz + w * sz] +} + +fn quat_matrix(q: [f64; 4]) -> Mat4 { + let [w, x, y, z] = q; + Mat4([ + [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w), 0.0], + [2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w), 0.0], + [2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y), 0.0], + [0.0, 0.0, 0.0, 1.0], + ]) +} + +/// Angle (degrees, 0..=180) of the shortest rotation between two Orientations. +pub fn orientation_arc_deg(a: Vec3, b: Vec3) -> f64 { + let (p, q) = (orientation_quat(a), orientation_quat(b)); + let d = (p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]).abs().clamp(0.0, 1.0); + let arc = 2.0 * d.acos().to_degrees(); + if arc.is_finite() { arc } else { 0.0 } +} + +/// Orientation between two Orientations at fraction `t`, as After Effects interpolates keyed +/// Orientation: spherical linear interpolation of the two rotations along the shortest arc, +/// written as angles in [0, 360) (measured in After Effects 2026: keys [0,0,0] → [10,20,30] read +/// back [6.2643, 9.3531, 15.2190] half-way, [0,0,0] → [60,80,40] [30.6186, 35.7617, 28.4317], +/// [0,0,0] → [0,0,270] [0, 0, 315], [0,0,0] → [0,0,720] [0, 0, 0]). +pub fn orientation_slerp(a: Vec3, b: Vec3, t: f64) -> Vec3 { + let blend = a + (b - a) * t; + if !t.is_finite() || !blend.x.is_finite() || !blend.y.is_finite() || !blend.z.is_finite() { + return a; + } + let p = orientation_quat(a); + let mut q = orientation_quat(b); + let mut d = p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + p[3] * q[3]; + if d < 0.0 { + q = q.map(|c| -c); + d = -d; + } + let d = d.clamp(-1.0, 1.0); + let (ka, kb) = if d > 1.0 - 1e-12 { + (1.0 - t, t) + } else { + let om = d.acos(); + let s = om.sin(); + (((1.0 - t) * om).sin() / s, (t * om).sin() / s) + }; + let mut r = [0.0; 4]; + for (i, c) in r.iter_mut().enumerate() { + *c = ka * p[i] + kb * q[i]; + } + let n = r.iter().map(|c| c * c).sum::().sqrt(); + if !(n > 1e-12 && n.is_finite()) { + return a; + } + let e = quat_matrix(r.map(|c| c / n)).orientation_angles(); + let wrap = |v: f64| { + let w = v.rem_euclid(360.0); + if w >= 360.0 - 1e-9 || w.abs() < 1e-9 { 0.0 } else { w } + }; + vec3(wrap(e.x), wrap(e.y), wrap(e.z)) +} + #[cfg(test)] mod tests { use super::*; @@ -512,6 +598,66 @@ mod tests { assert!((i.apply(m.apply(p)) - p).length() < 1e-9); } + fn near3(a: Vec3, b: [f64; 3], tol: f64) -> bool { + (a.x - b[0]).abs() < tol && (a.y - b[1]).abs() < tol && (a.z - b[2]).abs() < tol + } + + #[test] + fn orientation_applies_z_then_y_then_x() { + // Rx · Ry · Rz: the vector is turned about Z first. + let o = Mat4::orientation(vec3(90.0, 0.0, 90.0)); + let p = o.apply_vec(vec3(1.0, 0.0, 0.0)); + // Rz(90): +x → +y; Rx(90): +y → +z. + assert!((p - vec3(0.0, 0.0, 1.0)).length() < 1e-12, "{p:?}"); + let r = Mat4::rotation_xyz(10.0, 20.0, 30.0); + assert_eq!(r, Mat4::orientation(vec3(10.0, 20.0, 30.0))); + } + + #[test] + fn orientation_angles_invert_orientation() { + for o in [[10.0, 20.0, 30.0], [-45.0, 80.0, 170.0], [120.0, -30.0, -90.0], [0.0, 90.0, 0.0], [30.0, -90.0, 0.0]] { + let m = Mat4::orientation(Vec3::from(o)); + let back = Mat4::orientation(m.orientation_angles()); + for r in 0..3 { + for c in 0..3 { + assert!((m.0[r][c] - back.0[r][c]).abs() < 1e-9, "{o:?}"); + } + } + } + } + + #[test] + fn keyed_orientation_slerps_as_after_effects() { + // After Effects 2026 read-backs of linear Orientation keys (cases 703 and 802). + let a = vec3(0.0, 0.0, 0.0); + let b = vec3(10.0, 20.0, 30.0); + assert!(near3(orientation_slerp(a, b, 0.25), [3.4341, 4.4691, 7.7129], 1e-3)); + assert!(near3(orientation_slerp(a, b, 0.5), [6.2643, 9.3531, 15.2190], 1e-3)); + assert!(near3(orientation_slerp(a, b, 0.75), [8.4650, 14.5598, 22.6095], 1e-3)); + let b = vec3(60.0, 80.0, 40.0); + assert!(near3(orientation_slerp(a, b, 0.25), [16.9201, 16.0688, 16.0629], 1e-3)); + assert!(near3(orientation_slerp(a, b, 0.5), [30.6186, 35.7617, 28.4317], 1e-3)); + // The shortest way round, written in [0, 360). + assert!(near3(orientation_slerp(a, vec3(0.0, 0.0, 270.0), 0.5), [0.0, 0.0, 315.0], 1e-9)); + assert!(near3(orientation_slerp(a, vec3(0.0, 0.0, 270.0), 0.25), [0.0, 0.0, 337.5], 1e-9)); + assert!(near3(orientation_slerp(a, vec3(350.0, 0.0, 0.0), 0.5), [355.0, 0.0, 0.0], 1e-9)); + assert!(near3(orientation_slerp(a, vec3(0.0, 0.0, 720.0), 0.5), [0.0, 0.0, 0.0], 1e-9)); + // The ends are the key rotations. + let k = vec3(200.0, 10.0, 5.0); + let end = Mat4::orientation(orientation_slerp(vec3(0.0, 0.0, 350.0), k, 1.0)); + let want = Mat4::orientation(k); + assert!((0..3).all(|r| (0..3).all(|c| (end.0[r][c] - want.0[r][c]).abs() < 1e-9))); + // Equal rotations and hostile input do not blow up. + let whole = Mat4::orientation(orientation_slerp(a, vec3(0.0, 0.0, 360.0), 0.5)).apply_vec(vec3(1.0, 0.0, 0.0)); + assert!((whole - vec3(1.0, 0.0, 0.0)).length() < 1e-9 && orientation_arc_deg(a, vec3(0.0, 0.0, 360.0)) < 1e-6); + let nan = orientation_slerp(vec3(f64::NAN, 0.0, 0.0), b, 0.5); + // A non-finite key comes back as given (the first key), not as a blended rotation. + assert!(nan.x.is_nan() && nan.y == 0.0 && nan.z == 0.0, "{nan:?}"); + assert!(orientation_slerp(a, b, f64::INFINITY).x.is_finite()); + assert!((orientation_arc_deg(a, vec3(0.0, 0.0, 90.0)) - 90.0).abs() < 1e-9); + assert!((orientation_arc_deg(a, vec3(0.0, 0.0, 270.0)) - 90.0).abs() < 1e-9); + } + #[test] fn default_camera_sees_z0_plane_unscaled() { let (w, h) = (1920.0, 1080.0); diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index abf929fe9..0998790a1 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -57,6 +57,12 @@ impl Ease { pub const EASY: Ease = Ease { speed: 0.0, influence: 1.0 / 3.0 }; } +/// Influence of a linear key side in a segment whose other side is eased: the side keeps the +/// segment's average speed with a 16.67 % influence, as After Effects shows it in the Keyframe +/// Velocity dialog and evaluates it (time-remap read-backs: a bezier-out (speed 0, 50 %) into a +/// linear-in from 3.5 to 0.5 over 2 s gives 3.290 at a quarter and 2.657 at half of the segment). +pub const LINEAR_SIDE_INFLUENCE: f64 = 1.0 / 6.0; + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Keyframe { pub time: Tick, @@ -408,7 +414,7 @@ fn effective_ease>(keys: &[K], i: usize, d: usize, spatial: /// The ease a key side presents in the Graph Editor and the Keyframe Velocity dialog: stored /// Bezier eases, auto-Bezier slopes, or the equivalent of a linear side (speed of the straight -/// segment, 33.3 % influence). `None` for hold sides and sides without a neighbour. +/// segment, [`LINEAR_SIDE_INFLUENCE`]). `None` for hold sides and sides without a neighbour. pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Option { let k = keys.get(i)?; let j = if out { i + 1 } else { i.checked_sub(1)? }; @@ -429,7 +435,7 @@ pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool let dv = b.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0); if out { dv } else { -dv } }; - return Some(Ease { speed: dv / dur, influence: 1.0 / 3.0 }); + return Some(Ease { speed: dv / dur, influence: LINEAR_SIDE_INFLUENCE }); } Some(effective_ease(keys, i, if is_spatial { 0 } else { d }, is_spatial, out)) } @@ -546,6 +552,63 @@ fn evaluate_active>(keys: &[K], t: Tick, spatial: bool) -> O Some(segment_value_of(keys, i, t, spatial)) } +/// Evaluate keyed 3D Orientation at `t` as After Effects does: the temporal ease gives the +/// progress through each segment, and the rotation moves along the shortest arc between the two +/// keys ([`effectcraft_geom::orientation_slerp`]) instead of blending each angle. Speeds are +/// degrees per second along that arc. Values other than 3D vectors evaluate as [`evaluate`]. +pub fn evaluate_orientation(keys: &[Keyframe], t: Tick) -> Option { + let first = keys.first()?; + if keys.len() == 1 || t <= first.time { + return Some(first.value.clone()); + } + let last = keys.last()?; + if t >= last.time { + return Some(last.value.clone()); + } + let i = keys.partition_point(|k| k.time <= t).checked_sub(1)?; + let (a, b) = (keys.get(i)?, keys.get(i + 1)?); + let (Value::Vec3(va), Value::Vec3(vb)) = (&a.value, &b.value) else { return Some(segment_value(keys, i, t, false)) }; + if a.out_interp == Interp::Hold { + return Some(a.value.clone()); + } + let (t0, t1) = (secs(a.time), secs(b.time)); + let dur = (t1 - t0).max(1e-12); + let x = ((secs(t) - t0) / dur).clamp(0.0, 1.0); + // On a key: its own value, as stored. + if x <= 0.0 { + return Some(a.value.clone()); + } + let arc = effectcraft_geom::orientation_arc_deg((*va).into(), (*vb).into()); + let lin_out = a.out_interp == Interp::Linear; + let lin_in = b.in_interp != Interp::Bezier; + let f = if lin_out && lin_in || arc <= 1e-9 { + x + } else { + // Auto-Bezier keys take the arc speed between their neighbours. + let side = |j: usize, out: bool| -> Ease { + // A linear side keeps the segment's average speed at the same influence as in + // `segment_value` (and a missing key, unreachable here, is treated as one). + let Some(k) = keys.get(j) else { return Ease { speed: arc / dur, influence: LINEAR_SIDE_INFLUENCE } }; + let linear = if out { lin_out } else { lin_in }; + if linear { + return Ease { speed: arc / dur, influence: LINEAR_SIDE_INFLUENCE }; + } + if k.auto_bezier && j > 0 && j + 1 < keys.len() { + let span = (secs(keys[j + 1].time) - secs(keys[j - 1].time)).max(1e-12); + let (Value::Vec3(p), Value::Vec3(n)) = (&keys[j - 1].value, &keys[j + 1].value) else { return Ease { speed: arc / dur, influence: 1.0 / 6.0 } }; + return Ease { speed: effectcraft_geom::orientation_arc_deg((*p).into(), (*n).into()) / span, influence: 1.0 / 6.0 }; + } + let list = if out { &k.out_ease } else { &k.in_ease }; + list.first().copied().unwrap_or_default() + }; + let (eo, ei) = (side(i, true), side(i + 1, false)); + ease_progress(eo.speed * dur / arc, eo.influence, ei.speed * dur / arc, ei.influence, x) + }; + let f = if f.is_finite() { f } else { x }; + let o = effectcraft_geom::orientation_slerp((*va).into(), (*vb).into(), f); + Some(Value::Vec3([o.x, o.y, o.z])) +} + /// Value inside segment `i` → `i+1` at time `t`. pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Value { segment_value_of(keys, i, t, spatial) @@ -576,8 +639,8 @@ fn segment_value_of>(keys: &[K], i: usize, t: Tick, spatial: let f = if lin_out && lin_in || len <= 1e-12 { x } else { - let eo = if lin_out { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, true, true) }; - let ei = if lin_in { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, true, false) }; + let eo = if lin_out { Ease { speed: len / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i, 0, true, true) }; + let ei = if lin_in { Ease { speed: len / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i + 1, 0, true, false) }; ease_progress(eo.speed * dur / len, eo.influence, ei.speed * dur / len, ei.influence, x) }; // A straight path (both handles on the chord, as auto-Bezier keys on a line have) is the @@ -596,16 +659,16 @@ fn segment_value_of>(keys: &[K], i: usize, t: Tick, spatial: let cb = b.value.components(); if ca.is_empty() || ca.len() != cb.len() { // Paths, gradients: ease on dimension 0 as overall progress. - let eo = if lin_out { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, 0, false, true) }; - let ei = if lin_in { Ease { speed: 1.0 / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, false, false) }; + let eo = if lin_out { Ease { speed: 1.0 / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i, 0, false, true) }; + let ei = if lin_in { Ease { speed: 1.0 / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i + 1, 0, false, false) }; let f = ease_progress(eo.speed * dur, eo.influence, ei.speed * dur, ei.influence, x); return a.value.lerp(&b.value, f); } let mut out = Vec::with_capacity(ca.len()); for d in 0..ca.len() { let dv = cb[d] - ca[d]; - let eo = if lin_out { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i, d, false, true) }; - let ei = if lin_in { Ease { speed: dv / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, d, false, false) }; + let eo = if lin_out { Ease { speed: dv / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i, d, false, true) }; + let ei = if lin_in { Ease { speed: dv / dur, influence: LINEAR_SIDE_INFLUENCE } } else { effective_ease(keys, i + 1, d, false, false) }; // Bezier in (time, value) space directly: P1 = (i0, v0 + s0*i0*dur), P2 = (1-i1, v1 - s1*i1*dur). let (i0, i1) = (eo.influence.clamp(0.0, 1.0), ei.influence.clamp(0.0, 1.0)); let u = solve_u(0.0, i0, 1.0 - i1, 1.0, x); diff --git a/crates/keyframe/src/tests.rs b/crates/keyframe/src/tests.rs index 312703239..88f9f3249 100644 --- a/crates/keyframe/src/tests.rs +++ b/crates/keyframe/src/tests.rs @@ -92,6 +92,41 @@ fn maximum_influence_resolves_flat_midpoint_time_curve() { } } +/// Keyed Orientation moves along the shortest arc, eased in time, as After Effects 2026 reads +/// it back (cases 802 and 803: keys [0,0,0] at 0 s and [60,80,40] at 2 s). +#[test] +fn keyed_orientation_matches_after_effects() { + let close = |v: Value, e: [f64; 3]| { + let v = v.as_vec3(); + assert!((0..3).all(|i| (v[i] - e[i]).abs() < 2e-3), "{v:?} vs {e:?}"); + }; + let lin = vec![Keyframe::new(s(0.0), Value::Vec3([0.0; 3])), Keyframe::new(s(2.0), Value::Vec3([60.0, 80.0, 40.0]))]; + close(evaluate_orientation(&lin, s(0.5)).unwrap(), [16.9201, 16.0688, 16.0629]); + close(evaluate_orientation(&lin, s(1.0)).unwrap(), [30.6186, 35.7617, 28.4317]); + close(evaluate_orientation(&lin, s(2.0)).unwrap(), [60.0, 80.0, 40.0]); + // A middle key reads back as stored (not as another angle triple of the same rotation). + let three = + vec![Keyframe::new(s(0.0), Value::Vec3([0.0; 3])), Keyframe::new(s(1.0), Value::Vec3([0.0, 120.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([0.0; 3]))]; + assert_eq!(evaluate_orientation(&three, s(1.0)), Some(Value::Vec3([0.0, 120.0, 0.0]))); + // Easy ease (AE's preset: speed 0, influence 33 %) on both keys. + let ease = |k: Keyframe| { + let mut k = k.eased(); + k.in_ease = vec![Ease { speed: 0.0, influence: 0.33 }]; + k.out_ease = vec![Ease { speed: 0.0, influence: 0.33 }]; + k + }; + let eased: Vec = lin.into_iter().map(ease).collect(); + close(evaluate_orientation(&eased, s(0.5)).unwrap(), [11.0974, 9.5750, 10.5916]); + close(evaluate_orientation(&eased, s(1.0)).unwrap(), [30.6186, 35.7617, 28.4317]); + close(evaluate_orientation(&eased, s(1.5)).unwrap(), [47.6915, 65.7590, 40.3338]); + // Hold keys and other value types. + let held = vec![Keyframe::new(s(0.0), Value::Vec3([1.0, 2.0, 3.0])).hold(), Keyframe::new(s(1.0), Value::Vec3([0.0; 3]))]; + close(evaluate_orientation(&held, s(0.5)).unwrap(), [1.0, 2.0, 3.0]); + let scalar = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(10.0))]; + assert_eq!(evaluate_orientation(&scalar, s(0.5)), Some(Value::Scalar(5.0))); + assert_eq!(evaluate_orientation(&[], s(0.5)), None); +} + /// M13.15: agents set gradients as plain JSON, not only the tagged form `get` returns. #[test] fn gradients_coerce_from_plain_json() { @@ -189,6 +224,61 @@ fn bezier_with_linear_speed_matches_linear() { } } +/// A linear side next to an eased one keeps the segment's average speed at 16.67 % +/// influence. After Effects time-remap read-backs (a square at x = 50 + 455 * remap): out of +/// a key at 2 s (3.5, speed 0, influence 50 %) into a linear key at 4 s (0.5) the remap is +/// 3.290 at 2.5 s and 2.657 at 3 s (33.3 % gave 3.308 and 2.629). +#[test] +fn a_linear_side_next_to_an_eased_side_matches_after_effects() { + let mut a = Keyframe::new(s(2.0), Value::Scalar(3.5)); + a.out_interp = Interp::Bezier; + a.out_ease = vec![Ease { speed: 0.0, influence: 0.5 }]; + let keys = vec![a, Keyframe::new(s(4.0), Value::Scalar(0.5))]; + for (t, ae) in [(2.5, 3.290), (3.0, 2.657)] { + let v = evaluate(&keys, s(t), false).unwrap().as_f64(); + // AE's square edge is read to the pixel: 1 px = 1 / 455 of remap. + assert!((v - ae).abs() < 2.0 / 455.0, "{t}: {v} vs {ae}"); + } + // The mirror case: a linear out into an eased in. + let mut b = Keyframe::new(s(4.0), Value::Scalar(0.5)); + b.in_interp = Interp::Bezier; + b.in_ease = vec![Ease { speed: 0.0, influence: 0.5 }]; + let mirror = vec![Keyframe::new(s(2.0), Value::Scalar(3.5)), b]; + let v = evaluate(&mirror, s(3.5), false).unwrap().as_f64(); + assert!((v - (4.0 - 3.290)).abs() < 2.0 / 455.0, "{v}"); + assert_eq!(side_ease(&keys, 1, 0, false, false).map(|e| e.influence), Some(LINEAR_SIDE_INFLUENCE)); +} + +/// Keyed Orientation shapes a linear side next to an eased one as a scalar property does +/// (`LINEAR_SIDE_INFLUENCE`): about one axis the shortest arc is the angle itself, so the +/// orientation follows the same curve as a scalar with the same keys (CodeRabbit on #1876). +#[test] +fn orientation_linear_side_next_to_an_eased_side_matches_a_scalar() { + let ease = vec![Ease { speed: 0.0, influence: 0.5 }]; + let (mut a, mut sa) = (Keyframe::new(s(2.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(s(2.0), Value::Scalar(0.0))); + for k in [&mut a, &mut sa] { + k.out_interp = Interp::Bezier; + k.out_ease = ease.clone(); + } + let orient = vec![a, Keyframe::new(s(4.0), Value::Vec3([0.0, 0.0, 90.0]))]; + let scalar = vec![sa, Keyframe::new(s(4.0), Value::Scalar(90.0))]; + // The mirror: a linear out into an eased in. + let (mut b, mut sb) = (Keyframe::new(s(4.0), Value::Vec3([0.0, 0.0, 90.0])), Keyframe::new(s(4.0), Value::Scalar(90.0))); + for k in [&mut b, &mut sb] { + k.in_interp = Interp::Bezier; + k.in_ease = ease.clone(); + } + let orient_m = vec![Keyframe::new(s(2.0), Value::Vec3([0.0, 0.0, 0.0])), b]; + let scalar_m = vec![Keyframe::new(s(2.0), Value::Scalar(0.0)), sb]; + for (o, sc) in [(&orient, &scalar), (&orient_m, &scalar_m)] { + for t in [2.25, 2.5, 3.0, 3.5, 3.75] { + let Some(Value::Vec3(v)) = evaluate_orientation(o, s(t)) else { panic!("orientation at {t}") }; + let want = evaluate(sc, s(t), false).unwrap().as_f64(); + assert!((v[2] - want).abs() < 1e-6 && v[0].abs() < 1e-9 && v[1].abs() < 1e-9, "{t}: {v:?} vs {want}"); + } + } +} + #[test] fn spatial_linear_constant_speed() { let keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))]; diff --git a/crates/lottie/src/anim.rs b/crates/lottie/src/anim.rs index 4925460a9..5b6dabf50 100644 --- a/crates/lottie/src/anim.rs +++ b/crates/lottie/src/anim.rs @@ -150,11 +150,15 @@ fn segment_ease(keys: &[Keyframe], i: usize, spatial: bool) -> (Vec, Vec EvalCtx<'a> { // Auto-orient (along path / towards camera) replaces Orientation. let orient = crate::three_d::camera::auto_orient_3d(self, layer, p) .unwrap_or_else(|| Mat4::orientation(Vec3::from(self.v3(layer, tr, "orientation", [0.0; 3])))); - Mat4::translate(p) - * orient - * Mat4::rotate_z(rz) - * Mat4::rotate_y(ry) - * Mat4::rotate_x(rx) - * Mat4::scale(Vec3::from(scale) / 100.0) - * Mat4::translate(-Vec3::from(anchor)) + Mat4::translate(p) * orient * Mat4::rotation_xyz(rx, ry, rz) * Mat4::scale(Vec3::from(scale) / 100.0) * Mat4::translate(-Vec3::from(anchor)) } /// Layer space → comp (world) space, including parents. diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index a927e2ceb..6aee50a78 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -117,12 +117,15 @@ pub fn basis_view(eye: Vec3, forward: Vec3, down_hint: Vec3) -> Mat4 { Mat4([[r.x, r.y, r.z, 0.0], [d.x, d.y, d.z, 0.0], [f.x, f.y, f.z, 0.0], [0.0, 0.0, 0.0, 1.0]]) * Mat4::translate(-eye) } -/// Orientation angles (degrees, AE order X→Y→Z with Z = 0) that point +z along `dir`. +/// Orientation angles (degrees, [`Mat4::orientation`]'s order) that point +z along `dir` with +/// the layer's x axis kept level (no roll): the pitch about x, then the turn about the world's +/// vertical axis, written as Orientation. pub fn orientation_for(dir: Vec3) -> [f64; 3] { let d = dir.normalize(); - let x = (-d.y).clamp(-1.0, 1.0).asin().to_degrees(); - let y = d.x.atan2(d.z).to_degrees(); - [norm360(x), norm360(y), 0.0] + let pitch = (-d.y).clamp(-1.0, 1.0).asin().to_degrees(); + let yaw = d.x.atan2(d.z).to_degrees(); + let o = (Mat4::rotate_y(yaw) * Mat4::rotate_x(pitch)).orientation_angles(); + [norm360(o.x), norm360(o.y), norm360(o.z)] } fn norm360(a: f64) -> f64 { @@ -177,7 +180,7 @@ pub fn layer_rotation(ctx: &EvalCtx, layer: &Layer) -> Mat4 { let rx = ctx.f(layer, tr, "rotationX", 0.0); let ry = ctx.f(layer, tr, "rotationY", 0.0); let rz = ctx.f(layer, tr, "rotation", 0.0); - Mat4::orientation(Vec3::from(o)) * Mat4::rotate_z(rz) * Mat4::rotate_y(ry) * Mat4::rotate_x(rx) + Mat4::orientation(Vec3::from(o)) * Mat4::rotation_xyz(rx, ry, rz) } /// World matrix of a layer's parent chain (identity when unparented). diff --git a/crates/render/src/three_d/tests_orient.rs b/crates/render/src/three_d/tests_orient.rs index f9c72e296..bbecc7fc0 100644 --- a/crates/render/src/three_d/tests_orient.rs +++ b/crates/render/src/three_d/tests_orient.rs @@ -215,3 +215,264 @@ fn layer_cache_is_independent_of_camera_lights_and_view() { assert_ne!(third, second); assert!(cache.stats().hits > 0, "the cache was actually used"); } + +/// A 1920 × 1080 comp with a full-frame 3D solid whose transform `set` changes (and an optional +/// one-node camera at the default camera's place), as the After Effects reference cases. +fn ae_frame(set: impl Fn(&mut Layer), camera: Option<([f64; 3], [f64; 3])>) -> (Project, ItemId) { + let mut p = Project::default(); + let comp = Comp::new(1920, 1080, FrameRate::FPS_25, Tick::from_seconds_f64(5.0)); + let cid = p.add_item("C", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let sid = p.add_item("L", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 1920, height: 1080, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "L", LayerSource::Solid { item: sid }, (1920, 1080), None); + l.switches.three_d = true; + set(&mut l); + let mut layers = vec![]; + if let Some((orientation, rotation)) = camera { + let mut cam = build::layer(&mut p, &comp, "K", LayerSource::Camera, (1920, 1080), None); + cam.auto_orient = AutoOrient::Off; + cam.props.prop_mut("transform/position").unwrap().value = Value::Vec3([960.0, 540.0, -1920.0 * 50.0 / 36.0]); + cam.props.prop_mut("transform/orientation").unwrap().value = Value::Vec3(orientation); + for (m, v) in [("transform/rotationX", rotation[0]), ("transform/rotationY", rotation[1]), ("transform/rotation", rotation[2])] { + cam.props.prop_mut(m).unwrap().value = Value::Scalar(v); + } + cam.props.prop_mut("cameraOptions/zoom").unwrap().value = Value::Scalar(1920.0 * 50.0 / 36.0); + layers.push(cam); + } + layers.push(l); + p.comp_mut(cid).unwrap().layers = layers.into(); + (p, cid) +} + +/// Whether comp pixel (x, y) shows the solid, from its layer → comp mapping. +fn covers(p: &Project, cid: ItemId, secs: f64, x: u32, y: u32) -> bool { + let c = p.comp(cid).unwrap(); + let ctx = EvalCtx::new(p, cid, c, Tick::from_seconds_f64(secs)); + let l = c.layers.last().unwrap(); + let inv = ctx.layer_to_comp(l).0.inverse().unwrap(); + let u = inv.apply(effectcraft_geom::vec2(x as f64 + 0.5, y as f64 + 0.5)); + (0.0..=1920.0).contains(&u.x) && (0.0..=1080.0).contains(&u.y) +} + +fn check_points(p: &Project, cid: ItemId, secs: f64, case: &str, pts: &[(u32, u32, bool)]) { + for &(x, y, red) in pts { + assert_eq!(covers(p, cid, secs, x, y), red, "case {case}: pixel ({x}, {y}) should be {}", if red { "red" } else { "black" }); + } +} + +fn set3(l: &mut Layer, m: &str, v: [f64; 3]) { + l.props.prop_mut(m).unwrap().value = Value::Vec3(v); +} + +fn set1(l: &mut Layer, m: &str, v: f64) { + l.props.prop_mut(m).unwrap().value = Value::Scalar(v); +} + +/// Orientation and X/Y/Z Rotation compose as `Rx · Ry · Rz` (Z turns the layer first), and +/// Orientation comes before the axis rotations, in layers and cameras alike. The points are +/// sampled from After Effects 2026 renders of the synthetic cases (each at least 3 px from an +/// edge; red = the solid covers the pixel), a third of them where the old Z · Y · X order differed. +#[test] +fn rotation_order_matches_after_effects() { + // 488: Orientation [10, 20, 30]. + let (p, cid) = ae_frame(|l| set3(l, "transform/orientation", [10.0, 20.0, 30.0]), None); + check_points( + &p, + cid, + 1.0, + "488", + &[ + (328, 687, false), + (416, 764, false), + (209, 237, true), + (548, 861, false), + (1899, 425, false), + (87, 462, true), + (162, 581, false), + (662, 948, false), + (682, 849, true), + (618, 413, true), + (1380, 181, true), + (605, 378, true), + (1830, 518, true), + (559, 197, true), + (1760, 843, true), + (550, 823, true), + (572, 952, false), + (272, 963, false), + (268, 761, false), + (628, 1073, false), + (218, 721, false), + (256, 807, false), + (225, 881, false), + (167, 637, false), + ], + ); + // 800: X / Y / Z Rotation 10 / 20 / 30. + let (p, cid) = ae_frame( + |l| { + set1(l, "transform/rotationX", 10.0); + set1(l, "transform/rotationY", 20.0); + set1(l, "transform/rotation", 30.0); + }, + None, + ); + check_points( + &p, + cid, + 1.0, + "800", + &[ + (104, 450, true), + (755, 1002, false), + (1407, 149, false), + (271, 66, true), + (288, 54, true), + (113, 393, true), + (204, 174, true), + (1790, 360, false), + (1414, 324, true), + (682, 343, true), + (1147, 153, true), + (345, 446, true), + (1516, 542, true), + (666, 25, true), + (1327, 1065, true), + (778, 267, true), + (1588, 5, false), + (1706, 147, false), + (357, 978, false), + (97, 689, false), + (1483, 79, false), + (433, 848, false), + (255, 802, false), + (636, 1057, false), + ], + ); + // 801: Orientation [0, 30, 0] with X Rotation 40 (Orientation first). + let (p, cid) = ae_frame( + |l| { + set3(l, "transform/orientation", [0.0, 30.0, 0.0]); + set1(l, "transform/rotationX", 40.0); + }, + None, + ); + check_points( + &p, + cid, + 1.0, + "801", + &[ + (1356, 35, true), + (1762, 19, true), + (190, 227, true), + (108, 182, true), + (502, 832, true), + (1539, 203, true), + (722, 812, true), + (1872, 76, true), + (1166, 165, true), + (1640, 380, true), + (629, 619, true), + (1796, 574, true), + (1767, 311, true), + (1432, 421, true), + (1819, 425, true), + (283, 252, true), + (563, 1062, false), + (428, 963, false), + (77, 417, false), + (47, 833, false), + (995, 933, false), + (159, 839, false), + (827, 947, false), + (564, 908, false), + ], + ); + // 804: a one-node camera with Orientation [4, 6, 30]; 805: the same as X / Y / Z Rotation. + for (case, cam) in [("804", ([4.0, 6.0, 30.0], [0.0; 3])), ("805", ([0.0; 3], [4.0, 6.0, 30.0]))] { + let (p, cid) = ae_frame(|_| {}, Some(cam)); + check_points( + &p, + cid, + 1.0, + case, + &[ + (204, 410, false), + (1077, 38, false), + (60, 492, false), + (1732, 752, true), + (265, 475, false), + (1743, 821, true), + (1417, 1043, true), + (1559, 944, true), + (1280, 310, true), + (702, 984, true), + (1072, 893, true), + (370, 1042, true), + (1353, 570, true), + (1178, 136, true), + (1201, 289, true), + (1241, 605, true), + (75, 337, false), + (71, 138, false), + (352, 24, false), + (1764, 109, false), + (186, 338, false), + (1835, 668, false), + (211, 269, false), + (157, 143, false), + ], + ); + } +} + +/// Keyed Orientation turns along the shortest arc between the keys (case 802: [0, 0, 0] at 0 s +/// to [60, 80, 40] at 2 s, linear; points from the After Effects render at 1 s, a third of them +/// where blending each angle on its own differed). +#[test] +fn keyed_orientation_renders_as_after_effects() { + let (p, cid) = ae_frame( + |l| { + l.props.prop_mut("transform/orientation").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([0.0; 3])), Keyframe::new(Tick::from_seconds_f64(2.0), Value::Vec3([60.0, 80.0, 40.0]))]; + }, + None, + ); + check_points( + &p, + cid, + 1.0, + "802", + &[ + (1880, 472, false), + (1598, 191, false), + (248, 285, true), + (1157, 51, false), + (1628, 129, false), + (357, 37, true), + (1865, 891, true), + (1915, 946, true), + (1728, 1021, true), + (1178, 665, true), + (1303, 631, true), + (1339, 954, true), + (910, 891, true), + (1257, 1002, true), + (1349, 890, true), + (1388, 551, true), + (356, 500, false), + (490, 1058, false), + (524, 829, false), + (73, 29, false), + (393, 769, false), + (1867, 124, false), + (42, 733, false), + (648, 1007, false), + ], + ); + // The value After Effects reads back half-way. + let c = p.comp(cid).unwrap(); + let v = c.layers[0].props.prop("transform/orientation").unwrap().value_at(Tick::from_seconds_f64(1.0)).as_vec3(); + let want = [30.6186, 35.7617, 28.4317]; + assert!((0..3).all(|i| (v[i] - want[i]).abs() < 1e-3), "{v:?}"); +} diff --git a/crates/script/src/tests/ease_presets.rs b/crates/script/src/tests/ease_presets.rs index f075250b2..3d1f95f31 100644 --- a/crates/script/src/tests/ease_presets.rs +++ b/crates/script/src/tests/ease_presets.rs @@ -90,7 +90,7 @@ fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() { let r = ok( &mut s, r#"var comp = app.project.activeItem, p = comp.layer(1).transform.opacity; - // A linear side reports the speed it plays at (33.3 % influence). + // A linear side reports the speed it plays at (16.67 % influence, as After Effects). var lin = p.keyInTemporalEase(3)[0]; p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 50)], [new KeyframeEase(0, 75)]); p.setInterpolationTypeAtKey(3, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD); @@ -98,7 +98,7 @@ fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() { var sel = comp.selectedProperties; [lin.speed, lin.influence, p.selectedKeys, sel.length, sel.length ? sel[0].name : ""]"#, ); - assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 3.0, 1e-9), "{r}"); + assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 6.0, 1e-9), "{r}"); assert_eq!(r[2], json!([1, 3]), "the selection is the user's"); assert_eq!((r[3].clone(), r[4].clone()), (json!(1), json!("Opacity"))); assert_eq!(s.state.selected_keys, before); diff --git a/crates/track/src/camtrack/linalg.rs b/crates/track/src/camtrack/linalg.rs index 71e88e109..0e5ff8982 100644 --- a/crates/track/src/camtrack/linalg.rs +++ b/crates/track/src/camtrack/linalg.rs @@ -503,16 +503,16 @@ pub fn umeyama(src: &[V3], dst: &[V3]) -> Option { Some(Similarity { s, r, t }) } -/// Euler angles (degrees) `[x, y, z]` with `m = Rz(z) Ry(y) Rx(x)` (After Effects' Orientation -/// order: X, then Y, then Z applied to the vector). +/// Euler angles (degrees) `[x, y, z]` with `m = Rx(x) Ry(y) Rz(z)`: After Effects' Orientation +/// order (Z is applied to the vector first, then Y, then X; see `effectcraft_geom::Mat4::orientation`). pub fn euler_xyz(m: &M3) -> V3 { - let sy = (-m[2][0]).clamp(-1.0, 1.0); + let sy = m[0][2].clamp(-1.0, 1.0); let y = sy.asin(); - let (x, z) = if sy.abs() < 0.999_999 { (m[2][1].atan2(m[2][2]), m[1][0].atan2(m[0][0])) } else { ((-m[1][2]).atan2(m[1][1]), 0.0) }; + let (x, z) = if sy.abs() < 0.999_999 { ((-m[1][2]).atan2(m[2][2]), (-m[0][1]).atan2(m[0][0])) } else { (m[2][1].atan2(m[1][1]), 0.0) }; [x.to_degrees(), y.to_degrees(), z.to_degrees()] } -/// `Rz(z) Ry(y) Rx(x)` from degrees. +/// `Rx(x) Ry(y) Rz(z)` from degrees. pub fn from_euler_xyz(e: V3) -> M3 { let (sx, cx) = e[0].to_radians().sin_cos(); let (sy, cy) = e[1].to_radians().sin_cos(); @@ -520,7 +520,7 @@ pub fn from_euler_xyz(e: V3) -> M3 { let rx = [[1.0, 0.0, 0.0], [0.0, cx, -sx], [0.0, sx, cx]]; let ry = [[cy, 0.0, sy], [0.0, 1.0, 0.0], [-sy, 0.0, cy]]; let rz = [[cz, -sz, 0.0], [sz, cz, 0.0], [0.0, 0.0, 1.0]]; - mm(&rz, &mm(&ry, &rx)) + mm(&rx, &mm(&ry, &rz)) } #[cfg(test)] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index da4ae78db..c7f850834 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -44,7 +44,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k | Simulation | 18 | 18 | Particle Playground lacks Particle Exploder, text particles, ephemeral mapper; CC Particle World camera fixed (#397) | | Stylize | 25 | 25 | Glow's halo (coverage of a dim layer's edge) and blur width match After Effects 2026 renders; glows above the threshold match too (each channel 8 × (value − threshold + 0.061), measured at three thresholds and two radii); Composite Original's labels are swapped against After Effects (EffectCraft's On Top adds the glow over the original, which After Effects calls Behind); Motion Tile output size (#369) | | Text | 2 | 2 | — | -| Time | 8 | 8 | #535 (Pixel Motion Blur, Vector Detail) is closed | +| Time | 8 | 8 | Timewarp retimes the layer's source and masks without the effects above it, as After Effects 2026; #535 (Pixel Motion Blur, Vector Detail) is closed | | Transition | 17 | 17 | Venetian Blinds matches After Effects 2026 renders (band phase from the layer centre, Gaussian feather on both edges) except within ~6 px of the layer edge (up to 48/255); Radial Wipe's Feather matches After Effects 2026 (a Gaussian of σ = 0.4 × Feather pixels on both edges of the wedge) | | Utility | 9 | 7 | — | @@ -71,6 +71,7 @@ the GPU is performance, not parity. | 2026-10-19 | minor | Radial Wipe: Feather is in pixels on both edges of the wiped wedge (was degrees on the moving edge only), measured against After Effects 2026 (CPU and GPU) | | 2026-10-19 | minor | Fractal / Turbulent Noise: lattice cell = Scale × 2/3 px (Block noise every 66.7 px at Scale 100, as After Effects 2026), CPU and GPU | | 2026-10-19 | minor | Transform: After Effects' skew order and output beyond the layer bounds, measured against After Effects 2026 renders of three cases (CPU and GPU) | +| 2026-10-19 | minor | Timewarp ignores the effects above it (source and masks only), measured against After Effects 2026 | | 2026-10-19 | minor | Tritone uses Rec. 601 luminance, measured against After Effects 2026 renders of a red solid and a grey ramp (CPU and GPU) | | 2026-10-19 | minor | Black & White's Tint keeps the gray's luminosity (Color blend), measured against After Effects 2026 renders (CPU and GPU) | | 2026-10-19 | minor | Levels: "Off for 32 bpc Color" no longer clips in 32 bpc projects, unclipped values extrapolate below Output Black; Levels (Individual Controls) applies the channel controls before the RGB ones, measured against After Effects 2026 (CPU and GPU) | diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 0398118c5..f9ecdd99f 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,18 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Linear side next to an eased side + +Measured on 19 October 2026 with After Effects 2026 (26.2) through a time-remapped precomposition +(a square moving at 455 px per second of source time, read off rendered frames to the pixel): a +segment from a Bezier out side (speed 0, influence 50 %) at 2 s, value 3.5, into a linear key at +4 s, value 0.5, plays 3.290 at 2.5 s and 2.657 at 3 s. EffectCraft gave the linear side the +segment's average speed at 33.3 % influence (3.308 and 2.629, 8 and 13 px off); AE uses the average +speed at 16.67 %, the influence its Keyframe Velocity dialog shows for linear keys. Now the same +square positions to the pixel. `LINEAR_SIDE_INFLUENCE` holds the value for evaluation, the Graph +Editor / velocity dialog sides and the Lottie export; segments that are linear on both sides stay +straight. Regression test: `a_linear_side_next_to_an_eased_side_matches_after_effects`. + ## Justify All with the every-line composer Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6):