From 719b9170dd6cad31aaa75d6612ad37b4073d05b7 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 7 Nov 2026 00:03:52 +0100 Subject: [PATCH] 3D rotation order, keyed Orientation, linear-to-eased keyframes and Timewarp behave as in After Effects (#1876) * 3D: Orientation and X/Y/Z Rotation turn Z first, keyed Orientation slerps, as After Effects After Effects composes a 3D layer's (and a camera's) Orientation and its X/Y/Z Rotation as Rx * Ry * Rz each, Orientation before the axis rotations; EffectCraft used Rz * Ry * Rx. Measured on synthetic AE 2026 cases: Orientation [10,20,30] (488) was 5.2 % of the frame off and now matches to the anti-aliased edge (0.03 %); X/Y/Z Rotation 10/20/30 (800), Orientation + X Rotation (801) and one-node cameras (804, 805) likewise. Keyed Orientation moves along the shortest arc between the keys (quaternion slerp, eased in time, angles written in [0, 360)): keys [0,0,0] -> [10,20,30] read back [6.2643, 9.3531, 15.2190] half-way where EffectCraft gave [5,10,15]; [0,0,0] -> [60,80,40] linear and eased match AE's read-backs within 0.002 deg. lookAt() returns AE's angles (z axis on the target, x axis level; two AE read-backs within 1e-3 deg). The camera tracker, VR editor, Look At and orbit commands use the new decomposition. Co-Authored-By: Claude Opus 5.5 * Keyframes: a linear side next to an eased side has 16.67 % influence, as After Effects In a segment that is Bezier on one side and linear on the other, the linear side keeps the segment's average speed, but with the 16.67 % influence After Effects shows for linear keys in the Keyframe Velocity dialog (and reports to scripts), not 33.3 %. Measured on an After Effects 2026 time-remap render (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), AE plays 3.290 at 2.5 s and 2.657 at 3 s; EffectCraft played 3.308 and 2.629 (8 and 13 px off), now the same square positions to the pixel. LINEAR_SIDE_INFLUENCE is used by evaluation (scalar, per-dimension, paths and spatial), side_ease (Graph Editor, velocity dialog, keyIn/OutTemporalEase) and the Lottie export, so exported easing plays as EffectCraft does. Segments linear on both sides stay straight lines. Co-Authored-By: Claude Opus 5.5 * Timewarp: retime the layer's source without the effects above it, as After Effects After Effects' Timewarp reads the layer's source (with its masks) at the retimed times and ignores the effects applied before it: a red solid with a Gradient Ramp and then Timewarp (Speed 25) renders as the red solid in AE 2026 (EXPORTER e2 read-back). EffectCraft retimed the layer with its effects (mean difference 127.5/255 over the comp), now identical (0, CPU and GPU). Echo, Posterize Time and the other time effects keep reading the effects before them, as measured before. Co-Authored-By: Claude Opus 5.5 * Image sequences: a gap in the numbering holds the previous frame, as After Effects After Effects 2026 shows the last existing file across a gap in a sequence's numbering (case 860: files 1, 2, 4, 5 show frame 2 at frame 3; VERIFY read-back). EffectCraft drew colour bars there. The media pool now holds the previous file (Footage::sequence_file_held); colour bars remain only where no file precedes the frame. Missing-frame reports on import are unchanged. Co-Authored-By: Claude Opus 5.5 * Review: keyed Orientation uses LINEAR_SIDE_INFLUENCE for linear sides; real NaN assert evaluate_orientation still gave a linear side (and the unreachable missing-key fallback) 1/3 influence while segment_value, side_ease and the Lottie export use LINEAR_SIDE_INFLUENCE (1/6), so a keyed Orientation next to an eased key moved on a different curve than every other property (CodeRabbit on #1876). New test: orientation_linear_side_next_to_an_eased_side_matches_a_scalar (both directions). The always-true NaN assert in keyed_orientation_slerps_as_after_effects now checks that a non-finite key comes back as given; Property::value_at says why the match_id "orientation" routing is safe. Co-Authored-By: Claude Opus 5.5 * Format keyframe orientation test (cargo fmt) Co-Authored-By: Claude Sonnet 5.5 * Move the image-sequence gap change out of this branch The gap change (hold the previous file instead of colour bars) reverses #297's documented behaviour and goes to its own PR, as requested in review. pool.rs, sequence.rs, the footage test and docs/footage.md / docs/agents.md are back to main. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Codex Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> --- crates/effects/src/time_fx.rs | 49 +++- crates/engine/src/cmd/layer/parent.rs | 140 +--------- crates/engine/src/cmd/three_d.rs | 2 +- crates/engine/src/cmd/vr_editor.rs | 8 +- crates/engine/src/tests/rig3d.rs | 20 +- .../engine/tests/it/parenting_3d_position.rs | 48 +++- crates/expr/src/prelude.js | 22 +- crates/expr/src/tests.rs | 6 + crates/geom/src/lib.rs | 160 ++++++++++- crates/keyframe/src/lib.rs | 79 +++++- crates/keyframe/src/tests.rs | 90 ++++++ crates/lottie/src/anim.rs | 8 +- crates/project/src/props.rs | 7 + crates/render/src/eval.rs | 8 +- crates/render/src/three_d/camera.rs | 13 +- crates/render/src/three_d/tests_orient.rs | 261 ++++++++++++++++++ crates/script/src/tests/ease_presets.rs | 4 +- crates/track/src/camtrack/linalg.rs | 12 +- docs/effects-parity.md | 3 +- docs/fidelity/README.md | 12 + 20 files changed, 755 insertions(+), 197 deletions(-) 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):