From 6240ed646eec2ed1f12e3d693c3340cf9010ad5f Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:14:31 +0530 Subject: [PATCH] Keyframes: auto-Bezier spatial tangents follow After Effects Measured against After Effects 26.3 with the original examples/ae-spatial-tangents.jsx (three paths, ten keys, even and uneven timing): an auto-Bezier key's out tangent is (next - previous) / 6 and the in tangent its mirror, whatever the key times; an end key uses its one neighbour, (neighbour - key) / 6. EffectCraft gave end keys none and scaled middle tangents by segment length, so paths with auto-Bezier keys ran up to 28 px from AE's. All ten tangents now match; path samples are within 0.05 px. Tests that relied on end keys having no tangent now expect AE's: Spatial Bezier keeps auto keys' handles (linear keys still get a third), the motion-path handle is drawn at a sixth, and a straight loopOut path is compared within 1e-9. docs/fidelity records the method and scores. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/tests_timeline.rs | 20 ++++++----- crates/expr/src/tests.rs | 6 +++- crates/keyframe/src/lib.rs | 51 +++++++++++++++++++---------- crates/ui-egui/tests/ui_viewer.rs | 13 ++++---- docs/fidelity/README.md | 20 ++++++++++- docs/fidelity/ae-2026-acceptance.md | 2 +- examples/ae-spatial-tangents.jsx | 27 +++++++++++++++ 7 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 examples/ae-spatial-tangents.jsx diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index a741f5ad..37f66c77 100644 --- a/crates/engine/src/tests_timeline.rs +++ b/crates/engine/src/tests_timeline.rs @@ -163,20 +163,24 @@ fn velocity_dialog_values_per_dimension_and_continuous() { undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear)); } -/// Spatial Bezier on a straight two-key path gives both keys handles a third of the way along it -/// (they had none), so the path can be pulled into a curve; the motion stays the same (#290). +/// Spatial Bezier keeps the handles a key has and gives a straight linear path handles a third of +/// the way along it (they had none), so the path can be pulled into a curve; the motion stays the +/// same (#290). Auto-Bezier keys keep their tangents, a sixth of the way to the neighbour here. #[test] fn spatial_bezier_gives_a_straight_path_handles() { let (mut s, l) = setup(); animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (2.0, json!([300, 0]))]); let mid = prop(&s, l, "transform/position").value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); select_prop_keys(&mut s, l, "transform/position"); - s.execute("keys.interpolation", json!({"spatial": "bezier"})).unwrap(); - let p = prop(&s, l, "transform/position"); - assert!(p.keys.iter().all(|k| !k.spatial_auto)); - assert_eq!((p.keys[0].spatial_out, p.keys[1].spatial_in), ([100.0, 0.0, 0.0], [-100.0, 0.0, 0.0])); - let now = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); - assert!((now[0] - mid[0]).abs() < 0.01 && now[1].abs() < 1e-9, "{mid:?} → {now:?}"); + for (from, handle) in [("autoBezier", 50.0), ("linear", 100.0)] { + s.execute("keys.interpolation", json!({"spatial": from})).unwrap(); + s.execute("keys.interpolation", json!({"spatial": "bezier"})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert!(p.keys.iter().all(|k| !k.spatial_auto)); + assert_eq!((p.keys[0].spatial_out, p.keys[1].spatial_in), ([handle, 0.0, 0.0], [-handle, 0.0, 0.0]), "from {from}"); + let now = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.7)).as_vec3(); + assert!((now[0] - mid[0]).abs() < 0.01 && now[1].abs() < 1e-9, "from {from}: {mid:?} → {now:?}"); + } } #[test] diff --git a/crates/expr/src/tests.rs b/crates/expr/src/tests.rs index 3d5acb2d..d2ada7d5 100644 --- a/crates/expr/src/tests.rs +++ b/crates/expr/src/tests.rs @@ -588,7 +588,11 @@ fn loop_on_two_dimensional_position() { let mut f = fx(); f.layer_mut(f.a).props.prop_mut(POS).unwrap().keys = vec![Keyframe::new(secs(0.0), Value::Vec3([0.0, 0.0, 0.0])), Keyframe::new(secs(1.0), Value::Vec3([100.0, 50.0, 0.0]))]; - assert_eq!(f.pos("loopOut('offset')", 1.5), [150.0, 75.0, 0.0]); + // The auto-Bezier keys have tangents along the line, so the path is sampled by arc length. + let p = f.pos("loopOut('offset')", 1.5); + for (got, want) in p.into_iter().zip([150.0, 75.0, 0.0]) { + assert_close!(got, want, 1e-9); + } } #[test] diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 80537149..f1f34ea1 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -270,25 +270,18 @@ fn v3(v: &Value) -> [f64; 3] { v.as_vec3() } -/// Auto-Bezier spatial tangents (Catmull-Rom style) for key `i`. +/// Auto-Bezier spatial tangents for key `i`, as After Effects computes them (Catmull-Rom): the out +/// tangent is a sixth of the way from the previous key to the next, the in tangent its mirror, and +/// key times play no part. An end key uses its one neighbour, so the path leaves the first key +/// and enters the last one toward the neighbouring key instead of with no tangent. fn auto_spatial(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { - if i == 0 || i + 1 >= keys.len() { + if i >= keys.len() || keys.len() < 2 { return ([0.0; 3], [0.0; 3]); } - let a = v3(&keys[i - 1].value); - let b = v3(&keys[i].value); - let c = v3(&keys[i + 1].value); - let d = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; - let dl = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); - if dl < 1e-12 { - return ([0.0; 3], [0.0; 3]); - } - let lin = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2) + (b[2] - a[2]).powi(2)).sqrt(); - let lout = ((c[0] - b[0]).powi(2) + (c[1] - b[1]).powi(2) + (c[2] - b[2]).powi(2)).sqrt(); - let n = [d[0] / dl, d[1] / dl, d[2] / dl]; - let tin = [-n[0] * lin / 3.0, -n[1] * lin / 3.0, -n[2] * lin / 3.0]; - let tout = [n[0] * lout / 3.0, n[1] * lout / 3.0, n[2] * lout / 3.0]; - (tin, tout) + let a = v3(&keys[i.saturating_sub(1)].value); + let c = v3(&keys[(i + 1).min(keys.len() - 1)].value); + let tout = [(c[0] - a[0]) / 6.0, (c[1] - a[1]) / 6.0, (c[2] - a[2]) / 6.0]; + ([-tout[0], -tout[1], -tout[2]], tout) } /// Effective spatial tangents of key `i` (auto or stored). @@ -298,7 +291,7 @@ pub fn spatial_tangents(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { } /// Spatial tangents of key `i` as motion-path handles: [`spatial_tangents`], with a side that -/// has none but a neighbour (the ends of an auto-Bezier path, a straight two-key path) pointing +/// has none but a neighbour (a key whose stored tangents are zero, as on a straight path) pointing /// where the path leaves the key, toward the segment's next control point, a third of the way to /// the neighbouring key. After Effects shows Bezier handles there to drag the path into a curve. pub fn spatial_handles(keys: &[Keyframe], i: usize) -> ([f64; 3], [f64; 3]) { @@ -778,6 +771,30 @@ mod tests { assert!((m[0] - m[1]).abs() > 1.0, "{m:?}"); } + #[test] + fn spatial_auto_bezier_tangents_follow_after_effects() { + // Measured on After Effects 26.3 with original paths (docs/fidelity/README.md): each + // auto-Bezier key's out tangent is (next - previous) / 6 and its in tangent the mirror, + // whatever the key times. End keys use their one neighbour: (neighbour - key) / 6. + let p = [[40.0, 300.0, 0.0], [260.0, 80.0, 20.0], [700.0, 420.0, -40.0], [1100.0, 150.0, 0.0]]; + let mut keys: Vec = [0.0, 0.2, 1.5, 1.8].iter().zip(p).map(|(&t, v)| Keyframe::new(s(t), Value::Vec3(v))).collect(); + let sixth = |a: [f64; 3], b: [f64; 3]| [(b[0] - a[0]) / 6.0, (b[1] - a[1]) / 6.0, (b[2] - a[2]) / 6.0]; + let neg = |v: [f64; 3]| [-v[0], -v[1], -v[2]]; + let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9); + let want = [sixth(p[0], p[1]), sixth(p[0], p[2]), sixth(p[1], p[3]), sixth(p[2], p[3])]; + for (i, out) in want.into_iter().enumerate() { + let (tin, tout) = spatial_tangents(&keys, i); + assert!(close(tout, out) && close(tin, neg(out)), "key {i}: {tin:?} {tout:?}"); + } + // Moving a key in time leaves every tangent as it was. + keys[1].time = s(1.2); + assert!(close(spatial_tangents(&keys, 1).1, sixth(p[0], p[2]))); + // A key between two coincident neighbours, and a lone key, have none. + let still = [[5.0, 5.0], [9.0, 1.0], [5.0, 5.0]].iter().enumerate().map(|(i, &v)| Keyframe::new(s(i as f64), Value::Vec2(v))).collect::>(); + assert_eq!(spatial_tangents(&still, 1), ([0.0; 3], [0.0; 3])); + assert_eq!(spatial_tangents(&still[..1], 0), ([0.0; 3], [0.0; 3])); + } + #[test] fn set_key_replaces_same_time() { let mut keys = vec![]; diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index bada77a7..651dc6c7 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -770,8 +770,8 @@ fn clicking_off_a_mask_deselects_its_points() { assert_eq!(h.state().session.state.selected_layers, vec![LayerId(plate)], "the layer stays selected"); } -/// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging -/// one pulls the path into a curve (#290). +/// A straight two-key motion path shows Bezier handles at both keys, and dragging one pulls the +/// path into a curve (#290). #[test] fn a_straight_motion_path_has_handles_to_curve_it() { let mut h = harness(); @@ -785,15 +785,16 @@ fn a_straight_motion_path_has_handles_to_curve_it() { h.run_steps(3); let (out0, in1) = (format!("viewer.motionPath.{}.0.out", box_id.0), format!("viewer.motionPath.{}.1.in", box_id.0)); assert!(h.state().auto.find(&in1).is_some(), "the second key has a handle"); - // The first key's handle lies a third of the way along the path: drag it down 150 px. + // The first key's auto-Bezier handle lies a sixth of the way along the path, as After Effects + // puts it: drag it down 150 px. let from = rect(&h, &out0).center(); - assert!(from.distance(screen(&h, [200.0, 100.0])) < 2.0, "{from:?}"); - let to = screen(&h, [200.0, 250.0]); + assert!(from.distance(screen(&h, [150.0, 100.0])) < 2.0, "{from:?}"); + let to = screen(&h, [150.0, 250.0]); drag(&mut h, from, to); let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/position").unwrap().clone(); assert!(!p.keys[0].spatial_auto); let t = p.keys[0].spatial_out; - assert!((t[0] - 100.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}"); + assert!((t[0] - 50.0).abs() < 2.0 && (t[1] - 150.0).abs() < 2.0, "{t:?}"); let mid = p.value_at(effectcraft_engine::time::Tick::from_seconds_f64(1.0)).as_vec3(); assert!(mid[1] > 130.0, "the path curves: {mid:?}"); } diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index ae88186b..025997d4 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -50,12 +50,30 @@ time curve. Its closed-form inverse and a parameter-based convergence check now mathematical regression test at times within `1e-12` of the midpoint; that test is additional to the 168 live AE samples. +## Auto-Bezier spatial tangents + +Measured on 10 October 2026 with After Effects 2026 (26.3) by running the original +`examples/ae-spatial-tangents.jsx` in both hosts (`AfterFX.exe -r` and `effectcraft-cli script`): +three original Position paths, ten keys, with even and uneven key timing. The script pins spatial +interpolation to auto-Bezier, since an AE preference can default new keys to linear. + +AE gives each auto-Bezier key an out tangent of (next key − previous key) / 6 and the mirrored in +tangent, whatever the key times; an end key uses its one neighbour, (neighbour − key) / 6. That is a +Catmull-Rom spline. EffectCraft gave end keys no tangent and scaled middle keys' tangents by the +lengths of their segments, so all ten tangents differed. Sampled Position along an auto-Bezier path +with Easy Ease was up to 25.1 px off, and a roving path up to 27.7 px. + +After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two path cases are within +0.05 px over 61 samples each; what remains is the arc-length table's sub-pixel speed. The regression +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. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames` helper. Corpus runners can use it with locally generated host/reference frames and fail on either a mean or maximum channel error without committing proprietary renders. -Mixed interpolation sides, non-unit durations, automatic/continuous Bezier, spatial speeds and paths, +Mixed interpolation sides, non-unit durations, temporal automatic/continuous Bezier, spatial speeds, time remapping, expressions, actual rendered motion and other effects need separate corpus cases. The initial probe makes no claim about those features. diff --git a/docs/fidelity/ae-2026-acceptance.md b/docs/fidelity/ae-2026-acceptance.md index 042d1177..018d1ddf 100644 --- a/docs/fidelity/ae-2026-acceptance.md +++ b/docs/fidelity/ae-2026-acceptance.md @@ -12,7 +12,7 @@ platform, fonts, color settings, source files, and project parameters. | --- | --- | --- | | Work projects | Representative projects recreated or imported, edited, saved, reopened and delivered without lost state | Native AE project import and third-party AE plug-in execution remain gaps | | Workflow | Pointer and keyboard cases for all panels, tools, shortcuts, docking, undo, dialogs and selections | Current pass covers 34 panel layouts and selected interaction regressions; every control is not verified | -| Animation | AE-sampled values for scalar, spatial, multidimensional and expression-driven properties, timing and interpolation | Eight scalar Rotation/ease cases, 168 samples verified against AE 26.3x87; other cases remain open | +| Animation | AE-sampled values for scalar, spatial, multidimensional and expression-driven properties, timing and interpolation | Eight scalar Rotation/ease cases, 168 samples verified against AE 26.3x87; auto-Bezier spatial tangents (ten keys, three paths) match AE 26.3; other cases remain open | | Render fidelity | Original test scenes compared at 8/16/32 bpc, specified color spaces and alpha conventions; edges, impulses, gradients and temporal sequences | Most AE-rendered comparisons remain open; CPU/GPU agreement alone is insufficient | | Media and delivery | Real footage import, seek, frame rates, audio sync, relinking, image sequences, codec/container metadata and exports checked in downstream tools | Actual work fixtures still required | | Performance | Matched AE/EffectCraft input-to-present and export timings at 1080p/4K, warm/cold caches, memory/VRAM and long sessions | No claim of beating AE or measured blur response yet | diff --git a/examples/ae-spatial-tangents.jsx b/examples/ae-spatial-tangents.jsx new file mode 100644 index 00000000..a9a9218b --- /dev/null +++ b/examples/ae-spatial-tangents.jsx @@ -0,0 +1,27 @@ +// Original behavior probe: the spatial tangents a host gives auto-Bezier Position keys. +// Runs unchanged in After Effects (File > Scripts > Run Script File, or AfterFX.exe -r) and in +// EffectCraft (effectcraft-cli script examples/ae-spatial-tangents.jsx --empty). Builds three +// original comps with one solid each; run it in a scratch project. EffectCraft prints one JSON line +// per key; After Effects writes them to ae-spatial-tangents.jsonl next to this script. +// No Adobe assets, presets, application internals or implementation code are read. +var EC = String(app.version).indexOf("EffectCraft") >= 0; +var OUT = null; +if (!EC) { + OUT = new File(File($.fileName).parent.fsName + "/ae-spatial-tangents.jsonl"); + OUT.encoding = "UTF-8"; + OUT.open("w"); +} +function emit(s) { if (EC) { writeLn(s); } else { OUT.writeln(s); } } +function r(v) { var o = []; for (var i = 0; i < v.length; i++) o.push(Math.round(v[i] * 1000) / 1000); return "[" + o.join(",") + "]"; } +function dump(name, pts, times) { + var c = app.project.items.addComp(name, 1920, 1080, 1, 2, 30); + var p = c.layers.addSolid([1, 1, 1], "S", 100, 100, 1).property("Transform").property("Position"); + for (var i = 0; i < pts.length; i++) p.setValueAtTime(times[i], pts[i]); + // Pin what a preference could decide: AE can default new keys to linear spatial interpolation. + for (var k = 1; k <= p.numKeys; k++) p.setSpatialAutoBezierAtKey(k, true); + for (var k2 = 1; k2 <= p.numKeys; k2++) emit('{"case":"' + name + '","key":' + k2 + ',"in":' + r(p.keyInSpatialTangent(k2)) + ',"out":' + r(p.keyOutSpatialTangent(k2)) + "}"); +} +dump("T1 symmetric", [[200, 200], [960, 900], [1700, 200]], [0, 1, 2]); +dump("T2 uneven", [[100, 540], [400, 700], [1800, 540]], [0, 0.5, 2]); +dump("T3 four keys", [[100, 100], [500, 900], [900, 100], [1800, 600]], [0, 0.6, 1.2, 2]); +if (OUT) OUT.close();