diff --git a/Cargo.lock b/Cargo.lock index b29b9181..8bcf7fb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1054,14 +1054,23 @@ dependencies = [ ] [[package]] -name = "effectcraft-cli" +name = "effectcraft-automation" version = "0.1.0" dependencies = [ "effectcraft-engine", - "effectcraft-render", - "effectcraft-time", "image", "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "effectcraft-cli" +version = "0.1.0" +dependencies = [ + "effectcraft-automation", + "effectcraft-engine", + "effectcraft-host", + "serde_json", ] [[package]] diff --git a/apps/effectcraft-cli/Cargo.toml b/apps/effectcraft-cli/Cargo.toml index c5bc28d0..3b64105e 100644 --- a/apps/effectcraft-cli/Cargo.toml +++ b/apps/effectcraft-cli/Cargo.toml @@ -14,7 +14,6 @@ path = "src/main.rs" [dependencies] effectcraft-engine = { workspace = true } -effectcraft-render = { workspace = true } -effectcraft-time = { workspace = true } +effectcraft-host = { workspace = true } +effectcraft-automation = { workspace = true } serde_json = { workspace = true } -image = { workspace = true } diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index dc010d58..111887be 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -1,89 +1,391 @@ -//! EffectCraft headless CLI. +//! EffectCraft headless CLI: one-shot agent commands, frame rendering and the MCP server. //! //! ```text -//! effectcraft-cli frame [project.ecproj | --demo] [--comp NAME] [--time S | --frame N] [--scale K] --out out.png -//! effectcraft-cli run [project.ecproj | --demo] '' '' … (prints results) -//! effectcraft-cli commands (lists commands) +//! effectcraft-cli info project + engine summary +//! effectcraft-cli commands [--filter TEXT] [--enabled] list engine commands +//! effectcraft-cli exec [--params JSON] run one command +//! effectcraft-cli run [ …] run several commands in order +//! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) +//! effectcraft-cli get [--time S] read a property +//! effectcraft-cli set [--time S] [--expression E] +//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] +//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio +//! +//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty) +//! Saving: --save (back to --project) | --save-as F.ecproj +//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session +//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1) //! ``` +//! +//! `` is a comp id or name (`-` = the active comp); `` an id, `#n` or name; `` +//! is JSON (`50`, `[960,540]`, `"#ff0000"`) or a bare string. See `docs/agents.md`. -use effectcraft_engine::Session; -use effectcraft_render::RenderOpts; -use effectcraft_time::Tick; +use effectcraft_automation::tools::{self, Reply}; +use effectcraft_automation::{Backend, McpServer}; use serde_json::{Value, json}; -fn usage() -> ! { - eprintln!("usage: effectcraft-cli [args] (see --help in source docs)"); - std::process::exit(2) +const USAGE: &str = "usage: effectcraft-cli [args] [--json] + info project + engine summary + commands [--filter TEXT] [--enabled] list engine commands + exec [--params JSON] run one engine command + run [ ...] run several commands in order + props [--flat] [--time S] a layer's property tree with paths + get [--time S] read a property + set [--time S] [--expression E] + render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png] + mcp [--bridge PORT] MCP server (JSON-RPC over stdio) +options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json +: id or name, '-' = active comp; : id, '#n' or name; : JSON or bare string"; + +/// Options that take a value. +const VALUED: &[&str] = + &["--params", "--project", "--bridge", "--save-as", "--filter", "--time", "--frame", "--comp", "--max-side", "--scale", "--out", "--expression", "--depth"]; + +struct Args { + pos: Vec, + opts: Vec<(String, Option)>, + /// `--project F` or a positional `*.ecproj`. + project: Option, } -fn open(s: &mut Session, args: &[String]) { - if let Some(p) = args.iter().find(|a| a.ends_with(".ecproj")) { - if let Err(e) = s.execute("file.open", json!({"path": p})) { - eprintln!("effectcraft: {e}"); - std::process::exit(1); +impl Args { + fn parse(raw: Vec) -> Result { + let (mut pos, mut opts) = (vec![], vec![]); + let mut it = raw.into_iter(); + while let Some(a) = it.next() { + if a.starts_with("--") && a.len() > 2 { + let (k, inline) = match a.split_once('=') { + Some((k, v)) => (k.to_string(), Some(v.to_string())), + None => (a, None), + }; + if VALUED.contains(&k.as_str()) { + let v = inline.or_else(|| it.next()).ok_or_else(|| format!("{k} needs a value"))?; + opts.push((k, Some(v))); + } else { + opts.push((k, None)); + } + } else { + pos.push(a); + } } - } else if args.iter().any(|a| a == "--demo") || true { - let _ = s.execute("file.openDemoProject", json!({})); + let mut a = Args { pos, opts, project: None }; + a.project = match a.opt("--project") { + Some(p) => Some(p.to_string()), + None => a.pos.iter().position(|x| x.ends_with(".ecproj")).map(|i| a.pos.remove(i)), + }; + Ok(a) + } + fn flag(&self, k: &str) -> bool { + self.opts.iter().any(|(o, _)| o == k) + } + fn opt(&self, k: &str) -> Option<&str> { + self.opts.iter().rev().find(|(o, _)| o == k).and_then(|(_, v)| v.as_deref()) + } + fn num(&self, k: &str) -> Result, String> { + self.opt(k).map(|v| v.parse::().map_err(|_| format!("{k}: not a number: {v}"))).transpose() } } -fn opt<'a>(args: &'a [String], k: &str) -> Option<&'a str> { - args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).map(String::as_str) +/// A comp/layer reference: number → id, `-` → none (active), else a name / `#n`. +fn reference(s: &str) -> Option { + match s { + "-" | "" => None, + _ => Some(s.parse::().map(Value::from).unwrap_or_else(|_| json!(s))), + } +} + +/// JSON if it parses, else the bare string. +fn value_arg(s: &str) -> Value { + serde_json::from_str(s).unwrap_or_else(|_| json!(s)) } fn main() { - let args: Vec = std::env::args().skip(1).collect(); - let Some(cmd) = args.first().cloned() else { usage() }; - let rest = &args[1..]; - let mut s = Session::default(); - match cmd.as_str() { - "frame" => { - open(&mut s, rest); - if let Some(c) = opt(rest, "--comp") { - let _ = s.execute("comp.open", json!({"comp": c})); + let raw: Vec = std::env::args().skip(1).collect(); + if raw.is_empty() || raw.iter().any(|a| a == "-h" || a == "--help") { + println!("{USAGE}"); + std::process::exit(if raw.is_empty() { 2 } else { 0 }); + } + let mut args = match Args::parse(raw) { + Ok(a) => a, + Err(e) => fail_usage(&e), + }; + let json_out = args.flag("--json"); + if args.pos.is_empty() { + fail_usage("missing subcommand"); + } + let cmd = args.pos.remove(0); + match run(&cmd, &args, json_out) { + Ok(()) => {} + Err(Failure::Usage(e)) => fail_usage(&e), + Err(Failure::Error(e)) => { + if json_out { + println!("{}", json!({"error": e})); } - let cid = s.active_comp_id().unwrap_or_else(|| { - eprintln!("no composition"); - std::process::exit(1) - }); - let comp = s.project.comp(cid).cloned().unwrap_or_else(|| std::process::exit(1)); - let t = if let Some(f) = opt(rest, "--frame").and_then(|f| f.parse().ok()) { - comp.frame_rate.tick_of(f) - } else { - opt(rest, "--time").and_then(|v| v.parse().ok()).map(Tick::from_seconds_f64).unwrap_or(s.time()) - }; - let scale = opt(rest, "--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0); - let out = opt(rest, "--out").unwrap_or("frame.png"); - let t0 = std::time::Instant::now(); - let img = s.render(cid, t, RenderOpts { scale, ..Default::default() }); - let el = t0.elapsed(); - let bg = comp.background; - let rgba = img.to_rgba8_over(bg); - image::save_buffer(out, &rgba, img.width, img.height, image::ColorType::Rgba8).unwrap_or_else(|e| { - eprintln!("cannot write {out}: {e}"); - std::process::exit(1) - }); - eprintln!("rendered {}x{} at {:.3}s in {:.1} ms → {out}", img.width, img.height, t.seconds(), el.as_secs_f64() * 1000.0); + eprintln!("effectcraft-cli {cmd}: {e}"); + std::process::exit(1); } - "run" => { - open(&mut s, rest); - let pairs: Vec<&String> = rest.iter().filter(|a| !a.ends_with(".ecproj") && *a != "--demo").collect(); - for ch in pairs.chunks(2) { - let params: Value = ch.get(1).and_then(|p| serde_json::from_str(p).ok()).unwrap_or(json!({})); - match s.execute(ch[0], params) { - Ok(v) => println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()), - Err(e) => { - eprintln!("{}: {e}", ch[0]); - std::process::exit(1) + } +} + +fn fail_usage(e: &str) -> ! { + eprintln!("effectcraft-cli: {e}\n{USAGE}"); + std::process::exit(2) +} + +enum Failure { + Usage(String), + Error(String), +} +impl From for Failure { + fn from(e: effectcraft_automation::Error) -> Self { + Failure::Error(e.to_string()) + } +} +impl From for Failure { + fn from(e: String) -> Self { + Failure::Error(e) + } +} + +fn usage_err(m: &str) -> Result { + Err(Failure::Usage(m.to_string())) +} + +/// Build the backend: a bridge to the app, or a headless session with the requested project. +fn backend(args: &Args, default_demo: bool) -> Result { + if let Some(addr) = args.opt("--bridge") { + if args.project.is_some() || args.flag("--demo") { + return usage_err("--bridge drives the running app's project; use `exec file.open` to open another"); + } + return Ok(Backend::bridge(addr)?); + } + let mut b = Backend::headless(effectcraft_host::session()); + if let Some(p) = &args.project { + b.exec("file.open", json!({"path": p}))?; + } else if args.flag("--demo") || (default_demo && !args.flag("--empty")) { + b.exec("file.openDemoProject", json!({}))?; + } + Ok(b) +} + +/// `--save` / `--save-as` after a mutating command. +fn maybe_save(b: &mut Backend, args: &Args) -> Result, Failure> { + let r = if let Some(p) = args.opt("--save-as") { + b.exec("file.saveAs", json!({"path": p}))? + } else if args.flag("--save") { + b.exec("file.save", json!({})).map_err(|e| Failure::Error(format!("{e} (use --project F to save in place, or --save-as F)")))? + } else { + return Ok(None); + }; + Ok(r.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| args.opt("--save-as").map(str::to_string))) +} + +fn tool(b: &mut Backend, name: &str, a: Value) -> Result { + match tools::run(b, name, &a)? { + Reply::Json(v) => Ok(v), + Reply::Image { info, .. } => Ok(info), + } +} + +/// Print a result: compact JSON with `--json`, else pretty JSON. +fn emit(v: &Value, json_out: bool) { + if json_out { + println!("{v}"); + } else { + println!("{}", serde_json::to_string_pretty(v).unwrap_or_default()); + } +} + +fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { + match cmd { + "info" => { + let mut b = backend(args, true)?; + let p = tool(&mut b, "get_project", json!({}))?; + let comp = tool(&mut b, "get_comp", json!({})).ok(); + let info = json!({ + "version": env!("CARGO_PKG_VERSION"), + "mode": if b.is_bridge() { "bridge" } else { "headless" }, + "commands": effectcraft_engine::command_specs().len(), + "effects": effectcraft_engine::effects::registry().len(), + "project": p, + "activeComp": comp, + }); + if json_out { + emit(&info, true); + } else { + println!( + "EffectCraft {} ({}), {} commands, {} effects", + info["version"].as_str().unwrap_or(""), + info["mode"].as_str().unwrap_or(""), + info["commands"], + info["effects"] + ); + println!("project: {}", p["path"].as_str().unwrap_or("(unsaved)")); + for i in p["items"].as_array().into_iter().flatten() { + println!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or("")); + } + if let Some(c) = comp { + println!( + "active comp {} \"{}\" {}x{} @ {} fps, {} s", + c["id"], + c["name"].as_str().unwrap_or(""), + c["width"], + c["height"], + c["frameRate"], + c["duration"] + ); + for l in c["layers"].as_array().into_iter().flatten() { + println!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or("")); } } } } "commands" => { - for c in effectcraft_engine::command_specs() { - println!("{:<36} {:<40} {}", c.id, c.label, c.shortcut.unwrap_or("")); + let mut b = backend(args, false)?; + let v = tool(&mut b, "list_commands", json!({"filter": args.opt("--filter"), "enabled_only": args.flag("--enabled")}))?; + if json_out { + emit(&v, true); + } else { + for c in v.as_array().into_iter().flatten() { + println!( + "{:<36} {:<36} {:<16} {}", + c["id"].as_str().unwrap_or(""), + c["label"].as_str().unwrap_or(""), + c["shortcut"].as_str().unwrap_or(""), + c["params"].as_str().unwrap_or("") + ); + } } } - _ => usage(), + "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)) { + (Some(p), _) | (None, Some(p)) => serde_json::from_str(p).map_err(|e| Failure::Usage(format!("params are not valid JSON: {e}")))?, + (None, None) => json!({}), + }; + let mut b = backend(args, true)?; + let r = b.exec(&id, params)?; + let saved = maybe_save(&mut b, args)?; + emit(&with_saved(r, saved), json_out); + } + "run" => { + if args.pos.is_empty() { + return usage_err("run needs [] pairs"); + } + let pairs = args.pos.clone(); + let mut b = backend(args, true)?; + let mut results = vec![]; + let mut i = 0; + while i < pairs.len() { + let id = &pairs[i]; + let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) { + Some(p) => { + i += 1; + serde_json::from_str(p).map_err(|e| Failure::Usage(format!("{id}: params are not valid JSON: {e}")))? + } + None => json!({}), + }; + i += 1; + let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?; + if !json_out { + emit(&r, false); + } + results.push(json!({"command": id, "result": r})); + } + let saved = maybe_save(&mut b, args)?; + if json_out { + emit(&with_saved(json!(results), saved), true); + } + } + "props" => { + let [comp, layer] = positional::<2>(args, "props ")?; + let mut b = backend(args, true)?; + let a = json!({"comp": reference(&comp), "layer": reference(&layer), "flat": args.flag("--flat") || !json_out, "time": args.num("--time")?, "depth": args.num("--depth")?}); + let v = tool(&mut b, "get_layer", a)?; + if json_out { + emit(&v, true); + } else { + println!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]); + for p in v["properties"].as_array().into_iter().flatten() { + let mut extra = String::new(); + if let Some(k) = p.get("keys") { + extra += &format!(" [{k} keys]"); + } + if let Some(e) = p.get("expression").and_then(Value::as_str) { + extra += &format!(" expr: {e}"); + } + println!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]); + } + } + } + "get" => { + let [comp, layer, path] = positional::<3>(args, "get ")?; + let mut b = backend(args, true)?; + let v = tool(&mut b, "get_property", json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "time": args.num("--time")?}))?; + emit(&v, json_out); + } + "set" => { + let expr = args.opt("--expression").map(str::to_string); + let need = if expr.is_some() { 3 } else { 4 }; + if args.pos.len() < need { + return usage_err("set [--time S] [--expression E]"); + } + let (comp, layer, path) = (args.pos[0].clone(), args.pos[1].clone(), args.pos[2].clone()); + let value = args.pos.get(3).map(|v| value_arg(v)); + let mut b = backend(args, true)?; + let a = + json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "value": value, "time": args.num("--time")?, "expression": expr}); + let v = tool(&mut b, "set_property", a)?; + let saved = maybe_save(&mut b, args)?; + emit(&with_saved(v, saved), json_out); + } + "render-frame" | "frame" => { + let mut b = backend(args, true)?; + let comp = args.opt("--comp").and_then(reference); + let mut time = args.num("--time")?; + let mut max_side = args.num("--max-side")?.map(|m| m as u32).unwrap_or(0); + let needs_info = args.opt("--frame").is_some() || args.opt("--scale").is_some(); + if needs_info { + let c = tool(&mut b, "get_comp", json!({"comp": comp}))?; + if let Some(f) = args.num("--frame")? { + time = Some(f / c["frameRate"].as_f64().unwrap_or(30.0)); + } + if let Some(k) = args.num("--scale")? { + let long = c["width"].as_f64().unwrap_or(0.0).max(c["height"].as_f64().unwrap_or(0.0)); + max_side = (long * k).round().max(1.0) as u32; + } + } + let out = args.opt("--out").unwrap_or("frame.png").to_string(); + let t0 = std::time::Instant::now(); + let f = b.render(comp.as_ref(), time, max_side)?; + let ms = t0.elapsed().as_secs_f64() * 1000.0; + std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?; + let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0}); + if json_out { + emit(&info, true); + } else { + eprintln!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time); + } + } + "mcp" => { + let b = backend(args, false)?; + McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?; + } + other => return usage_err(&format!("unknown subcommand `{other}`")), + } + Ok(()) +} + +fn positional(args: &Args, usage: &str) -> Result<[String; N], Failure> { + if args.pos.len() < N { + return usage_err(usage); + } + Ok(std::array::from_fn(|i| args.pos[i].clone())) +} + +fn with_saved(v: Value, saved: Option) -> Value { + match saved { + None => v, + Some(p) => json!({"result": v, "saved": p}), } } diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs new file mode 100644 index 00000000..c8427720 --- /dev/null +++ b/apps/effectcraft-cli/tests/cli.rs @@ -0,0 +1,133 @@ +//! End-to-end tests of the agent CLI: JSON output, project round-trips, rendering and MCP over stdio. + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use serde_json::{Value, json}; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) +} + +/// Run with `--json`; returns (exit code, parsed stdout). +fn run_json(args: &[&str]) -> (i32, Value) { + let out = bin().args(args).arg("--json").output().unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let v = + serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: not JSON ({e}): {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr))); + (out.status.code().unwrap_or(-1), v) +} + +fn ok_json(args: &[&str]) -> Value { + let (code, v) = run_json(args); + assert_eq!(code, 0, "{args:?}: {v}"); + v +} + +fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +#[test] +fn info_and_commands() { + let v = ok_json(&["info"]); + assert_eq!(v["mode"], "headless"); + assert!(v["commands"].as_u64().unwrap() > 50); + assert!(v["activeComp"]["layers"].as_array().unwrap().len() > 1, "demo project by default"); + + let v = ok_json(&["commands", "--filter", "layer.new"]); + let ids: Vec<&str> = v.as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap()).collect(); + assert!(ids.contains(&"layer.newSolid") && ids.contains(&"layer.newText")); + assert!(ids.iter().all(|i| i.contains("layer.new") || !i.is_empty())); +} + +#[test] +fn exec_set_get_with_saved_project() { + let proj = tmp("edit.ecproj"); + let p = proj.to_str().unwrap(); + let v = ok_json(&["exec", "comp.new", "--params", r#"{"name":"Main","width":320,"height":180,"duration":3}"#, "--empty", "--save-as", p]); + assert_eq!(v["saved"], p); + // Positional params + positional project + in-place save. + let v = ok_json(&["exec", "layer.newSolid", r##"{"color":"#00ff00","name":"Green"}"##, p, "--save"]); + assert!(v["result"]["layer"].is_u64()); + + let v = ok_json(&["set", "Main", "Green", "transform/opacity", "25", "--project", p, "--save"]); + assert_eq!(v["result"]["value"], 25.0); + ok_json(&["set", "Main", "#1", "transform/position", "[0,90]", "--time", "0", p, "--save"]); + ok_json(&["set", "Main", "#1", "transform/position", "[320,90]", "--time", "2", p, "--save"]); + + let v = ok_json(&["get", "Main", "#1", "transform/position", "--time", "1", p]); + assert_eq!(v["keys"].as_array().unwrap().len(), 2); + assert!((v["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{v}"); + assert_eq!(ok_json(&["get", "-", "Green", "transform/opacity", p])["value"], 25.0); + + let v = ok_json(&["props", "Main", "#1", p]); + assert_eq!(v["name"], "Green"); + let tree = v["properties"]["children"].as_array().unwrap(); + assert!(!tree.is_empty()); + // The human (flat) listing shows paths. + let out = bin().args(["props", "Main", "#1", p]).output().unwrap(); + assert!(String::from_utf8_lossy(&out.stdout).contains("transform/position")); + + // Several commands in one process. + let v = ok_json(&["run", p, "time.set", r#"{"time":1}"#, "project.summary"]); + assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}"); +} + +#[test] +fn render_frame_writes_png() { + let out = tmp("f.png"); + let o = out.to_str().unwrap(); + let v = ok_json(&["render-frame", "--time", "1", "--max-side", "240", "--out", o]); + assert_eq!(v["width"], 240); + assert_eq!(v["height"], 135); + let png = std::fs::read(&out).unwrap(); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + let v = ok_json(&["render-frame", "--frame", "15", "--scale", "0.125", "--out", o]); + assert_eq!(v["width"], 240); + assert!((v["time"].as_f64().unwrap() - 0.5).abs() < 0.02, "{v}"); +} + +#[test] +fn errors_are_json() { + let (code, v) = run_json(&["exec", "no.such.command", "--empty"]); + assert_eq!(code, 1); + assert!(v["error"].as_str().unwrap().contains("unknown command")); + let (code, v) = run_json(&["exec", "layer.select", r#"{"index":2}"#]); + assert_eq!(code, 1); + assert!(v["error"].as_str().unwrap().contains("accepted: layers, add, toggle"), "{v}"); + let (code, v) = run_json(&["get", "-", "#99", "transform/opacity"]); + assert_eq!(code, 1); + assert!(v["error"].is_string()); + let out = bin().args(["get", "only-one-arg"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2)); +} + +#[test] +fn mcp_over_stdio() { + let mut child = bin().args(["mcp", "--demo"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let msgs = [ + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "0"}}}), + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_property", "arguments": {"layer": "#1", "path": "transform/opacity"}}}), + json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "render_frame", "arguments": {"max_side": 64}}}), + ]; + { + let mut stdin = child.stdin.take().unwrap(); + for m in &msgs { + writeln!(stdin, "{m}").unwrap(); + } + } // EOF ends the server. + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let replies: Vec = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 4, "one reply per request, none for the notification"); + assert_eq!(replies[0]["result"]["serverInfo"]["name"], "effectcraft"); + assert!(replies[1]["result"]["tools"].as_array().unwrap().len() >= 13); + assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); + assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml new file mode 100644 index 00000000..a3b16408 --- /dev/null +++ b/crates/automation/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "effectcraft-automation" +description = "EffectCraft MCP server (JSON-RPC 2.0 over stdio) over a headless session or bridged to the running app's control channel" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +effectcraft-engine = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +image = { workspace = true } diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs new file mode 100644 index 00000000..ada8166b --- /dev/null +++ b/crates/automation/src/backend.rs @@ -0,0 +1,104 @@ +//! Where tools run: an in-process session, or the desktop app over its control channel. + +use effectcraft_engine::Session; +use effectcraft_engine::time::Tick; +use serde_json::{Value, json}; + +use crate::{BridgeClient, Error, Result, base64, encode_png, recompress_png}; + +pub enum Backend { + /// An in-process session (no window): engine commands, inspection and rendering. + Headless(Box), + /// A running desktop app reached over its control channel: everything, plus the live UI. + Bridge(BridgeClient), +} + +/// A rendered comp frame. +pub struct Frame { + pub png: Vec, + pub comp: Value, + pub time: f64, + pub width: u32, + pub height: u32, +} + +impl Backend { + pub fn headless(session: Session) -> Self { + Backend::Headless(Box::new(session)) + } + + /// Bridge to the app's control channel at `addr` (`9877` or `127.0.0.1:9877`). + pub fn bridge(addr: &str) -> Result { + Ok(Backend::Bridge(BridgeClient::new(addr)?)) + } + + pub fn is_bridge(&self) -> bool { + matches!(self, Backend::Bridge(_)) + } + + pub fn session(&mut self) -> Option<&mut Session> { + match self { + Backend::Headless(s) => Some(s), + Backend::Bridge(_) => None, + } + } + + /// Run an engine command by id. + pub fn exec(&mut self, id: &str, params: Value) -> Result { + let params = if params.is_null() { json!({}) } else { params }; + match self { + Backend::Headless(s) => { + let r = s.execute_checked(id, params); + s.drain_events(); + Ok(r?) + } + Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})), + } + } + + /// Call a raw control-channel method (bridge only). + pub fn control(&mut self, method: &str, params: Value) -> Result { + match self { + Backend::Bridge(b) => b.call(method, if params.is_null() { json!({}) } else { params }), + Backend::Headless(_) => Err(Error::BadArgs(NEED_BRIDGE.into())), + } + } + + /// Render a comp frame (comp time `time` seconds, default the CTI) as PNG, longest side at most + /// `max_side` (0 = full size). + pub fn render(&mut self, comp: Option<&Value>, time: Option, max_side: u32) -> Result { + match self { + Backend::Headless(s) => { + let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?; + let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); + let (w, h, rgba) = s.render_rgba8(cid, t, max_side)?; + let png = encode_png(w, h, rgba, max_side)?; + Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h }) + } + Backend::Bridge(b) => { + let mut p = json!({"max_side": max_side, "base64": true}); + if let Some(t) = time { + p["time"] = json!(t); + } + if let Some(c) = comp.filter(|c| !c.is_null()) { + p["comp"] = c.clone(); + } + let r = b.call("render.frame", p)?; + let data = r.get("png").and_then(Value::as_str).ok_or_else(|| Error::App("render.frame returned no `png`".into()))?; + let raw = base64::decode(data).ok_or_else(|| Error::App("render.frame: bad base64".into()))?; + let (png, width, height) = recompress_png(&raw, max_side)?; + Ok(Frame { png, comp: r["comp"].clone(), time: r["time"].as_f64().unwrap_or(0.0), width, height }) + } + } + } +} + +fn no_comp(comp: Option<&Value>) -> Error { + match comp.filter(|c| !c.is_null()) { + Some(c) => Error::BadArgs(format!("no composition {c} (see get_project)")), + None => Error::BadArgs("no active composition: create one (execute_command comp.new) or open a project".into()), + } +} + +pub const NEED_BRIDGE: &str = + "this tool drives the live app: start it with `effectcraft --control 9877` and run the MCP server as `effectcraft-cli mcp --bridge 9877`"; diff --git a/crates/automation/src/base64.rs b/crates/automation/src/base64.rs new file mode 100644 index 00000000..90002c60 --- /dev/null +++ b/crates/automation/src/base64.rs @@ -0,0 +1,61 @@ +//! Standard base64 (RFC 4648 §4, padded): MCP image content and the control channel's inline PNGs. + +const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +pub fn encode(data: &[u8]) -> String { + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + for i in 0..4 { + s.push(if i <= c.len() { A[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' }); + } + } + s +} + +/// Decode, ignoring ASCII whitespace; `None` on any other invalid input. +pub fn decode(s: &str) -> Option> { + let val = |c: u8| -> Option { + Some(match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + } as u32) + }; + let bytes: Vec = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if !bytes.len().is_multiple_of(4) { + return None; + } + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + for (ci, c) in bytes.chunks(4).enumerate() { + let last = ci == bytes.len() / 4 - 1; + let pad = c.iter().rev().take_while(|b| **b == b'=').count(); + if pad > 2 || (pad > 0 && !last) { + return None; + } + let mut n = 0u32; + for &b in &c[..4 - pad] { + n = n << 6 | val(b)?; + } + n <<= 6 * pad as u32; + out.extend_from_slice(&[(n >> 16) as u8, (n >> 8) as u8, n as u8][..3 - pad]); + } + Some(out) +} + +#[cfg(test)] +mod tests { + #[test] + fn rfc4648_vectors() { + for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] { + assert_eq!(super::encode(i.as_bytes()), o); + assert_eq!(super::decode(o).unwrap(), i.as_bytes()); + } + assert!(super::decode("Zm9v!").is_none()); + let all: Vec = (0..=255).collect(); + assert_eq!(super::decode(&super::encode(&all)).unwrap(), all); + } +} diff --git a/crates/automation/src/bridge.rs b/crates/automation/src/bridge.rs new file mode 100644 index 00000000..eab1c01d --- /dev/null +++ b/crates/automation/src/bridge.rs @@ -0,0 +1,99 @@ +//! Blocking client for the desktop app's JSON-lines control channel (`docs/control-protocol.md`). +//! One persistent loopback connection, reconnected once if the app went away. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::{Error, Result}; + +/// Slightly longer than the app's own 60 s per-request timeout, so its error reply wins. +const READ_TIMEOUT: Duration = Duration::from_secs(65); + +pub struct BridgeClient { + addr: String, + conn: Option<(BufReader, TcpStream)>, + next_id: u64, +} + +impl BridgeClient { + /// `addr`: a port (`9877`) or `host:port` on loopback (`127.0.0.1`, `localhost`, `[::1]`). + pub fn new(addr: &str) -> Result { + let addr = if addr.parse::().is_ok() { format!("127.0.0.1:{addr}") } else { addr.to_string() }; + let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr); + if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") { + return Err(Error::BadArgs(format!("bridge address must be loopback (PORT or 127.0.0.1:PORT), got `{addr}`"))); + } + Ok(Self { addr, conn: None, next_id: 1 }) + } + + pub fn addr(&self) -> &str { + &self.addr + } + + fn connect(&self) -> Result<(BufReader, TcpStream)> { + let sa = self + .addr + .to_socket_addrs() + .map_err(|e| Error::Bridge(format!("{}: {e}", self.addr)))? + .next() + .ok_or_else(|| Error::Bridge(format!("cannot resolve {}", self.addr)))?; + let s = TcpStream::connect_timeout(&sa, Duration::from_secs(5)) + .map_err(|e| Error::Bridge(format!("cannot connect to {} ({e}); start the app with `effectcraft --control `", self.addr)))?; + s.set_read_timeout(Some(READ_TIMEOUT)).ok(); + s.set_nodelay(true).ok(); + let r = s.try_clone().map_err(|e| Error::Bridge(e.to_string()))?; + Ok((BufReader::new(r), s)) + } + + /// Call a control method; returns the reply's `result`, or the app's `error` as [`Error::App`]. + pub fn call(&mut self, method: &str, params: Value) -> Result { + let mut last = Error::Bridge("not attempted".into()); + for attempt in 0..2 { + if self.conn.is_none() { + self.conn = Some(self.connect()?); + } + let id = self.next_id; + self.next_id += 1; + let Some((r, w)) = self.conn.as_mut() else { continue }; + let line = format!("{}\n", json!({"id": id, "method": method, "params": params})); + // `Ok(Err(_))`: the connection is dead before a reply (safe to resend once); + // `Err(_)`: a timeout or bad reply (never resent: the app may have run the request). + let res = (|| -> Result> { + if let Err(e) = w.write_all(line.as_bytes()) { + return Ok(Err(Error::Bridge(e.to_string()))); + } + let mut buf = String::new(); + match r.read_line(&mut buf) { + Ok(0) => return Ok(Err(Error::Bridge("connection closed by the app".into()))), + Ok(_) => {} + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + return Err(Error::Bridge(format!("no reply to `{method}` within {}s (is the app window running?)", READ_TIMEOUT.as_secs()))); + } + Err(e) => return Ok(Err(Error::Bridge(e.to_string()))), + } + serde_json::from_str(&buf).map(Ok).map_err(|e| Error::Bridge(format!("bad reply: {e}"))) + })(); + match res { + Ok(Ok(v)) if v.get("ok").and_then(Value::as_bool) == Some(true) => return Ok(v.get("result").cloned().unwrap_or(Value::Null)), + Ok(Ok(v)) => return Err(Error::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())), + Ok(Err(e)) => { + // Stale connection (app restarted): reconnect and resend once. + self.conn = None; + last = e; + if attempt == 1 { + break; + } + } + Err(e) => { + // The stream may still deliver the late reply: start fresh next call. + self.conn = None; + return Err(e); + } + } + } + Err(last) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs new file mode 100644 index 00000000..e3bf88cd --- /dev/null +++ b/crates/automation/src/lib.rs @@ -0,0 +1,90 @@ +//! # effectcraft-automation (L5) +//! +//! Makes EffectCraft drivable by AI agents. See `docs/agents.md`. +//! +//! * [`McpServer`]: a Model Context Protocol server (JSON-RPC 2.0, newline-delimited over stdio), +//! hand-rolled on `serde_json`; no async runtime, starts instantly. +//! * [`Backend`]: where tools run. **Headless** owns an in-process [`Session`] (no window); +//! **bridge** forwards to a running desktop app over its JSON-lines control channel +//! (`effectcraft --control 9877`, `docs/control-protocol.md`), adding screenshots and UI input. +//! * [`tools`]: the tool catalogue (names, descriptions, JSON schemas) and their implementations, +//! shared by the MCP server and `effectcraft-cli`'s one-shot subcommands. +//! +//! Layering: depends on `effectcraft-engine` only (never egui); the CLI injects a fully wired +//! session (`effectcraft_host::session()`). + +pub mod backend; +pub mod base64; +pub mod bridge; +pub mod server; +pub mod tools; + +pub use backend::{Backend, Frame}; +pub use bridge::BridgeClient; +pub use effectcraft_engine::Session; +pub use server::McpServer; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Missing or malformed tool arguments. + #[error("{0}")] + BadArgs(String), + #[error(transparent)] + Engine(#[from] effectcraft_engine::EngineError), + /// Transport problem talking to the desktop app. + #[error("bridge: {0}")] + Bridge(String), + /// The desktop app replied with an error. + #[error("{0}")] + App(String), + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; + +/// Encode RGBA8 pixels as PNG (fast compression), downscaled so the longest side is at most +/// `max_side` (0 = keep). Fully opaque images are written as RGB to save space. +pub fn encode_png(w: u32, h: u32, rgba: Vec, max_side: u32) -> Result> { + use image::ImageEncoder; + use image::codecs::png::{CompressionType, FilterType, PngEncoder}; + let mut img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| Error::Other("bad image buffer".into()))?; + if max_side > 0 && w.max(h) > max_side { + let s = max_side as f64 / w.max(h) as f64; + let (nw, nh) = (((w as f64 * s).round() as u32).max(1), ((h as f64 * s).round() as u32).max(1)); + img = image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle); + } + let (w, h) = img.dimensions(); + let mut out = Vec::new(); + let enc = PngEncoder::new_with_quality(&mut out, CompressionType::Fast, FilterType::Adaptive); + let raw = img.into_raw(); + let r = if raw.chunks_exact(4).all(|p| p[3] == 255) { + let rgb: Vec = raw.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect(); + enc.write_image(&rgb, w, h, image::ExtendedColorType::Rgb8) + } else { + enc.write_image(&raw, w, h, image::ExtendedColorType::Rgba8) + }; + r.map_err(|e| Error::Other(format!("png: {e}")))?; + Ok(out) +} + +/// Decode any PNG and re-encode it compressed and capped to `max_side`. Returns (png, w, h). +pub fn recompress_png(bytes: &[u8], max_side: u32) -> Result<(Vec, u32, u32)> { + let img = image::load_from_memory(bytes).map_err(|e| Error::Other(format!("png: {e}")))?.to_rgba8(); + let (w, h) = img.dimensions(); + let png = encode_png(w, h, img.into_raw(), max_side)?; + let (w, h) = png_size(&png).unwrap_or((w, h)); + Ok((png, w, h)) +} + +/// Width and height from a PNG's IHDR. +pub fn png_size(png: &[u8]) -> Option<(u32, u32)> { + if png.len() < 24 || &png[12..16] != b"IHDR" { + return None; + } + let be = |i: usize| u32::from_be_bytes([png[i], png[i + 1], png[i + 2], png[i + 3]]); + Some((be(16), be(20))) +} + +#[cfg(test)] +mod tests; diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs new file mode 100644 index 00000000..2a6ea687 --- /dev/null +++ b/crates/automation/src/server.rs @@ -0,0 +1,144 @@ +//! MCP server: JSON-RPC 2.0, one message per line (the MCP stdio transport). Implements +//! `initialize`, `ping`, `tools/list` and `tools/call`; notifications are accepted and ignored. + +use std::io::{BufRead, Write}; + +use serde_json::{Value, json}; + +use crate::tools::{self, Reply}; +use crate::{Backend, Error, base64}; + +/// Protocol revisions we speak, newest first. We answer with the client's if we know it. +pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"]; + +const INSTRUCTIONS: &str = "EffectCraft is an After Effects-class motion graphics compositor. Everything is an engine command: list_commands discovers ids and params, execute_command runs them (undoable). Typical flow: execute_command comp.new -> execute_command layer.newText / layer.newSolid (returns the layer id) -> set_property / add_keyframe -> execute_command effect.apply -> render_frame to look at the result. Inspect with get_project, get_comp and get_layer (every property node carries its `path`, e.g. `transform/position`, `effects/#1/blurriness`). Times are seconds. In bridge mode (app started with `--control `) screenshot and the ui_* tools show and operate the live window."; + +// JSON-RPC error codes. +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; + +pub struct McpServer { + backend: Backend, +} + +impl McpServer { + pub fn new(backend: Backend) -> Self { + Self { backend } + } + + pub fn backend(&mut self) -> &mut Backend { + &mut self.backend + } + + /// Handle one decoded message (request, notification or batch); `None` when no reply is due. + pub fn handle(&mut self, msg: &Value) -> Option { + if let Value::Array(batch) = msg { + if batch.is_empty() { + return Some(error(Value::Null, INVALID_REQUEST, "empty batch")); + } + let out: Vec = batch.iter().filter_map(|m| self.handle(m)).collect(); + return (!out.is_empty()).then_some(Value::Array(out)); + } + let Some(method) = msg.get("method").and_then(Value::as_str) else { + // A response from the client (we send no requests) or garbage. + return msg + .get("id") + .filter(|_| msg.get("result").is_none() && msg.get("error").is_none()) + .map(|id| error(id.clone(), INVALID_REQUEST, "missing `method`")); + }; + let Some(id) = msg.get("id").cloned() else { + return None; // notification (notifications/initialized, notifications/cancelled, ...) + }; + let params = msg.get("params").cloned().unwrap_or(json!({})); + Some(match self.request(method, ¶ms) { + Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}), + Err((code, m)) => error(id, code, &m), + }) + } + + /// Handle one line of input; `None` when no reply is due. + pub fn handle_line(&mut self, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + let reply = match serde_json::from_str::(line) { + Ok(msg) => self.handle(&msg)?, + Err(e) => error(Value::Null, PARSE_ERROR, &format!("parse error: {e}")), + }; + Some(reply.to_string()) + } + + /// Serve until EOF on `input`. + pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + for line in input.lines() { + if let Some(reply) = self.handle_line(&line?) { + output.write_all(reply.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; + } + } + Ok(()) + } + + /// Serve on stdin/stdout (the MCP stdio transport). + pub fn serve_stdio(&mut self) -> std::io::Result<()> { + self.serve(std::io::stdin().lock(), std::io::stdout().lock()) + } + + fn request(&mut self, method: &str, params: &Value) -> Result { + match method { + "initialize" => { + let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(""); + let version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS[0]); + let mode = if self.backend.is_bridge() { "bridge" } else { "headless" }; + Ok(json!({ + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": false}}, + "serverInfo": {"name": "effectcraft", "title": format!("EffectCraft ({mode})"), "version": env!("CARGO_PKG_VERSION")}, + "instructions": INSTRUCTIONS, + })) + } + "ping" => Ok(json!({})), + "tools/list" => { + let tools: Vec = tools::available(self.backend.is_bridge()).map(|t| t.descriptor()).collect(); + Ok(json!({"tools": tools})) + } + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; + if tools::available(self.backend.is_bridge()).all(|t| t.name != name) { + let hint = if tools::find(name).is_some() { format!(" ({})", crate::backend::NEED_BRIDGE) } else { String::new() }; + return Err((INVALID_PARAMS, format!("unknown tool `{name}`{hint}"))); + } + let args = params.get("arguments").cloned().unwrap_or(json!({})); + Ok(call_result(tools::run(&mut self.backend, name, &args))) + } + // Advertised as absent, but answer politely for clients that probe anyway. + "resources/list" => Ok(json!({"resources": []})), + "resources/templates/list" => Ok(json!({"resourceTemplates": []})), + "prompts/list" => Ok(json!({"prompts": []})), + m => Err((METHOD_NOT_FOUND, format!("method not found: {m}"))), + } + } +} + +/// A `tools/call` result: tool failures are reported in-band (`isError`) so the model can react. +pub fn call_result(r: Result) -> Value { + match r { + Ok(Reply::Json(v)) => json!({"content": [{"type": "text", "text": v.to_string()}], "isError": false}), + Ok(Reply::Image { png, info }) => json!({ + "content": [ + {"type": "image", "data": base64::encode(&png), "mimeType": "image/png"}, + {"type": "text", "text": info.to_string()}, + ], + "isError": false, + }), + Err(e) => json!({"content": [{"type": "text", "text": e.to_string()}], "isError": true}), + } +} + +fn error(id: Value, code: i64, message: &str) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) +} diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs new file mode 100644 index 00000000..f175b12f --- /dev/null +++ b/crates/automation/src/tests.rs @@ -0,0 +1,265 @@ +//! MCP protocol round-trips, in-process (headless) and against a fake control channel (bridge). + +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; + +use serde_json::{Value, json}; + +use crate::{Backend, McpServer, Session, base64, png_size}; + +fn server() -> McpServer { + McpServer::new(Backend::headless(Session::default())) +} + +/// Send a request, return its `result` (panics on a JSON-RPC error). +fn rpc(s: &mut McpServer, id: u64, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); + let reply: Value = serde_json::from_str(&s.handle_line(&line).expect("reply")).unwrap(); + assert_eq!(reply["jsonrpc"], "2.0"); + assert_eq!(reply["id"], id); + assert!(reply.get("error").is_none(), "{method}: {reply}"); + reply["result"].clone() +} + +/// Call a tool; returns (content, isError). +fn call(s: &mut McpServer, name: &str, args: Value) -> (Vec, bool) { + let r = rpc(s, 99, "tools/call", json!({"name": name, "arguments": args})); + (r["content"].as_array().unwrap().clone(), r["isError"].as_bool().unwrap()) +} + +/// Call a tool that returns JSON text; panics if it failed. +fn call_json(s: &mut McpServer, name: &str, args: Value) -> Value { + let (c, err) = call(s, name, args.clone()); + assert!(!err, "{name} {args}: {c:?}"); + assert_eq!(c[0]["type"], "text"); + serde_json::from_str(c[0]["text"].as_str().unwrap()).unwrap() +} + +#[test] +fn initialize_and_list_tools() { + let mut s = server(); + let r = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}})); + assert_eq!(r["protocolVersion"], "2025-03-26"); + assert_eq!(r["serverInfo"]["name"], "effectcraft"); + assert!(r["capabilities"]["tools"].is_object()); + // Unknown revision: we answer with our latest. + let r = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"})); + assert_eq!(r["protocolVersion"], crate::server::PROTOCOL_VERSIONS[0]); + // Notifications get no reply. + assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none()); + assert_eq!(rpc(&mut s, 3, "ping", json!({})), json!({})); + + let tools = rpc(&mut s, 4, "tools/list", json!({}))["tools"].as_array().unwrap().clone(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for want in [ + "list_commands", + "execute_command", + "get_project", + "get_comp", + "get_layer", + "get_property", + "set_property", + "add_keyframe", + "render_frame", + "open_project", + "save_project", + "undo", + "redo", + ] { + assert!(names.contains(&want), "missing {want}"); + } + assert!(!names.contains(&"screenshot"), "bridge-only tools are hidden headless"); + for t in &tools { + assert!(t["description"].as_str().unwrap().len() > 20); + assert_eq!(t["inputSchema"]["type"], "object"); + } +} + +#[test] +fn protocol_errors() { + let mut s = server(); + let r: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap(); + assert_eq!(r["error"]["code"], -32700); + let r: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":5,"method":"nope"}"#).unwrap()).unwrap(); + assert_eq!(r["error"]["code"], -32601); + let r: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"screenshot"}}"#).unwrap()).unwrap(); + assert_eq!(r["error"]["code"], -32602); + // Batches. + let r: Value = + serde_json::from_str(&s.handle_line(r#"[{"jsonrpc":"2.0","id":7,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/x"}]"#).unwrap()).unwrap(); + assert_eq!(r.as_array().unwrap().len(), 1); + // Tool failures are in-band. + let (c, err) = call(&mut s, "execute_command", json!({"command": "no.such"})); + assert!(err); + assert!(c[0]["text"].as_str().unwrap().contains("unknown command")); + let (_, err) = call(&mut s, "render_frame", json!({})); + assert!(err, "no comp yet"); + // Unknown params are rejected with the accepted keys, not silently ignored. + let (c, err) = call(&mut s, "execute_command", json!({"command": "comp.new", "params": {"nmae": "X"}})); + assert!(err); + assert!(c[0]["text"].as_str().unwrap().contains("accepted: name, width"), "{c:?}"); +} + +#[test] +fn headless_workflow() { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18"})); + + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new"})); + assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "layer.newSolid")); + + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Main", "width": 320, "height": 180, "duration": 4}})); + let l = + call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": "{\"color\":\"#ff0000\",\"width\":100,\"height\":100}"}))["layer"] + .clone(); + assert!(l.is_u64()); + + let comp = call_json(&mut s, "get_comp", json!({"comp": "Main"})); + assert_eq!(comp["width"], 320); + assert_eq!(comp["layers"][0]["id"], l); + + // Static set + read back. + let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/opacity", "value": 50})); + assert_eq!(p["value"], 50.0); + assert_eq!(call_json(&mut s, "get_property", json!({"layer": "#1", "path": "transform/opacity"}))["value"], 50.0); + + // Keyframes with easing. + let p = call_json( + &mut s, + "add_keyframe", + json!({"layer": l, "path": "transform/position", "keys": [{"time": 0, "value": [0, 90]}, {"time": 2, "value": [320, 90]}], "interpolation": "easyEase"}), + ); + assert_eq!(p["keys"].as_array().unwrap().len(), 2); + assert_eq!(p["keys"][0]["out"], "Bezier"); + let mid = call_json(&mut s, "get_property", json!({"layer": l, "path": "transform/position", "time": 1.0})); + assert!((mid["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{mid}"); + + // Expression via set_property. + let p = call_json(&mut s, "set_property", json!({"layer": l, "path": "transform/rotation", "expression": "time * 90"})); + assert_eq!(p["expression"], "time * 90"); + + // Property tree carries paths; flat mode too. + let tree = call_json(&mut s, "get_layer", json!({"layer": l, "flat": true})); + let props = tree["properties"].as_array().unwrap(); + assert!(props.iter().any(|p| p["path"] == "transform/position" && p["keys"] == 2)); + + // Render: an inline PNG at the requested size. + let (c, err) = call(&mut s, "render_frame", json!({"time": 1.0, "max_side": 160})); + assert!(!err, "{c:?}"); + assert_eq!(c[0]["type"], "image"); + assert_eq!(c[0]["mimeType"], "image/png"); + let png = base64::decode(c[0]["data"].as_str().unwrap()).unwrap(); + assert_eq!(png_size(&png), Some((160, 90))); + let info: Value = serde_json::from_str(c[1]["text"].as_str().unwrap()).unwrap(); + assert_eq!(info["width"], 160); + + // Undo / redo. + let before = call_json(&mut s, "get_project", json!({}))["undo"].as_array().unwrap().len(); + let u = call_json(&mut s, "undo", json!({"steps": 2})); + assert_eq!(u["steps"], 2); + assert_eq!(u["undoStack"].as_array().unwrap().len(), before - 2); + call_json(&mut s, "redo", json!({"steps": 2})); + assert_eq!(call_json(&mut s, "get_project", json!({}))["undo"].as_array().unwrap().len(), before); + + // Save + reopen. + let dir = std::env::temp_dir().join(format!("ec-mcp-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.ecproj").to_string_lossy().into_owned(); + let saved = call_json(&mut s, "save_project", json!({"path": path})); + assert_eq!(saved["dirty"], false); + call_json(&mut s, "open_project", json!({"new": true})); + assert!(call(&mut s, "get_comp", json!({})).1, "empty project has no comp"); + let proj = call_json(&mut s, "open_project", json!({"path": path})); + assert_eq!(proj["items"].as_array().unwrap().len(), 3, "comp + Solids folder + solid: {proj}"); + let p = call_json(&mut s, "get_property", json!({"comp": "Main", "layer": "#1", "path": "transform/position"})); + assert_eq!(p["keys"].as_array().unwrap().len(), 2); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn stdio_loop() { + let mut s = server(); + let input = [ + json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}), + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "open_project", "arguments": {"demo": true}}}), + ] + .iter() + .map(|v| v.to_string() + "\n") + .collect::(); + let mut out = Vec::new(); + s.serve(input.as_bytes(), &mut out).unwrap(); + let lines: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[1]["id"], 2); + assert_eq!(lines[1]["result"]["isError"], false); +} + +/// A fake desktop control channel: answers `engine.execute` from a real session, and canned +/// `render.frame` / `ui.screenshot` replies. +fn fake_app() -> u16 { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + std::thread::spawn(move || { + let (stream, _) = l.accept().unwrap(); + let mut out = stream.try_clone().unwrap(); + let mut session = Session::default(); + for line in BufReader::new(stream).lines() { + let msg: Value = serde_json::from_str(&line.unwrap()).unwrap(); + let p = &msg["params"]; + let reply = match msg["method"].as_str().unwrap() { + "engine.execute" => match session.execute(p["command"].as_str().unwrap(), p["params"].clone()) { + Ok(v) => json!({"ok": true, "result": v}), + Err(e) => json!({"ok": false, "error": e.to_string()}), + }, + "render.frame" => { + let png = crate::encode_png(4, 2, vec![255; 32], 0).unwrap(); + json!({"ok": true, "result": {"comp": 1, "time": 0.5, "width": 4, "height": 2, "png": base64::encode(&png)}}) + } + "ui.screenshot" => { + let png = crate::encode_png(8, 8, vec![128; 256], 0).unwrap(); + std::fs::write(p["path"].as_str().unwrap(), png).unwrap(); + json!({"ok": true, "result": {"path": p["path"]}}) + } + "ui.key" => json!({"ok": true, "result": null}), + m => json!({"ok": false, "error": format!("unknown method `{m}`")}), + }; + let mut reply = reply; + reply["id"] = msg["id"].clone(); + writeln!(out, "{reply}").unwrap(); + } + }); + port +} + +#[test] +fn bridge_forwards_to_control_channel() { + let port = fake_app(); + let mut s = McpServer::new(Backend::bridge(&port.to_string()).unwrap()); + let r = rpc(&mut s, 1, "initialize", json!({})); + assert!(r["serverInfo"]["title"].as_str().unwrap().contains("bridge")); + let names: Vec = + rpc(&mut s, 2, "tools/list", json!({}))["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect(); + assert!(names.iter().any(|n| n == "screenshot") && names.iter().any(|n| n == "ui_click")); + + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}})); + assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B"); + + let (c, err) = call(&mut s, "render_frame", json!({})); + assert!(!err, "{c:?}"); + assert_eq!(png_size(&base64::decode(c[0]["data"].as_str().unwrap()).unwrap()), Some((4, 2))); + + let (c, err) = call(&mut s, "screenshot", json!({"max_side": 4})); + assert!(!err, "{c:?}"); + assert_eq!(png_size(&base64::decode(c[0]["data"].as_str().unwrap()).unwrap()), Some((4, 4))); + + assert!(!call(&mut s, "ui_key", json!({"key": "Space"})).1); + let (c, err) = call(&mut s, "control", json!({"method": "ui.nope"})); + assert!(err && c[0]["text"].as_str().unwrap().contains("unknown method")); +} + +#[test] +fn bridge_rejects_non_loopback() { + assert!(Backend::bridge("10.0.0.1:9877").is_err()); + assert!(Backend::bridge("localhost:9877").is_ok()); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs new file mode 100644 index 00000000..205573b6 --- /dev/null +++ b/crates/automation/src/tools.rs @@ -0,0 +1,609 @@ +//! The tool catalogue: names, descriptions, JSON input schemas and implementations. Shared by the +//! MCP server (`tools/list`, `tools/call`) and the CLI's one-shot subcommands, so both behave the +//! same. Every tool maps onto engine commands (`Session::execute`) or control-channel methods. + +use serde_json::{Map, Value, json}; + +use crate::backend::NEED_BRIDGE; +use crate::{Backend, Error, Result, recompress_png}; + +/// A tool's output. +pub enum Reply { + Json(Value), + /// A PNG plus a JSON description of it (comp, time, size, path). + Image { + png: Vec, + info: Value, + }, +} + +pub struct ToolDef { + pub name: &'static str, + pub description: &'static str, + /// Only listed (and usable) when bridged to the running app. + pub bridge_only: bool, + schema: fn() -> Value, + run: fn(&mut Backend, &Value) -> Result, +} + +impl ToolDef { + /// The JSON Schema of the tool's arguments. + pub fn input_schema(&self) -> Value { + (self.schema)() + } + /// The MCP `Tool` object. + pub fn descriptor(&self) -> Value { + json!({"name": self.name, "description": self.description, "inputSchema": self.input_schema()}) + } +} + +/// All tools (headless + bridge-only). +pub fn catalogue() -> &'static [ToolDef] { + TOOLS +} + +pub fn find(name: &str) -> Option<&'static ToolDef> { + TOOLS.iter().find(|t| t.name == name) +} + +/// The tools available on a backend (bridge-only tools are hidden in headless mode). +pub fn available(bridge: bool) -> impl Iterator { + TOOLS.iter().filter(move |t| bridge || !t.bridge_only) +} + +/// Run a tool by name. +pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result { + let t = find(name).ok_or_else(|| Error::BadArgs(format!("unknown tool `{name}`")))?; + if t.bridge_only && !b.is_bridge() { + return Err(Error::BadArgs(NEED_BRIDGE.into())); + } + let empty = json!({}); + (t.run)(b, if args.is_null() { &empty } else { args }) +} + +// ------------------------------------------------------------------ schema fragments + +fn schema(props: Value, required: &[&str]) -> Value { + json!({"type": "object", "properties": props, "required": required}) +} +fn comp_s() -> Value { + json!({"type": ["integer", "string"], "description": "Composition item id or name; omit for the active comp."}) +} +fn layer_s() -> Value { + json!({"type": ["integer", "string"], "description": "Layer id (from get_comp), \"#n\" (1-based index from the top) or layer name."}) +} +fn path_s() -> Value { + json!({"type": "string", "description": "Property path from get_layer: match ids joined by `/`, `#n` for the nth child, or `@uid`. E.g. `transform/position`, `transform/opacity`, `transform/scale`, `transform/rotation`, `effects/#1/blurriness`, `masks/#1/feather`, `@57`."}) +} +fn value_s() -> Value { + json!({"description": "Number for 1-D properties (50), array for vectors ([960,540]), \"#rrggbb\" or [r,g,b,a] (0-1) for colours, bool for checkboxes, string for text/menus."}) +} + +// ------------------------------------------------------------------ helpers + +/// Build a params object from the present, non-null entries. +fn obj(entries: &[(&str, Option<&Value>)]) -> Value { + let mut m = Map::new(); + for (k, v) in entries { + if let Some(v) = v.filter(|v| !v.is_null()) { + m.insert(k.to_string(), v.clone()); + } + } + Value::Object(m) +} + +fn get<'a>(a: &'a Value, k: &str) -> Option<&'a Value> { + a.get(k).filter(|v| !v.is_null()) +} + +fn need<'a>(a: &'a Value, k: &str) -> Result<&'a Value> { + get(a, k).ok_or_else(|| Error::BadArgs(format!("missing `{k}`"))) +} + +fn json_reply(v: Value) -> Result { + Ok(Reply::Json(v)) +} + +/// Accept params given as a JSON string too (some clients stringify nested objects). +fn params_of(v: Option<&Value>) -> Result { + match v { + None => Ok(json!({})), + Some(Value::String(s)) if s.trim().is_empty() => Ok(json!({})), + Some(Value::String(s)) => serde_json::from_str(s).map_err(|e| Error::BadArgs(format!("`params` is not valid JSON: {e}"))), + Some(v) => Ok(v.clone()), + } +} + +// ------------------------------------------------------------------ engine tools + +fn list_commands(b: &mut Backend, a: &Value) -> Result { + let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only"))]))?; + // Drop empty fields to keep the listing compact. + let list: Vec = r + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .map(|mut c| { + if let Some(o) = c.as_object_mut() { + o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}"))); + } + c + }) + .collect(); + json_reply(json!(list)) +} + +fn execute_command(b: &mut Backend, a: &Value) -> Result { + let id = get(a, "command").or(get(a, "id")).and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `command` (e.g. `comp.new`)".into()))?; + let params = params_of(get(a, "params"))?; + json_reply(b.exec(id, params)?) +} + +fn get_project(b: &mut Backend, _: &Value) -> Result { + json_reply(b.exec("project.summary", json!({}))?) +} + +fn get_comp(b: &mut Backend, a: &Value) -> Result { + json_reply(b.exec("comp.info", obj(&[("comp", get(a, "comp"))]))?) +} + +/// Flatten a `layer.tree` property tree into `[{path, type, value, keys?, expression?}]`. +fn flatten(node: &Value, out: &mut Vec) { + match node.get("children").and_then(Value::as_array) { + Some(ch) => ch.iter().for_each(|c| flatten(c, out)), + None => { + let mut o = json!({"path": node["path"], "type": node["type"], "value": node["value"]}); + if let Some(k) = node.get("keys").and_then(Value::as_array) { + o["keys"] = json!(k.len()); + } + if let Some(e) = node.get("expression") { + o["expression"] = e.clone(); + } + out.push(o); + } + } +} + +fn get_layer(b: &mut Backend, a: &Value) -> Result { + let p = obj(&[("layer", Some(need(a, "layer")?)), ("comp", get(a, "comp")), ("depth", get(a, "depth")), ("time", get(a, "time"))]); + let mut r = b.exec("layer.tree", p)?; + if get(a, "flat").and_then(Value::as_bool) == Some(true) { + let mut props = vec![]; + flatten(&r["properties"], &mut props); + r["properties"] = json!(props); + } + json_reply(r) +} + +fn get_property(b: &mut Backend, a: &Value) -> Result { + let p = obj(&[("layer", Some(need(a, "layer")?)), ("path", Some(need(a, "path")?)), ("comp", get(a, "comp")), ("time", get(a, "time"))]); + json_reply(b.exec("prop.get", p)?) +} + +fn set_property(b: &mut Backend, a: &Value) -> Result { + let (layer, path, comp, time) = (need(a, "layer")?, need(a, "path")?, get(a, "comp"), get(a, "time")); + let (value, expr) = (get(a, "value"), get(a, "expression")); + if value.is_none() && expr.is_none() { + return Err(Error::BadArgs("give `value` and/or `expression`".into())); + } + let base = [("layer", Some(layer)), ("path", Some(path)), ("comp", comp)]; + if let Some(v) = value { + let mut p = obj(&base); + p["value"] = v.clone(); + match time { + Some(t) => { + p["time"] = t.clone(); + b.exec("prop.addKey", p)?; + } + None => { + b.exec("prop.set", p)?; + } + } + } + if let Some(e) = expr { + let mut p = obj(&base); + p["expression"] = e.clone(); + b.exec("prop.setExpression", p)?; + } + json_reply(b.exec("prop.get", obj(&[("layer", Some(layer)), ("path", Some(path)), ("comp", comp), ("time", time)]))?) +} + +fn add_keyframe(b: &mut Backend, a: &Value) -> Result { + let (layer, path, comp) = (need(a, "layer")?, need(a, "path")?, get(a, "comp")); + let keys: Vec = match get(a, "keys") { + Some(Value::Array(k)) => k.clone(), + Some(_) => return Err(Error::BadArgs("`keys` must be an array of {time, value?}".into())), + None => vec![obj(&[("time", Some(need(a, "time")?)), ("value", get(a, "value"))])], + }; + if keys.is_empty() { + return Err(Error::BadArgs("no keys".into())); + } + let base = [("layer", Some(layer)), ("path", Some(path)), ("comp", comp)]; + for k in &keys { + let mut p = obj(&base); + p["time"] = need(k, "time")?.clone(); + if let Some(v) = get(k, "value") { + p["value"] = v.clone(); + } + b.exec("prop.addKey", p)?; + } + let info = b.exec("prop.get", obj(&base))?; + if let Some(interp) = get(a, "interpolation").and_then(Value::as_str) { + // Key selection works on the active comp. + if let Some(c) = comp { + b.exec("comp.open", json!({"comp": c}))?; + } + let sel: Vec = keys.iter().map(|k| json!({"layer": info["layer"], "prop": info["uid"], "time": k["time"]})).collect(); + b.exec("keys.select", json!({"keys": sel}))?; + match interp.to_ascii_lowercase().as_str() { + "easyease" | "ease" => b.exec("keys.easyEase", json!({}))?, + "easyeasein" => b.exec("keys.easyEase", json!({"which": "in"}))?, + "easyeaseout" => b.exec("keys.easyEase", json!({"which": "out"}))?, + i @ ("linear" | "bezier" | "hold") => b.exec("keys.interpolation", json!({"interpolation": i}))?, + other => return Err(Error::BadArgs(format!("unknown interpolation `{other}` (linear, bezier, hold, easyEase, easyEaseIn, easyEaseOut)"))), + }; + return json_reply(b.exec("prop.get", obj(&base))?); + } + json_reply(info) +} + +fn render_frame(b: &mut Backend, a: &Value) -> Result { + let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(960) as u32; + let f = b.render(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side)?; + let mut info = json!({"comp": f.comp, "time": f.time, "width": f.width, "height": f.height}); + if let Some(p) = get(a, "path").and_then(Value::as_str) { + std::fs::write(p, &f.png).map_err(|e| Error::Other(format!("cannot write {p}: {e}")))?; + info["path"] = json!(p); + } + if get(a, "inline").and_then(Value::as_bool) == Some(false) { + return json_reply(info); + } + Ok(Reply::Image { png: f.png, info }) +} + +fn open_project(b: &mut Backend, a: &Value) -> Result { + if let Some(p) = get(a, "path") { + b.exec("file.open", json!({"path": p}))?; + } else if get(a, "demo").and_then(Value::as_bool) == Some(true) { + b.exec("file.openDemoProject", json!({}))?; + } else if get(a, "new").and_then(Value::as_bool) == Some(true) { + b.exec("file.newProject", json!({}))?; + } else { + return Err(Error::BadArgs("give `path` (.ecproj), `demo: true` or `new: true`".into())); + } + get_project(b, &json!({})) +} + +fn save_project(b: &mut Backend, a: &Value) -> Result { + match get(a, "path") { + Some(p) => b.exec("file.saveAs", json!({"path": p}))?, + None => b.exec("file.save", json!({}))?, + }; + let s = b.exec("project.summary", json!({}))?; + json_reply(json!({"path": s["path"], "dirty": s["dirty"]})) +} + +fn history(b: &mut Backend, a: &Value, id: &str) -> Result { + let steps = get(a, "steps").and_then(Value::as_u64).unwrap_or(1).max(1); + let mut done = 0; + for _ in 0..steps { + match b.exec(id, json!({})) { + Ok(_) => done += 1, + Err(e) if done == 0 => return Err(e), + Err(_) => break, + } + } + let s = b.exec("project.summary", json!({}))?; + json_reply(json!({"steps": done, "undoStack": s["undo"]})) +} + +fn undo(b: &mut Backend, a: &Value) -> Result { + history(b, a, "edit.undo") +} + +fn redo(b: &mut Backend, a: &Value) -> Result { + history(b, a, "edit.redo") +} + +// ------------------------------------------------------------------ bridge-only tools + +fn screenshot(b: &mut Backend, a: &Value) -> Result { + let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(1600) as u32; + let keep = get(a, "path").and_then(Value::as_str).map(str::to_string); + let path = + keep.clone().unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-mcp-shot-{}.png", std::process::id())).to_string_lossy().into_owned()); + b.control("ui.screenshot", obj(&[("path", Some(&json!(path))), ("panel", get(a, "panel")), ("id", get(a, "id"))]))?; + let bytes = std::fs::read(&path).map_err(|e| Error::Other(format!("screenshot: {e}")))?; + if keep.is_none() { + let _ = std::fs::remove_file(&path); + } + let (png, w, h) = recompress_png(&bytes, max_side)?; + Ok(Reply::Image { png, info: json!({"width": w, "height": h, "path": keep}) }) +} + +fn passthrough(b: &mut Backend, method: &str, a: &Value) -> Result { + json_reply(b.control(method, a.clone())?) +} + +fn ui_inspect(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.inspect", a) +} +fn ui_elements(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.elements", a) +} +fn ui_click(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.click", a) +} +fn ui_drag(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.drag", a) +} +fn ui_key(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.key", a) +} +fn ui_type(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.type", a) +} +fn ui_set(b: &mut Backend, a: &Value) -> Result { + passthrough(b, "ui.set", a) +} +fn control(b: &mut Backend, a: &Value) -> Result { + let m = need(a, "method")?.as_str().ok_or_else(|| Error::BadArgs("`method` must be a string".into()))?; + let p = params_of(get(a, "params"))?; + json_reply(b.control(m, p)?) +} + +// ------------------------------------------------------------------ the catalogue + +static TOOLS: &[ToolDef] = &[ + ToolDef { + name: "list_commands", + description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).", + bridge_only: false, + schema: || { + schema( + json!({ + "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."}, + "enabled_only": {"type": "boolean", "description": "Only commands that can run right now."} + }), + &[], + ) + }, + run: list_commands, + }, + ToolDef { + name: "execute_command", + description: "Run any engine command by id with JSON params; edits are undoable. Examples: comp.new {\"name\":\"Main\",\"width\":1920,\"height\":1080,\"frameRate\":30,\"duration\":5}; layer.newText {\"text\":\"Hello\",\"size\":120,\"fill\":\"#ffffff\"} (returns {\"layer\":id}); layer.newSolid {\"color\":\"#3366ff\"}; layer.newShape; effect.apply {\"layer\":3,\"effect\":\"Gaussian Blur\"}; time.set {\"time\":2.5}; layer.select {\"layers\":[3]}. Layers are referenced by id, \"#n\" or name; times are seconds.", + bridge_only: false, + schema: || { + schema( + json!({ + "command": {"type": "string", "description": "Command id, e.g. `comp.new`, `layer.newText`, `effect.apply` (see list_commands)."}, + "params": {"type": "object", "description": "Command parameters as documented by list_commands.", "additionalProperties": true} + }), + &["command"], + ) + }, + run: execute_command, + }, + ToolDef { + name: "get_project", + description: "Project overview: file path, dirty flag, bit depth, every item (id, name, type Composition/Footage/Solid/Folder, size, duration, layer count), the active comp id, current time (s), selected layers and the undo stack.", + bridge_only: false, + schema: || schema(json!({}), &[]), + run: get_project, + }, + ToolDef { + name: "get_comp", + description: "A composition: id, name, size, frame rate, duration, work area, background and its layers top to bottom (id, index, name, type, in/out/start seconds, blend mode, parent, track matte, switches). Layer ids feed get_layer, get_property/set_property and commands.", + bridge_only: false, + schema: || schema(json!({"comp": comp_s()}), &[]), + run: get_comp, + }, + ToolDef { + name: "get_layer", + description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): every node has a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).", + bridge_only: false, + schema: || { + schema( + json!({ + "layer": layer_s(), "comp": comp_s(), + "time": {"type": "number", "description": "Comp time in seconds for values (default: current time)."}, + "depth": {"type": "integer", "description": "Group depth to expand (default all)."}, + "flat": {"type": "boolean", "description": "Return a flat list of properties instead of the tree."} + }), + &["layer"], + ) + }, + run: get_layer, + }, + ToolDef { + name: "get_property", + description: "Read one property: type, value at `time` (comp seconds; default the current time), whether it is animated, its keyframes (time, value, in/out interpolation) and expression.", + bridge_only: false, + schema: || { + schema( + json!({"layer": layer_s(), "path": path_s(), "comp": comp_s(), "time": {"type": "number", "description": "Comp time in seconds."}}), + &["layer", "path"], + ) + }, + run: get_property, + }, + ToolDef { + name: "set_property", + description: "Set a property. Without `time`: sets the static value (or a key at the current time if already animated). With `time`: adds/replaces a keyframe there, turning the stopwatch on. `expression` sets an After Effects-style JS expression (\"\" removes it). Returns the property as get_property does. Examples: {\"layer\":3,\"path\":\"transform/opacity\",\"value\":50}; {\"layer\":\"Title\",\"path\":\"transform/position\",\"value\":[200,540],\"time\":0}; {\"layer\":3,\"path\":\"transform/rotation\",\"expression\":\"time*90\"}.", + bridge_only: false, + schema: || { + schema( + json!({ + "layer": layer_s(), "path": path_s(), "value": value_s(), "comp": comp_s(), + "time": {"type": "number", "description": "Keyframe time in seconds (layer time; equals comp time unless the layer is offset/stretched)."}, + "expression": {"type": "string", "description": "Expression text; empty string removes the expression."} + }), + &["layer", "path"], + ) + }, + run: set_property, + }, + ToolDef { + name: "add_keyframe", + description: "Add keyframes to a property (turns the stopwatch on): one with `time` (+ optional `value`, default the current value) or many with `keys: [{time, value}]`, then optionally set their `interpolation`: linear, bezier, hold, easyEase, easyEaseIn, easyEaseOut. Example: {\"layer\":1,\"path\":\"transform/position\",\"keys\":[{\"time\":0,\"value\":[0,540]},{\"time\":2,\"value\":[1920,540]}],\"interpolation\":\"easyEase\"}.", + bridge_only: false, + schema: || { + schema( + json!({ + "layer": layer_s(), "path": path_s(), "comp": comp_s(), + "time": {"type": "number", "description": "Key time in seconds (layer time)."}, + "value": value_s(), + "keys": {"type": "array", "items": {"type": "object", "properties": {"time": {"type": "number"}, "value": value_s()}, "required": ["time"]}, "description": "Several keys at once."}, + "interpolation": {"type": "string", "enum": ["linear", "bezier", "hold", "easyEase", "easyEaseIn", "easyEaseOut"]} + }), + &["layer", "path"], + ) + }, + run: add_keyframe, + }, + ToolDef { + name: "render_frame", + description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). `path` also writes the PNG to disk; `inline: false` returns only the metadata. Use it to check your work.", + bridge_only: false, + schema: || { + schema( + json!({ + "comp": comp_s(), + "time": {"type": "number", "description": "Comp time in seconds (default: current time)."}, + "max_side": {"type": "integer", "description": "Longest side in pixels (default 960; 0 = full comp size)."}, + "path": {"type": "string", "description": "Also write the PNG here."}, + "inline": {"type": "boolean", "description": "Include the image in the reply (default true)."} + }), + &[], + ) + }, + run: render_frame, + }, + ToolDef { + name: "open_project", + description: "Open a project: `path` to an .ecproj file, `demo: true` for the built-in demo project, or `new: true` for an empty project. Returns the project overview.", + bridge_only: false, + schema: || { + schema(json!({"path": {"type": "string", "description": "Path of an .ecproj file."}, "demo": {"type": "boolean"}, "new": {"type": "boolean"}}), &[]) + }, + run: open_project, + }, + ToolDef { + name: "save_project", + description: "Save the project to its current file, or to `path` (Save As, .ecproj).", + bridge_only: false, + schema: || schema(json!({"path": {"type": "string", "description": "Destination .ecproj path (Save As)."}}), &[]), + run: save_project, + }, + ToolDef { + name: "undo", + description: "Undo the last `steps` edits (default 1). Returns the remaining undo stack.", + bridge_only: false, + schema: || schema(json!({"steps": {"type": "integer", "minimum": 1}}), &[]), + run: undo, + }, + ToolDef { + name: "redo", + description: "Redo `steps` undone edits (default 1).", + bridge_only: false, + schema: || schema(json!({"steps": {"type": "integer", "minimum": 1}}), &[]), + run: redo, + }, + ToolDef { + name: "screenshot", + description: "Screenshot of the live app window, or one panel (`Composition`, `Timeline`, `EffectControls`, `Project`, ...) or element id, as PNG (longest side `max_side`, default 1600). Use it to check what the UI looks like after an action.", + bridge_only: true, + schema: || { + schema( + json!({ + "panel": {"type": "string", "description": "Panel to crop to."}, + "id": {"type": "string", "description": "Element id (from ui_elements) to crop to."}, + "max_side": {"type": "integer"}, + "path": {"type": "string", "description": "Keep the full-size PNG at this path."} + }), + &[], + ) + }, + run: screenshot, + }, + ToolDef { + name: "ui_inspect", + description: "UI state: window size, tool, workspace, panels, viewer and timeline view state, playback, active comp, selection, open dialog, render time.", + bridge_only: true, + schema: || schema(json!({}), &[]), + run: ui_inspect, + }, + ToolDef { + name: "ui_elements", + description: "Interactive elements drawn in the last frame: id, label, rect [x,y,w,h] in points. Stable ids such as `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`, `project.item.`, `viewer.comp`, `timeline.layer..bar`, `timeline.prop..stopwatch`, `timeline.cti`, `effects.item.`. Filter with `prefix`.", + bridge_only: true, + schema: || schema(json!({"prefix": {"type": "string", "description": "Only ids starting with this, e.g. `tools.`, `timeline.layer.`."}}), &[]), + run: ui_elements, + }, + ToolDef { + name: "ui_click", + description: "Click an element by `id` (from ui_elements) or at `x`,`y` (points). `button`: left|right|middle, `count`: 2 for double-click, `modifiers`: {shift, alt, command, ctrl}.", + bridge_only: true, + schema: || { + schema( + json!({ + "id": {"type": "string"}, "x": {"type": "number"}, "y": {"type": "number"}, + "button": {"type": "string", "enum": ["left", "right", "middle"]}, + "count": {"type": "integer"}, "modifiers": {"type": "object"} + }), + &[], + ) + }, + run: ui_click, + }, + ToolDef { + name: "ui_drag", + description: "Press-move-release from `from` to `to`; each is {\"id\": element} (add fx/fy 0-1 for a spot inside it) or {\"x\",\"y\"}. Moves layers in the viewer, drags layer bars/keyframes/the CTI (`timeline.cti`), scrubs values, draws shapes with a shape tool.", + bridge_only: true, + schema: || { + schema( + json!({"from": {"type": "object"}, "to": {"type": "object"}, "steps": {"type": "integer"}, "modifiers": {"type": "object"}}), + &["from", "to"], + ) + }, + run: ui_drag, + }, + ToolDef { + name: "ui_key", + description: "Press a key or shortcut: `Space` (play), `F9` (Easy Ease), `Cmd+Z`, `Cmd+N`, `PageDown`, `Escape`, `Enter` (`Cmd` = Ctrl off macOS).", + bridge_only: true, + schema: || schema(json!({"key": {"type": "string"}}), &["key"]), + run: ui_key, + }, + ToolDef { + name: "ui_type", + description: "Type text into the focused field (text layer editing, search boxes, value fields).", + bridge_only: true, + schema: || schema(json!({"text": {"type": "string"}}), &["text"]), + run: ui_type, + }, + ToolDef { + name: "ui_set", + description: "Set UI state directly: tool, workspace, theme (dark|darker|light), focused panel, viewer {zoom, res, pan, grid, rulers, safeMargins, transparencyGrid}, timeline {pps, start, graphEditor, showModes, openLayers, openGroups}, menuBar, home.", + bridge_only: true, + schema: || { + schema( + json!({ + "tool": {"type": "string"}, "workspace": {"type": "string"}, "theme": {"type": "string"}, "focused": {"type": "string"}, + "viewer": {"type": "object"}, "timeline": {"type": "object"}, "menuBar": {"type": "boolean"}, "home": {"type": "boolean"} + }), + &[], + ) + }, + run: ui_set, + }, + ToolDef { + name: "control", + description: "Call any control-channel method (docs/control-protocol.md) with its params, e.g. ui.menu.list, ui.menu.invoke {id}, ui.panel.show {panel}, ui.scroll, ui.viewer.locate {x,y}, ui.timeline.locate {layer,time}, ui.playback {action}, ui.resize.", + bridge_only: true, + schema: || schema(json!({"method": {"type": "string"}, "params": {"type": "object", "additionalProperties": true}}), &["method"]), + run: control, + }, +]; diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 07d34134..bbccd5d6 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -205,7 +205,7 @@ pub fn specs() -> Vec { "New Composition…", ["Composition"], Some("Cmd+N"), - "{name?, width?, height?, frameRate?, duration? (s), background? [r,g,b]|#hex, open?}", + "{name?, width?, height?, frameRate?, duration? (s), background? [r,g,b]|#hex, pixelAspect?, open?}", always, new_comp ), @@ -214,7 +214,7 @@ pub fn specs() -> Vec { "Composition Settings…", ["Composition"], Some("Cmd+K"), - "{comp?, name?, width?, height?, frameRate?, duration?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?}", + "{comp?, name?, width?, height?, frameRate?, duration?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, pixelAspect?}", has_comp, settings ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 1a7b41a8..d93dd262 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -81,6 +81,65 @@ pub fn find(id: &str) -> Option<&'static CommandSpec> { command_specs().iter().find(|c| c.id == id) } +// ---------- parameter validation (agents) ---------- + +/// Top-level keys accepted by a params doc such as `{layers: [id|name|#n], add?, toggle?}` or +/// `{time? (s) | frame? | timecode?}`; `None` when the doc isn't a `{…}` key list. +pub fn accepted_params(doc: &str) -> Option> { + let body = doc.trim().strip_prefix('{')?; + // Top-level pieces up to the matching close brace, split at `,` and `|`; `:` starts a value. + let (mut keys, mut cur, mut depth, mut in_value) = (Vec::new(), String::new(), 0i32, false); + let push = |cur: &mut String, keys: &mut Vec| { + let k: String = cur.trim().chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + if !k.is_empty() && !keys.contains(&k) { + keys.push(k); + } + cur.clear(); + }; + for c in body.chars() { + match c { + '{' | '[' | '(' => depth += 1, + ']' | ')' => depth -= 1, + '}' if depth == 0 => { + push(&mut cur, &mut keys); + return Some(keys); + } + '}' => depth -= 1, + ',' if depth == 0 => { + push(&mut cur, &mut keys); + in_value = false; + } + '|' if depth == 0 && !in_value => push(&mut cur, &mut keys), + ':' if depth == 0 => { + push(&mut cur, &mut keys); + in_value = true; + } + _ if depth == 0 && !in_value => cur.push(c), + _ => {} + } + } + None +} + +/// Keys every command understands (comp targeting, gesture merging) and accepted aliases. +const ALWAYS_OK: &[&str] = &["comp", "merge"]; +const ALIASES: &[(&str, &str)] = &[("layers", "layer"), ("layer", "layers"), ("time", "frame"), ("frameRate", "fps"), ("properties", "property")]; + +/// Reject unknown top-level parameters (typos, guessed names) with the list of accepted keys. +pub fn check_params(spec: &CommandSpec, params: &Value) -> Result<()> { + let (Some(obj), Some(accepted)) = (params.as_object(), accepted_params(spec.params)) else { return Ok(()) }; + let ok = |k: &str| ALWAYS_OK.contains(&k) || accepted.iter().any(|a| a == k || ALIASES.iter().any(|(doc, alias)| doc == a && *alias == k)); + let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !ok(k)).collect(); + if unknown.is_empty() { + return Ok(()); + } + let list = if accepted.is_empty() { "none".to_string() } else { accepted.join(", ") }; + Err(bad( + spec.id, + format!("unknown parameter(s) {}; accepted: {list} (doc: {})", unknown.iter().map(|k| format!("`{k}`")).collect::>().join(", "), spec.params), + )) +} + // ---------- enablement ---------- pub fn always(_: &Session) -> std::result::Result<(), String> { @@ -182,6 +241,6 @@ pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { s.time() } -pub(crate) fn layer_mut<'a>(p: &'a mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&'a mut Layer> { +pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> { p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid))) } diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 43bd88e6..dc986987 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -6,7 +6,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mut, layer_p, merge_p, str_p}; -use crate::{EngineError, KeyRef, Result, Session, cmd}; +use crate::{EngineError, KeyRef, Result, Session, cmd, query}; /// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid). fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { @@ -57,6 +57,21 @@ fn set(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// Read one property: value at `time` (comp seconds, default the CTI), keyframes and expression. +fn get(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "prop.get")?; + let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; + let pr = l.props.find(uid).ok_or_else(|| bad("prop.get", "property vanished"))?; + let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); + let keys: Vec = + pr.keys.iter().map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})).collect(); + Ok(json!({ + "layer": lid.0, "uid": pr.uid, "match": pr.match_id, "name": pr.name, "type": pr.value.kind_name(), + "time": t.seconds(), "value": pr.value_at(l.layer_time(t)).to_json(), "animated": !pr.keys.is_empty(), + "keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()), + })) +} + fn toggle_anim(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.toggleAnimation")?; let on = b_p(p, "value"); @@ -404,6 +419,7 @@ fn convert_expr_to_keys(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get), cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set), cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim), cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key), diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 1804eaaf..f3823150 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -76,33 +76,51 @@ fn comp_info(s: &mut Session, p: &Value) -> Result { })) } -fn group_json(g: &PropGroup, l: &Layer, t: effectcraft_time::Tick, depth: usize, max_depth: usize) -> Value { +/// The path segment that addresses child `i` of `g` (see `effectcraft_project::parse_path`): the match +/// id (with `#n` when it is not the first sibling matching it), or `#index` when the match id is not +/// path-safe (effects and other ids containing `.`). +fn segment(g: &PropGroup, i: usize) -> String { + let c = &g.children[i]; + let m = c.match_id(); + if m.is_empty() || m.contains(['/', '.', '#', '@']) || m.parse::().is_ok() { + return format!("#{}", i + 1); + } + let n = g.children[..i].iter().filter(|x| x.match_id() == m || x.name().eq_ignore_ascii_case(m)).count() + 1; + if n == 1 { m.to_string() } else { format!("{m}#{n}") } +} + +fn group_json(g: &PropGroup, path: &str, l: &Layer, t: effectcraft_time::Tick, depth: usize, max_depth: usize) -> Value { let children: Vec = if depth >= max_depth { vec![] } else { g.children .iter() - .map(|c| match c { - Node::Prop(p) => { - let mut o = json!({"uid": p.uid, "match": p.match_id, "name": p.name, "value": p.value_at(l.layer_time(t)).to_json()}); - if !p.keys.is_empty() { - o["keys"] = json!( - p.keys - .iter() - .map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})) - .collect::>() - ); + .enumerate() + .map(|(i, c)| { + let seg = segment(g, i); + let cpath = if path.is_empty() { seg } else { format!("{path}/{seg}") }; + match c { + Node::Prop(p) => { + let mut o = json!({"path": cpath, "uid": p.uid, "match": p.match_id, "name": p.name, "type": p.value.kind_name(), "value": p.value_at(l.layer_time(t)).to_json()}); + if !p.keys.is_empty() { + o["keys"] = json!( + p.keys + .iter() + .map(|k| json!({"time": k.time.seconds(), "value": k.value.to_json(), "in": k.in_interp.label(), "out": k.out_interp.label()})) + .collect::>() + ); + } + if let Some(e) = &p.expr { + o["expression"] = json!(e.text); + } + o } - if let Some(e) = &p.expr { - o["expression"] = json!(e.text); - } - o + Node::Group(g) => group_json(g, &cpath, l, t, depth + 1, max_depth), } - Node::Group(g) => group_json(g, l, t, depth + 1, max_depth), }) .collect() }; - let mut o = json!({"uid": g.uid, "match": g.match_id, "name": g.name, "enabled": g.enabled, "children": children}); + let mut o = json!({"path": path, "uid": g.uid, "match": g.match_id, "name": g.name, "enabled": g.enabled, "children": children}); match &g.kind { GroupKind::Mask { mode, inverted, .. } => { o["mask"] = json!({"mode": mode.label(), "inverted": inverted}); @@ -121,8 +139,10 @@ fn layer_tree(s: &mut Session, p: &Value) -> Result { let idx = c.index_of(lid).unwrap_or(0); let l = c.layer(lid).ok_or(EngineError::NoComp)?; let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(16) as usize; + let t = p.get("time").and_then(Value::as_f64).map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time()); let mut o = layer_json(l, idx); - o["properties"] = group_json(&l.props, l, s.time(), 0, depth); + o["time"] = json!(t.seconds()); + o["properties"] = group_json(&l.props, "", l, t, 0, depth); Ok(o) } @@ -135,7 +155,7 @@ pub fn specs() -> Vec { query!("command.list", "List Commands", "{filter?, enabledOnly?}", list), query!("project.summary", "Project Summary", "{}", summary), query!("comp.info", "Composition Info", "{comp?}", comp_info), - query!("layer.tree", "Layer Property Tree", "{layer?, depth?}", layer_tree), + query!("layer.tree", "Layer Property Tree", "{layer?, comp?, depth?, time? (comp s)} → nodes with `path`", layer_tree), query!("editor.state", "Editor State", "{}", state), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f93caa18..1cdfbbca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -181,6 +181,14 @@ impl Session { Ok(r) } + /// [`Session::execute`] for agents and scripts (MCP, CLI, control channel): unknown top-level + /// parameters are rejected with the accepted keys instead of being silently ignored. + pub fn execute_checked(&mut self, id: &str, params: Value) -> Result { + let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + commands::check_params(spec, ¶ms)?; + self.execute(id, params) + } + pub fn is_enabled(&self, id: &str) -> bool { commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok()) } @@ -306,6 +314,25 @@ impl Session { r.comp_frame(comp, t) } + /// Resolve a comp reference (item id number or name; `None`/null = the active comp). + pub fn resolve_comp(&self, comp: Option<&Value>) -> Result { + let p = match comp { + Some(v) if !v.is_null() => serde_json::json!({ "comp": v }), + _ => serde_json::json!({}), + }; + commands::comp_id(self, &p) + } + + /// Render a comp frame (comp time `t`) as opaque 8-bit RGBA over the comp background, scaled + /// so the longest side is at most `max_side` pixels (0 = full size). Returns (width, height, rgba). + pub fn render_rgba8(&self, comp: ItemId, t: Tick, max_side: u32) -> Result<(u32, u32, Vec)> { + let c = self.project.comp(comp).ok_or(EngineError::NoComp)?; + let long = c.width.max(c.height).max(1) as f64; + let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) }; + let img = self.render(comp, t, RenderOpts { scale, ..Default::default() }); + Ok((img.width, img.height, img.to_rgba8_over(c.background))) + } + /// Replace the whole project (open/new), resetting history and state. pub fn replace_project(&mut self, mut p: Project, path: Option) { p.fix_next_id(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef095c52..587190f9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -114,3 +114,76 @@ fn time_navigation() { s.execute("time.set", json!({"frame": 45})).unwrap(); assert_eq!(s.execute("time.step", json!({"frames": 5})).unwrap()["frame"], 50); } + +#[test] +fn prop_get_and_render_rgba8() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 2.0})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [0, 0]})).unwrap(); + s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 1.0, "value": [100, 50]})).unwrap(); + let v = s.execute("prop.get", json!({"layer": l, "path": "transform/position", "time": 1.0})).unwrap(); + assert_eq!(v["animated"], true); + assert_eq!(v["keys"].as_array().unwrap().len(), 2); + assert_eq!(v["value"][0].as_f64().unwrap(), 100.0); + let cid = s.resolve_comp(Some(&json!("T"))).unwrap(); + let (w, h, rgba) = s.render_rgba8(cid, effectcraft_time::Tick::ZERO, 160).unwrap(); + assert_eq!((w, h), (160, 90)); + assert_eq!(rgba.len(), (w * h * 4) as usize); +} + +#[test] +fn params_docs_parse() { + use crate::commands::accepted_params; + let k = |d: &str| accepted_params(d).unwrap(); + assert_eq!(k("{layers: [id|name|#n], add?, toggle?}"), ["layers", "add", "toggle"]); + assert_eq!(k("{time? (s) | frame? | timecode?}"), ["time", "frame", "timecode"]); + assert_eq!(k("{interpolation?|in?|out?: linear|bezier|hold, autoBezier?}"), ["interpolation", "in", "out", "autoBezier"]); + assert_eq!(k("{keys: [{layer, prop, time}], add?}"), ["keys", "add"]); + assert_eq!(k("{label: Red|Yellow|Aqua|…, layers?}"), ["label", "layers"]); + assert_eq!(k("{name?, background? [r,g,b]|#hex, open?}"), ["name", "background", "open"]); + assert!(k("{}").is_empty()); + assert!(accepted_params("free text").is_none()); + // Every registered command documents its params as a parseable key list. + for c in crate::command_specs() { + assert!(accepted_params(c.params).is_some(), "{}: {}", c.id, c.params); + } +} + +#[test] +fn execute_checked_rejects_unknown_params() { + let mut s = demo(); + let e = s.execute_checked("layer.select", json!({"index": 2})).unwrap_err().to_string(); + assert!(e.contains("`index`") && e.contains("layers, add, toggle"), "{e}"); + s.execute_checked("layer.select", json!({"layers": ["#2"]})).unwrap(); + assert_eq!(s.state.selected_layers.len(), 1); + // Aliases and always-accepted keys. + s.execute_checked("layer.select", json!({"layer": "#1", "comp": s.active_comp_id().unwrap().0})).unwrap(); + s.execute_checked("time.set", json!({"frame": 3})).unwrap(); + assert!(s.execute_checked("edit.undo", json!({"steps": 2})).is_err()); + // Non-object params are not validated; the unchecked path stays lenient. + s.execute("layer.select", json!({"layers": ["#1"], "index": 2})).unwrap(); +} + +#[test] +fn layer_tree_paths_resolve() { + let mut s = demo(); + s.execute("effect.apply", json!({"layer": 1, "effect": "Gaussian Blur"})).unwrap(); + let comp = s.execute("comp.info", json!({})).unwrap(); + let mut checked = 0; + for l in comp["layers"].as_array().unwrap() { + let tree = s.execute("layer.tree", json!({"layer": l["id"]})).unwrap(); + let mut stack = vec![tree["properties"].clone()]; + while let Some(n) = stack.pop() { + if let Some(ch) = n["children"].as_array() { + stack.extend(ch.iter().cloned()); + continue; + } + let path = n["path"].as_str().unwrap(); + let got = s.execute("prop.get", json!({"layer": l["id"], "path": path})).unwrap(); + assert_eq!(got["uid"], n["uid"], "path {path} on layer {}", l["name"]); + checked += 1; + } + } + assert!(checked > 20, "{checked}"); +} diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index 15ef3f54..49218768 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -16,11 +16,12 @@ impl Importer for MediaImporter { /// A new session with media, import and expressions enabled. pub fn session() -> Session { - let mut s = Session::default(); - s.footage = Arc::new(effectcraft_media::MediaPool::new()); - s.importer = Some(Arc::new(MediaImporter)); - s.expr = Some(Arc::new(effectcraft_expr::Expressions)); - s + Session { + footage: Arc::new(effectcraft_media::MediaPool::new()), + importer: Some(Arc::new(MediaImporter)), + expr: Some(Arc::new(effectcraft_expr::Expressions)), + ..Session::default() + } } #[cfg(test)] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index eaf6ba93..5aa3c9a8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -16,7 +16,10 @@ //! - `ui.timeline.locate {layer, time?}`: screen point of a layer bar (at a comp time) //! - `ui.playback {action: play|stop|toggle}` //! - `ui.screenshot {path?, panel?, id?}`: PNG of the window (or one panel / element) +//! - `render.frame {comp?, time?, max_side?, path?, base64?}`: PNG of a comp frame rendered by the session //! - `ui.resize {width, height}` / `ui.focus` / `app.quit` +//! +//! The full reference with examples is `docs/control-protocol.md`. use std::sync::mpsc::Sender; @@ -86,13 +89,24 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques match req.method.as_str() { "engine.execute" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; - let params = p.get("params").cloned().unwrap_or(json!({})); - match crate::menus::invoke(app, ctx, id, params) { + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + // `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g. + // `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`, + // `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click. + let r = if req.method == "engine.execute" && effectcraft_engine::find_command(id).is_some() { + app.session.execute_checked(id, params).map_err(|e| e.to_string()) + } else { + crate::menus::invoke(app, ctx, id, params) + }; + match r { Ok(v) => ok(v), - Err(e) => err(e), + Err(e) => { + app.ui.status = e.clone(); + err(e) + } } } - "engine.commands" => match app.session.execute("command.list", json!({})) { + "engine.commands" => match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) { Ok(v) => ok(v), Err(e) => err(e), }, @@ -301,6 +315,7 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques app.raise_for_control(ctx); Outcome::Screenshot { path: s("path").map(str::to_string), crop } } + "render.frame" => render_frame(app, p), "ui.resize" => { let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32; let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32; @@ -320,6 +335,61 @@ pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlReques } } +/// `render.frame {comp?, time?, max_side?, path?, base64?}`: render a comp frame through the session +/// (independent of the viewer zoom/resolution) and return it as PNG, written to `path` (default a +/// temp file) or inline as base64 when `base64: true`. +fn render_frame(app: &EffectcraftApp, p: &Value) -> Outcome { + let s = &app.session; + let cid = match s.resolve_comp(p.get("comp")) { + Ok(c) => c, + Err(e) => return err(e), + }; + let t = p.get("time").or(p.get("seconds")).and_then(Value::as_f64).map(effectcraft_time::Tick::from_seconds_f64).unwrap_or_else(|| s.time()); + let max_side = p.get("max_side").or(p.get("maxSide")).and_then(Value::as_u64).unwrap_or(0) as u32; + let (w, h, rgba) = match s.render_rgba8(cid, t, max_side) { + Ok(r) => r, + Err(e) => return err(e), + }; + let png = match encode_png(&rgba, w, h) { + Ok(p) => p, + Err(e) => return err(e), + }; + let mut out = json!({"comp": cid.0, "time": t.seconds(), "width": w, "height": h}); + if p.get("base64").and_then(Value::as_bool) == Some(true) { + out["png"] = json!(base64(&png)); + return ok(out); + } + let path = p + .get("path") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-frame-{}.png", std::process::id())).to_string_lossy().to_string()); + match std::fs::write(&path, png) { + Ok(()) => { + out["path"] = json!(path); + ok(out) + } + Err(e) => err(format!("cannot write {path}: {e}")), + } +} + +/// Standard base64 (RFC 4648, padded). +pub fn base64(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut s = String::with_capacity(data.len().div_ceil(3) * 4); + for c in data.chunks(3) { + let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32; + for i in 0..4 { + if i <= c.len() { + s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char); + } else { + s.push('='); + } + } + } + s +} + pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); json!({ @@ -417,3 +487,13 @@ pub fn encode_png(rgba: &[u8], w: u32, h: u32) -> Result, String> { chunk(&mut out, b"IEND", &[]); Ok(out) } + +#[cfg(test)] +mod tests { + #[test] + fn base64_rfc4648_vectors() { + for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] { + assert_eq!(super::base64(i.as_bytes()), o); + } + } +} diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 00000000..ea4abf69 --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,103 @@ +# Driving EffectCraft from agents + +Everything in EffectCraft is an engine command with a stable id and JSON params. Menus, shortcuts, +panel gestures, the CLI, the control channel and MCP all go through the same commands, so anything a +user can do, an agent can do too. There are three ways in: + +| Interface | Best for | Needs a window | +|---|---|---| +| **MCP** (`effectcraft-cli mcp`) | Claude Code and other MCP clients | no (headless), or yes with `--bridge` | +| **CLI** (`effectcraft-cli exec/get/set/...`) | one-shot scripting and CI; JSON output with `--json` | no | +| **Control channel** (`effectcraft --control 9877`) | driving and seeing the live UI; see [control-protocol.md](control-protocol.md) | yes | + +## MCP setup + +Build once with `cargo build --release -p effectcraft-cli`, then register the server. For Claude +Code, add a `.mcp.json` at the project root: + +```json +{ + "mcpServers": { + "effectcraft": { + "command": "/path/to/effectcraft/target/release/effectcraft-cli", + "args": ["mcp"] + } + } +} +``` + +You can also register it from the command line: +`claude mcp add effectcraft -- /path/to/target/release/effectcraft-cli mcp`. Other clients (Claude +Desktop, Cursor and the like) take the same `command` and `args`. + +- **Headless** (`["mcp"]`): an in-process session with no window. Add `"--demo"` or + `"--project", "file.ecproj"` to start with content. Startup is instant. +- **Bridge** (`["mcp", "--bridge", "9877"]`): drives a running `effectcraft --control 9877`, so you + see every change live. Bridge mode adds `screenshot` and the `ui_*` tools. + +The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MCP protocol versions +2025-06-18, 2025-03-26 and 2024-11-05 (`initialize`, `ping`, `tools/list`, `tools/call`). + +### Tools + +| Tool | What it does | +|---|---| +| `list_commands {filter?, enabled_only?}` | Discover command ids, their param docs, and whether each can run now. | +| `execute_command {command, params?}` | Run any command (undoable). | +| `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. | +| `get_layer {layer, comp?, time?, flat?}` | A layer's property tree. Every node has a `path`. | +| `get_property {layer, path, comp?, time?}` | Value at a time, keyframes and expression. | +| `set_property {layer, path, value?, time?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe; with `expression` it sets an expression. | +| `add_keyframe {layer, path, time+value \| keys:[...], interpolation?}` | Adds keys, then optionally applies linear/bezier/hold/easyEase. | +| `render_frame {comp?, time?, max_side?, path?, inline?}` | Returns a PNG image of a frame. | +| `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. | +| `undo {steps?}` / `redo {steps?}` | History. | +| *(bridge)* `screenshot {panel?, id?}`, `ui_inspect`, `ui_elements {prefix?}`, `ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_set`, `control {method, params}` | Look at and operate the live window. | + +Layers are referenced by id (from `get_comp`), `"#n"` (1-based index from the top) or name. Comps are +referenced by id or name, and default to the active comp. Property paths come from `get_layer`, for +example `transform/position`, `transform/opacity`, `effects/#1/blurriness` or `@57` (by uid). Times +are in seconds. Keyframe times are layer time, which equals comp time unless the layer is offset or +stretched. + +Command params are checked against each command's `params` doc. An unknown key, such as +`layer.select {"index": 2}`, returns an error that lists the accepted keys (here `layers, add, toggle`) +instead of being ignored. + +### A typical session + +1. `execute_command {"command":"comp.new","params":{"name":"Intro","width":1920,"height":1080,"frameRate":30,"duration":4}}` +2. `execute_command {"command":"layer.newText","params":{"text":"Hello","size":160,"fill":"#ffffff"}}` returns `{"layer": 2}`. +3. `add_keyframe {"layer":2,"path":"transform/position","keys":[{"time":0,"value":[-300,540]},{"time":1.5,"value":[960,540]}],"interpolation":"easyEase"}` +4. `execute_command {"command":"effect.apply","params":{"layer":2,"effect":"Gaussian Blur"}}`, then `set_property {"layer":2,"path":"effects/#1/blurriness","value":8}` +5. `render_frame {"time":1.0}` lets you look at the result, and `save_project {"path":"intro.ecproj"}` saves it. + +## CLI + +Each invocation runs a headless engine with no window. It opens the demo project unless you pass +`--project F.ecproj`, a positional `*.ecproj` or `--empty`. Add `--json` for one compact JSON document +on stdout. Errors print `{"error": ...}` and exit with status 1; usage errors exit with status 2. + +```sh +effectcraft-cli info --json +effectcraft-cli commands --filter keys +effectcraft-cli exec comp.new --params '{"name":"Main","width":1280,"height":720}' --empty --save-as main.ecproj +effectcraft-cli exec layer.newSolid '{"color":"#3366ff"}' main.ecproj --save +effectcraft-cli props Main '#1' --project main.ecproj # flat property list with paths +effectcraft-cli set Main '#1' transform/opacity 40 main.ecproj --save +effectcraft-cli set Main '#1' transform/position '[100,360]' --time 0 main.ecproj --save +effectcraft-cli get Main '#1' transform/position --time 0.5 main.ecproj --json +effectcraft-cli run main.ecproj comp.open '{"comp":"Main"}' time.set '{"time":1}' --json +effectcraft-cli render-frame main.ecproj --time 1 --max-side 640 --out f.png --json +effectcraft-cli exec layer.newNull --bridge 9877 # same commands, against the live app +``` + +`` is an id or name, or `-` for the active comp. `` is JSON (`50`, `[960,540]`, +`"#ff0000"`) or a bare string. + +## Seeing the UI + +To work on the UI, start the app with `cargo run -p effectcraft -- --control 9877` and use MCP bridge +mode or the raw control channel. A good loop is: `ui_elements` to find an id, `ui_click` or `ui_drag` +to act, then `screenshot {"panel":"Timeline"}` to check the result. `render_frame` shows the +composition itself at any zoom. diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 00000000..2f2f25d1 --- /dev/null +++ b/docs/control-protocol.md @@ -0,0 +1,87 @@ +# Control protocol + +The desktop app exposes a control channel for agents, tests and the MCP bridge: + +```sh +effectcraft --control 9877 # or EFFECTCRAFT_CONTROL_PORT=9877 +``` + +- **Transport:** TCP on `127.0.0.1` only, one JSON object per line in each direction. Connections are + persistent; requests on one connection are answered in order. +- **Request:** `{"id": 1, "method": "engine.execute", "params": {...}}` (`id` is echoed back, `params` + defaults to `{}`). +- **Reply:** `{"id": 1, "ok": true, "result": ...}` or `{"id": 1, "ok": false, "error": "message"}`. +- Requests run on the UI thread between frames. Input methods (`ui.click`, `ui.key` and similar) + reply after the synthetic input has been processed. Methods that need an element that isn't drawn + yet are retried for a few frames. A request times out after 60 s. + +Quick test: + +```sh +printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params":{"name":"A"}}}\n' | nc 127.0.0.1 9877 +``` + +## Engine + +| Method | Params | Result | +|---|---|---| +| `engine.execute` | `{command, params?}` | The command's result. Every engine command (`effectcraft-cli commands`) runs exactly as it does headless, so it never opens a dialog: `comp.new {}` creates a default comp. UI-only ids (`tool.*`, `view.*`, `window.*`, `playback.*`, `timeline.*`, `app.*`) fall through to the menu dispatcher. Unknown top-level params are rejected, and the error lists the accepted keys. | +| `ui.menu.invoke` | `{id, params?}` | Like a menu click: commands with empty params may open their dialog (New Composition, Solid Settings, file pickers). | +| `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` | +| `ui.menu.list` | `{}` | The menu tree with enablement. | +| `render.frame` | `{comp?, time?, max_side?, path?, base64?}` | Renders a comp frame through the session, independent of the viewer zoom or resolution. `comp` is an id or name (default: the active comp), `time` is in comp seconds (default: the CTI), `max_side` caps the longest side (0 = full size). Writes a PNG to `path` (default: a temp file) and returns `{comp, time, width, height, path}`. With `base64: true` it returns `{…, png: ""}` and writes no file. | + +Useful query commands for `engine.execute` (they aren't journaled or undoable): `project.summary`, +`comp.info {comp?}`, `layer.tree {layer, comp?, depth?, time?}` (every node has a `path`), +`prop.get {layer, path, comp?, time?}`, `editor.state`, `command.list {filter?, enabledOnly?}`. + +## UI state + +| Method | Params | Result | +|---|---|---| +| `ui.inspect` | `{}` | Window size, pixels per point, fps, serialized `UiState`, playback, time, active comp, selection, open dialog, last render ms. | +| `ui.elements` | `{prefix?}` | `[{id, label, rect: [x, y, w, h]}]`: the widgets drawn in the last frame, in points. | +| `ui.set` | `{tool?, workspace?, theme?, focused?, viewer?, timeline?, menuBar?, home?}` | `viewer: {zoom (null = fit), res: Full\|Half\|Third\|Quarter\|Auto, pan: [x,y], grid, rulers, safeMargins, transparencyGrid}`; `timeline: {pps (null = fit), start, graphEditor, showModes, openLayers: [ids], openGroups: [uids]}`. | +| `ui.panel.show` / `ui.panel.close` | `{panel}` | Panels: `Project`, `Composition`, `Timeline`, `EffectControls`, `EffectsPresets`, `Info`, `Preview`, `Character`, `Paragraph`, `Align` and others. | +| `ui.playback` | `{action: play\|stop\|toggle}` | `{playing, time}` | +| `ui.resize` | `{width, height}` | Resizes the window (points). | +| `ui.focus` | `{}` | Activates the window and takes keyboard focus. Agents normally avoid this, because the app is started without stealing focus. | +| `app.quit` | `{}` | Closes the app. | + +## Input + +Points are logical window coordinates. A target is `{id}` (the element's centre; add `fx`/`fy` between +0 and 1 to pick a spot inside it) or `{x, y}`. `modifiers` is `{shift, alt, command, ctrl}`, where +`command` is ⌘ on macOS and Ctrl elsewhere. + +| Method | Params | +|---|---| +| `ui.click` | `{id \| x,y, button?: left\|right\|middle, count?, modifiers?}` | +| `ui.move` | `{id \| x,y}` | +| `ui.drag` | `{from: target, to: target, steps? (12), modifiers?}`: press, move, release | +| `ui.scroll` | `{id \| x,y, dx, dy}` | +| `ui.key` | `{key}`, for example `Space`, `F9`, `Cmd+Z`, `Cmd+Shift+Z`, `PageDown`, `Escape` | +| `ui.type` | `{text}`: text input into the focused field | + +## Locating things + +| Method | Params | Result | +|---|---|---| +| `ui.viewer.locate` | `{x, y}` (comp pixels) | `{x, y}` window point | +| `ui.viewer.hit` | `{id \| x,y}` (window point) | `{x, y}` comp pixel | +| `ui.timeline.locate` | `{layer (id), time?}` | `{x, y}` window point on the layer bar | + +## Screenshots + +| Method | Params | Result | +|---|---|---| +| `ui.screenshot` | `{path?, panel?, id?}` | `{path, width, height}`: a PNG of the window, or cropped to one panel or element. | + +Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`, +`header.workspace.Animation`, `project.item.`, `viewer.comp`, `viewer.handle..`, +`timeline.layer..bar`, `timeline.layer..twirl`, `timeline.prop..stopwatch`, +`timeline.key..`, `timeline.cti`, `effectControls.prop..value` and +`effects.item.`. Use `ui.elements` to see what is on screen. + +The MCP server's bridge mode (`effectcraft-cli mcp --bridge 9877`) is a thin client of this protocol; +see [agents.md](agents.md).