diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index cfc04fc3b..dfb46ab51 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -316,219 +316,238 @@ async fn cli() { match cmd { "help" | "-h" => out!("{HELP}"), "version" => outln!("filmcraft-cli {}", env!("CARGO_PKG_VERSION")), - "probe" => { - let path = a.pos(1).unwrap_or_else(|| usage("probe [--image-sequence]")); - match probe::probe(path, a.flag("--image-sequence")) { - Ok(v) => print(&a, &v), - Err(e) => fail(e), - } - } - "bench-decode" => { - let path = a.pos(1).unwrap_or_else(|| usage("bench-decode ")); - let n: i64 = a.opt("--frames").and_then(|v| v.parse().ok()).unwrap_or(120); - let src = filmcraft_codecs::open_bytes(path, read_file(path).await.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))).unwrap_or_else(|e| fail(e)); - } - let dt = t0.elapsed().as_secs_f64(); - outln!("{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))).unwrap_or_else(|e| fail(e)); - let (w, h, _) = f.to_linear_f32_decimated(2); - outln!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0); - } - "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 ")); - 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 = 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(); - outln!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params")); - } - } - } - "exec" => { - let id = a.pos(1).unwrap_or_else(|| usage("exec [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 script = a.pos(1).unwrap_or_else(|| usage("run ")); - let text = if script == "-" { - std::io::read_to_string(std::io::stdin()).unwrap_or_else(|e| fail(e)) - } else { - read_text(script).await.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) => outln!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})), - Err(e) => { - outln!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e})); - failed += 1; - if !a.flag("--keep-going") { - std::process::exit(1); - } - } - } - } - b.finish(&a).await; - if failed > 0 { - std::process::exit(1); - } - } - "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 = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect(); - if paths.is_empty() { - usage("import ..."); - } - 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" => { - if a.flag("--list-presets") { - let mut b = Backend::open(&a); - let q = a.pos(1).map(|q| json!({"query": q})).unwrap_or(json!({})); - match b.exec("export.presets.list", q).await { - Ok(v) => print(&a, &v["presets"]), - Err(e) => fail(format!("export: {e}")), - } - return; - } - let out = a.pos(1).unwrap_or_else(|| usage("export [--preset name | --format f]")); - let mut p = json!({"path": out, "wait": true}); - match (a.opt("--preset"), a.opt("--format")) { - (Some(preset), f) => { - p["preset"] = json!(preset); - if let Some(f) = f { - p["format"] = json!(f); - } - } - (None, f) => { - let format = f.or_else(|| format_for(out)).unwrap_or_else(|| usage("export: give --preset or --format (unknown extension)")); - p["format"] = json!(format); - } - } - for (k, key) in [("--scale", "scale"), ("--quality", "quality")] { - if let Some(v) = a.opt(k) { - p[key] = parse_value(v); - } - } - if let Some(r) = a.opt("--range") { - p["range"] = json!(r); - } - set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end")); - if let Some(js) = a.opt("--settings") { - p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}"))); - } - if a.flag("--no-audio") { - p["audio"] = json!(false); - } - if let Some(v) = a.opt("--gpu-rendering") { - p["gpuRendering"] = json!(v); // off | auto (validated by the export command) - } - let mut b = Backend::open(&a); - let t0 = std::time::Instant::now(); - let r = if a.flag("--queue") { - let mut q = p.clone(); - q["start"] = json!(true); - b.exec("export.queue.add", q).await.and_then(|v| { - let failed: Vec<&Value> = v["items"].as_array().map(|i| i.iter().filter(|x| x["status"] != "done").collect()).unwrap_or_default(); - if failed.is_empty() { Ok(v) } else { Err(format!("queue: {}", Value::Array(failed.into_iter().cloned().collect()))) } - }) - } else { - b.exec("file.exportMedia", p).await - }; - match r { - Ok(v) => { - print(&a, &v); - let written = written_paths(&v, out).join(", "); - check_offline(&a, &offline_media(&v), &format!("export: {written}")); - diag(format_args!("exported {written} 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 { usage("render is headless") }; - let secs: f64 = finite_opt(&a, "--seconds").unwrap_or(0.0); - let scale: f32 = finite_opt(&a, "--scale").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 (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); - 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)); - write_file(out, png).await.unwrap_or_else(|e| fail(format!("{out}: {e}"))); - let offline: Vec = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect(); - check_offline(&a, &offline, &format!("render: {out}")); - diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); - } - "mcp" => { - if let Some(opt) = a.unknown_opt(&["--bridge", "--project", "--demo", "--data-dir"]) { - usage(format!("unknown mcp option `{opt}`")); - } - let server = match a.opt("--bridge") { - Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| usage(e)), - None => match Backend::open(&a) { - Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s), - Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"), - }, - }; - if let Err(e) = server.serve_stdio().await { - fail(format!("mcp: {e}")); - } - } + "probe" => cli_probe(&a).await, + "bench-decode" => cli_bench_decode(&a).await, + "commands" | "describe" => cli_commands(&a, cmd).await, + "exec" => cli_exec(&a).await, + "run" => cli_run(&a).await, + "inspect" => cli_inspect(&a).await, + "import" => cli_import(&a).await, + "export" => cli_export(&a).await, + "render" => cli_render(&a).await, + "mcp" => cli_mcp(&a).await, other => usage(format!("unknown subcommand `{other}`")), } } +async fn cli_probe(a: &Args) { + let path = a.pos(1).unwrap_or_else(|| usage("probe [--image-sequence]")); + match probe::probe(path, a.flag("--image-sequence")) { + Ok(v) => print(a, &v), + Err(e) => fail(e), + } +} + +async fn cli_bench_decode(a: &Args) { + let path = a.pos(1).unwrap_or_else(|| usage("bench-decode ")); + let n: i64 = a.opt("--frames").and_then(|v| v.parse().ok()).unwrap_or(120); + let src = filmcraft_codecs::open_bytes(path, read_file(path).await.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))).unwrap_or_else(|e| fail(e)); + } + let dt = t0.elapsed().as_secs_f64(); + outln!("{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))).unwrap_or_else(|e| fail(e)); + let (w, h, _) = f.to_linear_f32_decimated(2); + outln!("½-res linear conversion {w}x{h}: {:.1} ms", t1.elapsed().as_secs_f64() * 1000.0); +} + +async fn cli_commands(a: &Args, cmd: &str) { + 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 ")); + 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 = 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(); + outln!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params")); + } + } +} + +async fn cli_exec(a: &Args) { + let id = a.pos(1).unwrap_or_else(|| usage("exec [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; +} + +async fn cli_run(a: &Args) { + let script = a.pos(1).unwrap_or_else(|| usage("run ")); + let text = if script == "-" { + std::io::read_to_string(std::io::stdin()).unwrap_or_else(|e| fail(e)) + } else { + read_text(script).await.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) => outln!("{}", json!({"line": n + 1, "id": id, "ok": true, "result": r})), + Err(e) => { + outln!("{}", json!({"line": n + 1, "id": id, "ok": false, "error": e})); + failed += 1; + if !a.flag("--keep-going") { + std::process::exit(1); + } + } + } + } + b.finish(a).await; + if failed > 0 { + std::process::exit(1); + } +} + +async fn cli_inspect(a: &Args) { + 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); +} + +async fn cli_import(a: &Args) { + let paths: Vec = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect(); + if paths.is_empty() { + usage("import ..."); + } + 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; +} + +async fn cli_export(a: &Args) { + if a.flag("--list-presets") { + let mut b = Backend::open(a); + let q = a.pos(1).map(|q| json!({"query": q})).unwrap_or(json!({})); + match b.exec("export.presets.list", q).await { + Ok(v) => print(a, &v["presets"]), + Err(e) => fail(format!("export: {e}")), + } + return; + } + let out = a.pos(1).unwrap_or_else(|| usage("export [--preset name | --format f]")); + let mut p = json!({"path": out, "wait": true}); + match (a.opt("--preset"), a.opt("--format")) { + (Some(preset), f) => { + p["preset"] = json!(preset); + if let Some(f) = f { + p["format"] = json!(f); + } + } + (None, f) => { + let format = f.or_else(|| format_for(out)).unwrap_or_else(|| usage("export: give --preset or --format (unknown extension)")); + p["format"] = json!(format); + } + } + for (k, key) in [("--scale", "scale"), ("--quality", "quality")] { + if let Some(v) = a.opt(k) { + p[key] = parse_value(v); + } + } + if let Some(r) = a.opt("--range") { + p["range"] = json!(r); + } + set_custom_bounds(&mut p, a.opt("--start"), a.opt("--end")); + if let Some(js) = a.opt("--settings") { + p["settings"] = serde_json::from_str(js).unwrap_or_else(|e| usage(format!("--settings: {e}"))); + } + if a.flag("--no-audio") { + p["audio"] = json!(false); + } + if let Some(v) = a.opt("--gpu-rendering") { + p["gpuRendering"] = json!(v); // off | auto (validated by the export command) + } + let mut b = Backend::open(a); + let t0 = std::time::Instant::now(); + let r = if a.flag("--queue") { + let mut q = p.clone(); + q["start"] = json!(true); + b.exec("export.queue.add", q).await.and_then(|v| { + let failed: Vec<&Value> = v["items"].as_array().map(|i| i.iter().filter(|x| x["status"] != "done").collect()).unwrap_or_default(); + if failed.is_empty() { Ok(v) } else { Err(format!("queue: {}", Value::Array(failed.into_iter().cloned().collect()))) } + }) + } else { + b.exec("file.exportMedia", p).await + }; + match r { + Ok(v) => { + print(a, &v); + let written = written_paths(&v, out).join(", "); + check_offline(a, &offline_media(&v), &format!("export: {written}")); + diag(format_args!("exported {written} in {:.1}s", t0.elapsed().as_secs_f64())); + } + Err(e) => fail(format!("export: {e}")), + } +} + +async fn cli_render(a: &Args) { + 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 { usage("render is headless") }; + let secs: f64 = finite_opt(a, "--seconds").unwrap_or(0.0); + let scale: f32 = finite_opt(a, "--scale").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 (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e)); + 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)); + write_file(out, png).await.unwrap_or_else(|e| fail(format!("{out}: {e}"))); + let offline: Vec = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect(); + check_offline(a, &offline, &format!("render: {out}")); + diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0)); +} + +async fn cli_mcp(a: &Args) { + if let Some(opt) = a.unknown_opt(&["--bridge", "--project", "--demo", "--data-dir"]) { + usage(format!("unknown mcp option `{opt}`")); + } + let server = match a.opt("--bridge") { + Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| usage(e)), + None => match Backend::open(a) { + Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s), + Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"), + }, + }; + if let Err(e) = server.serve_stdio().await { + fail(format!("mcp: {e}")); + } +} /// The files an export wrote, from its result: `path` of a direct export, or each queued item's /// `path` (the format may have changed the extension: `out.mp4` exported as APV is `out.mp4.mov`). diff --git a/apps/filmcraft-cli/tests/it/workflows.rs b/apps/filmcraft-cli/tests/it/workflows.rs index b0f7a57a8..8c4f08169 100644 --- a/apps/filmcraft-cli/tests/it/workflows.rs +++ b/apps/filmcraft-cli/tests/it/workflows.rs @@ -308,8 +308,12 @@ fn phone_vfr_clip_extract_gain_title_to_h264_mp4() { let r = m.run("file.exportMedia", json!({"path": out, "format": "h264", "range": "entire", "wait": true})); assert_eq!(r["result"]["frames"], 89, "{r}"); + verify_phone_export(&ffmpeg, &ffprobe, &src, &out); +} + +fn verify_phone_export(ffmpeg: &Path, ffprobe: &Path, src: &str, out: &str) { // container and streams - let j = probe(&ffprobe, &out); + let j = probe(ffprobe, out); let (v, a) = (streams(&j, "video"), streams(&j, "audio")); assert_eq!((v.len(), a.len()), (1, 1), "{j}"); assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("h264"), Some(320), Some(240))); @@ -322,8 +326,8 @@ fn phone_vfr_clip_extract_gain_title_to_h264_mp4() { // picture: frame k of the export is frame k (before the cut) or k + 31 (after) of the source // as an editor plays it (CFR 30, a dropped frame holding the previous one) - let src_frames = frames(&ffmpeg, &src, Some("fps=30"), "gray"); - let got = frames(&ffmpeg, &out, None, "gray"); + let src_frames = frames(ffmpeg, src, Some("fps=30"), "gray"); + let got = frames(ffmpeg, out, None, "gray"); assert!(src_frames.len() >= 120, "{} source frames", src_frames.len()); assert_eq!(got.len(), 89); for k in [20, 32, 40, 44, 45, 50, 70, 88] { @@ -359,11 +363,11 @@ fn phone_vfr_clip_extract_gain_title_to_h264_mp4() { } // sound: beeps where the flashes are (A/V sync across the extract), 6 dB down - let src_pcm = pcm(&ffmpeg, &src, 0); - let got_pcm = pcm(&ffmpeg, &out, 0); + let src_pcm = pcm(ffmpeg, src, 0); + let got_pcm = pcm(ffmpeg, out, 0); let beeps = onsets(&got_pcm); assert_eq!(beeps.len(), 2, "beeps at {beeps:?}"); - for (beep, flash) in beeps.iter().zip([30.0 / 30.0, 59.0 / 30.0]) { + for (beep, flash) in beeps.iter().zip([1.0, 59.0 / 30.0]) { assert!((beep - flash).abs() <= 1.0 / 30.0, "beep at {beep:.3} s, flash at {flash:.3} s"); } let change = db(rms(&got_pcm, 1.01, 1.09) / rms(&src_pcm, 1.01, 1.09));