Merge pull request #668 from storytold/claude/community-pr-review-lwtrlr

Community batch: font folders, empty-Timeline drop, AE auto-Bezier tangents, clippy 1.95
This commit is contained in:
BFlat authored and GitHub committed 2026-10-11 06:05:39 +07:00
commit ca89a975c7
17 files changed
+232 -64

No files matched your search

+2 -4
View File
@@ -245,10 +245,8 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64
}
let sr = rate as f64;
match id {
"ec.audio.backwards" => {
if params.b("swapChannels") {
buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1));
}
"ec.audio.backwards" if params.b("swapChannels") => {
buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1));
}
"ec.audio.basstreble" => {
let bass = params.f("bass") / 100.0 * 12.0;
+2 -4
View File
@@ -503,10 +503,8 @@ fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64;
let entering = k % 2 == 0;
let l = ew / sin;
match mode {
0 => {
if entering && k + 1 < xs.len() {
out.push((t, xs[k + 1].0));
}
0 if entering && k + 1 < xs.len() => {
out.push((t, xs[k + 1].0));
}
1 => out.push((t - l * 0.5, t + l * 0.5)),
2 => out.push(if entering { (t, t + l) } else { (t - l, t) }),
+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]
+62 -18
View File
@@ -224,6 +224,20 @@ pub fn ease_progress(out_speed_n: f64, out_inf: f64, in_speed_n: f64, in_inf: f6
bez(0.0, out_speed_n * i0, 1.0 - in_speed_n * i1, 1.0, u)
}
/// Whether control point `c` lies on the segment `a`–`b` (within rounding), so a cubic with
/// such handles traces the straight chord.
fn on_chord(a: [f64; 3], c: [f64; 3], b: [f64; 3]) -> bool {
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
let dd = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if dd <= 1e-24 {
return false;
}
let t = (v[0] * d[0] + v[1] * d[1] + v[2] * d[2]) / dd;
let off = [v[0] - d[0] * t, v[1] - d[1] * t, v[2] - d[2] * t];
(0.0..=1.0).contains(&t) && off[0] * off[0] + off[1] * off[1] + off[2] * off[2] <= dd * 1e-18
}
/// Spatial cubic segment with an arc-length table.
struct SpatialSeg {
p: [[f64; 3]; 4],
@@ -300,25 +314,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).
@@ -328,7 +335,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]) {
@@ -536,7 +543,13 @@ pub fn segment_value(keys: &[Keyframe], i: usize, t: Tick, spatial: bool) -> Val
let ei = if lin_in { Ease { speed: len / dur, influence: 1.0 / 3.0 } } else { effective_ease(keys, i + 1, 0, true, false) };
ease_progress(eo.speed * dur / len, eo.influence, ei.speed * dur / len, ei.influence, x)
};
let p = seg.at_length(f * len);
// A straight path (both handles on the chord, as auto-Bezier keys on a line have) is the
// chord itself: interpolate it exactly instead of through the arc-length table.
let p = if on_chord(p0, seg.p[1], p3) && on_chord(p0, seg.p[2], p3) {
[p0[0] + (p3[0] - p0[0]) * f, p0[1] + (p3[1] - p0[1]) * f, p0[2] + (p3[2] - p0[2]) * f]
} else {
seg.at_length(f * len)
};
return a.value.with_components(&p);
}
if lin_out && lin_in {
@@ -826,6 +839,37 @@ mod tests {
assert!((m[0] - m[1]).abs() > 1.0, "{m:?}");
}
#[test]
fn straight_auto_bezier_path_is_exact() {
// Auto-Bezier ends now have handles along the line; the chord is still hit exactly.
let keys = vec![Keyframe::new(s(0.0), Value::Vec2([40.0, 50.0])), Keyframe::new(s(2.0), Value::Vec2([120.0, 50.0]))];
assert_eq!(segment_value(&keys, 0, s(1.0), true), Value::Vec2([80.0, 50.0]));
}
#[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![];
+3 -5
View File
@@ -221,11 +221,9 @@ fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec<PsOp> {
match o {
Obj::Num(n) => out.push(PsOp::Num(n)),
Obj::Bool(b) => out.push(PsOp::Num(if b { 1.0 } else { 0.0 })),
Obj::Op(op) if op == "{" => {
if depth < 32 {
out.push(PsOp::Proc(ps_parse(lx, depth + 1)));
}
}
Obj::Op(op) if op == "{" && depth < 32 => out.push(PsOp::Proc(ps_parse(lx, depth + 1))),
// Nested too deep: skip the procedure's opening brace (never push it as an operator).
Obj::Op(op) if op == "{" => {}
Obj::Op(op) if op == "}" => return out,
Obj::Op(op) => out.push(PsOp::Op(op)),
_ => {}
+3 -5
View File
@@ -313,11 +313,9 @@ impl<'a> Interp<'a> {
self.path.curve_to(Point::new(n(0), n(1)), p, p);
self.cur = Some(p);
}
"h" => {
if self.cur.is_some() {
self.path.close_path();
self.cur = self.start;
}
"h" if self.cur.is_some() => {
self.path.close_path();
self.cur = self.start;
}
"re" => {
let (x, y, w, h) = (n(0), n(1), n(2), n(3));
+2 -4
View File
@@ -656,10 +656,8 @@ impl Psd {
let gamma = r.f32()? as f64;
l.adjustment = Some(Adjustment::Exposure { exposure, offset, gamma });
}
b"curv" | b"blnc" | b"post" | b"thrs" | b"selc" | b"mixr" | b"phfl" | b"vibA" | b"blwh" | b"grdm" | b"clrL" => {
if l.adjustment.is_none() {
l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned()));
}
b"curv" | b"blnc" | b"post" | b"thrs" | b"selc" | b"mixr" | b"phfl" | b"vibA" | b"blwh" | b"grdm" | b"clrL" if l.adjustment.is_none() => {
l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned()));
}
b"SoLd" | b"SoLE" => {
let _id = r.tag()?;
+2 -4
View File
@@ -114,10 +114,8 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec<effectcraft_effects::MaskShap
let mut pts: Vec<[f64; 2]> = Vec::new();
let mut closed = false;
kurbo::flatten(path.iter(), 0.25, |el| match el {
effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) => {
if pts.last() != Some(&[p.x, p.y]) {
pts.push([p.x, p.y]);
}
effectcraft_path::PathEl::MoveTo(p) | effectcraft_path::PathEl::LineTo(p) if pts.last() != Some(&[p.x, p.y]) => {
pts.push([p.x, p.y]);
}
effectcraft_path::PathEl::ClosePath => closed = true,
_ => {}
+16
View File
@@ -94,10 +94,17 @@ impl DirectorySource {
dirs.push(h.join(".fonts"));
}
}
dirs.extend(extra_dirs(std::env::var_os("EFFECTCRAFT_FONT_DIRS").as_deref()));
Self { dirs }
}
}
/// Extra font folders from `EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows), so a
/// tool can hand EffectCraft its own font library without installing the fonts system-wide.
fn extra_dirs(var: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
var.map(|v| std::env::split_paths(v).filter(|p| !p.as_os_str().is_empty()).collect()).unwrap_or_default()
}
fn walk(dir: &std::path::Path, depth: u32, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
@@ -119,6 +126,7 @@ impl FontSource for DirectorySource {
walk(d, 0, &mut files);
}
files.sort();
files.dedup();
let mut out = Vec::new();
for f in files {
let Ok(mut file) = std::fs::File::open(&f) else { continue };
@@ -824,6 +832,14 @@ fn system_fallback(c: char, style: &str) -> Option<FaceId> {
mod tests {
use super::*;
#[test]
fn extra_font_dirs_come_from_a_path_list() {
assert!(extra_dirs(None).is_empty());
let v = std::env::join_paths(["/a/fonts", "/b/more"]).unwrap_or_default();
assert_eq!(extra_dirs(Some(&v)), vec![PathBuf::from("/a/fonts"), PathBuf::from("/b/more")]);
assert!(extra_dirs(Some(std::ffi::OsStr::new(""))).is_empty());
}
#[test]
fn bundled_families_and_resolution() {
let fams = families();
+2 -4
View File
@@ -32,10 +32,8 @@ impl PathMeasure {
pts.push(p);
}
}
PathEl::LineTo(p) => {
if pts.last().is_none_or(|q| q.distance(p) > 1e-9) {
pts.push(p);
}
PathEl::LineTo(p) if pts.last().is_none_or(|q| q.distance(p) > 1e-9) => {
pts.push(p);
}
PathEl::ClosePath => {
closed = true;
+28
View File
@@ -1207,6 +1207,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ctx = ui.ctx().clone();
let Some(cid) = app.session.active_comp_id() else {
ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
// Dropping footage into an empty Timeline should create its first composition,
// just like dropping onto the Project panel's New Composition button (#649).
if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx).as_deref()
&& ui.rect_contains_pointer(rect)
{
let items: Vec<ItemId> = crate::panels::project_drop_items(app, *dragged)
.into_iter()
.map(ItemId)
.filter(|id| {
app.session.project.item(*id).is_some_and(|item| match &item.kind {
effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data,
effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
_ => false,
})
})
.collect();
if !items.is_empty() {
ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition");
if ctx.input(|i| i.pointer.any_released()) {
app.session.state.project_selection = items;
if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
app.ui.status = e;
}
egui::DragAndDrop::clear_payload(&ctx);
}
}
}
return;
};
let Some(comp) = app.session.project.comp_arc(cid) else { return };
+42
View File
@@ -210,6 +210,48 @@ fn project_items_dropped_on_new_comp_make_a_composition() {
assert_eq!(comps(&h), before + 1, "nothing made yet");
}
/// #649: a Project footage item dropped into an empty Timeline makes a composition
/// with that item's dimensions, frame rate, duration, and a source layer.
#[test]
fn project_footage_dropped_on_empty_timeline_creates_first_comp() {
let mut s = Session::default();
let footage =
s.execute("file.importPlaceholder", json!({"name": "Landscape.png", "width": 640, "height": 360, "frameRate": 24, "duration": 5})).unwrap()["item"]
.as_u64()
.unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
assert!(h.state().session.active_comp().is_none());
let from = rect(&h, &format!("project.item.{footage}.name")).center();
let to = rect(&h, "panel.Timeline").center();
drag(&mut h, from, to, Modifiers::NONE);
let comp = h.state().session.active_comp().expect("created a composition");
assert_eq!((comp.width, comp.height), (640, 360));
assert_eq!(comp.frame_rate, effectcraft_engine::time::FrameRate::from_f64(24.0));
assert_eq!(comp.duration.seconds(), 5.0);
assert_eq!(comp.layers.len(), 1);
assert_eq!(comp.layers[0].name, "Landscape.png");
assert!(h.state().dialog.is_none());
}
/// Multiple source items use the existing New Comp from Selection dialog, rather than
/// quietly creating only the dragged item or changing the composition settings.
#[test]
fn multiple_selected_footage_dropped_on_empty_timeline_asks_how_to_create_comps() {
let mut s = Session::default();
let first = s.execute("file.importPlaceholder", json!({"name": "First.png", "width": 160, "height": 90})).unwrap()["item"].as_u64().unwrap();
let second = s.execute("file.importPlaceholder", json!({"name": "Second.png", "width": 300, "height": 200})).unwrap()["item"].as_u64().unwrap();
s.state.project_selection = vec![effectcraft_engine::project::ItemId(first), effectcraft_engine::project::ItemId(second)];
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
let from = rect(&h, &format!("project.item.{first}.name")).center();
let to = rect(&h, "panel.Timeline").center();
drag(&mut h, from, to, Modifiers::NONE);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form));
assert!(h.state().auto.find("form.field.single").is_some());
assert!(h.state().session.active_comp().is_none(), "no composition until the user chooses");
}
/// A file dropped on the window (egui hands it over as a dropped file).
#[derive(Debug)]
struct Dropped(std::path::PathBuf);
+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:?}");
}
+2
View File
@@ -495,6 +495,8 @@ styles, origin (`bundled`, `system`, `user`) and own-language name (`{"query":"g
by name; `{"rescan":true}` first picks up fonts installed since launch). Pass a listed `family` and
one of its `styles` to `layer.setText {"font", "style"}`; `text.fontFeatures` says which OpenType
options a font draws with its own glyphs.
A tool with its own font library can hand it over without installing it: folders listed in
`EFFECTCRAFT_FONT_DIRS` (a path list, `:`-separated, `;` on Windows) are scanned like the system font folders.
In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the setters
(`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a
+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();