Merge pull request #188 from storytold/fix/163-179-cli-mcp-engine

CLI, MCP and engine fixes (#163, #166, #167, #168, #175, #176, #177, #179)
This commit is contained in:
BFlat 2026-10-07 16:00:52 +07:00 committed by GitHub
commit 17d844e969
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 399 additions and 57 deletions

View File

@ -9,7 +9,7 @@
//! 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 render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
@ -36,10 +36,54 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::io::Write;
use effectcraft_automation::tools::{self, Reply};
use effectcraft_automation::{Backend, McpServer};
use serde_json::{Value, json};
/// `println!` that never panics: see [`write_line`].
macro_rules! say {
($($t:tt)*) => {
write_line(format_args!($($t)*))
};
}
/// `eprintln!` that never panics: a diagnostic that can't be written is dropped.
macro_rules! note {
($($t:tt)*) => {{
let _ = writeln!(std::io::stderr(), $($t)*);
}};
}
/// Why stdout stopped taking output, once it has.
static STDOUT_FAILED: std::sync::OnceLock<std::io::ErrorKind> = std::sync::OnceLock::new();
/// Write a line to stdout. Once that fails, later output is dropped but the command still
/// finishes its work (a `run … --save` sequence isn't cut short). A reader that closes stdout
/// early (`info --json | head`) has read what it wanted; any other failure is reported and makes
/// the exit status 1 ([`stdout_broken`]).
fn write_line(text: std::fmt::Arguments) {
if STDOUT_FAILED.get().is_some() {
return;
}
let r = {
let mut out = std::io::stdout().lock();
out.write_fmt(text).and_then(|()| out.write_all(b"\n"))
};
if let Err(e) = r {
let _ = STDOUT_FAILED.set(e.kind());
if e.kind() != std::io::ErrorKind::BrokenPipe {
note!("effectcraft-cli: cannot write to stdout: {e}");
}
}
}
/// Whether stdout failed for a reason other than its reader closing it early.
fn stdout_broken() -> bool {
STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe)
}
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
@ -48,7 +92,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
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]
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png] [--transparent]
render [--comp C] --out FILE [--format F] [--start S] [--end S] [--work-area] [--fps N]
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
@ -112,6 +156,25 @@ const VALUED: &[&str] = &[
"--opus-app",
];
/// Options without a value. Any other `--option` is a usage error.
const FLAGS: &[&str] = &[
"--json",
"--save",
"--demo",
"--empty",
"--gpu",
"--list",
"--schemas",
"--enabled",
"--flat",
"--transparent",
"--work-area",
"--queue",
"--ops",
"--small",
"--adv3d",
];
struct Args {
pos: Vec<String>,
opts: Vec<(String, Option<String>)>,
@ -121,7 +184,7 @@ struct Args {
impl Args {
fn parse(raw: Vec<String>) -> Result<Args, String> {
let (mut pos, mut opts) = (vec![], vec![]);
let (mut pos, mut opts, mut unknown) = (vec![], vec![], vec![]);
let mut it = raw.into_iter();
while let Some(a) = it.next() {
if a.starts_with("--") && a.len() > 2 {
@ -132,6 +195,10 @@ impl Args {
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 if !FLAGS.contains(&k.as_str()) {
unknown.push(format!("`{k}`"));
} else if inline.is_some() {
return Err(format!("{k} takes no value"));
} else {
opts.push((k, None));
}
@ -139,6 +206,12 @@ impl Args {
pos.push(a);
}
}
// Before anything runs: a misspelt option must not run the command with defaults, or
// leave its value behind as a positional (`--saveas x.ecproj` opening x.ecproj).
if !unknown.is_empty() {
let s = if unknown.len() == 1 { "" } else { "s" };
return Err(format!("unknown option{s} {}", unknown.join(", ")));
}
let mut a = Args { pos, opts, project: None };
a.project = match a.opt("--project") {
Some(p) => Some(p.to_string()),
@ -173,11 +246,11 @@ fn value_arg(s: &str) -> Value {
fn main() {
let raw: Vec<String> = std::env::args().skip(1).collect();
if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) {
println!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
return;
}
if raw.is_empty() || raw.iter().any(|a| a == "-h" || a == "--help") {
println!("{USAGE}");
say!("{USAGE}");
std::process::exit(if raw.is_empty() { 2 } else { 0 });
}
let mut args = match Args::parse(raw) {
@ -190,20 +263,21 @@ fn main() {
}
let cmd = args.pos.remove(0);
match run(&cmd, &args, json_out) {
Ok(()) if stdout_broken() => std::process::exit(1),
Ok(()) => {}
Err(Failure::Usage(e)) => fail_usage(&e),
Err(Failure::Error(e)) => {
if json_out {
println!("{}", json!({"error": e}));
say!("{}", json!({"error": e}));
}
eprintln!("effectcraft-cli {cmd}: {e}");
note!("effectcraft-cli {cmd}: {e}");
std::process::exit(1);
}
}
}
fn fail_usage(e: &str) -> ! {
eprintln!("effectcraft-cli: {e}\n{USAGE}");
note!("effectcraft-cli: {e}\n{USAGE}");
std::process::exit(2)
}
@ -292,13 +366,13 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&o, true);
} else {
if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) {
println!("{out}");
say!("{out}");
}
if !r["result"].is_null() && r["error"].is_null() {
println!("{}", r["result"]);
say!("{}", r["result"]);
}
if let Some(p) = saved {
eprintln!("saved {p}");
note!("saved {p}");
}
}
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
@ -319,9 +393,9 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
/// Print a result: compact JSON with `--json`, else pretty JSON.
fn emit(v: &Value, json_out: bool) {
if json_out {
println!("{v}");
say!("{v}");
} else {
println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
say!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
}
}
@ -344,19 +418,19 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
if json_out {
emit(&info, true);
} else {
println!(
say!(
"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)"));
say!("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(""));
say!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
}
if let Some(c) = comp {
println!(
say!(
"active comp {} \"{}\" {}x{} @ {} fps, {} s",
c["id"],
c["name"].as_str().unwrap_or(""),
@ -366,7 +440,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
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(""));
say!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
}
}
}
@ -422,7 +496,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
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"]);
say!("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") {
@ -431,7 +505,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
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"]);
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
}
}
}
@ -474,19 +548,23 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
}
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 f = b.render_with(comp.as_ref(), time, max_side, args.flag("--transparent"))?;
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);
note!("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())?;
// A client that closes its end of stdout has ended the session.
match McpServer::new(b).serve_stdio() {
Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => return Err(e.to_string().into()),
_ => {}
}
}
other => return usage_err(&format!("unknown subcommand `{other}`")),
}
@ -577,7 +655,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
let _ = s.execute("renderQueue.setRender", json!({"index": k + 1, "render": false}));
}
let r = s.execute("renderQueue.add", p).map_err(err)?;
eprintln!(
note!(
"{} → {} ({}×{}, {} frames, {})",
r["compName"].as_str().unwrap_or("?"),
r["outputPath"].as_str().unwrap_or("?"),
@ -593,7 +671,8 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
std::thread::sleep(std::time::Duration::from_millis(100));
if tty && let Some(p) = s.render_progress() {
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
eprint!(
let _ = write!(
std::io::stderr(),
"
[{}/{}] frame {}/{} {:.1}s{left}\x1b[K",
(p.items_done + 1).min(p.items_total),
@ -607,7 +686,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
}
s.poll_render();
if tty {
eprintln!();
note!();
}
let mut results = vec![];
let mut failed = vec![];
@ -615,7 +694,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
let name = s.project.item(it.comp).map(|i| i.name.clone()).unwrap_or_else(|| "?".into());
match &it.status {
RenderStatus::Done => {
eprintln!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
note!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
results.push(json!({"comp": name, "output": it.last_output, "seconds": it.render_time}));
}
RenderStatus::Failed(e) => failed.push(format!("{name}: {e}")),
@ -640,7 +719,7 @@ fn list_commands_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
emit(&v, true);
} else {
for c in v.as_array().into_iter().flatten() {
println!(
say!(
"{:<36} {:<36} {:<16} {}",
c["id"].as_str().unwrap_or(""),
c["label"].as_str().unwrap_or(""),
@ -708,7 +787,7 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
m.extend(effectcraft_ui_egui::bench::ui_ops(|| {
let mut s = effectcraft_host::session();
if let Err(e) = s.execute("file.open", json!({"path": path})) {
eprintln!("bench --ops: {e}");
note!("bench --ops: {e}");
}
s
}));
@ -716,9 +795,9 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
if args.flag("--json") {
emit(&json!({"spec": format!("{spec:?}"), "measures": m.iter().map(perf::Measure::json).collect::<Vec<_>>()}), true);
} else {
println!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
say!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
for x in &m {
println!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
say!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
}
}
Ok(())
@ -788,7 +867,7 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
}
}
}
eprintln!(
note!(
"bench (no cache): {n} runs at scale {}: wall min {:.2} ms, median {:.2} ms; CPU (all threads) min {:.2} ms",
opts.scale,
min(&totals),
@ -796,9 +875,9 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
min(&cpus)
);
for (d, name, p, c, fx) in per_layer {
eprintln!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
note!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
for (id, v) in fx {
eprintln!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
note!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
}
}
}
@ -819,7 +898,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
let mut v: Vec<f64> = runs.iter().map(|r| r.0).collect();
let mean = v.iter().sum::<f64>() / n as f64;
let cpu = runs.iter().map(|r| r.1).sum::<f64>() / n as f64;
eprintln!(
note!(
"bench-play ({label}): {n} frames at scale {}: wall mean {mean:.2} ms, median {:.2} ms, min {:.2} ms; CPU mean {cpu:.2} ms",
opts.scale,
median(&mut v),
@ -827,7 +906,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
);
}
let st = cache.stats();
eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
note!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
}
/// Median wall time (ms) of `n` runs of `f` after one warm-up run.
@ -1008,10 +1087,21 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
}
};
let project = &project;
eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
eprintln!(
note!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
note!(
"{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9} {:>9} {:>11}",
"comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "auto", "gpu≠cpu", "up/dn MB"
"comp",
"res",
"size",
"cpu cold",
"gpu cold",
"cpu warm",
"gpu warm",
"gpu view",
"speedup",
"auto",
"gpu≠cpu",
"up/dn MB"
);
for cid in comps {
let Some(comp) = project.comp(cid) else { continue };
@ -1077,10 +1167,46 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
.count();
let pct = 100.0 * off as f64 / a.data.len().max(1) as f64;
let speedup = cpu_warm / gpu_warm.max(1e-9);
eprintln!(
note!(
"{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {auto:>9.2} {pct:>8.3}% {traffic:>11}"
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Args, String> {
Args::parse(args.iter().map(|a| a.to_string()).collect())
}
/// Unknown options are usage errors naming them, not ignored (#168).
#[test]
fn unknown_options_are_refused() {
let err = |args: &[&str]| parse(args).err().unwrap();
assert_eq!(err(&["exec", "comp.new", "--bogus", "--empty", "--saveas", "z.ecproj"]), "unknown options `--bogus`, `--saveas`");
assert_eq!(err(&["render", "--bogus=1"]), "unknown option `--bogus`");
assert_eq!(err(&["info", "--json=yes"]), "--json takes no value");
let a = parse(&["render", "t.ecproj", "--comp=Main", "--work-area", "--out", "x.mp4", "--json"]).unwrap();
assert_eq!((a.project.as_deref(), a.opt("--comp"), a.opt("--out")), (Some("t.ecproj"), Some("Main"), Some("x.mp4")));
assert!(a.flag("--work-area") && a.flag("--json"));
}
/// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused.
#[test]
fn every_option_read_is_declared() {
let code = include_str!("main.rs").split("#[cfg(test)]").next().unwrap();
for rest in code.split("\"--").skip(1) {
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '-').collect();
let opt = format!("--{name}");
// `--help` and `--version` are handled before parsing.
if name.is_empty() || !rest[name.len()..].starts_with('"') || ["--help", "--version"].contains(&opt.as_str()) {
continue;
}
assert!(VALUED.contains(&opt.as_str()) || FLAGS.contains(&opt.as_str()), "{opt} is read but not declared");
}
}
}

View File

@ -130,6 +130,60 @@ fn errors_are_json() {
assert_eq!(out.status.code(), Some(2));
}
/// An unknown option is a usage error (exit 2) naming it, before anything runs: the command
/// doesn't run with defaults, and a misspelt option's value isn't opened as the project (#168).
#[test]
fn unknown_options_are_usage_errors() {
let png = tmp("bogus.png");
let o = png.to_str().unwrap();
for (args, named) in [
(&["exec", "comp.new", "--bogus", "--empty", "--json"][..], "`--bogus`"),
(&["render-frame", "--bogusflag", "--out", o, "--json"], "`--bogusflag`"),
(&["exec", "comp.new", "--empty", "--saveas", "z.ecproj", "--json"], "`--saveas`"),
] {
let out = bin().args(args).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{args:?}: {err}");
assert!(err.contains(&format!("unknown option {named}")), "{args:?}: {err}");
assert!(out.stdout.is_empty(), "{args:?}: nothing ran");
}
assert!(!png.exists(), "render-frame didn't render");
// `--transparent` (silently ignored before) keeps the frame's alpha.
let proj = tmp("transparent.ecproj");
let p = proj.to_str().unwrap();
ok_json(&["run", "comp.new", r#"{"name":"T","width":32,"height":32}"#, "layer.newSolid", r#"{"width":8,"height":8}"#, "--empty", "--save-as", p]);
for (flag, alpha) in [(None, 255), (Some("--transparent"), 0)] {
ok_json(&[&["render-frame", p, "--out", o][..], flag.as_slice()].concat());
assert_eq!(image::open(&png).unwrap().to_rgba8().get_pixel(0, 0)[3], alpha, "{flag:?}");
}
}
/// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command
/// still does its work and exits 0, without a panic (#167).
#[test]
fn a_closed_stdout_is_not_a_crash() {
let proj = tmp("closed-stdout.ecproj");
let p = proj.to_str().unwrap();
for args in [&["info", "--json"][..], &["exec", "--list"], &["run", "comp.new", r#"{"name":"Piped"}"#, "project.summary", "--empty", "--save-as", p]] {
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(0), "{args:?}: {err}");
assert!(!err.contains("panicked"), "{args:?}: {err}");
}
assert!(std::fs::read_to_string(&proj).unwrap().contains("Piped"), "the sequence ran to the end and saved");
// The MCP server ends quietly when its client closes stdout.
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
{
let mut stdin = child.stdin.take().unwrap();
let _ = writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "ping"}));
}
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr));
}
#[test]
fn mcp_over_stdio() {
let mut child = bin().args(["mcp", "--demo"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
@ -185,4 +239,11 @@ fn script_file_and_eval() {
let (code, v) = run_json(&["script", "--eval", "\nnope()"]);
assert_eq!(code, 1);
assert_eq!(v["error"]["line"], json!(2));
// A compiled .jsxbin script is reported as unsupported, not as a SyntaxError (#176).
let bin_script = tmp("compiled.jsxbin");
std::fs::write(&bin_script, "@JSXBIN@ES@2.0@MyBbyBn0ABJAnAEjzFjBjMjFjSjUBfRBFeFjIjFjMjMjPff0DzACByB\n").unwrap();
let out = bin().args(["script", bin_script.to_str().unwrap()]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains(".jsxbin scripts are not supported: run the .jsx source") && !err.contains("SyntaxError"), "{err}");
}

View File

@ -177,6 +177,28 @@ fn headless_workflow() {
let _ = std::fs::remove_dir_all(dir);
}
/// An expression with a syntax error is kept but disabled; set_property and get_property report
/// its error, and `evaluated` is the static value that renders (#163).
#[test]
fn expression_syntax_errors_reach_the_reply() {
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000", "width": 32, "height": 8}}));
let at = |expr: &str| json!({"layer": "S", "path": "transform/rotation", "expression": expr});
call_json(&mut s, "set_property", json!({"layer": "S", "path": "transform/rotation", "value": 30}));
let set = call_json(&mut s, "set_property", at("thisIsBroken("));
let got = call_json(&mut s, "get_property", json!({"layer": "S", "path": "transform/rotation"}));
for r in [&set, &got] {
assert_eq!((&r["expression"], &r["evaluated"]), (&json!("thisIsBroken("), &json!(30.0)), "{r}");
assert!(r["expressionError"].as_str().unwrap().contains("SyntaxError"), "{r}");
}
// A runtime error and a valid expression report as before.
let r = call_json(&mut s, "set_property", at("nope*2"));
assert!(r["expressionError"].as_str().unwrap().contains("nope is not defined"), "{r}");
let r = call_json(&mut s, "set_property", at("60"));
assert_eq!((&r["evaluated"], r.get("expressionError")), (&json!(60.0), None), "{r}");
}
#[test]
fn stdio_loop() {
let mut s = server();
@ -394,6 +416,32 @@ fn high_level_tools_add_effect_and_report_state() {
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown effect"), "{c:?}");
}
/// add_effect is one undo step, and a call that fails leaves the layer and the history as they
/// were (#175).
#[test]
fn add_effect_is_one_undo_step_and_all_or_nothing() {
let mut s = server();
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000"}}));
let undo = |s: &mut McpServer| call_json(s, "get_project", json!({}))["undo"].as_array().unwrap().clone();
let effects = |s: &mut McpServer| {
let l = call_json(s, "get_layer", json!({"layer": "S", "flat": true}));
l["properties"].as_array().unwrap().iter().filter(|p| p["path"].as_str().unwrap().starts_with("effects/")).count()
};
let before = undo(&mut s);
let (c, err) = call(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdgePixels": true}}));
assert!(err && c[0]["text"].as_str().unwrap().contains("effects/#1/repeatEdgePixels"), "{c:?}");
assert_eq!((undo(&mut s), effects(&mut s)), (before.clone(), 0), "nothing applied");
// The corrected retry lands at #1, in one undo step named like effect.apply's.
let r = call_json(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdge": true}}));
assert_eq!((&r["path"], &r["set"]), (&json!("effects/#1"), &json!(["effects/#1/blurriness", "effects/#1/repeatEdge"])), "{r}");
let after = undo(&mut s);
assert_eq!(after.len(), before.len() + 1, "{after:?}");
assert_eq!(after.last(), Some(&json!("Apply Gaussian Blur")));
call_json(&mut s, "undo", json!({}));
assert_eq!((undo(&mut s), effects(&mut s)), (before, 0), "one undo removes the effect and its values");
}
#[test]
fn list_fonts_lists_families_with_styles() {
let mut s = server();

View File

@ -154,7 +154,8 @@ fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
}
/// `effect.apply` on one layer, then set parameters on the new instance by param id.
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
/// so one undo step, and a failing value leaves nothing applied.
fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
let (layer, effect, comp) = (need(a, "layer")?, need(a, "effect")?, get(a, "comp"));
let count = |t: &Value| effects_group(t).and_then(|g| g.get("children")).and_then(Value::as_array).map_or(0, Vec::len);
@ -162,16 +163,22 @@ fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
let index = count(&before) + 1;
let mut p = obj(&[("effect", Some(effect)), ("comp", comp)]);
p["layers"] = json!([layer]);
b.exec("effect.apply", p)?;
let mut steps = vec![json!({"command": "effect.apply", "params": p})];
let prefix = format!("effects/#{index}");
let mut set = vec![];
if let Some(vals) = get(a, "values").and_then(Value::as_object) {
for (k, v) in vals {
let path = format!("{prefix}/{k}");
b.exec("prop.set", obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))]))?;
steps.push(
json!({"command": "prop.set", "params": obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))])}),
);
set.push(path);
}
}
// The undo step is named like effect.apply's own ("Apply Gaussian Blur").
let name = effect.as_str().map(|e| effectcraft_engine::effects::lookup(e).map_or(e, |s| s.name));
let label = name.map(|n| json!(format!("Apply {n}")));
b.exec("engine.batch", obj(&[("steps", Some(&json!(steps))), ("label", label.as_ref())]))?;
let tree = b.exec("layer.tree", obj(&[("layer", Some(layer)), ("comp", comp)]))?;
let inst = effects_group(&tree).and_then(|g| g.get("children")).and_then(Value::as_array).and_then(|c| c.get(index - 1)).cloned();
let mut params = vec![];
@ -590,7 +597,7 @@ static TOOLS: &[ToolDef] = &[
},
ToolDef {
name: "add_effect",
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. One undo step; if a value fails, nothing is applied. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
bridge_only: false,
schema: || {
schema(

View File

@ -268,7 +268,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Composition...",
["Composition"],
Some("Cmd+N"),
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer? classic3D|advanced3D, anchor?, open?}",
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer?: classic3D|advanced3D, anchor?, open?}",
always,
new_comp
),
@ -277,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Composition Settings...",
["Composition"],
Some("Cmd+K"),
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer? classic3D|advanced3D}",
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer?: classic3D|advanced3D}",
has_comp,
settings
),

View File

@ -438,8 +438,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
&& p.get("steps").is_none()
&& is_js_script(path)
{
if path.to_ascii_lowercase().ends_with(".jsxbin") {
return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source"));
if is_jsxbin(path) {
return Err(bad("file.runScript", JSXBIN));
}
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let code = String::from_utf8_lossy(&bytes).to_string();
@ -487,7 +487,14 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
/// command scripts.
fn is_js_script(path: &str) -> bool {
let l = path.to_ascii_lowercase();
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
l.ends_with(".jsx") || l.ends_with(".js") || is_jsxbin(path)
}
/// Compiled `.jsxbin` scripts are an undocumented binary encoding, not JavaScript.
const JSXBIN: &str = "binary .jsxbin scripts are not supported: run the .jsx source";
fn is_jsxbin(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".jsxbin")
}
/// A script error becomes a toast and a command error.
@ -519,6 +526,11 @@ fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Valu
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
let name = str_p(p, "name").unwrap_or("script").to_string();
// A compiled script (named `.jsxbin`, or its text starts with the format's header) would only
// fail to parse at 1:1.
if is_jsxbin(&name) || code.trim_start_matches('\u{feff}').trim_start().starts_with("@JSXBIN@") {
return Err(bad("script.run", JSXBIN));
}
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
}

View File

@ -118,8 +118,14 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
});
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
// expression makes of it (what renders), or why it fails.
if pr.has_expression()
// expression makes of it (what renders), or why it fails. A disabled one renders `value`; one
// disabled by its syntax error (prop.setExpression) reports that error.
if let Some(e) = pr.expr.as_ref().filter(|e| !e.enabled && !e.text.trim().is_empty()) {
out["evaluated"] = raw.to_json();
if let Some(err) = s.expr_check.and_then(|check| check(&e.text).err()) {
out["expressionError"] = json!(err);
}
} else if pr.has_expression()
&& let (Some(h), Some(comp)) = (s.expr.as_deref(), s.project.comp(cid))
{
let mut ctx = crate::render::EvalCtx::new(&s.project, cid, comp, t);

View File

@ -219,6 +219,9 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
om.set_format(f);
}
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
any |= apply_codec_options(om, p, cmd)?;
if let Some(c) = str_p(p, "channels") {
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
"rgb" => Channels::Rgb,
@ -227,7 +230,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
_ => return Err(bad(cmd, "channels: rgb|rgba|alpha")),
};
if om.channels == Channels::Rgba && !om.supports_alpha() {
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
let what = match om.format {
OutputFormat::WebM => format!("{} WebM has no alpha channel (VP9 WebM has)", om.webm_codec.label()),
f => format!("{} has no alpha channel", f.label()),
};
return Err(bad(cmd, what));
}
}
if let Some(c) = str_p(p, "color").or(str_p(p, "alphaMode")) {
@ -237,7 +244,6 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
_ => return Err(bad(cmd, "color: straight|premultiplied")),
};
}
any |= apply_codec_options(om, p, cmd)?;
if let Some(q) = f_p(p, "quality") {
om.quality = q.clamp(1.0, 100.0) as u8;
}
@ -360,7 +366,8 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
}
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
om.output = o.to_string();
if let Some(f) = OutputFormat::from_path(o).filter(|f| *f != om.format && !o.contains("[fileExtension]")) {
// Only an extension the format doesn't write changes it: `.mp4` is H.264's, HEVC's and AV1's.
if let Some(f) = OutputFormat::from_path(o).filter(|f| f.extension() != om.format.extension() && !o.contains("[fileExtension]")) {
if p.get("format").is_some() {
// An explicit format wins and the extension follows it, as choosing a format in
// the Output Module renames Output To (#154: `--format hevc --out x.mp4`).

View File

@ -371,6 +371,27 @@ fn comp_settings_anchor_start_timecode_renderer() {
assert_eq!(c.renderer, crate::project::Renderer::Advanced3D);
}
/// `advanced3D` is a value of `renderer`, not a key: comp.new / comp.settings don't advertise it
/// in their schemas and reject it as unknown instead of ignoring it (#177).
#[test]
fn renderer_values_are_not_parameter_keys() {
let mut s = Session::default();
for (id, p) in [("comp.new", json!({"name": "A"})), ("comp.settings", json!({}))] {
let spec = crate::command_specs().iter().find(|c| c.id == id).unwrap();
let keys = crate::commands::accepted_params(spec.params).unwrap();
assert!(keys.contains(&"renderer".to_string()) && !keys.contains(&"advanced3D".to_string()), "{id}: {keys:?}");
let mut bad = p.clone();
bad["advanced3D"] = json!(true);
let e = s.execute_checked(id, bad).unwrap_err().to_string();
assert!(e.contains("unknown parameter(s) `advanced3D`"), "{id}: {e}");
let mut good = p;
good["renderer"] = json!("advanced3D");
s.execute_checked(id, good).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, crate::project::Renderer::Advanced3D, "{id}");
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
}
}
#[test]
fn comp_rejects_zero_and_negative_frame_rates() {
let mut s = Session::default();

View File

@ -83,6 +83,52 @@ fn an_explicit_format_wins_over_the_output_extension() {
assert_eq!((om.format, om.output.as_str()), (OutputFormat::PngSequence, "/out/still.png"));
}
/// Output To only changes the format when the format can't write the new extension: HEVC and AV1
/// write `.mp4` too, so they stay (#179). Other extensions still pick a file type.
#[test]
fn output_to_keeps_a_format_that_writes_the_extension() {
let mut s = session();
let cases = [
("hevc", "/out/x.mp4", OutputFormat::Hevc, "/out/x.mp4"),
("av1", "/out/x.mp4", OutputFormat::Av1, "/out/x.mp4"),
("h264", "/out/x.mov", OutputFormat::ProRes, "/out/x.mov"),
("prores", "/out/x.mp4", OutputFormat::H264, "/out/x.mp4"),
("h264", "/out/x.mp4", OutputFormat::H264, "/out/x.mp4"),
];
for (format, out, want, path) in cases {
let a = s.execute("renderQueue.add", json!({"format": format})).unwrap();
let r = s.execute("renderQueue.setOutput", json!({"item": a["item"], "path": out})).unwrap();
let om = &s.project.render_queue.last().unwrap().output;
assert_eq!((om.format, om.output.as_str()), (want, path), "{format} → {out}");
if OutputFormat::from_name(format) == Some(want) {
assert_eq!(r["outputModuleSummary"], a["outputModuleSummary"], "{format} → {out}: the module is unchanged");
}
}
// The module's own default name (HEVC resolves to `Main.mp4`) changes nothing either.
let a = s.execute("renderQueue.add", json!({"format": "hevc"})).unwrap();
let r = s.execute("renderQueue.setOutput", json!({"item": a["item"], "path": a["outputPath"]})).unwrap();
assert_eq!((&r["output"]["format"], &r["outputModuleSummary"]), (&json!("Hevc"), &a["outputModuleSummary"]), "{r}");
}
/// An explicit RGB + Alpha request with the AV1 WebM codec (which has no alpha) is refused, not
/// silently rendered opaque (#166). Without `channels`, AV1 WebM renders RGB as before.
#[test]
fn av1_webm_refuses_requested_alpha() {
let mut s = session();
let n = s.project.render_queue.len();
let e = s.execute("renderQueue.add", json!({"format": "webm", "webmCodec": "av1", "channels": "rgba"})).unwrap_err().to_string();
assert!(e.contains("AV1 WebM has no alpha channel"), "{e}");
assert_eq!(s.project.render_queue.len(), n, "nothing queued");
let a = s.execute("renderQueue.add", json!({"format": "webm", "channels": "rgba"})).unwrap();
let e = s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "webmCodec": "av1", "channels": "rgba"})).unwrap_err().to_string();
assert!(e.contains("AV1 WebM has no alpha channel"), "{e}");
let om = &s.project.render_queue.last().unwrap().output;
assert_eq!((om.webm_codec, om.channels), (WebmVideoCodec::Vp9, Channels::Rgba), "unchanged");
s.execute("renderQueue.add", json!({"format": "webm", "webmCodec": "av1"})).unwrap();
let om = &s.project.render_queue.last().unwrap().output;
assert_eq!((om.webm_codec, om.channels), (WebmVideoCodec::Av1, Channels::Rgb));
}
/// A `comp` that names no composition says so, rather than "no active composition" (#155).
#[test]
fn an_unknown_comp_is_named_in_the_error() {

View File

@ -376,6 +376,12 @@ fn engine_entry_points() {
// Scripts can't run scripts.
let r = s.execute("script.run", json!({"code": "app.run('script.run', {code: '1'})"})).unwrap();
assert!(r["error"]["message"].as_str().unwrap().contains("scripts can't run scripts"), "{r}");
// Compiled .jsxbin scripts (by name or by their header) are unsupported, not a SyntaxError
// at 1:1 (#176).
for p in [json!({"code": "@JSXBIN@ES@2.0@MyBbyBn0AB", "name": "compiled.bin"}), json!({"code": "1", "name": "/x/compiled.JSXBIN"})] {
let e = s.execute("script.run", p.clone()).unwrap_err().to_string();
assert!(e.contains(".jsxbin scripts are not supported: run the .jsx source"), "{p}: {e}");
}
// File ▸ Scripts ▸ Run Script File… runs .jsx as JavaScript.
let dir = std::env::temp_dir().join(format!("ec-script-file-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();

View File

@ -59,7 +59,7 @@ The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MC
| `add_keyframe {layer, path, time+value \| keys:[...], interpolation?, comp?}` | Adds keys, then optionally applies linear/bezier/hold/easyEase. |
| `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. |
| `list_fonts {query?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin and own-language name; `rescan` picks up fonts installed since launch. |
| `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call; returns the instance path (`effects/#n`) and its parameter paths. |
| `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. |
| `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. |
| `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. |
| `undo {steps?}` / `redo {steps?}` | History. |
@ -136,7 +136,8 @@ build real projects through these interfaces; they are worked examples of everyt
* **One call, one undo step**: `batch {"steps":[{"command":"layer.newShape","params":{"kind":"ellipse","name":"Ring"}},{"command":"layer.addShapeItem","params":{"layer":"$1.layer","kind":"trim"}},{"command":"prop.addKey","params":{"layer":"$1.layer","path":"transform/opacity","time":0,"value":0}}]}`
(a string param that is exactly `"$N"` or `"$N.key"` is replaced; `$2.path` is the trim's path).
* **Expressions**: `set_property`/`get_property` replies carry `evaluated` (what renders) next to
`value` (the keyframed value) and `expressionError` when the expression fails.
`value` (the keyframed value) and `expressionError` when the expression fails. An expression with
a syntax error is kept but disabled, so it renders `value` and reports the syntax error.
* **Keyframes like a person**: `keys.select {keys, toggle: true}` (Shift+click), `keys.selectEqual`
/ `selectPrevious` / `selectFollowing`, `keys.move {delta, merge}` (steps sharing a merge key are
one drag: each applies to the keys as they were before it, so a key passed over survives) and
@ -430,7 +431,8 @@ that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript
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.
on stdout. Errors print `{"error": ...}` and exit with status 1; usage errors, such as an unknown
option, exit with status 2 before anything runs.
```sh
effectcraft-cli info --json