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 <noreply@anthropic.com>
This commit is contained in:
KattandClaude Opus 5.5 committed 2026-10-11 02:44:49 +05:30
1 parent 3b4036f536
commit 6240ed646e
7 files changed
+105 -34

No files matched your search

+12 -8
View File
@@ -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]
+5 -1
View File
@@ -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]
+34 -17
View File
@@ -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<Keyframe> = [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::<Vec<_>>();
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![];
+7 -6
View File
@@ -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:?}");
}
+19 -1
View File
@@ -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.
+1 -1
View File
@@ -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 |
+27
View File
@@ -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();