mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
e911cc5c93
commit
78cd313d3c
36 files changed
+4621
-28
No files matched your search
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -105,8 +105,8 @@ fn query_flag(name: &str) -> bool {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).and_then(|s| web_sys::UrlSearchParams::new_with_str(&s).ok()).is_some_and(|p| p.has(name))
|
||||
}
|
||||
|
||||
/// A session wired for the browser: media decoding from memory, expressions, Render Queue export
|
||||
/// to downloads.
|
||||
/// A session wired for the browser: media decoding from memory, expressions, scripting, Render
|
||||
/// Queue export to downloads.
|
||||
pub fn session() -> Session {
|
||||
let pool = Arc::new(effectcraft_media::MediaPool::new());
|
||||
Session {
|
||||
@@ -115,6 +115,7 @@ pub fn session() -> Session {
|
||||
importer: Some(Arc::new(files::WebImporter { pool })),
|
||||
expr: Some(Arc::new(effectcraft_expr::Expressions)),
|
||||
expr_check: Some(effectcraft_expr::check_syntax),
|
||||
script: Some(effectcraft_host::script::runner),
|
||||
exporter: Some(Arc::new(effectcraft_host::FileExporter { sink: Some(files::export_sink()) })),
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user