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:
parent
dee16181fe
commit
c672e03160
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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`";
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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, ¶ms) {
|
||||
Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
|
||||
Err((code, m)) => error(id, code, &m),
|
||||
})
|
||||
}
|
||||
|
||||
/// Handle one line of input; `None` when no reply is due.
|
||||
pub fn handle_line(&mut self, line: &str) -> Option<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}})
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ¶ms)?;
|
||||
self.execute(id, params)
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self, id: &str) -> bool {
|
||||
commands::find(id).is_some_and(|c| (c.enabled)(self).is_ok())
|
||||
}
|
||||
|
|
@ -306,6 +314,25 @@ impl Session {
|
|||
r.comp_frame(comp, t)
|
||||
}
|
||||
|
||||
/// Resolve a comp reference (item id number or name; `None`/null = the active comp).
|
||||
pub fn resolve_comp(&self, comp: Option<&Value>) -> Result<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();
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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).
|
||||
Loading…
Reference in New Issue