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:
parent
ff07ed1dc8
commit
2d5267a7df
|
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue