//! Text animation commands: animators, properties, selectors, per-character 3D, presets (with //! undo/redo and serde round-trips). use effectcraft_keyframe::Value as KV; use effectcraft_project::{Node, Project, PropGroup}; use serde_json::json; use crate::Session; fn session() -> (Session, u64) { let mut s = Session::default(); s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap(); let t = s.execute("layer.newText", json!({"text": "Hello World", "size": 60})).unwrap()["layer"].as_u64().unwrap(); (s, t) } fn text_group(s: &Session) -> PropGroup { s.active_comp().unwrap().layers[0].props.sub("text").unwrap().clone() } fn matches(g: &PropGroup) -> Vec { g.children.iter().map(|c| c.match_id().to_string()).collect() } #[test] fn animate_text_adds_animators_with_companion_properties() { let (mut s, t) = session(); let r = s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["skew", "tracking", "characterOffset"]})).unwrap(); assert!(r["animator"].as_u64().is_some()); let tg = text_group(&s); let a = tg.group("animators/#1").unwrap(); assert_eq!( matches(a.sub("properties").unwrap()), ["skew", "skewAxis", "trackingType", "tracking", "characterAlignment", "characterRange", "characterOffset"] ); assert_eq!(matches(a.sub("selectors").unwrap()), ["rangeSelector"]); // Every Animate entry is accepted. for (k, _) in effectcraft_project::build::TEXT_ANIMATOR_KINDS.iter().filter(|(k, _)| *k != "-") { s.execute("layer.addTextAnimator", json!({"layer": t, "property": k})).unwrap_or_else(|e| panic!("{k}: {e}")); } assert!(s.execute("layer.addTextAnimator", json!({"layer": t, "property": "nope"})).is_err()); // All Transform Properties. s.execute("layer.addTextAnimator", json!({"layer": t, "property": "transformAll"})).unwrap(); let tg = text_group(&s); let last = tg.sub("animators").unwrap().groups().last().unwrap().clone(); assert_eq!(matches(last.sub("properties").unwrap()), ["anchor", "position", "scale", "skew", "skewAxis", "rotation", "opacity"]); // Rendering with every property present works. let cid = s.active_comp_id().unwrap(); let _ = s.render(cid, s.time(), Default::default()); } #[test] fn add_property_and_selectors_with_undo_redo() { let (mut s, t) = session(); s.execute("layer.addTextAnimator", json!({"layer": t, "property": "position"})).unwrap(); s.execute("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 1, "property": "fillHue"})).unwrap(); assert!(s.execute("layer.addTextAnimatorProperty", json!({"layer": t, "animator": 1, "property": "bogus"})).is_err()); for kind in ["wiggly", "expression", "range"] { s.execute("layer.addTextSelector", json!({"layer": t, "kind": kind})).unwrap(); } assert!(s.execute("layer.addTextSelector", json!({"layer": t, "kind": "other"})).is_err()); let tg = text_group(&s); let a = tg.group("animators/#1").unwrap(); assert_eq!(matches(a.sub("properties").unwrap()), ["position", "fillHue"]); let sels = a.sub("selectors").unwrap(); assert_eq!(matches(sels), ["rangeSelector", "wigglySelector", "expressionSelector", "rangeSelector"]); let names: Vec<&str> = sels.children.iter().map(Node::name).collect(); assert_eq!(names, ["Range Selector 1", "Wiggly Selector 1", "Expression Selector 1", "Range Selector 2"]); // Expression selector carries the default amount expression (and no Mode); wiggly defaults to // Intersect. let ex = sels.group("expressionSelector").unwrap(); assert!(ex.get("amount").unwrap().has_expression()); assert!(ex.get("mode").is_none()); assert_eq!(sels.group("wigglySelector").unwrap().get("mode").unwrap().value, KV::Enum(2)); let cid = s.active_comp_id().unwrap(); let a1 = s.render(cid, s.time(), Default::default()); let a2 = s.render(cid, s.time(), Default::default()); assert_eq!(a1.data, a2.data, "wiggly text renders deterministically"); // Undo the three selector additions and the property, redo them. for _ in 0..3 { s.execute("edit.undo", json!({})).unwrap(); } assert_eq!(matches(text_group(&s).group("animators/#1/selectors").unwrap()), ["rangeSelector"]); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(matches(text_group(&s).group("animators/#1/properties").unwrap()), ["position"]); for _ in 0..4 { s.execute("edit.redo", json!({})).unwrap(); } assert_eq!(text_group(&s).group("animators/#1/selectors").unwrap().children.len(), 4); } #[test] fn per_character_3d_toggles_layer_and_dimensions() { let (mut s, t) = session(); s.execute("layer.addTextAnimator", json!({"layer": t, "properties": ["position", "rotation"]})).unwrap(); s.execute("layer.enablePerChar3D", json!({"layer": t})).unwrap(); let l = s.active_comp().unwrap().layers[0].clone(); assert!(l.switches.three_d); assert_eq!(l.props.prop("text/perChar3d").unwrap().value, KV::Bool(true)); let props = l.props.group("text/animators/#1/properties").unwrap(); assert_eq!(props.get("position").unwrap().shown_dims, 3); assert_eq!(matches(props), ["position", "rotationX", "rotationY", "rotation"]); assert_eq!(props.get("rotation").unwrap().name, "Z Rotation"); // New animators get 3D properties too. s.execute("layer.addTextAnimator", json!({"layer": t, "property": "rotation"})).unwrap(); assert_eq!(matches(text_group(&s).group("animators/#2/properties").unwrap()), ["rotationX", "rotationY", "rotation"]); // Renders through the 3D path. s.execute("prop.set", json!({"layer": t, "path": "text/animators/#1/properties/rotationY", "value": 45})).unwrap(); let cid = s.active_comp_id().unwrap(); let img = s.render(cid, s.time(), Default::default()); assert!(img.data.iter().any(|p| p[3] > 0.5)); s.execute("edit.undo", json!({})).unwrap(); s.execute("edit.undo", json!({})).unwrap(); s.execute("edit.undo", json!({})).unwrap(); let l = s.active_comp().unwrap().layers[0].clone(); assert!(!l.switches.three_d); assert_eq!(matches(l.props.group("text/animators/#1/properties").unwrap()), ["position", "rotation"]); // Disable removes X / Y rotation again. s.execute("edit.redo", json!({})).unwrap(); s.execute("layer.enablePerChar3D", json!({"layer": t, "enabled": false})).unwrap(); let l = s.active_comp().unwrap().layers[0].clone(); assert_eq!(matches(l.props.group("text/animators/#1/properties").unwrap()), ["position", "rotation"]); assert_eq!(l.props.prop("text/perChar3d").unwrap().value, KV::Bool(false)); } #[test] fn presets_apply_render_and_round_trip() { let (mut s, t) = session(); let list = s.execute("text.presets", json!({})).unwrap(); let ids: Vec = list.as_array().unwrap().iter().map(|p| p["id"].as_str().unwrap().to_string()).collect(); assert!(ids.len() >= 5 && ids.len() <= 8, "{ids:?}"); let cid = s.active_comp_id().unwrap(); for id in &ids { let before = s.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().children.len(); let r = s.execute("layer.applyTextPreset", json!({"layer": t, "preset": id})).unwrap(); assert!(!r["animators"].as_array().unwrap().is_empty(), "{id}"); let after = s.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().children.len(); assert!(after > before, "{id}"); for secs in [0.0, 0.7, 3.0] { let _ = s.render(cid, effectcraft_time::Tick::from_seconds_f64(secs), effectcraft_render::RenderOpts { scale: 0.25, ..Default::default() }); } s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.active_comp().unwrap().layers[0].props.group("text/animators").unwrap().children.len(), before, "{id}"); } // Typewriter: the start keyframes reveal the text over time. s.execute("layer.applyTextPreset", json!({"layer": t, "preset": "Typewriter"})).unwrap(); let opts = effectcraft_render::RenderOpts { scale: 0.5, ..Default::default() }; let cov = |img: &effectcraft_render::Image| img.data.iter().filter(|p| p[3] > 0.5).count(); let a = cov(&s.render(cid, effectcraft_time::Tick::from_seconds_f64(0.0), opts)); let b = cov(&s.render(cid, effectcraft_time::Tick::from_seconds_f64(1.0), opts)); let c = cov(&s.render(cid, effectcraft_time::Tick::from_seconds_f64(3.0), opts)); assert!(a < b && b < c, "{a} {b} {c}"); // Every selector kind and More / Path Options survive a save/load round trip. s.execute("layer.addTextSelector", json!({"layer": t, "kind": "wiggly"})).unwrap(); s.execute("layer.addTextSelector", json!({"layer": t, "kind": "expression"})).unwrap(); s.execute("layer.enablePerChar3D", json!({"layer": t})).unwrap(); let json = s.project.to_json(); let back = Project::from_json(&json).unwrap(); assert_eq!(*s.project, back); let tg = text_group(&s); for m in ["pathOptions", "moreOptions"] { assert!(tg.sub(m).is_some(), "{m}"); } assert!(matches(tg.sub("pathOptions").unwrap()).contains(&"forceAlignment".to_string())); } #[test] fn menus_have_text_animation_entries() { let (s, _) = session(); let spec = crate::commands::find("layer.enablePerChar3D").unwrap(); assert!(spec.menu.is_empty(), "placed by the engine menu tree"); let entries = crate::menus::entries(); let has = |path: &[&str], cmd: &str| { let (label, parent) = path.split_last().unwrap(); entries.iter().any(|(p, e)| p.iter().map(String::as_str).eq(parent.iter().copied()) && e.label == *label && e.command == cmd) }; assert!(has(&["Animation", "Animate Text", "Enable Per-character 3D"], "layer.enablePerChar3D")); assert!(has(&["Animation", "Animate Text", "Fill Color", "Hue"], "layer.addTextAnimator")); assert!(has(&["Animation", "Add Text Selector", "Wiggly"], "text.addSelector")); assert!(has(&["Animation", "Text Animation Presets", "Typewriter"], "layer.applyTextPreset")); // Every preset in the menu exists. let ids: Vec = crate::text_presets().into_iter().map(|p| p.0).collect(); for (_, e) in entries.iter().filter(|(_, e)| e.command == "layer.applyTextPreset") { assert!(ids.iter().any(|i| e.params["preset"] == i.as_str()), "{:?}", e.params); } assert!(s.is_enabled("layer.enablePerChar3D")); assert!(!s.is_enabled("layer.addTextSelector"), "needs an animator"); }