mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
271 lines
15 KiB
Rust
271 lines
15 KiB
Rust
//! Copied keyframes as After Effects keyframe text for other applications (#1908).
|
||
|
||
use effectcraft_project::{LayerId, Uid};
|
||
use effectcraft_time::Tick;
|
||
use serde_json::json;
|
||
|
||
use crate::{KeyRef, Session};
|
||
|
||
/// A 1920×1080 23.976 fps comp with a 200×100 solid.
|
||
fn session() -> (Session, LayerId) {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap();
|
||
let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||
(s, LayerId(id))
|
||
}
|
||
|
||
fn frame(s: &Session, n: i64) -> Tick {
|
||
Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n)
|
||
}
|
||
|
||
fn uid(s: &Session, lid: LayerId, path: &str) -> Uid {
|
||
s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid
|
||
}
|
||
|
||
fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||
let cid = s.active_comp_id().unwrap();
|
||
f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap());
|
||
}
|
||
|
||
fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) {
|
||
s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn copied_keys_are_after_effects_text_in_comp_frames() {
|
||
let (mut s, l) = session();
|
||
// The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19.
|
||
let start = frame(&s, 10);
|
||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||
for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] {
|
||
let t = frame(&s, n);
|
||
add_key(&mut s, l, "transform/position", t, json!(v));
|
||
}
|
||
let t = frame(&s, 5);
|
||
add_key(&mut s, l, "transform/rotation", t, json!(-45));
|
||
let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation"));
|
||
// A subset: two of the three Position keys and the Rotation key.
|
||
s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec();
|
||
s.execute("keys.copy", json!({})).unwrap();
|
||
assert_eq!(
|
||
s.state.keyframe_text.as_deref(),
|
||
Some(
|
||
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\
|
||
Layer\n\
|
||
Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\
|
||
Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\n\
|
||
\n\
|
||
End of Keyframe Data\n"
|
||
)
|
||
);
|
||
// The internal clipboard still has the keys themselves, relative to the earliest.
|
||
let times: Vec<Vec<Tick>> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect();
|
||
assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_stretched_layer_writes_the_frames_its_keys_show_at() {
|
||
let (mut s, l) = session();
|
||
edit_layer(&mut s, l, |l| l.stretch = 200.0);
|
||
let t = frame(&s, 6);
|
||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0));
|
||
add_key(&mut s, l, "transform/opacity", t, json!(100));
|
||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap();
|
||
s.execute("edit.copy", json!({})).unwrap();
|
||
let text = s.state.keyframe_text.clone().unwrap();
|
||
assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn copied_static_values_have_no_frame() {
|
||
let (mut s, l) = session();
|
||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap();
|
||
s.execute("edit.copy", json!({})).unwrap();
|
||
let text = s.state.keyframe_text.clone().unwrap();
|
||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t\t100\t200\t0\t\n\n"), "{text}");
|
||
assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value");
|
||
}
|
||
|
||
#[test]
|
||
fn a_copied_transform_group_lists_what_the_layer_shows() {
|
||
let (mut s, l) = session();
|
||
let t = frame(&s, 24);
|
||
add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100]));
|
||
add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100]));
|
||
let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid;
|
||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||
s.execute("edit.copy", json!({})).unwrap();
|
||
let text = s.state.keyframe_text.clone().unwrap();
|
||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation");
|
||
assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}");
|
||
// 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three
|
||
// values sit under one label, as After Effects writes them.
|
||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||
s.execute("edit.copy", json!({})).unwrap();
|
||
let text = s.state.keyframe_text.clone().unwrap();
|
||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]);
|
||
assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}");
|
||
}
|
||
|
||
fn copy_transform(s: &mut Session, lid: LayerId) -> String {
|
||
let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid;
|
||
s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap();
|
||
s.execute("edit.copy", json!({})).unwrap();
|
||
s.state.keyframe_text.clone().unwrap()
|
||
}
|
||
|
||
fn names(text: &str) -> Vec<&str> {
|
||
text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn properties_the_layer_does_not_use_are_left_out() {
|
||
// Separate Dimensions: the old Position no longer moves the layer.
|
||
let (mut s, l) = session();
|
||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap();
|
||
let text = copy_transform(&mut s, l);
|
||
assert!(!names(&text).contains(&"Position"), "{text}");
|
||
// A one-node camera has no Point of Interest; a two-node one does.
|
||
let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap());
|
||
let text = copy_transform(&mut s, cam);
|
||
assert!(!names(&text).contains(&"Point of Interest"), "{text}");
|
||
s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap();
|
||
let text = copy_transform(&mut s, cam);
|
||
assert!(names(&text).contains(&"Point of Interest"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn separated_position_writes_x_y_and_z_position() {
|
||
let (mut s, l) = session();
|
||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||
let t = frame(&s, 12);
|
||
add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100));
|
||
add_key(&mut s, l, "transform/positionX", t, json!(321));
|
||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap();
|
||
let text = copy_transform(&mut s, l);
|
||
// After Effects labels their single column with no unit.
|
||
assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}");
|
||
assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}");
|
||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||
let text = copy_transform(&mut s, l);
|
||
assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn time_remap_writes_source_seconds_at_comp_frames() {
|
||
let (mut s, l) = session();
|
||
let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap();
|
||
let l = LayerId(r["layer"].as_u64().unwrap());
|
||
s.execute("layer.select", json!({"layers": [l.0]})).unwrap();
|
||
s.execute("layer.enableTimeRemap", json!({})).unwrap();
|
||
let start = frame(&s, 10);
|
||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||
let t = frame(&s, 6);
|
||
add_key(&mut s, l, "timeRemap", t, json!(2.5));
|
||
s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap();
|
||
s.execute("edit.copy", json!({})).unwrap();
|
||
let text = s.state.keyframe_text.clone().unwrap();
|
||
// As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16.
|
||
assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}");
|
||
}
|
||
|
||
/// Selects the layer's properties at `paths` and copies them.
|
||
fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String {
|
||
for (i, path) in paths.iter().enumerate() {
|
||
s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap();
|
||
}
|
||
s.execute("edit.copy", json!({})).unwrap();
|
||
s.state.keyframe_text.clone().unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn effect_properties_are_written_as_after_effects_does() {
|
||
// The blocks After Effects 2019 wrote for the same effects and values.
|
||
let (mut s, l) = session();
|
||
for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] {
|
||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||
}
|
||
let t = frame(&s, 12);
|
||
add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1]));
|
||
add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1]));
|
||
add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true));
|
||
add_key(&mut s, l, "effects/#2/checkbox", t, json!(false));
|
||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap();
|
||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap();
|
||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap();
|
||
// After Effects writes a renamed effect under the effect's own name.
|
||
let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid;
|
||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap();
|
||
let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]);
|
||
assert!(
|
||
text.contains(
|
||
"Layer\n\
|
||
Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\
|
||
Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\
|
||
Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\
|
||
Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\
|
||
Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n"
|
||
),
|
||
"{text}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_2d_layer_writes_the_z_it_shows() {
|
||
// Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it.
|
||
let (mut s, l) = session();
|
||
let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||
edit_layer(&mut s, src, |l| l.switches.three_d = true);
|
||
add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300]));
|
||
add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70]));
|
||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec();
|
||
s.execute("keys.copy", json!({})).unwrap();
|
||
s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap();
|
||
let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components();
|
||
assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z");
|
||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec();
|
||
s.execute("keys.copy", json!({})).unwrap();
|
||
let text = s.state.keyframe_text.clone().unwrap();
|
||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}");
|
||
assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_header_has_the_source_pixel_aspect() {
|
||
let mut s = Session::default();
|
||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap();
|
||
let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap());
|
||
let text = copy_transform(&mut s, solid);
|
||
assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||
// A text layer has no source of its own: the comp's.
|
||
let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap());
|
||
let text = copy_transform(&mut s, t);
|
||
assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||
}
|
||
|
||
#[test]
|
||
fn keys_from_two_layers_have_no_text() {
|
||
let (mut s, a) = session();
|
||
let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||
for l in [a, b] {
|
||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50));
|
||
}
|
||
s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec();
|
||
s.execute("keys.copy", json!({})).unwrap();
|
||
assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text");
|
||
assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both");
|
||
// A copied layer leaves no text behind.
|
||
s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }];
|
||
s.execute("keys.copy", json!({})).unwrap();
|
||
assert!(s.state.keyframe_text.is_some());
|
||
s.state.selected_keys.clear();
|
||
s.execute("edit.copy", json!({"layers": [a.0]})).unwrap();
|
||
assert_eq!(s.state.keyframe_text, None);
|
||
}
|