mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-13 15:09:03 +00:00
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:
1 parent
3b4036f536
commit
6240ed646e
7 files changed
+105
-34
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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
@@ -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![];
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user