Merge pull request #188 from storytold/fix/163-179-cli-mcp-engine
CLI, MCP and engine fixes (#163, #166, #167, #168, #175, #176, #177, #179)
This commit is contained in:
commit
17d844e969
|
|
@ -9,7 +9,7 @@
|
|||
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
|
||||
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
|
||||
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png]
|
||||
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
|
||||
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
|
||||
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
|
|
@ -36,10 +36,54 @@
|
|||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use effectcraft_automation::tools::{self, Reply};
|
||||
use effectcraft_automation::{Backend, McpServer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// `println!` that never panics: see [`write_line`].
|
||||
macro_rules! say {
|
||||
($($t:tt)*) => {
|
||||
write_line(format_args!($($t)*))
|
||||
};
|
||||
}
|
||||
|
||||
/// `eprintln!` that never panics: a diagnostic that can't be written is dropped.
|
||||
macro_rules! note {
|
||||
($($t:tt)*) => {{
|
||||
let _ = writeln!(std::io::stderr(), $($t)*);
|
||||
}};
|
||||
}
|
||||
|
||||
/// Why stdout stopped taking output, once it has.
|
||||
static STDOUT_FAILED: std::sync::OnceLock<std::io::ErrorKind> = std::sync::OnceLock::new();
|
||||
|
||||
/// Write a line to stdout. Once that fails, later output is dropped but the command still
|
||||
/// finishes its work (a `run … --save` sequence isn't cut short). A reader that closes stdout
|
||||
/// early (`info --json | head`) has read what it wanted; any other failure is reported and makes
|
||||
/// the exit status 1 ([`stdout_broken`]).
|
||||
fn write_line(text: std::fmt::Arguments) {
|
||||
if STDOUT_FAILED.get().is_some() {
|
||||
return;
|
||||
}
|
||||
let r = {
|
||||
let mut out = std::io::stdout().lock();
|
||||
out.write_fmt(text).and_then(|()| out.write_all(b"\n"))
|
||||
};
|
||||
if let Err(e) = r {
|
||||
let _ = STDOUT_FAILED.set(e.kind());
|
||||
if e.kind() != std::io::ErrorKind::BrokenPipe {
|
||||
note!("effectcraft-cli: cannot write to stdout: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether stdout failed for a reason other than its reader closing it early.
|
||||
fn stdout_broken() -> bool {
|
||||
STDOUT_FAILED.get().is_some_and(|k| *k != std::io::ErrorKind::BrokenPipe)
|
||||
}
|
||||
|
||||
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
|
||||
info project + engine summary
|
||||
commands [--filter TEXT] [--enabled] list engine commands
|
||||
|
|
@ -48,7 +92,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
|||
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
|
||||
get <comp> <layer> <path> [--time S] read a property
|
||||
set <comp> <layer> <path> <value> [--time S] [--expression E]
|
||||
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png]
|
||||
render-frame [--comp C] [--time S | --frame N] [--max-side PX | --scale K] [--out F.png] [--transparent]
|
||||
render [--comp C] --out FILE [--format F] [--start S] [--end S] [--work-area] [--fps N]
|
||||
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
|
||||
|
|
@ -112,6 +156,25 @@ const VALUED: &[&str] = &[
|
|||
"--opus-app",
|
||||
];
|
||||
|
||||
/// Options without a value. Any other `--option` is a usage error.
|
||||
const FLAGS: &[&str] = &[
|
||||
"--json",
|
||||
"--save",
|
||||
"--demo",
|
||||
"--empty",
|
||||
"--gpu",
|
||||
"--list",
|
||||
"--schemas",
|
||||
"--enabled",
|
||||
"--flat",
|
||||
"--transparent",
|
||||
"--work-area",
|
||||
"--queue",
|
||||
"--ops",
|
||||
"--small",
|
||||
"--adv3d",
|
||||
];
|
||||
|
||||
struct Args {
|
||||
pos: Vec<String>,
|
||||
opts: Vec<(String, Option<String>)>,
|
||||
|
|
@ -121,7 +184,7 @@ struct Args {
|
|||
|
||||
impl Args {
|
||||
fn parse(raw: Vec<String>) -> Result<Args, String> {
|
||||
let (mut pos, mut opts) = (vec![], vec![]);
|
||||
let (mut pos, mut opts, mut unknown) = (vec![], vec![], vec![]);
|
||||
let mut it = raw.into_iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a.starts_with("--") && a.len() > 2 {
|
||||
|
|
@ -132,6 +195,10 @@ impl Args {
|
|||
if VALUED.contains(&k.as_str()) {
|
||||
let v = inline.or_else(|| it.next()).ok_or_else(|| format!("{k} needs a value"))?;
|
||||
opts.push((k, Some(v)));
|
||||
} else if !FLAGS.contains(&k.as_str()) {
|
||||
unknown.push(format!("`{k}`"));
|
||||
} else if inline.is_some() {
|
||||
return Err(format!("{k} takes no value"));
|
||||
} else {
|
||||
opts.push((k, None));
|
||||
}
|
||||
|
|
@ -139,6 +206,12 @@ impl Args {
|
|||
pos.push(a);
|
||||
}
|
||||
}
|
||||
// Before anything runs: a misspelt option must not run the command with defaults, or
|
||||
// leave its value behind as a positional (`--saveas x.ecproj` opening x.ecproj).
|
||||
if !unknown.is_empty() {
|
||||
let s = if unknown.len() == 1 { "" } else { "s" };
|
||||
return Err(format!("unknown option{s} {}", unknown.join(", ")));
|
||||
}
|
||||
let mut a = Args { pos, opts, project: None };
|
||||
a.project = match a.opt("--project") {
|
||||
Some(p) => Some(p.to_string()),
|
||||
|
|
@ -173,11 +246,11 @@ fn value_arg(s: &str) -> Value {
|
|||
fn main() {
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
if matches!(raw.first().map(String::as_str), Some("--version" | "-V" | "version")) {
|
||||
println!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
say!("effectcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
return;
|
||||
}
|
||||
if raw.is_empty() || raw.iter().any(|a| a == "-h" || a == "--help") {
|
||||
println!("{USAGE}");
|
||||
say!("{USAGE}");
|
||||
std::process::exit(if raw.is_empty() { 2 } else { 0 });
|
||||
}
|
||||
let mut args = match Args::parse(raw) {
|
||||
|
|
@ -190,20 +263,21 @@ fn main() {
|
|||
}
|
||||
let cmd = args.pos.remove(0);
|
||||
match run(&cmd, &args, json_out) {
|
||||
Ok(()) if stdout_broken() => std::process::exit(1),
|
||||
Ok(()) => {}
|
||||
Err(Failure::Usage(e)) => fail_usage(&e),
|
||||
Err(Failure::Error(e)) => {
|
||||
if json_out {
|
||||
println!("{}", json!({"error": e}));
|
||||
say!("{}", json!({"error": e}));
|
||||
}
|
||||
eprintln!("effectcraft-cli {cmd}: {e}");
|
||||
note!("effectcraft-cli {cmd}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fail_usage(e: &str) -> ! {
|
||||
eprintln!("effectcraft-cli: {e}\n{USAGE}");
|
||||
note!("effectcraft-cli: {e}\n{USAGE}");
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
|
|
@ -292,13 +366,13 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
emit(&o, true);
|
||||
} else {
|
||||
if let Some(out) = r["output"].as_str().filter(|o| !o.is_empty()) {
|
||||
println!("{out}");
|
||||
say!("{out}");
|
||||
}
|
||||
if !r["result"].is_null() && r["error"].is_null() {
|
||||
println!("{}", r["result"]);
|
||||
say!("{}", r["result"]);
|
||||
}
|
||||
if let Some(p) = saved {
|
||||
eprintln!("saved {p}");
|
||||
note!("saved {p}");
|
||||
}
|
||||
}
|
||||
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
|
||||
|
|
@ -319,9 +393,9 @@ fn script_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
/// Print a result: compact JSON with `--json`, else pretty JSON.
|
||||
fn emit(v: &Value, json_out: bool) {
|
||||
if json_out {
|
||||
println!("{v}");
|
||||
say!("{v}");
|
||||
} else {
|
||||
println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
|
||||
say!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -344,19 +418,19 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
if json_out {
|
||||
emit(&info, true);
|
||||
} else {
|
||||
println!(
|
||||
say!(
|
||||
"EffectCraft {} ({}), {} commands, {} effects",
|
||||
info["version"].as_str().unwrap_or(""),
|
||||
info["mode"].as_str().unwrap_or(""),
|
||||
info["commands"],
|
||||
info["effects"]
|
||||
);
|
||||
println!("project: {}", p["path"].as_str().unwrap_or("(unsaved)"));
|
||||
say!("project: {}", p["path"].as_str().unwrap_or("(unsaved)"));
|
||||
for i in p["items"].as_array().into_iter().flatten() {
|
||||
println!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
|
||||
say!(" item {:>3} {:<12} {}", i["id"], i["type"].as_str().unwrap_or(""), i["name"].as_str().unwrap_or(""));
|
||||
}
|
||||
if let Some(c) = comp {
|
||||
println!(
|
||||
say!(
|
||||
"active comp {} \"{}\" {}x{} @ {} fps, {} s",
|
||||
c["id"],
|
||||
c["name"].as_str().unwrap_or(""),
|
||||
|
|
@ -366,7 +440,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
c["duration"]
|
||||
);
|
||||
for l in c["layers"].as_array().into_iter().flatten() {
|
||||
println!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
|
||||
say!(" #{:<3} id {:<4} {:<10} {}", l["index"], l["id"], l["type"].as_str().unwrap_or(""), l["name"].as_str().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -422,7 +496,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
if json_out {
|
||||
emit(&v, true);
|
||||
} else {
|
||||
println!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]);
|
||||
say!("layer {} \"{}\" ({}) at {} s", v["id"], v["name"].as_str().unwrap_or(""), v["type"].as_str().unwrap_or(""), v["time"]);
|
||||
for p in v["properties"].as_array().into_iter().flatten() {
|
||||
let mut extra = String::new();
|
||||
if let Some(k) = p.get("keys") {
|
||||
|
|
@ -431,7 +505,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
if let Some(e) = p.get("expression").and_then(Value::as_str) {
|
||||
extra += &format!(" expr: {e}");
|
||||
}
|
||||
println!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
|
||||
say!(" {:<44} {:<8} {}{extra}", p["path"].as_str().unwrap_or(""), p["type"].as_str().unwrap_or(""), p["value"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -474,19 +548,23 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
}
|
||||
let out = args.opt("--out").unwrap_or("frame.png").to_string();
|
||||
let t0 = std::time::Instant::now();
|
||||
let f = b.render(comp.as_ref(), time, max_side)?;
|
||||
let f = b.render_with(comp.as_ref(), time, max_side, args.flag("--transparent"))?;
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?;
|
||||
let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0});
|
||||
if json_out {
|
||||
emit(&info, true);
|
||||
} else {
|
||||
eprintln!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time);
|
||||
note!("rendered {}x{} at {:.3}s in {ms:.1} ms -> {out}", f.width, f.height, f.time);
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let b = backend(args, false)?;
|
||||
McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?;
|
||||
// A client that closes its end of stdout has ended the session.
|
||||
match McpServer::new(b).serve_stdio() {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => return Err(e.to_string().into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
other => return usage_err(&format!("unknown subcommand `{other}`")),
|
||||
}
|
||||
|
|
@ -577,7 +655,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
let _ = s.execute("renderQueue.setRender", json!({"index": k + 1, "render": false}));
|
||||
}
|
||||
let r = s.execute("renderQueue.add", p).map_err(err)?;
|
||||
eprintln!(
|
||||
note!(
|
||||
"{} → {} ({}×{}, {} frames, {})",
|
||||
r["compName"].as_str().unwrap_or("?"),
|
||||
r["outputPath"].as_str().unwrap_or("?"),
|
||||
|
|
@ -593,7 +671,8 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
if tty && let Some(p) = s.render_progress() {
|
||||
let left = p.remaining.map(|r| format!(", ~{r:.1}s left")).unwrap_or_default();
|
||||
eprint!(
|
||||
let _ = write!(
|
||||
std::io::stderr(),
|
||||
"
|
||||
[{}/{}] frame {}/{} {:.1}s{left}\x1b[K",
|
||||
(p.items_done + 1).min(p.items_total),
|
||||
|
|
@ -607,7 +686,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
}
|
||||
s.poll_render();
|
||||
if tty {
|
||||
eprintln!();
|
||||
note!();
|
||||
}
|
||||
let mut results = vec![];
|
||||
let mut failed = vec![];
|
||||
|
|
@ -615,7 +694,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
let name = s.project.item(it.comp).map(|i| i.name.clone()).unwrap_or_else(|| "?".into());
|
||||
match &it.status {
|
||||
RenderStatus::Done => {
|
||||
eprintln!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
|
||||
note!("done: {name} → {} in {:.2}s", it.last_output.as_deref().unwrap_or("?"), it.render_time.unwrap_or(0.0));
|
||||
results.push(json!({"comp": name, "output": it.last_output, "seconds": it.render_time}));
|
||||
}
|
||||
RenderStatus::Failed(e) => failed.push(format!("{name}: {e}")),
|
||||
|
|
@ -640,7 +719,7 @@ fn list_commands_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
|||
emit(&v, true);
|
||||
} else {
|
||||
for c in v.as_array().into_iter().flatten() {
|
||||
println!(
|
||||
say!(
|
||||
"{:<36} {:<36} {:<16} {}",
|
||||
c["id"].as_str().unwrap_or(""),
|
||||
c["label"].as_str().unwrap_or(""),
|
||||
|
|
@ -708,7 +787,7 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
|
|||
m.extend(effectcraft_ui_egui::bench::ui_ops(|| {
|
||||
let mut s = effectcraft_host::session();
|
||||
if let Err(e) = s.execute("file.open", json!({"path": path})) {
|
||||
eprintln!("bench --ops: {e}");
|
||||
note!("bench --ops: {e}");
|
||||
}
|
||||
s
|
||||
}));
|
||||
|
|
@ -716,9 +795,9 @@ fn bench_ops(args: &Args) -> Result<(), Failure> {
|
|||
if args.flag("--json") {
|
||||
emit(&json!({"spec": format!("{spec:?}"), "measures": m.iter().map(perf::Measure::json).collect::<Vec<_>>()}), true);
|
||||
} else {
|
||||
println!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
|
||||
say!("bench --ops: {} comps, {} layers in Main, {} footage items", spec.comps, spec.main_layers, spec.footage);
|
||||
for x in &m {
|
||||
println!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
|
||||
say!(" {:<22} {:>10.2} ms {}", x.name, x.ms, x.note);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -788,7 +867,7 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
|||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
note!(
|
||||
"bench (no cache): {n} runs at scale {}: wall min {:.2} ms, median {:.2} ms; CPU (all threads) min {:.2} ms",
|
||||
opts.scale,
|
||||
min(&totals),
|
||||
|
|
@ -796,9 +875,9 @@ fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
|||
min(&cpus)
|
||||
);
|
||||
for (d, name, p, c, fx) in per_layer {
|
||||
eprintln!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
|
||||
note!(" {:indent$}{name:<28} process {:8.2} ms composite {:8.2} ms (min)", "", min(&p), min(&c), indent = d * 2);
|
||||
for (id, v) in fx {
|
||||
eprintln!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
|
||||
note!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -819,7 +898,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
|||
let mut v: Vec<f64> = runs.iter().map(|r| r.0).collect();
|
||||
let mean = v.iter().sum::<f64>() / n as f64;
|
||||
let cpu = runs.iter().map(|r| r.1).sum::<f64>() / n as f64;
|
||||
eprintln!(
|
||||
note!(
|
||||
"bench-play ({label}): {n} frames at scale {}: wall mean {mean:.2} ms, median {:.2} ms, min {:.2} ms; CPU mean {cpu:.2} ms",
|
||||
opts.scale,
|
||||
median(&mut v),
|
||||
|
|
@ -827,7 +906,7 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
|
|||
);
|
||||
}
|
||||
let st = cache.stats();
|
||||
eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
|
||||
note!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
|
||||
}
|
||||
|
||||
/// Median wall time (ms) of `n` runs of `f` after one warm-up run.
|
||||
|
|
@ -1008,10 +1087,21 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
|||
}
|
||||
};
|
||||
let project = &project;
|
||||
eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
|
||||
eprintln!(
|
||||
note!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
|
||||
note!(
|
||||
"{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9} {:>9} {:>11}",
|
||||
"comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "auto", "gpu≠cpu", "up/dn MB"
|
||||
"comp",
|
||||
"res",
|
||||
"size",
|
||||
"cpu cold",
|
||||
"gpu cold",
|
||||
"cpu warm",
|
||||
"gpu warm",
|
||||
"gpu view",
|
||||
"speedup",
|
||||
"auto",
|
||||
"gpu≠cpu",
|
||||
"up/dn MB"
|
||||
);
|
||||
for cid in comps {
|
||||
let Some(comp) = project.comp(cid) else { continue };
|
||||
|
|
@ -1077,10 +1167,46 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
|||
.count();
|
||||
let pct = 100.0 * off as f64 / a.data.len().max(1) as f64;
|
||||
let speedup = cpu_warm / gpu_warm.max(1e-9);
|
||||
eprintln!(
|
||||
note!(
|
||||
"{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {auto:>9.2} {pct:>8.3}% {traffic:>11}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(args: &[&str]) -> Result<Args, String> {
|
||||
Args::parse(args.iter().map(|a| a.to_string()).collect())
|
||||
}
|
||||
|
||||
/// Unknown options are usage errors naming them, not ignored (#168).
|
||||
#[test]
|
||||
fn unknown_options_are_refused() {
|
||||
let err = |args: &[&str]| parse(args).err().unwrap();
|
||||
assert_eq!(err(&["exec", "comp.new", "--bogus", "--empty", "--saveas", "z.ecproj"]), "unknown options `--bogus`, `--saveas`");
|
||||
assert_eq!(err(&["render", "--bogus=1"]), "unknown option `--bogus`");
|
||||
assert_eq!(err(&["info", "--json=yes"]), "--json takes no value");
|
||||
let a = parse(&["render", "t.ecproj", "--comp=Main", "--work-area", "--out", "x.mp4", "--json"]).unwrap();
|
||||
assert_eq!((a.project.as_deref(), a.opt("--comp"), a.opt("--out")), (Some("t.ecproj"), Some("Main"), Some("x.mp4")));
|
||||
assert!(a.flag("--work-area") && a.flag("--json"));
|
||||
}
|
||||
|
||||
/// Every `--option` the CLI reads is declared in VALUED or FLAGS, so none is refused.
|
||||
#[test]
|
||||
fn every_option_read_is_declared() {
|
||||
let code = include_str!("main.rs").split("#[cfg(test)]").next().unwrap();
|
||||
for rest in code.split("\"--").skip(1) {
|
||||
let name: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '-').collect();
|
||||
let opt = format!("--{name}");
|
||||
// `--help` and `--version` are handled before parsing.
|
||||
if name.is_empty() || !rest[name.len()..].starts_with('"') || ["--help", "--version"].contains(&opt.as_str()) {
|
||||
continue;
|
||||
}
|
||||
assert!(VALUED.contains(&opt.as_str()) || FLAGS.contains(&opt.as_str()), "{opt} is read but not declared");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,60 @@ fn errors_are_json() {
|
|||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
/// An unknown option is a usage error (exit 2) naming it, before anything runs: the command
|
||||
/// doesn't run with defaults, and a misspelt option's value isn't opened as the project (#168).
|
||||
#[test]
|
||||
fn unknown_options_are_usage_errors() {
|
||||
let png = tmp("bogus.png");
|
||||
let o = png.to_str().unwrap();
|
||||
for (args, named) in [
|
||||
(&["exec", "comp.new", "--bogus", "--empty", "--json"][..], "`--bogus`"),
|
||||
(&["render-frame", "--bogusflag", "--out", o, "--json"], "`--bogusflag`"),
|
||||
(&["exec", "comp.new", "--empty", "--saveas", "z.ecproj", "--json"], "`--saveas`"),
|
||||
] {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: {err}");
|
||||
assert!(err.contains(&format!("unknown option {named}")), "{args:?}: {err}");
|
||||
assert!(out.stdout.is_empty(), "{args:?}: nothing ran");
|
||||
}
|
||||
assert!(!png.exists(), "render-frame didn't render");
|
||||
// `--transparent` (silently ignored before) keeps the frame's alpha.
|
||||
let proj = tmp("transparent.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
ok_json(&["run", "comp.new", r#"{"name":"T","width":32,"height":32}"#, "layer.newSolid", r#"{"width":8,"height":8}"#, "--empty", "--save-as", p]);
|
||||
for (flag, alpha) in [(None, 255), (Some("--transparent"), 0)] {
|
||||
ok_json(&[&["render-frame", p, "--out", o][..], flag.as_slice()].concat());
|
||||
assert_eq!(image::open(&png).unwrap().to_rgba8().get_pixel(0, 0)[3], alpha, "{flag:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A reader that closes stdout before the CLI writes (`| head`) is not a crash: the command
|
||||
/// still does its work and exits 0, without a panic (#167).
|
||||
#[test]
|
||||
fn a_closed_stdout_is_not_a_crash() {
|
||||
let proj = tmp("closed-stdout.ecproj");
|
||||
let p = proj.to_str().unwrap();
|
||||
for args in [&["info", "--json"][..], &["exec", "--list"], &["run", "comp.new", r#"{"name":"Piped"}"#, "project.summary", "--empty", "--save-as", p]] {
|
||||
let mut child = bin().args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(0), "{args:?}: {err}");
|
||||
assert!(!err.contains("panicked"), "{args:?}: {err}");
|
||||
}
|
||||
assert!(std::fs::read_to_string(&proj).unwrap().contains("Piped"), "the sequence ran to the end and saved");
|
||||
// The MCP server ends quietly when its client closes stdout.
|
||||
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let _ = writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "ping"}));
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(0), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio() {
|
||||
let mut child = bin().args(["mcp", "--demo"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
|
|
@ -185,4 +239,11 @@ fn script_file_and_eval() {
|
|||
let (code, v) = run_json(&["script", "--eval", "\nnope()"]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(v["error"]["line"], json!(2));
|
||||
// A compiled .jsxbin script is reported as unsupported, not as a SyntaxError (#176).
|
||||
let bin_script = tmp("compiled.jsxbin");
|
||||
std::fs::write(&bin_script, "@JSXBIN@ES@2.0@MyBbyBn0ABJAnAEjzFjBjMjFjSjUBfRBFeFjIjFjMjMjPff0DzACByB\n").unwrap();
|
||||
let out = bin().args(["script", bin_script.to_str().unwrap()]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains(".jsxbin scripts are not supported: run the .jsx source") && !err.contains("SyntaxError"), "{err}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,28 @@ fn headless_workflow() {
|
|||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// An expression with a syntax error is kept but disabled; set_property and get_property report
|
||||
/// its error, and `evaluated` is the static value that renders (#163).
|
||||
#[test]
|
||||
fn expression_syntax_errors_reach_the_reply() {
|
||||
let mut s = McpServer::new(Backend::headless(effectcraft_host::session()));
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000", "width": 32, "height": 8}}));
|
||||
let at = |expr: &str| json!({"layer": "S", "path": "transform/rotation", "expression": expr});
|
||||
call_json(&mut s, "set_property", json!({"layer": "S", "path": "transform/rotation", "value": 30}));
|
||||
let set = call_json(&mut s, "set_property", at("thisIsBroken("));
|
||||
let got = call_json(&mut s, "get_property", json!({"layer": "S", "path": "transform/rotation"}));
|
||||
for r in [&set, &got] {
|
||||
assert_eq!((&r["expression"], &r["evaluated"]), (&json!("thisIsBroken("), &json!(30.0)), "{r}");
|
||||
assert!(r["expressionError"].as_str().unwrap().contains("SyntaxError"), "{r}");
|
||||
}
|
||||
// A runtime error and a valid expression report as before.
|
||||
let r = call_json(&mut s, "set_property", at("nope*2"));
|
||||
assert!(r["expressionError"].as_str().unwrap().contains("nope is not defined"), "{r}");
|
||||
let r = call_json(&mut s, "set_property", at("60"));
|
||||
assert_eq!((&r["evaluated"], r.get("expressionError")), (&json!(60.0), None), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_loop() {
|
||||
let mut s = server();
|
||||
|
|
@ -394,6 +416,32 @@ fn high_level_tools_add_effect_and_report_state() {
|
|||
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown effect"), "{c:?}");
|
||||
}
|
||||
|
||||
/// add_effect is one undo step, and a call that fails leaves the layer and the history as they
|
||||
/// were (#175).
|
||||
#[test]
|
||||
fn add_effect_is_one_undo_step_and_all_or_nothing() {
|
||||
let mut s = server();
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 64}}));
|
||||
call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"name": "S", "color": "#ff0000"}}));
|
||||
let undo = |s: &mut McpServer| call_json(s, "get_project", json!({}))["undo"].as_array().unwrap().clone();
|
||||
let effects = |s: &mut McpServer| {
|
||||
let l = call_json(s, "get_layer", json!({"layer": "S", "flat": true}));
|
||||
l["properties"].as_array().unwrap().iter().filter(|p| p["path"].as_str().unwrap().starts_with("effects/")).count()
|
||||
};
|
||||
let before = undo(&mut s);
|
||||
let (c, err) = call(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdgePixels": true}}));
|
||||
assert!(err && c[0]["text"].as_str().unwrap().contains("effects/#1/repeatEdgePixels"), "{c:?}");
|
||||
assert_eq!((undo(&mut s), effects(&mut s)), (before.clone(), 0), "nothing applied");
|
||||
// The corrected retry lands at #1, in one undo step named like effect.apply's.
|
||||
let r = call_json(&mut s, "add_effect", json!({"layer": "S", "effect": "Gaussian Blur", "values": {"blurriness": 8, "repeatEdge": true}}));
|
||||
assert_eq!((&r["path"], &r["set"]), (&json!("effects/#1"), &json!(["effects/#1/blurriness", "effects/#1/repeatEdge"])), "{r}");
|
||||
let after = undo(&mut s);
|
||||
assert_eq!(after.len(), before.len() + 1, "{after:?}");
|
||||
assert_eq!(after.last(), Some(&json!("Apply Gaussian Blur")));
|
||||
call_json(&mut s, "undo", json!({}));
|
||||
assert_eq!((undo(&mut s), effects(&mut s)), (before, 0), "one undo removes the effect and its values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_fonts_lists_families_with_styles() {
|
||||
let mut s = server();
|
||||
|
|
|
|||
|
|
@ -154,7 +154,8 @@ fn list_fonts(b: &mut Backend, a: &Value) -> Result<Reply> {
|
|||
json_reply(b.exec("text.fonts", obj(&[("query", get(a, "query")), ("rescan", get(a, "rescan"))]))?)
|
||||
}
|
||||
|
||||
/// `effect.apply` on one layer, then set parameters on the new instance by param id.
|
||||
/// `effect.apply` on one layer, then set parameters on the new instance by param id: one batch,
|
||||
/// so one undo step, and a failing value leaves nothing applied.
|
||||
fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let (layer, effect, comp) = (need(a, "layer")?, need(a, "effect")?, get(a, "comp"));
|
||||
let count = |t: &Value| effects_group(t).and_then(|g| g.get("children")).and_then(Value::as_array).map_or(0, Vec::len);
|
||||
|
|
@ -162,16 +163,22 @@ fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
|
|||
let index = count(&before) + 1;
|
||||
let mut p = obj(&[("effect", Some(effect)), ("comp", comp)]);
|
||||
p["layers"] = json!([layer]);
|
||||
b.exec("effect.apply", p)?;
|
||||
let mut steps = vec![json!({"command": "effect.apply", "params": p})];
|
||||
let prefix = format!("effects/#{index}");
|
||||
let mut set = vec![];
|
||||
if let Some(vals) = get(a, "values").and_then(Value::as_object) {
|
||||
for (k, v) in vals {
|
||||
let path = format!("{prefix}/{k}");
|
||||
b.exec("prop.set", obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))]))?;
|
||||
steps.push(
|
||||
json!({"command": "prop.set", "params": obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))])}),
|
||||
);
|
||||
set.push(path);
|
||||
}
|
||||
}
|
||||
// The undo step is named like effect.apply's own ("Apply Gaussian Blur").
|
||||
let name = effect.as_str().map(|e| effectcraft_engine::effects::lookup(e).map_or(e, |s| s.name));
|
||||
let label = name.map(|n| json!(format!("Apply {n}")));
|
||||
b.exec("engine.batch", obj(&[("steps", Some(&json!(steps))), ("label", label.as_ref())]))?;
|
||||
let tree = b.exec("layer.tree", obj(&[("layer", Some(layer)), ("comp", comp)]))?;
|
||||
let inst = effects_group(&tree).and_then(|g| g.get("children")).and_then(Value::as_array).and_then(|c| c.get(index - 1)).cloned();
|
||||
let mut params = vec![];
|
||||
|
|
@ -590,7 +597,7 @@ static TOOLS: &[ToolDef] = &[
|
|||
},
|
||||
ToolDef {
|
||||
name: "add_effect",
|
||||
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
|
||||
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. One undo step; if a value fails, nothing is applied. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"New Composition...",
|
||||
["Composition"],
|
||||
Some("Cmd+N"),
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer? classic3D|advanced3D, anchor?, open?}",
|
||||
"{name?, width?, height?, frameRate?, duration? (s), startTime? (s) | startTimecode?, background? [r,g,b]|#hex, pixelAspect?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool, preserveResolution?: bool, renderer?: classic3D|advanced3D, anchor?, open?}",
|
||||
always,
|
||||
new_comp
|
||||
),
|
||||
|
|
@ -277,7 +277,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Composition Settings...",
|
||||
["Composition"],
|
||||
Some("Cmd+K"),
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer? classic3D|advanced3D}",
|
||||
"{comp?, name?, width?, height?, anchor? 0-8 (resize anchor, 4 = center), frameRate?, duration?, startTime? (s) | startTimecode?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?, adaptiveSampleLimit? (16–256), preserveFrameRate?: bool (nested or in the render queue it shows only its own frames), preserveResolution?: bool (nested, it renders at full size), pixelAspect?, renderer?: classic3D|advanced3D}",
|
||||
has_comp,
|
||||
settings
|
||||
),
|
||||
|
|
|
|||
|
|
@ -438,8 +438,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
&& p.get("steps").is_none()
|
||||
&& is_js_script(path)
|
||||
{
|
||||
if path.to_ascii_lowercase().ends_with(".jsxbin") {
|
||||
return Err(bad("file.runScript", "binary .jsxbin scripts are not supported: run the .jsx source"));
|
||||
if is_jsxbin(path) {
|
||||
return Err(bad("file.runScript", JSXBIN));
|
||||
}
|
||||
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||
let code = String::from_utf8_lossy(&bytes).to_string();
|
||||
|
|
@ -487,7 +487,14 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// command scripts.
|
||||
fn is_js_script(path: &str) -> bool {
|
||||
let l = path.to_ascii_lowercase();
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin")
|
||||
l.ends_with(".jsx") || l.ends_with(".js") || is_jsxbin(path)
|
||||
}
|
||||
|
||||
/// Compiled `.jsxbin` scripts are an undocumented binary encoding, not JavaScript.
|
||||
const JSXBIN: &str = "binary .jsxbin scripts are not supported: run the .jsx source";
|
||||
|
||||
fn is_jsxbin(name: &str) -> bool {
|
||||
name.to_ascii_lowercase().ends_with(".jsxbin")
|
||||
}
|
||||
|
||||
/// A script error becomes a toast and a command error.
|
||||
|
|
@ -519,6 +526,11 @@ fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result<Valu
|
|||
fn script_run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let code = str_p(p, "code").ok_or_else(|| bad("script.run", "missing `code` (JavaScript)"))?.to_string();
|
||||
let name = str_p(p, "name").unwrap_or("script").to_string();
|
||||
// A compiled script (named `.jsxbin`, or its text starts with the format's header) would only
|
||||
// fail to parse at 1:1.
|
||||
if is_jsxbin(&name) || code.trim_start_matches('\u{feff}').trim_start().starts_with("@JSXBIN@") {
|
||||
return Err(bad("script.run", JSXBIN));
|
||||
}
|
||||
run_js(s, &code, &name, super::b_p(p, "console").unwrap_or(false))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,8 +118,14 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()),
|
||||
});
|
||||
// With an expression, `value` is the keyframed (pre-expression) value; also report what the
|
||||
// expression makes of it (what renders), or why it fails.
|
||||
if pr.has_expression()
|
||||
// expression makes of it (what renders), or why it fails. A disabled one renders `value`; one
|
||||
// disabled by its syntax error (prop.setExpression) reports that error.
|
||||
if let Some(e) = pr.expr.as_ref().filter(|e| !e.enabled && !e.text.trim().is_empty()) {
|
||||
out["evaluated"] = raw.to_json();
|
||||
if let Some(err) = s.expr_check.and_then(|check| check(&e.text).err()) {
|
||||
out["expressionError"] = json!(err);
|
||||
}
|
||||
} else if pr.has_expression()
|
||||
&& let (Some(h), Some(comp)) = (s.expr.as_deref(), s.project.comp(cid))
|
||||
{
|
||||
let mut ctx = crate::render::EvalCtx::new(&s.project, cid, comp, t);
|
||||
|
|
|
|||
|
|
@ -219,6 +219,9 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?;
|
||||
om.set_format(f);
|
||||
}
|
||||
// Before Channels: the WebM codec decides whether alpha can be written, so an explicit
|
||||
// `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166).
|
||||
any |= apply_codec_options(om, p, cmd)?;
|
||||
if let Some(c) = str_p(p, "channels") {
|
||||
om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() {
|
||||
"rgb" => Channels::Rgb,
|
||||
|
|
@ -227,7 +230,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
_ => return Err(bad(cmd, "channels: rgb|rgba|alpha")),
|
||||
};
|
||||
if om.channels == Channels::Rgba && !om.supports_alpha() {
|
||||
return Err(bad(cmd, format!("{} has no alpha channel", om.format.label())));
|
||||
let what = match om.format {
|
||||
OutputFormat::WebM => format!("{} WebM has no alpha channel (VP9 WebM has)", om.webm_codec.label()),
|
||||
f => format!("{} has no alpha channel", f.label()),
|
||||
};
|
||||
return Err(bad(cmd, what));
|
||||
}
|
||||
}
|
||||
if let Some(c) = str_p(p, "color").or(str_p(p, "alphaMode")) {
|
||||
|
|
@ -237,7 +244,6 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
_ => return Err(bad(cmd, "color: straight|premultiplied")),
|
||||
};
|
||||
}
|
||||
any |= apply_codec_options(om, p, cmd)?;
|
||||
if let Some(q) = f_p(p, "quality") {
|
||||
om.quality = q.clamp(1.0, 100.0) as u8;
|
||||
}
|
||||
|
|
@ -360,7 +366,8 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<
|
|||
}
|
||||
if let Some(o) = str_p(p, "output").or(str_p(p, "path")) {
|
||||
om.output = o.to_string();
|
||||
if let Some(f) = OutputFormat::from_path(o).filter(|f| *f != om.format && !o.contains("[fileExtension]")) {
|
||||
// Only an extension the format doesn't write changes it: `.mp4` is H.264's, HEVC's and AV1's.
|
||||
if let Some(f) = OutputFormat::from_path(o).filter(|f| f.extension() != om.format.extension() && !o.contains("[fileExtension]")) {
|
||||
if p.get("format").is_some() {
|
||||
// An explicit format wins and the extension follows it, as choosing a format in
|
||||
// the Output Module renames Output To (#154: `--format hevc --out x.mp4`).
|
||||
|
|
|
|||
|
|
@ -371,6 +371,27 @@ fn comp_settings_anchor_start_timecode_renderer() {
|
|||
assert_eq!(c.renderer, crate::project::Renderer::Advanced3D);
|
||||
}
|
||||
|
||||
/// `advanced3D` is a value of `renderer`, not a key: comp.new / comp.settings don't advertise it
|
||||
/// in their schemas and reject it as unknown instead of ignoring it (#177).
|
||||
#[test]
|
||||
fn renderer_values_are_not_parameter_keys() {
|
||||
let mut s = Session::default();
|
||||
for (id, p) in [("comp.new", json!({"name": "A"})), ("comp.settings", json!({}))] {
|
||||
let spec = crate::command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
let keys = crate::commands::accepted_params(spec.params).unwrap();
|
||||
assert!(keys.contains(&"renderer".to_string()) && !keys.contains(&"advanced3D".to_string()), "{id}: {keys:?}");
|
||||
let mut bad = p.clone();
|
||||
bad["advanced3D"] = json!(true);
|
||||
let e = s.execute_checked(id, bad).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown parameter(s) `advanced3D`"), "{id}: {e}");
|
||||
let mut good = p;
|
||||
good["renderer"] = json!("advanced3D");
|
||||
s.execute_checked(id, good).unwrap();
|
||||
assert_eq!(s.active_comp().unwrap().renderer, crate::project::Renderer::Advanced3D, "{id}");
|
||||
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_rejects_zero_and_negative_frame_rates() {
|
||||
let mut s = Session::default();
|
||||
|
|
|
|||
|
|
@ -83,6 +83,52 @@ fn an_explicit_format_wins_over_the_output_extension() {
|
|||
assert_eq!((om.format, om.output.as_str()), (OutputFormat::PngSequence, "/out/still.png"));
|
||||
}
|
||||
|
||||
/// Output To only changes the format when the format can't write the new extension: HEVC and AV1
|
||||
/// write `.mp4` too, so they stay (#179). Other extensions still pick a file type.
|
||||
#[test]
|
||||
fn output_to_keeps_a_format_that_writes_the_extension() {
|
||||
let mut s = session();
|
||||
let cases = [
|
||||
("hevc", "/out/x.mp4", OutputFormat::Hevc, "/out/x.mp4"),
|
||||
("av1", "/out/x.mp4", OutputFormat::Av1, "/out/x.mp4"),
|
||||
("h264", "/out/x.mov", OutputFormat::ProRes, "/out/x.mov"),
|
||||
("prores", "/out/x.mp4", OutputFormat::H264, "/out/x.mp4"),
|
||||
("h264", "/out/x.mp4", OutputFormat::H264, "/out/x.mp4"),
|
||||
];
|
||||
for (format, out, want, path) in cases {
|
||||
let a = s.execute("renderQueue.add", json!({"format": format})).unwrap();
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": a["item"], "path": out})).unwrap();
|
||||
let om = &s.project.render_queue.last().unwrap().output;
|
||||
assert_eq!((om.format, om.output.as_str()), (want, path), "{format} → {out}");
|
||||
if OutputFormat::from_name(format) == Some(want) {
|
||||
assert_eq!(r["outputModuleSummary"], a["outputModuleSummary"], "{format} → {out}: the module is unchanged");
|
||||
}
|
||||
}
|
||||
// The module's own default name (HEVC resolves to `Main.mp4`) changes nothing either.
|
||||
let a = s.execute("renderQueue.add", json!({"format": "hevc"})).unwrap();
|
||||
let r = s.execute("renderQueue.setOutput", json!({"item": a["item"], "path": a["outputPath"]})).unwrap();
|
||||
assert_eq!((&r["output"]["format"], &r["outputModuleSummary"]), (&json!("Hevc"), &a["outputModuleSummary"]), "{r}");
|
||||
}
|
||||
|
||||
/// An explicit RGB + Alpha request with the AV1 WebM codec (which has no alpha) is refused, not
|
||||
/// silently rendered opaque (#166). Without `channels`, AV1 WebM renders RGB as before.
|
||||
#[test]
|
||||
fn av1_webm_refuses_requested_alpha() {
|
||||
let mut s = session();
|
||||
let n = s.project.render_queue.len();
|
||||
let e = s.execute("renderQueue.add", json!({"format": "webm", "webmCodec": "av1", "channels": "rgba"})).unwrap_err().to_string();
|
||||
assert!(e.contains("AV1 WebM has no alpha channel"), "{e}");
|
||||
assert_eq!(s.project.render_queue.len(), n, "nothing queued");
|
||||
let a = s.execute("renderQueue.add", json!({"format": "webm", "channels": "rgba"})).unwrap();
|
||||
let e = s.execute("renderQueue.setOutputModule", json!({"item": a["item"], "webmCodec": "av1", "channels": "rgba"})).unwrap_err().to_string();
|
||||
assert!(e.contains("AV1 WebM has no alpha channel"), "{e}");
|
||||
let om = &s.project.render_queue.last().unwrap().output;
|
||||
assert_eq!((om.webm_codec, om.channels), (WebmVideoCodec::Vp9, Channels::Rgba), "unchanged");
|
||||
s.execute("renderQueue.add", json!({"format": "webm", "webmCodec": "av1"})).unwrap();
|
||||
let om = &s.project.render_queue.last().unwrap().output;
|
||||
assert_eq!((om.webm_codec, om.channels), (WebmVideoCodec::Av1, Channels::Rgb));
|
||||
}
|
||||
|
||||
/// A `comp` that names no composition says so, rather than "no active composition" (#155).
|
||||
#[test]
|
||||
fn an_unknown_comp_is_named_in_the_error() {
|
||||
|
|
|
|||
|
|
@ -376,6 +376,12 @@ fn engine_entry_points() {
|
|||
// Scripts can't run scripts.
|
||||
let r = s.execute("script.run", json!({"code": "app.run('script.run', {code: '1'})"})).unwrap();
|
||||
assert!(r["error"]["message"].as_str().unwrap().contains("scripts can't run scripts"), "{r}");
|
||||
// Compiled .jsxbin scripts (by name or by their header) are unsupported, not a SyntaxError
|
||||
// at 1:1 (#176).
|
||||
for p in [json!({"code": "@JSXBIN@ES@2.0@MyBbyBn0AB", "name": "compiled.bin"}), json!({"code": "1", "name": "/x/compiled.JSXBIN"})] {
|
||||
let e = s.execute("script.run", p.clone()).unwrap_err().to_string();
|
||||
assert!(e.contains(".jsxbin scripts are not supported: run the .jsx source"), "{p}: {e}");
|
||||
}
|
||||
// File ▸ Scripts ▸ Run Script File… runs .jsx as JavaScript.
|
||||
let dir = std::env::temp_dir().join(format!("ec-script-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ The server speaks JSON-RPC 2.0 over stdio, one message per line, and supports MC
|
|||
| `add_keyframe {layer, path, time+value \| keys:[...], interpolation?, comp?}` | Adds keys, then optionally applies linear/bezier/hold/easyEase. |
|
||||
| `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. |
|
||||
| `list_fonts {query?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin and own-language name; `rescan` picks up fonts installed since launch. |
|
||||
| `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call; returns the instance path (`effects/#n`) and its parameter paths. |
|
||||
| `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. |
|
||||
| `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. |
|
||||
| `open_project {path \| demo \| new}` / `save_project {path?}` | Open and save files. |
|
||||
| `undo {steps?}` / `redo {steps?}` | History. |
|
||||
|
|
@ -136,7 +136,8 @@ build real projects through these interfaces; they are worked examples of everyt
|
|||
* **One call, one undo step**: `batch {"steps":[{"command":"layer.newShape","params":{"kind":"ellipse","name":"Ring"}},{"command":"layer.addShapeItem","params":{"layer":"$1.layer","kind":"trim"}},{"command":"prop.addKey","params":{"layer":"$1.layer","path":"transform/opacity","time":0,"value":0}}]}`
|
||||
(a string param that is exactly `"$N"` or `"$N.key"` is replaced; `$2.path` is the trim's path).
|
||||
* **Expressions**: `set_property`/`get_property` replies carry `evaluated` (what renders) next to
|
||||
`value` (the keyframed value) and `expressionError` when the expression fails.
|
||||
`value` (the keyframed value) and `expressionError` when the expression fails. An expression with
|
||||
a syntax error is kept but disabled, so it renders `value` and reports the syntax error.
|
||||
* **Keyframes like a person**: `keys.select {keys, toggle: true}` (Shift+click), `keys.selectEqual`
|
||||
/ `selectPrevious` / `selectFollowing`, `keys.move {delta, merge}` (steps sharing a merge key are
|
||||
one drag: each applies to the keys as they were before it, so a key passed over survives) and
|
||||
|
|
@ -430,7 +431,8 @@ that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript
|
|||
|
||||
Each invocation runs a headless engine with no window. It opens the demo project unless you pass
|
||||
`--project F.ecproj`, a positional `*.ecproj` or `--empty`. Add `--json` for one compact JSON document
|
||||
on stdout. Errors print `{"error": ...}` and exit with status 1; usage errors exit with status 2.
|
||||
on stdout. Errors print `{"error": ...}` and exit with status 1; usage errors, such as an unknown
|
||||
option, exit with status 2 before anything runs.
|
||||
|
||||
```sh
|
||||
effectcraft-cli info --json
|
||||
|
|
|
|||
Loading…
Reference in New Issue