Refactor filmcraft-cli for strict Clippy checks

This commit is contained in:
Dylan M. Taylor
2026-10-28 02:05:37 +00:00
parent 343ce0503f
commit be560f5aa8
2 changed files with 239 additions and 216 deletions
+229 -210
View File
@@ -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 <media> [--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 <media>"));
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 <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();
outln!("{:<32} {:<34} {:<14} {}", s("id"), s("label"), s("shortcut"), s("params"));
}
}
}
"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 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 {
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<String> = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).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" => {
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 <out> [--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<Value> = 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 <media> [--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 <media>"));
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 <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();
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 <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;
}
async fn cli_run(a: &Args) {
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 {
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<String> = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).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;
}
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 <out> [--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<Value> = 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`).
+10 -6
View File
@@ -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));