//! 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> = 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); }