From 77ab92fb7da0d073dbe5919b716f5dee0f34e339 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 12:30:11 -0400 Subject: [PATCH] M13.15: qa essential graphics template via CLI script Fill's ADBE Fill-0002 is Color (documented numbering); essential.* controls by name with a listing error; an explicit comp param satisfies a command's active-comp enablement. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/tests/qa_template.rs | 115 ++++++++++++++++++++++ crates/engine/src/commands/essential.rs | 26 +++-- crates/engine/src/lib.rs | 4 +- crates/script/src/matchnames.rs | 18 +++- crates/script/src/tests.rs | 13 +++ 5 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 apps/effectcraft-cli/tests/qa_template.rs diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/qa_template.rs new file mode 100644 index 00000000..e6525561 --- /dev/null +++ b/apps/effectcraft-cli/tests/qa_template.rs @@ -0,0 +1,115 @@ +//! M13.15 scenario 5 — Essential Graphics through the CLI: a MOGRT-style template built by an +//! After Effects-style script (`effectcraft-cli script`), its controls changed with commands on +//! an instance, rendered, exported as a template and imported into a fresh project. + +use std::path::PathBuf; +use std::process::Command; + +use serde_json::Value; + +fn cli(dir: &PathBuf, args: &[&str]) -> (i32, Value) { + let out = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(dir).args(args).arg("--json").output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let v = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: {e}: {stdout} {}", String::from_utf8_lossy(&out.stderr))); + (out.status.code().unwrap_or(-1), v) +} + +fn ok(dir: &PathBuf, args: &[&str]) -> Value { + let (code, v) = cli(dir, args); + assert_eq!(code, 0, "{args:?}: {v}"); + v +} + +const BUILD: &str = r#" +app.beginUndoGroup("Build Template"); +var comp = app.project.items.addComp("Lower Third", 320, 90, 1, 2, 12); +var bar = comp.layers.addSolid([1, 1, 1], "Bar", 320, 40, 1, 2); +bar.property("ADBE Transform Group").property("ADBE Position").setValue([160, 65]); +var t = comp.layers.addText("Jane Doe"); +t.name = "Name"; +t.property("ADBE Transform Group").property("ADBE Position").setValue([160, 30]); +var fill = bar.property("ADBE Effect Parade").addProperty("ADBE Fill"); +t.property("ADBE Text Properties").property("ADBE Text Document").addToMotionGraphicsTemplateAs(comp, "Title"); +fill.property("ADBE Fill-0002").addToMotionGraphicsTemplateAs(comp, "Bar Color"); +bar.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Bar Opacity"); +comp.motionGraphicsTemplateName = "Lower Third"; +app.endUndoGroup(); +writeLn("controls: " + comp.motionGraphicsTemplateControllerCount); +comp.motionGraphicsTemplateName; +"#; + +#[test] +fn essential_graphics_template_via_script_and_commands() { + let dir = std::env::temp_dir().join(format!("ec-qa-template-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + + let r = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["output"], "controls: 3"); + let list = ok(&dir, &["exec", "essential.list", r##"{"comp":"Lower Third"}"##, "t.ecproj"]); + let controls: Vec<(String, String)> = + list["controls"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap().to_string(), c["type"].as_str().unwrap().to_string())).collect(); + assert_eq!( + controls, + [("Title".into(), "text".into()), ("Bar Color".into(), "color".into()), ("Bar Opacity".into(), "slider".into())], + "ADBE Fill-0002 is the Fill's Color: {list}" + ); + + // An edit comp with an instance; controls addressed by name. + let r = ok( + &dir, + &[ + "run", + "t.ecproj", + "comp.new", + r##"{"name":"Edit","width":320,"height":90,"frameRate":12,"duration":2}"##, + "layer.addItem", + r##"{"item":"Lower Third"}"##, + "comp.new", + r##"{"name":"Plain","width":320,"height":90,"frameRate":12,"duration":2}"##, + "layer.addItem", + r##"{"item":"Lower Third"}"##, + "comp.open", + r##"{"comp":"Edit"}"##, + "essential.set", + r##"{"layer":"#1","control":"Title","value":"John Smith"}"##, + "essential.set", + r##"{"layer":"#1","control":"Bar Color","value":"#00c080"}"##, + "essential.set", + r##"{"layer":"#1","control":"bar opacity","value":50}"##, + "--save", + ], + ); + let inst = ok(&dir, &["exec", "essential.instance", r##"{"comp":"Edit","layer":"#1"}"##, "t.ecproj"]); + assert!(inst["controls"].as_array().unwrap().iter().all(|c| c["overridden"] == true), "{inst}"); + let (code, e) = cli(&dir, &["exec", "essential.set", r##"{"comp":"Edit","layer":"#1","control":"Subtitle","value":"x"}"##, "t.ecproj"]); + assert_eq!(code, 1); + assert!(e["error"].as_str().unwrap().contains("controls: Title, Bar Color, Bar Opacity"), "{e} ({r})"); + + // Render both instances and compare. + ok(&dir, &["render-frame", "t.ecproj", "--comp", "Edit", "--time", "1", "--out", "edit.png"]); + ok(&dir, &["render-frame", "t.ecproj", "--comp", "Plain", "--time", "1", "--out", "plain.png"]); + let edit = image::open(dir.join("edit.png")).unwrap().to_rgba8(); + let plain = image::open(dir.join("plain.png")).unwrap().to_rgba8(); + let bar = edit.get_pixel(10, 75).0; + assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "50 % of #00c080 over black: {bar:?}"); + let pbar = plain.get_pixel(10, 75).0; + assert!(pbar[0] > 240 && pbar[1] < 15, "the template's own red: {pbar:?}"); + let diff = |y0: u32, y1: u32| -> u32 { + (y0..y1).flat_map(|y| (0..320).map(move |x| (x, y))).filter(|(x, y)| edit.get_pixel(*x, *y).0 != plain.get_pixel(*x, *y).0).count() as u32 + }; + assert!(diff(10, 45) > 100, "the title text differs"); + + // Export the template and bring it into a fresh project. + let x = ok(&dir, &["exec", "essential.exportTemplate", r##"{"comp":"Lower Third","path":"lt.ectemplate"}"##, "t.ecproj"]); + assert_eq!(x["controls"], 3, "{x}"); + let info = ok(&dir, &["exec", "essential.templateInfo", r##"{"path":"lt.ectemplate"}"##, "--empty"]); + assert_eq!(info["name"], "Lower Third", "{info}"); + let imp = ok(&dir, &["run", "--empty", "essential.importTemplate", r##"{"path":"lt.ectemplate"}"##, "--save-as", "fresh.ecproj"]); + assert!(imp["saved"].is_string(), "{imp}"); + let list = ok(&dir, &["exec", "essential.list", r##"{"comp":"Lower Third"}"##, "fresh.ecproj"]); + assert_eq!(list["controls"].as_array().unwrap().len(), 3, "{list}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/commands/essential.rs index d7340f68..2c03efbd 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/commands/essential.rs @@ -25,8 +25,20 @@ use crate::{EngineError, Result, Session, cmd, query}; pub const TEMPLATE_FORMAT: &str = "effectcraft-template"; pub const TEMPLATE_VERSION: u32 = 1; -fn control_p(p: &Value, cmd: &str) -> Result { - p.get("control").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `control` (control id)")) +/// `control`: a control id, or its name in the comp's Essential Graphics (`"Title"`). +fn control_p(s: &Session, comp: ItemId, p: &Value, cmd: &str) -> Result { + match p.get("control") { + Some(Value::Number(n)) => n.as_u64().ok_or_else(|| bad(cmd, "`control` is a control id or name")), + Some(Value::String(name)) => { + let eg = s.project.comp(comp).and_then(|c| c.essential.as_ref()); + let all: Vec<(u64, String)> = eg.map(|e| e.flat().into_iter().map(|c| (c.id, c.name.clone())).collect()).unwrap_or_default(); + all.iter().find(|(_, n)| n == name).or_else(|| all.iter().find(|(_, n)| n.eq_ignore_ascii_case(name))).map(|(id, _)| *id).ok_or_else(|| { + let names: Vec<&str> = all.iter().map(|(_, n)| n.as_str()).collect(); + bad(cmd, format!("no control named `{name}` (controls: {})", names.join(", "))) + }) + } + _ => Err(bad(cmd, "missing `control` (control id or name, see essential.list)")), + } } /// Comp whose Essential Graphics a command edits: `comp`, else the panel's Primary comp, else @@ -231,7 +243,7 @@ fn add_comment(s: &mut Session, p: &Value) -> Result { fn rename(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.rename"; let cid = eg_comp(s, p)?; - let id = control_p(p, C)?; + let id = control_p(s, cid, p, C)?; let name = str_p(p, "name").or(str_p(p, "text")).ok_or_else(|| bad(C, "missing `name`"))?.to_string(); s.edit("Rename Essential Graphics Control", None, |proj, _| { let c = eg_of(proj, cid)?.find_mut(id).ok_or_else(|| bad(C, format!("no control {id}")))?; @@ -248,7 +260,7 @@ fn rename(s: &mut Session, p: &Value) -> Result { fn remove(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.remove"; let cid = eg_comp(s, p)?; - let id = control_p(p, C)?; + let id = control_p(s, cid, p, C)?; s.edit("Remove Essential Graphics Control", None, |proj, _| { eg_of(proj, cid)?.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?; Ok(()) @@ -259,7 +271,7 @@ fn remove(s: &mut Session, p: &Value) -> Result { fn move_control(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.move"; let cid = eg_comp(s, p)?; - let id = control_p(p, C)?; + let id = control_p(s, cid, p, C)?; let group = p.get("group").and_then(Value::as_u64); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); if group == Some(id) { @@ -484,7 +496,7 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { fn set_override(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.set"; let (cid, lid, src) = instance(s, p, C)?; - let control = control_p(p, C)?; + let control = control_p(s, src, p, C)?; let uid = master_uid(s, cid, lid, control).ok_or_else(|| bad(C, format!("no control {control} on this instance")))?; if let Some(item) = p.get("item") { let item = match item { @@ -872,7 +884,7 @@ pub fn specs() -> Vec { super::always, import_template ), - cmd!("essential.set", "Set Essential Property", [], None, "{layer, control, value | item (media)}", has_instance, set_override), + cmd!("essential.set", "Set Essential Property", [], None, "{layer, control: id|name, value | item (media)}", has_instance, set_override), cmd!("essential.pushToComp", "Push Override Values to Source", [], None, "{layer, control? (default: all overridden)}", has_instance, push_to_comp), cmd!("essential.revert", "Revert", [], None, "{layer, control? (default: all overridden)}", has_instance, revert), cmd!("effect.editDropdown", "Edit Dropdown Menu", [], None, "{layer, path?|prop?, items: [string]}", has_comp, edit_dropdown), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b1680483..12076b2a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -452,7 +452,9 @@ impl Session { if let Err(why) = (spec.enabled)(self) { // Explicit targets (agents, scripts) don't need a UI selection. let explicit = (["layer", "layers", "prop", "keys"].iter().any(|k| params.get(k).is_some()) && commands::has_comp(self).is_ok()) - || ["item", "items"].iter().any(|k| params.get(k).is_some()); + || ["item", "items"].iter().any(|k| params.get(k).is_some()) + // An explicit composition (the enablement looks at the active one). + || params.get("comp").is_some_and(|c| !c.is_null() && self.resolve_comp(Some(c)).is_ok()); if !explicit { return Err(EngineError::Disabled(id.to_string(), why)); } diff --git a/crates/script/src/matchnames.rs b/crates/script/src/matchnames.rs index 03a3090e..a48a36a0 100644 --- a/crates/script/src/matchnames.rs +++ b/crates/script/src/matchnames.rs @@ -366,6 +366,18 @@ pub fn shape_kind(name: &str) -> Option<&'static str> { }) } +/// Effects whose documented parameter match names are not numbered in display order: +/// (effect id, param match id, number). Fill lists All Masks second but it is `-0007`. +const PARAM_NUMBERS: &[(&str, &str, usize)] = &[ + ("ec.generate.fill", "fillMask", 1), + ("ec.generate.fill", "color", 2), + ("ec.generate.fill", "horizontalFeather", 3), + ("ec.generate.fill", "verticalFeather", 4), + ("ec.generate.fill", "opacity", 5), + ("ec.generate.fill", "invert", 6), + ("ec.generate.fill", "allMasks", 7), +]; + /// Match name of the node at the end of `chain` (layer root's children → … → node). `effect` /// params are numbered `-0001…` in parameter order, like After Effects. pub fn node(chain: &[&Node]) -> String { @@ -388,7 +400,11 @@ pub fn node(chain: &[&Node]) -> String { let fx = parent.and_then(Node::as_group); match fx { Some(g) => { - let i = g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1; + let i = PARAM_NUMBERS + .iter() + .find(|(fx, p, _)| *fx == g.match_id && *p == id) + .map(|(_, _, n)| *n) + .unwrap_or_else(|| g.children.iter().position(|c| c.uid() == last.uid()).unwrap_or(0) + 1); format!("{}-{i:04}", effect(&g.match_id)) } None => own(), diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 458f2b41..0f7e97b2 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -86,6 +86,19 @@ fn builds_a_comp_end_to_end() { assert!(s.project.items.is_empty()); } +#[test] +fn fill_parameters_use_documented_match_names() { + // M13.15: `ADBE Fill-0002` is Fill's Color (All Masks is -0007), not the second row. + let mut s = session(); + let o = ok( + &mut s, + r#"var c = app.project.items.addComp("F", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1); + var fx = l.property("ADBE Effect Parade").addProperty("ADBE Fill"); + [fx.property("ADBE Fill-0002").name, fx.property("ADBE Fill-0007").name, fx.property(2).matchName].join("|")"#, + ); + assert_eq!(o.result, json!("Color|All Masks|ADBE Fill-0007")); +} + #[test] fn undo_steps_without_a_group() { let mut s = session();