M14.1: MCP server + agent CLI

- crates/automation (L5, no egui): hand-rolled JSON-RPC 2.0 MCP server over
  stdio (initialize, ping, tools/list, tools/call); headless backend (in-process
  Session) and bridge backend (desktop control channel). Tools: list_commands,
  execute_command, get_project/comp/layer, get/set_property, add_keyframe,
  render_frame (inline PNG), open/save_project, undo/redo; bridge-only
  screenshot, ui_* and raw control passthrough.
- effectcraft-cli: info, commands, exec, run, props, get, set, render-frame,
  mcp [--bridge PORT]; --json, --project/--demo/--empty, --save/--save-as.
- engine: prop.get, layer.tree node paths + time, Session::resolve_comp,
  render_rgba8, execute_checked (rejects unknown params, lists accepted keys).
- control channel: render.frame; engine.execute runs every engine command
  directly (no dialogs, validated params).
- docs: control-protocol.md, agents.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:40:31 -04:00
parent dee16181fe
commit c672e03160
22 changed files with 2398 additions and 101 deletions

15
Cargo.lock generated
View File

@ -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]]

View File

@ -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 }

View File

@ -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] '<command-id>' '<json params>' … (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 <command-id> [--params JSON] run one command
//! effectcraft-cli run <id> <json> [<id> <json> …] run several commands in order
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
//! effectcraft-cli set <comp> <layer> <path> <value> [--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)
//! ```
//!
//! `<comp>` is a comp id or name (`-` = the active comp); `<layer>` an id, `#n` or name; `<value>`
//! 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 <frame|run|commands> [args] (see --help in source docs)");
std::process::exit(2)
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|mcp> [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
exec <command-id> [--params JSON] run one engine command
run <id> <json> [<id> <json> ...] run several commands in order
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
get <comp> <layer> <path> [--time S] read a property
set <comp> <layer> <path> <value> [--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
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: 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<String>,
opts: Vec<(String, Option<String>)>,
/// `--project F` or a positional `*.ecproj`.
project: Option<String>,
}
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<String>) -> Result<Args, String> {
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<Option<f64>, String> {
self.opt(k).map(|v| v.parse::<f64>().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<Value> {
match s {
"-" | "" => None,
_ => Some(s.parse::<u64>().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<String> = 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<String> = 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<effectcraft_automation::Error> for Failure {
fn from(e: effectcraft_automation::Error) -> Self {
Failure::Error(e.to_string())
}
}
impl From<String> for Failure {
fn from(e: String) -> Self {
Failure::Error(e)
}
}
fn usage_err<T>(m: &str) -> Result<T, Failure> {
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<Backend, Failure> {
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<Option<String>, 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<Value, Failure> {
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 <command-id> [<json>] 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 <comp> <layer>")?;
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 <comp> <layer> <path>")?;
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 <comp> <layer> <path> <value> [--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<const N: usize>(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<String>) -> Value {
match saved {
None => v,
Some(p) => json!({"result": v, "saved": p}),
}
}

View File

@ -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<Value> = 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");
}

View File

@ -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 }

View File

@ -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<Session>),
/// 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<u8>,
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<Self> {
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<Value> {
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<Value> {
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<f64>, max_side: u32) -> Result<Frame> {
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`";

View File

@ -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<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
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<u8> = 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<u8> = (0..=255).collect();
assert_eq!(super::decode(&super::encode(&all)).unwrap(), all);
}
}

View File

@ -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>, 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<Self> {
let addr = if addr.parse::<u16>().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>, 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 <port>`", 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<Value> {
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<std::result::Result<Value, Error>> {
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)
}
}

View File

@ -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<T> = std::result::Result<T, Error>;
/// 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<u8>, max_side: u32) -> Result<Vec<u8>> {
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<u8> = 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<u8>, 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;

View File

@ -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 <port>`) 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<Value> {
if let Value::Array(batch) = msg {
if batch.is_empty() {
return Some(error(Value::Null, INVALID_REQUEST, "empty batch"));
}
let out: Vec<Value> = 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, &params) {
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<String> {
let line = line.trim();
if line.is_empty() {
return None;
}
let reply = match serde_json::from_str::<Value>(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<Value, (i64, String)> {
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<Value> = 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<Reply, Error>) -> 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}})
}

View File

@ -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<Value>, 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::<String>();
let mut out = Vec::new();
s.serve(input.as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = 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<String> =
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());
}

View File

@ -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<u8>,
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<Reply>,
}
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<Item = &'static ToolDef> {
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<Reply> {
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<Reply> {
Ok(Reply::Json(v))
}
/// Accept params given as a JSON string too (some clients stringify nested objects).
fn params_of(v: Option<&Value>) -> Result<Value> {
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<Reply> {
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<Value> = 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<Reply> {
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<Reply> {
json_reply(b.exec("project.summary", json!({}))?)
}
fn get_comp(b: &mut Backend, a: &Value) -> Result<Reply> {
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<Value>) {
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<Reply> {
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<Reply> {
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<Reply> {
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<Reply> {
let (layer, path, comp) = (need(a, "layer")?, need(a, "path")?, get(a, "comp"));
let keys: Vec<Value> = 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<Value> = 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<Reply> {
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<Reply> {
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<Reply> {
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<Reply> {
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<Reply> {
history(b, a, "edit.undo")
}
fn redo(b: &mut Backend, a: &Value) -> Result<Reply> {
history(b, a, "edit.redo")
}
// ------------------------------------------------------------------ bridge-only tools
fn screenshot(b: &mut Backend, a: &Value) -> Result<Reply> {
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<Reply> {
json_reply(b.control(method, a.clone())?)
}
fn ui_inspect(b: &mut Backend, a: &Value) -> Result<Reply> {
passthrough(b, "ui.inspect", a)
}
fn ui_elements(b: &mut Backend, a: &Value) -> Result<Reply> {
passthrough(b, "ui.elements", a)
}
fn ui_click(b: &mut Backend, a: &Value) -> Result<Reply> {
passthrough(b, "ui.click", a)
}
fn ui_drag(b: &mut Backend, a: &Value) -> Result<Reply> {
passthrough(b, "ui.drag", a)
}
fn ui_key(b: &mut Backend, a: &Value) -> Result<Reply> {
passthrough(b, "ui.key", a)
}
fn ui_type(b: &mut Backend, a: &Value) -> Result<Reply> {
passthrough(b, "ui.type", a)
}
fn ui_set(b: &mut Backend, a: &Value) -> Result<Reply> {
passthrough(b, "ui.set", a)
}
fn control(b: &mut Backend, a: &Value) -> Result<Reply> {
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.<id>`, `viewer.comp`, `timeline.layer.<id>.bar`, `timeline.prop.<uid>.stopwatch`, `timeline.cti`, `effects.item.<name>`. 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,
},
];

View File

@ -205,7 +205,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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
),

View File

@ -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<Vec<String>> {
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<String>| {
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::<Vec<_>>().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)))
}

View File

@ -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<Value> {
Ok(out)
}
/// Read one property: value at `time` (comp seconds, default the CTI), keyframes and expression.
fn get(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> =
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<Value> {
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<Value> {
pub fn specs() -> Vec<CommandSpec> {
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),

View File

@ -76,33 +76,51 @@ fn comp_info(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
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::<u64>().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<Value> = 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::<Vec<_>>()
);
.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::<Vec<_>>()
);
}
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<Value> {
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<CommandSpec> {
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),
]
}

View File

@ -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<Value> {
let spec = commands::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
commands::check_params(spec, &params)?;
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<ItemId> {
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<u8>)> {
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<String>) {
p.fix_next_id();

View File

@ -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}");
}

View File

@ -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)]

View File

@ -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<Vec<u8>, 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);
}
}
}

103
docs/agents.md Normal file
View File

@ -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
```
`<comp>` is an id or name, or `-` for the active comp. `<value>` 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.

87
docs/control-protocol.md Normal file
View File

@ -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: "<base64>"}` 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.<id>`, `viewer.comp`, `viewer.handle.<layer>.<i>`,
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.prop.<uid>.stopwatch`,
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
`effects.item.<name>`. 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).