diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index a5e1fc4e3..cd9153d66 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -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; diff --git a/crates/effects/src/generate3.rs b/crates/effects/src/generate3.rs index eaa924119..6beec4cb7 100644 --- a/crates/effects/src/generate3.rs +++ b/crates/effects/src/generate3.rs @@ -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) }), diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs index 746a8a0bc..6a74a23b3 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/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 3ef50882d..787a0643b 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -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 = [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/pdf/src/color.rs b/crates/pdf/src/color.rs index 019121e0a..6943476bc 100644 --- a/crates/pdf/src/color.rs +++ b/crates/pdf/src/color.rs @@ -221,11 +221,9 @@ fn ps_parse(lx: &mut crate::object::Lexer, depth: usize) -> Vec { 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)), _ => {} diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index 94f629335..13e98b3c9 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -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)); diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 422c3153c..1c0fd3d4d 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -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()?; diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 969e8d641..3f8d7542b 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -114,10 +114,8 @@ pub fn shapes(ctx: &EvalCtx, layer: &Layer) -> Vec = 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, _ => {} diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 2a2c08e8c..2f7311c58 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -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 { + 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) { 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 { 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(); diff --git a/crates/text/src/path_text.rs b/crates/text/src/path_text.rs index 5c2b4338b..85cab0719 100644 --- a/crates/text/src/path_text.rs +++ b/crates/text/src/path_text.rs @@ -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; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index fe21ba7c7..61b2d22d4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::(&ctx).as_deref() + && ui.rect_contains_pointer(rect) + { + let items: Vec = 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 }; diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index d9e04ec38..ce09d7fd8 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -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); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 120c4db00..ee13761b2 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/agents.md b/docs/agents.md index c631bc05b..6a3b9e85e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index ae88186b3..025997d47 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 042d11774..018d1ddf8 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 000000000..a9a9218bd --- /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();