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 <noreply@anthropic.com>
This commit is contained in:
parent
fecf054e5f
commit
77ab92fb7d
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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<u64> {
|
||||
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<u64> {
|
||||
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<Value> {
|
|||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
|||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
|||
fn move_control(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
|||
fn set_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<CommandSpec> {
|
|||
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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `<effect match name>-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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue