M12.4: agent-friendly CLI: exec/describe/inspect/import/export, run from stdin with JSON lines and --keep-going, --save/--save-as, --bridge to the live app, exit codes; tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:26:44 -04:00
parent ff07ed1dc8
commit 2d5267a7df
5 changed files with 504 additions and 95 deletions

View File

@ -0,0 +1,125 @@
//! Argument parsing: options may appear anywhere; everything else is positional.
use serde_json::{Map, Value};
/// Options that take a value (`--project p`); every other `--x` is a flag.
const VALUED: &[&str] = &["--project", "--save-as", "--bridge", "--format", "--scale", "--quality", "--seconds", "--out", "--frames"];
#[derive(Debug, Default)]
pub struct Args {
pub positionals: Vec<String>,
opts: Vec<(String, Option<String>)>,
}
impl Args {
pub fn parse(it: impl IntoIterator<Item = String>) -> Self {
let mut a = Args::default();
let mut it = it.into_iter();
while let Some(s) = it.next() {
if s == "--" {
a.positionals.extend(it.by_ref());
} else if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
a.opts.push((k.to_string(), Some(v.to_string())));
} else if VALUED.contains(&s.as_str()) {
let v = it.next();
a.opts.push((s, v));
} else if s.starts_with("--") && s.len() > 2 {
a.opts.push((s, None));
} else {
a.positionals.push(s);
}
}
a
}
pub fn pos(&self, i: usize) -> Option<&str> {
self.positionals.get(i).map(String::as_str)
}
pub fn opt(&self, k: &str) -> Option<&str> {
self.opts.iter().rev().find(|(n, _)| n == k).and_then(|(_, v)| v.as_deref())
}
pub fn flag(&self, k: &str) -> bool {
self.opts.iter().any(|(n, _)| n == k)
}
/// Command params from positionals `from..`: one JSON object, or `key=value` pairs
/// (dotted keys nest: `color.r=1`).
pub fn params_from(&self, from: usize) -> Result<Value, String> {
let rest = self.positionals.get(from..).unwrap_or_default();
if let [one] = rest
&& one.trim_start().starts_with('{')
{
return serde_json::from_str(one).map_err(|e| format!("params JSON: {e}"));
}
let mut m = Map::new();
for kv in rest {
let (k, v) = kv.split_once('=').ok_or_else(|| format!("expected key=value, got `{kv}`"))?;
insert_dotted(&mut m, k, parse_value(v));
}
Ok(Value::Object(m))
}
}
fn insert_dotted(m: &mut Map<String, Value>, key: &str, v: Value) {
match key.split_once('.') {
None => {
m.insert(key.to_string(), v);
}
Some((head, tail)) => {
let e = m.entry(head.to_string()).or_insert_with(|| Value::Object(Map::new()));
if !e.is_object() {
*e = Value::Object(Map::new());
}
insert_dotted(e.as_object_mut().expect("object"), tail, v);
}
}
}
/// JSON when it parses (numbers, bools, null, arrays, objects, quoted strings), else a string.
pub fn parse_value(s: &str) -> Value {
serde_json::from_str(s).unwrap_or_else(|_| Value::String(s.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn args(s: &str) -> Args {
Args::parse(s.split_whitespace().map(str::to_string))
}
#[test]
fn options_anywhere_and_positionals() {
let a = args("exec --demo timeline.razor seconds=3.5 --project p.fcproj --save --compact");
assert_eq!(a.positionals, ["exec", "timeline.razor", "seconds=3.5"]);
assert_eq!(a.opt("--project"), Some("p.fcproj"));
assert!(a.flag("--save") && a.flag("--demo") && a.flag("--compact"));
assert!(!a.flag("--bridge"));
let b = args("--bridge=127.0.0.1:9876 inspect sequence");
assert_eq!(b.opt("--bridge"), Some("127.0.0.1:9876"));
assert_eq!(b.pos(1), Some("sequence"));
}
#[test]
fn key_value_params() {
let a = args("exec x seconds=3.5 name=Selects on=true ids=[1,2] color.r=1 color.g=0.5 s=\"7\"");
assert_eq!(a.params_from(2).unwrap(), json!({"seconds": 3.5, "name": "Selects", "on": true, "ids": [1, 2], "color": {"r": 1, "g": 0.5}, "s": "7"}));
assert!(args("exec x novalue").params_from(2).is_err());
assert_eq!(args("exec x").params_from(2).unwrap(), json!({}));
}
#[test]
fn json_params() {
let a = Args::parse(["exec".into(), "x".into(), r#"{"a": {"b": [1]}}"#.into()]);
assert_eq!(a.params_from(2).unwrap(), json!({"a": {"b": [1]}}));
}
#[test]
fn double_dash_ends_options() {
let a = args("import -- --weird-name.mov a.mov");
assert_eq!(a.positionals, ["import", "--weird-name.mov", "a.mov"]);
}
}

View File

@ -1,134 +1,304 @@
//! FilmCraft headless CLI.
//! FilmCraft headless CLI: every engine command is one shell call away, for scripts and AI agents.
//!
//! ```text
//! filmcraft-cli probe <media> media info as JSON
//! filmcraft-cli commands [filter] list engine commands
//! filmcraft-cli render [--project p.fcproj|--demo] --seconds S --out frame.png [--scale 0.5]
//! filmcraft-cli run [--project p.fcproj|--demo] <script.jsonl> run commands (one {"id","params"} per line)
//! filmcraft-cli mcp [--bridge 127.0.0.1:9876] [--demo] MCP server on stdio
//! ```
//! Run `filmcraft-cli help` for the full reference (also in docs/agents.md).
mod args;
use args::{Args, parse_value};
use filmcraft_automation::BridgeClient;
use filmcraft_engine::Session;
use serde_json::{Value, json};
fn usage() -> ! {
eprintln!("usage: filmcraft-cli <probe|commands|render|run|mcp> … (see --help in source header)");
const HELP: &str = "\
filmcraft-cli: drive FilmCraft from the shell (headless engine, or the live app with --bridge)
USAGE
filmcraft-cli [OPTIONS] <SUBCOMMAND> [ARGS]
SUBCOMMANDS
exec <id> [key=value ...] run one command; prints its result as JSON
exec <id> '<json params>' same, params as one JSON object
run <script.jsonl | -> run commands, one {\"id\",\"params\"} per line (# comments ok)
commands [filter] [--json] list commands (id, label, shortcut, params)
describe <id> one command as JSON (menu, shortcut, params, enabled now)
inspect [project|sequence] project tree or active sequence as JSON (default: both)
import <file>... import media into the project
export <out> [--format f] export the active sequence (h264|prores|dnxhr|mjpeg|png|gif|wav;
guessed from the extension) and wait for it to finish
render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG
probe <media> media info as JSON
bench-decode <media> [--frames N]
mcp MCP server on stdio (headless, or --bridge to the live app)
help this text
OPTIONS
--project <p.fcproj> open this project first (headless)
--demo open the built-in demo project (headless)
--save save the project back to --project when done
--save-as <p.fcproj> save the project to this path when done
--bridge <127.0.0.1:PORT> send commands to the running app (`filmcraft --control PORT`)
--keep-going `run`: report failing lines and continue
--compact one-line JSON output
VALUES
key=value parses value as JSON when it can (3.5, true, [1,2], {\"a\":1}), else as a string:
`exec timeline.razor seconds=3.5`, `exec file.newBin name=Selects`.
Times: 254016000000 ticks per second; most commands also take seconds=, frame= or timecode=.
EXIT STATUS
0 success · 1 a command failed · 2 usage error
";
fn usage(msg: impl std::fmt::Display) -> ! {
eprintln!("filmcraft-cli: {msg}\nRun `filmcraft-cli help` for usage.");
std::process::exit(2)
}
fn session_from(args: &[String]) -> Session {
let mut s = Session::default();
if let Some(i) = args.iter().position(|a| a == "--project") {
let p = args.get(i + 1).cloned().unwrap_or_else(|| usage());
if let Err(e) = s.execute("file.open", json!({"path": p})) {
eprintln!("filmcraft-cli: {e}");
std::process::exit(1);
}
} else if args.iter().any(|a| a == "--demo") {
let _ = s.execute("file.openDemoProject", json!({}));
}
s
fn fail(msg: impl std::fmt::Display) -> ! {
eprintln!("filmcraft-cli: {msg}");
std::process::exit(1)
}
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)
/// Where commands go: an in-process session, or the desktop app's control channel.
enum Backend {
Local(Box<Session>),
Bridge(BridgeClient),
}
impl Backend {
fn open(a: &Args) -> Self {
if let Some(addr) = a.opt("--bridge") {
if a.opt("--project").is_some() || a.flag("--demo") {
usage("--bridge drives the running app; open projects there (exec file.open path=…)");
}
return Backend::Bridge(BridgeClient::new(addr).unwrap_or_else(|e| usage(e)));
}
let mut s = Session::default();
if let Some(p) = a.opt("--project") {
if let Err(e) = s.execute("file.open", json!({"path": p})) {
fail(e);
}
} else if a.flag("--demo") {
let _ = s.execute("file.openDemoProject", json!({}));
}
Backend::Local(Box::new(s))
}
async fn exec(&mut self, id: &str, params: Value) -> Result<Value, String> {
match self {
Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()),
Backend::Bridge(b) => b.call("engine.execute", json!({"command": id, "params": params})).await.map_err(|e| e.to_string()),
}
}
/// Save to the --project path (`--save`) or to `--save-as`. Bridge mode saves in the app.
async fn finish(&mut self, a: &Args) {
let r = if let Some(p) = a.opt("--save-as") {
Some(self.exec("file.saveAs", json!({"path": p})).await)
} else if a.flag("--save") {
match a.opt("--project") {
Some(p) => Some(self.exec("file.save", json!({"path": p})).await),
None => Some(self.exec("file.save", json!({})).await),
}
} else {
None
};
if let Some(Err(e)) = r {
fail(format!("saving: {e}"));
}
}
}
fn print(a: &Args, v: &Value) {
let s = if a.flag("--compact") { serde_json::to_string(v) } else { serde_json::to_string_pretty(v) };
println!("{}", s.unwrap_or_default());
}
/// Export format from the output file's extension.
fn format_for(path: &str) -> Option<&'static str> {
let ext = path.rsplit_once('.')?.1.to_ascii_lowercase();
Some(match ext.as_str() {
"mp4" | "m4v" => "h264",
"mov" => "prores",
"mxf" => "dnxhr",
"png" => "png",
"gif" => "gif",
"wav" => "wav",
_ => return None,
})
}
#[tokio::main]
async fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(cmd) = args.first() else { usage() };
match cmd.as_str() {
let a = Args::parse(std::env::args().skip(1));
let Some(cmd) = a.pos(0) else { usage("missing subcommand") };
match cmd {
"help" | "--help" | "-h" => print!("{HELP}"),
"probe" => {
let path = args.get(1).unwrap_or_else(|| usage());
let bytes = std::fs::read(path).unwrap_or_else(|e| {
eprintln!("{path}: {e}");
std::process::exit(1)
});
let path = a.pos(1).unwrap_or_else(|| usage("probe <media>"));
let bytes = std::fs::read(path).unwrap_or_else(|e| fail(format!("{path}: {e}")));
match filmcraft_codecs::open_bytes(path, bytes.into()) {
Ok(src) => println!("{}", serde_json::to_string_pretty(src.info()).unwrap_or_default()),
Err(e) => {
eprintln!("{path}: {e}");
std::process::exit(1)
}
Ok(src) => print(&a, &serde_json::to_value(src.info()).unwrap_or_default()),
Err(e) => fail(format!("{path}: {e}")),
}
}
"bench-decode" => {
let path = args.get(1).unwrap_or_else(|| usage());
let n: i64 = opt(&args, "--frames").and_then(|v| v.parse().ok()).unwrap_or(120);
let src = filmcraft_codecs::open_bytes(path, std::fs::read(path).expect("read").into()).expect("open");
let path = a.pos(1).unwrap_or_else(|| usage("bench-decode <media>"));
let n: i64 = a.opt("--frames").and_then(|v| v.parse().ok()).unwrap_or(120);
let src = filmcraft_codecs::open_bytes(path, std::fs::read(path).unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e));
let rate = src.info().frame_rate();
let t0 = std::time::Instant::now();
for f in 0..n {
src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(f))).expect("frame");
src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(f))).unwrap_or_else(|e| fail(e));
}
let dt = t0.elapsed().as_secs_f64();
println!("{n} frames in {dt:.2}s → {:.1} fps (sequential, via Mp4Source)", n as f64 / dt);
let t1 = std::time::Instant::now();
let f = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(n / 2))).expect("frame");
let f = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(n / 2))).unwrap_or_else(|e| fail(e));
let (w, h, _) = f.to_linear_f32_decimated(2);
println!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0);
}
"commands" => {
let f = args.get(1).map(|s| s.to_ascii_lowercase()).unwrap_or_default();
for c in filmcraft_engine::command_specs() {
if f.is_empty() || c.id.to_ascii_lowercase().contains(&f) {
println!("{:<32} {:<34} {:<14} {}", c.id, c.label, c.shortcut.unwrap_or(""), c.params);
"commands" | "describe" => {
let mut b = Backend::open(&a);
let list = b.exec("command.list", json!({})).await.unwrap_or_else(|e| fail(e));
let list = list.as_array().cloned().unwrap_or_default();
if cmd == "describe" {
let id = a.pos(1).unwrap_or_else(|| usage("describe <id>"));
match list.iter().find(|c| c["id"] == id) {
Some(c) => print(&a, c),
None => fail(format!("unknown command `{id}` (try `filmcraft-cli commands {id}`)")),
}
return;
}
let f = a.pos(1).map(str::to_ascii_lowercase).unwrap_or_default();
let hit = |c: &Value| {
let id = c["id"].as_str().unwrap_or("").to_ascii_lowercase();
let label = c["label"].as_str().unwrap_or("").to_ascii_lowercase();
f.is_empty() || id.contains(&f) || label.contains(&f)
};
let list: Vec<Value> = list.into_iter().filter(hit).collect();
if a.flag("--json") {
print(&a, &Value::Array(list));
} else {
for c in &list {
let s = |k: &str| c[k].as_str().unwrap_or("").to_string();
println!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
}
}
}
"render" => {
let mut s = session_from(&args);
let secs: f64 = opt(&args, "--seconds").and_then(|v| v.parse().ok()).unwrap_or(0.0);
let scale: f32 = opt(&args, "--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0);
let out = opt(&args, "--out").unwrap_or("frame.png");
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
let t0 = std::time::Instant::now();
let Some(img) = s.render_program(scale) else {
eprintln!("no sequence");
std::process::exit(1)
};
let dt = t0.elapsed();
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).expect("png");
std::fs::write(out, png).expect("write");
eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0);
"exec" => {
let id = a.pos(1).unwrap_or_else(|| usage("exec <id> [key=value ...]"));
let params = a.params_from(2).unwrap_or_else(|e| usage(e));
let mut b = Backend::open(&a);
match b.exec(id, params).await {
Ok(v) => print(&a, &v),
Err(e) => fail(format!("{id}: {e}")),
}
b.finish(&a).await;
}
"run" => {
let mut s = session_from(&args);
let script = args.last().unwrap_or_else(|| usage());
let text = std::fs::read_to_string(script).unwrap_or_else(|e| {
eprintln!("{script}: {e}");
std::process::exit(1)
});
for line in text.lines().filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#')) {
let v: Value = serde_json::from_str(line).unwrap_or_else(|e| {
eprintln!("bad line `{line}`: {e}");
std::process::exit(1)
});
let id = v["id"].as_str().unwrap_or("");
match s.execute(id, v.get("params").cloned().unwrap_or(json!({}))) {
Ok(r) => println!("{id}: {r}"),
let script = a.pos(1).unwrap_or_else(|| usage("run <script.jsonl | ->"));
let text = if script == "-" {
std::io::read_to_string(std::io::stdin()).unwrap_or_else(|e| fail(e))
} else {
std::fs::read_to_string(script).unwrap_or_else(|e| fail(format!("{script}: {e}")))
};
let mut b = Backend::open(&a);
let mut failed = 0;
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty() && !l.trim_start().starts_with('#')) {
let v: Value = serde_json::from_str(line).unwrap_or_else(|e| usage(format!("line {}: {e}", n + 1)));
let id = v["id"].as_str().unwrap_or_else(|| usage(format!("line {}: missing \"id\"", n + 1)));
match b.exec(id, v.get("params").cloned().unwrap_or(json!({}))).await {
Ok(r) => println!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})),
Err(e) => {
eprintln!("{id}: {e}");
std::process::exit(1)
println!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e}));
failed += 1;
if !a.flag("--keep-going") {
std::process::exit(1);
}
}
}
}
}
"mcp" => {
let server = match opt(&args, "--bridge") {
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| {
eprintln!("{e}");
std::process::exit(1)
}),
None => filmcraft_automation::FilmcraftMcp::headless(session_from(&args)),
};
if let Err(e) = server.serve_stdio().await {
eprintln!("filmcraft-cli mcp: {e}");
std::process::exit(1)
b.finish(&a).await;
if failed > 0 {
std::process::exit(1);
}
}
_ => usage(),
"inspect" => {
let mut b = Backend::open(&a);
let what = a.pos(1).unwrap_or("all");
let mut get = async |id: &str| b.exec(id, json!({})).await.unwrap_or_else(|e| fail(e));
let v = match what {
"project" => get("project.inspect").await,
"sequence" => get("sequence.inspect").await,
"all" => json!({"project": get("project.inspect").await, "sequence": get("sequence.inspect").await}),
other => usage(format!("inspect: unknown `{other}` (project | sequence)")),
};
print(&a, &v);
}
"import" => {
let paths: Vec<String> =
a.positionals[1..].iter().map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned()).unwrap_or_else(|_| p.clone())).collect();
if paths.is_empty() {
usage("import <file>...");
}
let mut b = Backend::open(&a);
match b.exec("file.import", json!({"paths": paths})).await {
Ok(v) => print(&a, &v),
Err(e) => fail(format!("import: {e}")),
}
b.finish(&a).await;
}
"export" => {
let out = a.pos(1).unwrap_or_else(|| usage("export <out> [--format f]"));
let format = a.opt("--format").or_else(|| format_for(out)).unwrap_or_else(|| usage("export: give --format (unknown extension)"));
let mut p = json!({"path": out, "format": format, "wait": true});
for (k, key) in [("--scale", "scale"), ("--quality", "quality")] {
if let Some(v) = a.opt(k) {
p[key] = parse_value(v);
}
}
if a.flag("--no-audio") {
p["audio"] = json!(false);
}
let mut b = Backend::open(&a);
let t0 = std::time::Instant::now();
match b.exec("file.exportMedia", p).await {
Ok(v) => {
print(&a, &v);
eprintln!("exported {out} in {:.1}s", t0.elapsed().as_secs_f64());
}
Err(e) => fail(format!("export: {e}")),
}
}
"render" => {
if a.opt("--bridge").is_some() {
usage("render is headless; with --bridge use the MCP `render_frame` tool or `exec` ui commands");
}
let Backend::Local(mut s) = Backend::open(&a) else { unreachable!() };
let secs: f64 = a.opt("--seconds").and_then(|v| v.parse().ok()).unwrap_or(0.0);
let scale: f32 = a.opt("--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0);
let out = a.opt("--out").unwrap_or("frame.png");
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
let t0 = std::time::Instant::now();
let Some(img) = s.render_program(scale) else { fail("no sequence") };
let dt = t0.elapsed();
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
eprintln!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0);
}
"mcp" => {
let server = match a.opt("--bridge") {
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)),
None => match Backend::open(&a) {
Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s),
Backend::Bridge(_) => unreachable!(),
},
};
if let Err(e) = server.serve_stdio().await {
fail(format!("mcp: {e}"));
}
}
other => usage(format!("unknown subcommand `{other}`")),
}
let _ = filmcraft_media::MediaKind::Movie;
}

View File

@ -0,0 +1,92 @@
//! End-to-end tests of the CLI binary (headless backend).
use std::io::Write;
use std::process::{Command, Output, Stdio};
use serde_json::Value;
fn cli(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).output().expect("spawn filmcraft-cli")
}
fn json_out(o: &Output) -> Value {
assert!(o.status.success(), "failed: {}", String::from_utf8_lossy(&o.stderr));
serde_json::from_slice(&o.stdout).expect("JSON on stdout")
}
#[test]
fn help_and_usage_errors() {
let h = cli(&["help"]);
assert!(h.status.success());
assert!(String::from_utf8_lossy(&h.stdout).contains("exec <id>"));
assert_eq!(cli(&[]).status.code(), Some(2));
assert_eq!(cli(&["frobnicate"]).status.code(), Some(2));
assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));
}
#[test]
fn exec_inspect_and_describe() {
let seq = json_out(&cli(&["--demo", "inspect", "sequence", "--compact"]));
assert!(seq["tracks"].is_array() || seq.is_object(), "{seq}");
let d = json_out(&cli(&["describe", "timeline.razor"]));
assert_eq!(d["id"], "timeline.razor");
assert!(d["params"].is_string());
let list = json_out(&cli(&["commands", "razor", "--json"]));
assert!(list.as_array().unwrap().iter().any(|c| c["id"] == "timeline.razor"));
// An unknown command fails with exit status 1.
let bad = cli(&["--demo", "exec", "no.such.command"]);
assert_eq!(bad.status.code(), Some(1));
}
#[test]
fn exec_save_as_then_reopen() {
let dir = std::env::temp_dir().join(format!("fc-cli-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let proj = dir.join("p.fcproj");
let p = proj.to_str().unwrap();
json_out(&cli(&["--demo", "exec", "file.newBin", "name=CLI Selects", "--save-as", p]));
let tree = json_out(&cli(&["--project", p, "inspect", "project"]));
assert!(tree.to_string().contains("CLI Selects"), "{tree}");
// `run` from stdin, editing and saving back in place.
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli"))
.args(["--project", p, "--save", "run", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(b"# comment\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"Second\"}}\n").unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let line: Value = serde_json::from_slice(out.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
assert_eq!(line["ok"], true);
assert!(json_out(&cli(&["--project", p, "inspect", "project"])).to_string().contains("Second"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn run_keep_going_reports_failures() {
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli"))
.args(["--demo", "--keep-going", "run", "-"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(b"{\"id\":\"no.such\"}\n{\"id\":\"file.newBin\",\"params\":{\"name\":\"x\"}}\n").unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(1));
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["ok"], false);
assert_eq!(lines[1]["ok"], true);
}
#[test]
fn export_wav_waits_for_job() {
let dir = std::env::temp_dir().join(format!("fc-cli-x-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("still.wav");
let o = cli(&["--demo", "export", out.to_str().unwrap()]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
assert!(std::fs::metadata(&out).map(|m| m.len() > 44).unwrap_or(false));
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -94,7 +94,29 @@ Notes:
- Element ids come from the previous frame. If an element is missing right after a layout change,
the app retries on later frames before giving up.
- Without a window, `filmcraft-cli run script.jsonl` (lines of `{"id":"…","params":{…}}`) runs the
same commands headlessly.
same commands headlessly. See §2b for the rest of the CLI.
## 2b. Command-line interface
Every command is also one shell call away. Options go anywhere; output is JSON; exit status is 0 on
success, 1 when a command fails and 2 on a usage error. `filmcraft-cli help` prints the reference.
```sh
filmcraft-cli commands razor # find ids (add --json for machine output)
filmcraft-cli describe timeline.razor # one command: menu, shortcut, params, enabled now
filmcraft-cli --demo inspect sequence # project / sequence as JSON
filmcraft-cli --project p.fcproj --save exec timeline.razor seconds=3.5
filmcraft-cli --project p.fcproj exec effects.apply '{"effect":"Gaussian Blur"}'
filmcraft-cli --project p.fcproj --save import a.mov b.wav
filmcraft-cli --project p.fcproj export out.mp4 # format from the extension; waits for the job
echo '{"id":"file.newBin","params":{"name":"Selects"}}' | filmcraft-cli --project p.fcproj --save run -
filmcraft-cli --bridge 127.0.0.1:9876 exec window.workspace.color # the running app
```
`key=value` values are parsed as JSON when they can be (`3.5`, `true`, `[1,2]`), otherwise taken as
strings; dotted keys nest (`color.r=1`). `run` prints one JSON line per command
(`{"line","id","ok","result"|"error"}`) and stops at the first failure unless `--keep-going`.
`--save` writes back to `--project`; `--save-as path` writes elsewhere.
## 3. Verifying UI work

View File

@ -64,7 +64,7 @@ and `filmcraft-cli`.
| `ui-egui` | L5 | the egui frontend: docking, panels, timeline, monitors, playback, control-channel handlers |
| `automation` | L5 | MCP server (`rmcp`, stdio), headless or bridged to the running app |
| `filmcraft` | L6 | desktop binary: eframe/wgpu window, cpal audio output, file dialogs, native macOS menu, TCP control server |
| `filmcraft-cli` | L6 | headless CLI: `probe`, `commands`, `render`, `run`, `mcp` |
| `filmcraft-cli` | L6 | headless CLI: `exec`, `run`, `inspect`, `describe`, `commands`, `import`, `export`, `render`, `probe`, `mcp`; `--bridge` targets the running app |
| `filmcraft-web` | L6 | the browser app (wasm32): eframe web runner on WebGPU/WebGL2, Blob-backed services, OPFS recovery, WebAudio, WebCodecs, `window.filmcraft` API ([web.md](web.md)) |
### What `cargo xtask layers` enforces
@ -460,7 +460,7 @@ All of these dispatch the same command ids.
| Surface | Where | Scope |
|---|---|---|
| UI | `ui-egui` menus, shortcuts, panels | `menus::invoke` → engine or UI command |
| CLI | `filmcraft-cli` | `commands`, `run script.jsonl` (one `{"id","params"}` per line), `render`, `probe` |
| CLI | `filmcraft-cli` | `exec <id> key=value…`, `run script.jsonl` (one `{"id","params"}` per line), `inspect`, `import`, `export`, `render`, `probe`; `--save`, `--bridge` |
| Control channel | `filmcraft --control <port>` | JSON lines on loopback TCP: engine commands plus synthetic input, inspection and screenshots of the live UI |
| MCP | `filmcraft-cli mcp` | stdio MCP server: headless in-process session, or `--bridge` to the control channel |