effectcraft/crates/engine/src/tests_timeline.rs

574 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Timeline depth: keyframe clipboard, easing, velocity/interpolation, roving, nudging, graph
//! editor key edits, time remapping/stretch/reverse/freeze, separate dimensions, pick-whips,
//! expressions and masks (pen tool). Every command is checked with undo/redo.
use effectcraft_keyframe::{Interp, Value as KV};
use effectcraft_project::Property;
use serde_json::{Value, json};
use crate::Session;
/// A 4 s, 30 fps comp with one solid; returns (session, layer id).
pub(crate) fn setup() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap();
let r = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 100, "height": 100})).unwrap();
(s, r["layer"].as_u64().unwrap())
}
pub(crate) fn prop(s: &Session, layer: u64, path: &str) -> Property {
s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().props.prop(path).unwrap().clone()
}
/// Key `path` at the given (time, value) pairs.
pub(crate) fn animate(s: &mut Session, layer: u64, path: &str, keys: &[(f64, Value)]) {
for (t, v) in keys {
s.execute("prop.addKey", json!({"layer": layer, "path": path, "time": t, "value": v})).unwrap();
}
}
fn select_prop_keys(s: &mut Session, layer: u64, path: &str) {
s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap();
}
fn undo_redo_roundtrip(s: &mut Session, check_after: impl Fn(&Session), check_before: impl Fn(&Session)) {
check_after(s);
s.execute("edit.undo", json!({})).unwrap();
check_before(s);
s.execute("edit.redo", json!({})).unwrap();
check_after(s);
}
#[test]
fn easy_ease_family_sets_influence_and_zero_speed() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]);
select_prop_keys(&mut s, l, "transform/opacity");
s.execute("keys.easyEase", json!({})).unwrap();
undo_redo_roundtrip(
&mut s,
|s| {
let p = prop(s, l, "transform/opacity");
assert!(p.keys.iter().all(|k| k.in_interp == Interp::Bezier && k.out_interp == Interp::Bezier));
assert!((p.keys[1].in_ease[0].influence - 1.0 / 3.0).abs() < 1e-9 && p.keys[1].in_ease[0].speed == 0.0);
},
|s| assert!(prop(s, l, "transform/opacity").keys.iter().all(|k| k.in_interp == Interp::Linear)),
);
// Easy Ease In only touches the incoming side; Out only the outgoing side.
s.execute("keys.interpolation", json!({"interpolation": "linear"})).unwrap();
s.execute("keys.easyEaseIn", json!({})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!(p.keys.iter().all(|k| k.in_interp == Interp::Bezier && k.out_interp == Interp::Linear));
s.execute("keys.interpolation", json!({"interpolation": "linear"})).unwrap();
s.execute("keys.easyEaseOut", json!({})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!(p.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Bezier));
// Eased motion is slow near the key.
s.execute("keys.easyEase", json!({})).unwrap();
let p = prop(&s, l, "transform/opacity");
let early = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.1)).as_f64();
assert!(early < 5.0, "{early}");
}
#[test]
fn keyframe_copy_paste_keeps_relative_timing_and_crosses_properties() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/rotation", &[(0.5, json!(0)), (1.0, json!(90)), (2.0, json!(45))]);
select_prop_keys(&mut s, l, "transform/rotation");
s.execute("keys.easyEase", json!({})).unwrap();
assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!(3));
assert!(s.state.clip_is_keys);
// Paste into the same property at 2.5 s: keys at 2.5, 3.0 and 4.0 (clamped by nothing).
s.execute("time.set", json!({"time": 2.5})).unwrap();
s.execute("keys.select", json!({"keys": []})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "transform/rotation");
let times: Vec<f64> = p.keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect();
assert_eq!(times, vec![0.5, 1.0, 2.0, 2.5, 3.0, 4.0]);
assert_eq!(p.keys[4].value, KV::Scalar(90.0));
assert_eq!(p.keys[4].in_interp, Interp::Bezier, "interpolation travels with the keys");
assert_eq!(s.state.selected_keys.len(), 3, "pasted keys are selected");
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/rotation").keys.len(), 3));
// Cross-property paste of one property's keys into another of the same type (Scalar).
let r = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let l2 = r["layer"].as_u64().unwrap();
select_prop_keys(&mut s, l, "transform/rotation");
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": prop(&s, l, "transform/rotation").uid, "time": 1.0}]})).unwrap();
s.execute("keys.copy", json!({})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.select", json!({"layer": l2, "path": "transform/opacity", "selectKeys": false})).unwrap();
s.execute("keys.paste", json!({"layers": [l2]})).unwrap();
let op = prop(&s, l2, "transform/opacity");
assert_eq!(op.keys.len(), 1);
assert_eq!(op.keys[0].value, KV::Scalar(90.0));
// Incompatible target (Vec3 position) is refused when explicitly given.
let e = s.execute("keys.paste", json!({"layers": [l2], "path": "transform/position"}));
assert!(e.is_err() || prop(&s, l2, "transform/position").keys.is_empty());
}
#[test]
fn cut_removes_keys_and_paste_restores_them() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(10)), (1.0, json!(20))]);
select_prop_keys(&mut s, l, "transform/opacity");
s.execute("edit.cut", json!({})).unwrap();
assert!(prop(&s, l, "transform/opacity").keys.is_empty());
s.execute("time.set", json!({"time": 0.0})).unwrap();
s.execute("layer.select", json!({"layers": [l]})).unwrap();
s.execute("edit.paste", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys.len(), 2);
}
#[test]
fn select_all_and_nudge_keys() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(10)), (1.0, json!(20))]);
animate(&mut s, l, "transform/rotation", &[(0.5, json!(10))]);
s.state.selected_props.clear();
s.execute("layer.select", json!({"layers": [l]})).unwrap();
assert_eq!(s.execute("keys.selectAll", json!({})).unwrap(), json!(3));
s.execute("keys.nudgeForward", json!({})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "transform/opacity");
assert!((p.keys[0].time.seconds() - 1.0 / 30.0).abs() < 1e-6);
assert_eq!(s.state.selected_keys.len(), 3);
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].time.0, 0));
s.execute("keys.nudgeBackward10", json!({})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!((p.keys[0].time.seconds() + 9.0 / 30.0).abs() < 1e-6, "{}", p.keys[0].time.seconds());
}
#[test]
fn velocity_dialog_values_per_dimension_and_continuous() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (2.0, json!([100, 100]))]);
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": prop(&s, l, "transform/scale").uid, "time": 1.0}]})).unwrap();
let info = s.execute("keys.info", json!({})).unwrap();
assert_eq!(info[0]["units"], "%/sec");
assert_eq!(info[0]["dims"], 2);
// Linear sides present the segment slope: +100 %/s in X on the incoming side.
assert!((info[0]["inEase"][0]["speed"].as_f64().unwrap() - 100.0).abs() < 1e-6, "{info}");
s.execute("keys.velocity", json!({"inSpeed": [10, -20], "inInfluence": 50, "continuous": true})).unwrap();
let after = |s: &Session| {
let k = &prop(s, l, "transform/scale").keys[1];
assert_eq!(k.in_ease[0].speed, 10.0);
assert_eq!(k.in_ease[1].speed, -20.0);
assert_eq!(k.out_ease[1].speed, -20.0, "continuous mirrors incoming");
assert!((k.out_ease[0].influence - 0.5).abs() < 1e-9);
assert!(k.continuous);
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear));
}
#[test]
fn interpolation_dialog_temporal_spatial_and_roving() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]);
select_prop_keys(&mut s, l, "transform/position");
s.execute("keys.interpolation", json!({"interpolation": "hold"})).unwrap();
assert!(prop(&s, l, "transform/position").keys.iter().all(|k| k.out_interp == Interp::Hold));
s.execute("keys.interpolation", json!({"interpolation": "autoBezier"})).unwrap();
assert!(prop(&s, l, "transform/position").keys.iter().all(|k| k.auto_bezier));
s.execute("keys.interpolation", json!({"interpolation": "continuousBezier", "spatial": "linear"})).unwrap();
let p = prop(&s, l, "transform/position");
assert!(p.keys.iter().all(|k| k.continuous && !k.auto_bezier && !k.spatial_auto && k.spatial_out == [0.0; 3]));
// Rove the middle key: its time follows the path length (300 of 400 px → 1.5 s).
let uid = p.uid;
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap();
s.execute("keys.interpolation", json!({"roving": true})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "transform/position");
assert!(p.keys[1].roving);
assert!((p.keys[1].time.seconds() - 1.5).abs() < 0.01, "{}", p.keys[1].time.seconds());
// The selection follows the roving key.
assert!((s.state.selected_keys[0].time.seconds() - 1.5).abs() < 0.01);
};
undo_redo_roundtrip(&mut s, after, |s| assert!(!prop(s, l, "transform/position").keys[1].roving));
// Moving the last key re-times the roving key.
s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 4.0})).unwrap();
let p = prop(&s, l, "transform/position");
assert!((p.keys[1].time.seconds() - 3.0).abs() < 0.02, "{}", p.keys[1].time.seconds());
// Toggle hold on a key and back.
s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.0}]})).unwrap();
s.execute("keys.toggleHold", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold);
s.execute("keys.toggleHold", json!({})).unwrap();
assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold);
}
#[test]
fn graph_editor_key_and_handle_edits() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (2.0, json!(100))]);
// Drag a key in the value graph: new time + value, merged into one undo step.
s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 2.0, "newTime": 1.0, "value": 50, "merge": "g1"})).unwrap();
s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "newTime": 1.5, "value": 60, "merge": "g1"})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!((p.keys[1].time.seconds() - 1.5).abs() < 1e-6);
assert_eq!(p.keys[1].value, KV::Scalar(60.0));
s.execute("edit.undo", json!({})).unwrap();
assert!((prop(&s, l, "transform/opacity").keys[1].time.seconds() - 2.0).abs() < 1e-6);
s.execute("edit.redo", json!({})).unwrap();
// A key can't be dragged past its neighbour.
s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 1.5, "newTime": -1.0})).unwrap();
let p = prop(&s, l, "transform/opacity");
assert!(p.keys[1].time > p.keys[0].time);
// Handle drag: out side of key 0 → speed 200 %/s, 60 % influence.
s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 0.0, "side": "out", "speed": 200, "influence": 60})).unwrap();
let k = &prop(&s, l, "transform/opacity").keys[0];
assert_eq!(k.out_interp, Interp::Bezier);
assert_eq!(k.out_ease[0].speed, 200.0);
assert!((k.out_ease[0].influence - 0.6).abs() < 1e-9);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear);
}
#[test]
fn time_reverse_keyframes() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(30)), (3.0, json!(100))]);
select_prop_keys(&mut s, l, "transform/opacity");
s.execute("keys.timeReverse", json!({})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "transform/opacity");
assert_eq!(p.keys[0].value, KV::Scalar(100.0));
assert!((p.keys[1].time.seconds() - 2.0).abs() < 1e-6);
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].value, KV::Scalar(0.0)));
}
#[test]
fn pen_tool_draws_edits_and_closes_a_mask() {
let (mut s, l) = setup();
// Click (corner), click, drag (Bezier), click on the first vertex (close).
let r = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10]]})).unwrap();
let m = r["mask"].as_u64().unwrap();
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [90, 10]})).unwrap();
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [90, 90]})).unwrap();
s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [0, -20], "out": [0, 20], "merge": "pen"})).unwrap();
s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [10, 90]})).unwrap();
s.execute("mask.setClosed", json!({"layer": l, "mask": m, "closed": true})).unwrap();
let path = |s: &Session| prop(s, l, "masks/#1/path").value.as_path().unwrap().clone();
let p = path(&s);
assert_eq!(p.vertices, vec![[10.0, 10.0], [90.0, 10.0], [90.0, 90.0], [10.0, 90.0]]);
assert_eq!(p.out_tangents[2], [0.0, 20.0]);
assert!(p.closed);
assert_eq!(s.state.selected_vertices.last().unwrap().index, 3);
// The mask cuts the 100×100 solid (layer centred in the 400×300 comp at 150..250, 100..200).
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::ZERO, effectcraft_render::RenderOpts { scale: 1.0, ..Default::default() });
assert!(img.get(200, 150)[3] > 0.99 && img.get(152, 102)[3] < 0.01);
// Undo removes the close, redo restores it.
s.execute("edit.undo", json!({})).unwrap();
assert!(!path(&s).closed);
s.execute("edit.redo", json!({})).unwrap();
// Select two vertices and drag them; Delete removes them.
s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 0}, {"layer": l, "mask": m, "index": 1}]})).unwrap();
s.execute("mask.moveVertices", json!({"delta": [5, -5], "merge": "v1"})).unwrap();
s.execute("mask.moveVertices", json!({"delta": [5, -5], "merge": "v1"})).unwrap();
assert_eq!(path(&s).vertices[1], [100.0, 0.0]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(path(&s).vertices[1], [90.0, 10.0], "a drag is one undo step");
s.execute("edit.clear", json!({})).unwrap();
assert_eq!(path(&s).vertices.len(), 2);
assert!(!path(&s).closed);
// Animated mask paths get a key at the current time.
s.execute("prop.toggleAnimation", json!({"layer": l, "path": "masks/#1/path"})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("mask.setVertex", json!({"layer": l, "mask": 1, "index": 0, "point": [0, 0]})).unwrap();
assert_eq!(prop(&s, l, "masks/#1/path").keys.len(), 2);
// Mode / feather / opacity / expansion through the timeline commands.
s.execute("layer.setMask", json!({"layer": l, "mask": m, "mode": "Subtract", "inverted": true})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "masks/#1/feather", "value": [4, 4]})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "masks/#1/expansion", "value": 3})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "masks/#1/opacity", "value": 50})).unwrap();
assert_eq!(prop(&s, l, "masks/#1/opacity").value, KV::Scalar(50.0));
s.execute("mask.removeAll", json!({"layer": l})).unwrap();
assert!(s.active_comp().unwrap().layers[0].masks().unwrap().children.is_empty());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers[0].masks().unwrap().children.len(), 1);
}
#[test]
fn alt_click_stopwatch_toggles_a_self_reference_expression() {
let (mut s, l) = setup();
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity"})).unwrap();
assert_eq!(prop(&s, l, "transform/opacity").expr.unwrap().text, "transform.opacity");
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "enabled": false})).unwrap();
assert!(!prop(&s, l, "transform/opacity").expr.unwrap().enabled);
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "enabled": true})).unwrap();
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity"})).unwrap();
assert!(prop(&s, l, "transform/opacity").expr.is_none());
s.execute("edit.undo", json!({})).unwrap();
assert!(prop(&s, l, "transform/opacity").expr.unwrap().enabled);
}
#[test]
fn layer_comp_and_key_times_stay_frame_aligned() {
let aligned = |fr: effectcraft_time::FrameRate, t: effectcraft_time::Tick| fr.snap(t) == t;
// The demo project: every comp duration, layer in/out/start and key time is on a frame.
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
for (_, c) in s.project.comps() {
let fr = c.frame_rate;
assert!(aligned(fr, c.duration), "duration {:?}", c.duration);
for l in &c.layers {
for t in [l.in_point, l.out_point, l.start_time] {
assert!(aligned(fr, t), "{} {t:?}", l.name);
}
l.props.walk("", &mut |_, pr| {
for k in &pr.keys {
assert!(aligned(fr, l.comp_time(k.time)), "{} {}", l.name, pr.name);
}
});
}
}
let lower = s.active_comp().unwrap().layer_by_name("Lower Third").unwrap();
let fr = s.active_comp().unwrap().frame_rate;
assert_eq!(fr.frame_at(lower.in_point), 150, "5 s at 29.97 is frame 150 (0:00:05:00)");
// Commands snap: comp duration, layer timing, key times.
s.execute("comp.new", json!({"name": "F", "frameRate": 29.97, "duration": 10.0})).unwrap();
let c = s.active_comp().unwrap();
assert_eq!(c.frame_rate.frame_at(c.duration), 300);
assert!(aligned(c.frame_rate, c.duration));
let l = s.execute("layer.newSolid", json!({"color": "#fff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [l], "in": 1.01, "out": 3.3333, "delta": 0.02})).unwrap();
let ly = s.active_comp().unwrap().layers[0].clone();
let fr = s.active_comp().unwrap().frame_rate;
for t in [ly.in_point, ly.out_point, ly.start_time] {
assert!(aligned(fr, t), "{t:?}");
}
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": 1.2345, "value": 10})).unwrap();
let k = prop(&s, l, "transform/opacity").keys[0].time;
assert!(aligned(fr, ly.comp_time(k)));
// Layer shifted one frame (delta 0.02 s rounds to 1 frame): 1.2345 s + 1 frame -> frame 38.
assert_eq!(fr.frame_at(ly.comp_time(k)), 38);
}
#[test]
fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "T", "width": 100, "height": 100, "frameRate": 29.97, "duration": 10})).unwrap();
let r = s.execute("time.set", json!({"time": 2.5})).unwrap();
assert_eq!(r["frame"], 75);
let fr = s.active_comp().unwrap().frame_rate;
assert_eq!(effectcraft_time::format_timecode_ae(75, fr, false), "0:00:02:15");
assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15");
}
#[test]
fn separate_dimensions_split_and_rejoin() {
let (mut s, l) = setup();
animate(&mut s, l, "transform/position", &[(0.0, json!([0, 100])), (2.0, json!([200, 50]))]);
s.execute("prop.separateDimensions", json!({"layer": l})).unwrap();
let after = |s: &Session| {
let x = prop(s, l, "transform/positionX");
let y = prop(s, l, "transform/positionY");
assert_eq!(x.keys.len(), 2);
assert_eq!(y.keys[1].value, KV::Scalar(50.0));
// The layer still renders at the same place (render sees the separated values).
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let layer = &comp.layers[0];
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, effectcraft_time::Tick::from_seconds_f64(1.0));
let p = ctx.value(layer, layer.transform().unwrap().get("position").unwrap()).as_vec3();
assert!((p[0] - 100.0).abs() < 1e-6 && (p[1] - 75.0).abs() < 1e-6, "{p:?}");
};
undo_redo_roundtrip(&mut s, after, |s| assert!(s.active_comp().unwrap().layers[0].transform().unwrap().get("positionX").is_none()));
// X and Y animate independently.
s.execute("keys.select", json!({"keys": []})).unwrap();
s.execute("prop.select", json!({"layer": l, "path": "transform/positionX"})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
assert_eq!(prop(&s, l, "transform/positionY").keys[0].out_interp, Interp::Linear);
// Setting Position (viewer drags) writes X/Y.
s.execute("time.set", json!({"time": 3.0})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [7, 8, 0]})).unwrap();
assert_eq!(prop(&s, l, "transform/positionX").keys.len(), 3);
// Re-join: one Position with keys at the union of times.
s.execute("prop.separateDimensions", json!({"layer": l})).unwrap();
let p = prop(&s, l, "transform/position");
assert_eq!(p.keys.len(), 3);
assert_eq!(p.keys[2].value, KV::Vec3([7.0, 8.0, 0.0]));
assert!(s.active_comp().unwrap().layers[0].transform().unwrap().get("positionX").is_none());
}
#[test]
fn parenting_keeps_the_layer_in_place() {
let (mut s, child) = setup();
let r = s.execute("layer.newNull", json!({})).unwrap();
let par = r["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": par, "path": "transform/position", "value": [300, 200]})).unwrap();
s.execute("prop.set", json!({"layer": par, "path": "transform/rotation", "value": 90})).unwrap();
s.execute("prop.set", json!({"layer": par, "path": "transform/scale", "value": [200, 200]})).unwrap();
s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [100, 50]})).unwrap();
let world = |s: &Session| {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let l = comp.layer(effectcraft_project::LayerId(child)).unwrap();
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, effectcraft_time::Tick::ZERO);
let (m, _) = ctx.layer_to_comp(l);
let a = m.apply(effectcraft_geom::vec2(50.0, 50.0));
let b = m.apply(effectcraft_geom::vec2(100.0, 50.0));
(a, (b.y - a.y).atan2(b.x - a.x).to_degrees(), (b.x - a.x).hypot(b.y - a.y))
};
let before = world(&s);
s.execute("layer.setParent", json!({"layers": [child], "parent": par})).unwrap();
let after = world(&s);
assert!((before.0.x - after.0.x).abs() < 1e-6 && (before.0.y - after.0.y).abs() < 1e-6, "{before:?} {after:?}");
assert!((before.1 - after.1).abs() < 1e-6 && (before.2 - after.2).abs() < 1e-6);
assert!((prop(&s, child, "transform/rotation").value.as_f64() + 90.0).abs() < 1e-9);
// Unparenting restores comp-space values.
s.execute("layer.setParent", json!({"layers": [child], "parent": null})).unwrap();
let p = prop(&s, child, "transform/position").value.as_vec3();
assert!((p[0] - 100.0).abs() < 1e-6 && (p[1] - 50.0).abs() < 1e-6, "{p:?}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(child)).unwrap().parent.is_some());
}
#[test]
fn pick_whip_generates_ae_reference_expressions() {
let (mut s, l) = setup();
let r = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap();
let o = r["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": o, "effect": "Gaussian Blur"})).unwrap();
s.execute("layer.addMask", json!({"layer": o})).unwrap();
let cases = [
("transform/opacity", json!({"layer": o, "path": "transform/opacity"}), "thisComp.layer(\"Other\").transform.opacity"),
("transform/rotation", json!({"layer": l, "path": "transform/opacity"}), "transform.opacity"),
("transform/rotation", json!({"layer": o, "path": "effects/#1/blurriness"}), "thisComp.layer(\"Other\").effect(\"Gaussian Blur\")(\"Blurriness\")"),
("transform/opacity", json!({"layer": o, "path": "masks/#1/feather"}), "thisComp.layer(\"Other\").mask(\"Mask 1\").maskFeather[0]"),
("transform/scale", json!({"layer": o, "path": "transform/rotation"}), "temp = thisComp.layer(\"Other\").transform.rotation;\n[temp, temp]"),
("transform/position", json!({"layer": o, "path": "transform/position"}), "thisComp.layer(\"Other\").transform.position"),
];
for (path, target, want) in cases {
let got = s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": target})).unwrap();
assert_eq!(got, json!(want));
assert_eq!(prop(&s, l, path).expr.unwrap().text, want);
}
s.execute("edit.undo", json!({})).unwrap();
assert_ne!(prop(&s, l, "transform/position").expr.map(|e| e.text), Some("thisComp.layer(\"Other\").transform.position".into()));
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
}
/// Outer comp (4 s) containing a precomp whose 10×10 white solid moves x = 10 + 20·t (t in s).
/// Returns (session, precomp layer id in the outer comp).
fn precomp_setup() -> (Session, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
let r = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 10, "height": 10})).unwrap();
let l = r["layer"].as_u64().unwrap();
animate(&mut s, l, "transform/position", &[(0.0, json!([10, 50])), (4.0, json!([90, 50]))]);
s.execute("comp.new", json!({"name": "Outer", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap();
let r = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap();
let pl = r["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [pl]})).unwrap();
(s, pl)
}
/// X of the moving square's centre in the outer comp at `t`.
fn square_x(s: &Session, t: f64) -> f64 {
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), effectcraft_render::RenderOpts { scale: 1.0, ..Default::default() });
let xs: Vec<usize> = (0..img.width as usize).filter(|x| img.get(*x as i64, 50)[3] > 0.5).collect();
assert!(!xs.is_empty(), "nothing drawn at {t}");
(xs[0] + xs[xs.len() - 1]) as f64 / 2.0 + 0.5
}
#[test]
fn time_remap_enable_freeze_and_disable() {
let (mut s, l) = precomp_setup();
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
s.execute("layer.enableTimeRemap", json!({})).unwrap();
let after = |s: &Session| {
let p = prop(s, l, "timeRemap");
assert_eq!(p.keys.len(), 2);
assert_eq!(p.keys[1].value, KV::Scalar(4.0));
// Remapping at identity shows the same frames.
assert!((square_x(s, 1.0) - 30.0).abs() < 1.5);
};
undo_redo_roundtrip(&mut s, after, |s| assert!(s.active_comp().unwrap().layers[0].props.get("timeRemap").is_none()));
// Remap the first key to show source time 3 s at comp time 0.
s.execute("keys.set", json!({"layer": l, "path": "timeRemap", "time": 0.0, "value": 3.0})).unwrap();
assert!((square_x(&s, 0.0) - 70.0).abs() < 1.5, "{}", square_x(&s, 0.0));
// Freeze Frame at 2 s: one hold key; every frame shows source time 2 s.
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("keys.set", json!({"layer": l, "path": "timeRemap", "time": 0.0, "value": 0.0})).unwrap();
s.execute("layer.freezeFrame", json!({})).unwrap();
let p = prop(&s, l, "timeRemap");
assert_eq!(p.keys.len(), 1);
assert_eq!(p.keys[0].out_interp, Interp::Hold);
for t in [0.0, 1.0, 3.5] {
assert!((square_x(&s, t) - 50.0).abs() < 1.5, "{t}: {}", square_x(&s, t));
}
// Ctrl+Alt+T again disables remapping.
s.execute("layer.enableTimeRemap", json!({})).unwrap();
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
// Solids can't be remapped.
let r = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
assert!(s.execute("layer.enableTimeRemap", json!({"layers": [r["layer"]]})).is_err());
}
#[test]
fn freeze_on_last_frame_extends_the_layer() {
let (mut s, l) = precomp_setup();
s.execute("layer.timing", json!({"layers": [l], "out": 2.0})).unwrap();
s.execute("layer.freezeOnLastFrame", json!({})).unwrap();
let layer = s.active_comp().unwrap().layers[0].clone();
assert_eq!(layer.out_point, s.active_comp().unwrap().duration);
// Plays normally, then holds the frame before the old out point.
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
let hold = 10.0 + 20.0 * (2.0 - 1.0 / 30.0);
assert!((square_x(&s, 3.5) - hold).abs() < 1.5, "{}", square_x(&s, 3.5));
}
#[test]
fn time_reverse_layer_plays_backwards_and_round_trips() {
let (mut s, l) = precomp_setup();
let before = s.active_comp().unwrap().layers[0].clone();
s.execute("layer.timeReverse", json!({})).unwrap();
let after = |s: &Session| {
let ly = &s.active_comp().unwrap().layers[0];
assert_eq!(ly.stretch, -100.0);
assert_eq!((ly.in_point, ly.out_point), (before.in_point, before.out_point));
// First frame shows the last source frame; the end shows the start.
let last = 10.0 + 20.0 * (4.0 - 1.0 / 30.0);
assert!((square_x(s, 0.0) - last).abs() < 1.5, "{}", square_x(s, 0.0));
assert!((square_x(s, 4.0 - 1.0 / 30.0) - 10.0).abs() < 1.5);
};
undo_redo_roundtrip(&mut s, after, |s| assert_eq!(s.active_comp().unwrap().layers[0].stretch, 100.0));
// Reversing twice restores normal playback.
s.execute("layer.timeReverse", json!({"layers": [l]})).unwrap();
let ly = &s.active_comp().unwrap().layers[0];
assert_eq!(ly.stretch, 100.0);
assert_eq!(ly.start_time, before.start_time);
assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5);
}
#[test]
fn time_stretch_holds_in_current_or_out() {
let (mut s, l) = precomp_setup();
s.execute("layer.timeStretch", json!({"percent": 50})).unwrap();
let ly = s.active_comp().unwrap().layers[0].clone();
assert_eq!(ly.stretch, 50.0);
assert!((ly.out_point.seconds() - 2.0).abs() < 1e-6);
// Twice as fast: comp 1 s shows source 2 s.
assert!((square_x(&s, 1.0) - 50.0).abs() < 1.5);
s.execute("edit.undo", json!({})).unwrap();
// Hold the current frame (2 s) in place while doubling the duration.
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("layer.timeStretch", json!({"layers": [l], "duration": 8.0, "hold": "current"})).unwrap();
let ly = s.active_comp().unwrap().layers[0].clone();
assert!((ly.stretch - 200.0).abs() < 1e-6);
assert!((ly.in_point.seconds() + 2.0).abs() < 1e-6, "{}", ly.in_point.seconds());
assert!((square_x(&s, 2.0) - 50.0).abs() < 1.5);
// Hold the out point.
s.execute("edit.undo", json!({})).unwrap();
s.execute("layer.timeStretch", json!({"layers": [l], "percent": 50, "hold": "out"})).unwrap();
let ly = s.active_comp().unwrap().layers[0].clone();
assert!((ly.out_point.seconds() - 4.0).abs() < 1e-6 && (ly.in_point.seconds() - 2.0).abs() < 1e-6);
assert!(s.execute("layer.timeStretch", json!({"layers": [l], "percent": 0})).is_err());
}