mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
17 files changed
+232
-64
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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
@@ -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![];
|
||||
|
||||
@@ -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)),
|
||||
_ => {}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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,
|
||||
_ => {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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