M14.4: scripting with an After Effects-style JavaScript object model

New crate effectcraft-script (L4): boa runtime with app/Project/CompItem/
Layer/PropertyGroup/Property/TextDocument/MarkerValue/RenderQueue/File
object model over engine commands (undoable, undo groups), AE match-name
table, file/network security gate. Entry points: script.run command,
File > Scripts > Run Script File (.jsx/.js), Window > Script Console,
effectcraft-cli script, MCP run_script. Engine: project.delete/duplicate,
prop.renameGroup/setGroupEnabled/removeGroup/duplicateGroup/moveGroup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 22:13:09 -04:00
1 parent e911cc5c93
commit 78cd313d3c
36 files changed
+4621 -28

No files matched your search

+50 -1
View File
@@ -15,6 +15,7 @@
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] render timings
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
//!
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
@@ -32,7 +33,7 @@ use effectcraft_automation::tools::{self, Reply};
use effectcraft_automation::{Backend, McpServer};
use serde_json::{Value, json};
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|mcp> [args] [--json]
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
exec <command-id> [--params JSON] run one engine command
@@ -47,6 +48,9 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] per-layer/effect render timings;
--play N renders N consecutive frames with/without the layer cache;
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
(app.project, comps, layers, properties…); prints writeLn
output and the result; errors exit 1 with file:line:col
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
@@ -68,6 +72,7 @@ const VALUED: &[&str] = &[
"--out",
"--expression",
"--depth",
"--eval",
// render
"--format",
"--start",
@@ -243,6 +248,49 @@ fn tool(b: &mut Backend, name: &str, a: Value) -> Result<Value, Failure> {
}
}
/// `script FILE.jsx [project]` / `script --eval CODE`: run JavaScript against a headless session
/// (an empty project unless one is given, or `--demo`) or the running app (`--bridge`).
fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
let (code, name) = match (args.opt("--eval"), args.pos.first()) {
(Some(c), _) => (c.to_string(), "eval".to_string()),
(None, Some(f)) => (std::fs::read_to_string(f).map_err(|e| Failure::Error(format!("cannot read {f}: {e}")))?, f.clone()),
(None, None) => return usage_err("script needs a .jsx/.js file or --eval CODE"),
};
let mut b = backend(args, false)?;
let r = b.exec("script.run", json!({"code": code, "name": name}))?;
let saved = maybe_save(&mut b, args)?;
if json_out {
let mut o = r.clone();
if let Some(p) = &saved {
o["saved"] = json!(p);
}
emit(&o, true);
} else {
if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) {
println!("{out}");
}
if !r["result"].is_null() && r["error"].is_null() {
println!("{}", r["result"]);
}
if let Some(p) = saved {
eprintln!("saved {p}");
}
}
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
let pos = match (e["line"].as_u64(), e["column"].as_u64()) {
(Some(l), Some(c)) => format!(":{l}:{c}"),
(Some(l), None) => format!(":{l}"),
_ => String::new(),
};
let msg = format!("{}{pos}: {}", e["file"].as_str().unwrap_or(&name), e["message"].as_str().unwrap_or("script error"));
if json_out {
std::process::exit(1);
}
return Err(Failure::Error(msg));
}
Ok(())
}
/// Print a result: compact JSON with `--json`, else pretty JSON.
fn emit(v: &Value, json_out: bool) {
if json_out {
@@ -315,6 +363,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
}
}
}
"script" => script_cmd(args, json_out)?,
"exec" => {
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) {
+31
View File
@@ -131,3 +131,34 @@ fn mcp_over_stdio() {
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
}
#[test]
fn script_file_and_eval() {
let proj = tmp("scripted.ecproj");
let p = proj.to_str().unwrap();
let jsx = tmp("build.jsx");
std::fs::write(
&jsx,
"app.beginUndoGroup('Build');\nvar c = app.project.items.addComp('FromJsx', 160, 90, 1, 2, 24);\nc.layers.addSolid([1, 0, 0], 'Red', 160, 90, 1);\napp.endUndoGroup();\nwriteLn('built ' + c.name);\nc.numLayers",
)
.unwrap();
let v = ok_json(&["script", jsx.to_str().unwrap(), "--save-as", p]);
assert_eq!(v["ok"], json!(true), "{v}");
assert_eq!(v["result"], json!(1));
assert_eq!(v["output"], json!("built FromJsx"));
assert_eq!(v["saved"], p);
// Run against the saved project (positional), plain output.
let out = bin().args(["script", "--eval", "app.project.item(1).name + ' has ' + app.project.item(1).numLayers", p]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "\"FromJsx has 1\"");
// Errors: exit 1 with file:line:col.
let bad = tmp("bad.jsx");
std::fs::write(&bad, "var a = 1;\n\nmissingFunction();\n").unwrap();
let out = bin().args(["script", bad.to_str().unwrap()]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("bad.jsx:3:1") && err.contains("missingFunction"), "{err}");
let (code, v) = run_json(&["script", "--eval", "\nnope()"]);
assert_eq!(code, 1);
assert_eq!(v["error"]["line"], json!(2));
}