Files
effectcraft/apps/effectcraft-cli/src/main.rs
T
Brandon ThomasandClaude Opus 5.5 83aabf0a87 Merge main into M7 3D branch
Resolve conflicts with perf (layer cache / Arc<Buf>), export (RenderOpts.view: None
for output), timeline (separate dimensions + one-node POI visibility), mask/pen
viewer gestures (alongside camera tools and view-aware layer matrices), key
dialogs (alongside Camera/Light Settings), render queue + MCP command modules.
3D path tangent honours separated position; DOF blurs a per-frame copy so cached
layer buffers stay shared. New test: cached renders match uncached after camera,
focus, light and 3D view changes. Fix a clone-on-copy lint from main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:24:08 -04:00

667 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! EffectCraft headless CLI: one-shot agent commands, frame rendering and the MCP server.
//!
//! ```text
//! effectcraft-cli info project + engine summary
//! effectcraft-cli commands [--filter TEXT] [--enabled] list engine commands
//! effectcraft-cli exec <command-id> [--params JSON] run one command
//! effectcraft-cli run <id> <json> [<id> <json> …] run several commands in order
//! 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 [--comp C] --out FILE [--format h264|prores|png|jpeg|tiff|exr|gif] [--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]
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] render timings
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
//!
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
//! Saving: --save (back to --project) | --save-as F.ecproj
//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session
//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1)
//! ```
//!
//! `<comp>` is a comp id or name (`-` = the active comp); `<layer>` an id, `#n` or name; `<value>`
//! is JSON (`50`, `[960,540]`, `"#ff0000"`) or a bare string. See `docs/agents.md`.
use effectcraft_automation::tools::{self, Reply};
use effectcraft_automation::{Backend, McpServer};
use serde_json::{Value, json};
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|mcp> [args] [--json]
info project + engine summary
commands [--filter TEXT] [--enabled] list engine commands
exec <command-id> [--params JSON] run one engine command
run <id> <json> [<id> <json> ...] run several commands in order
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 [--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] | --queue
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] per-layer/effect render timings;
--play N renders N consecutive frames with/without the layer cache
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
/// Options that take a value.
const VALUED: &[&str] = &[
"--n",
"--play",
"--params",
"--project",
"--bridge",
"--save-as",
"--filter",
"--time",
"--frame",
"--comp",
"--max-side",
"--scale",
"--out",
"--expression",
"--depth",
// render
"--format",
"--start",
"--end",
"--fps",
"--resolution",
"--quality",
"--channels",
"--jpeg-quality",
"--bitrate",
"--prores",
"--audio",
];
struct Args {
pos: Vec<String>,
opts: Vec<(String, Option<String>)>,
/// `--project F` or a positional `*.ecproj`.
project: Option<String>,
}
impl Args {
fn parse(raw: Vec<String>) -> Result<Args, String> {
let (mut pos, mut opts) = (vec![], vec![]);
let mut it = raw.into_iter();
while let Some(a) = it.next() {
if a.starts_with("--") && a.len() > 2 {
let (k, inline) = match a.split_once('=') {
Some((k, v)) => (k.to_string(), Some(v.to_string())),
None => (a, None),
};
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 {
opts.push((k, None));
}
} else {
pos.push(a);
}
}
let mut a = Args { pos, opts, project: None };
a.project = match a.opt("--project") {
Some(p) => Some(p.to_string()),
None => a.pos.iter().position(|x| x.ends_with(".ecproj")).map(|i| a.pos.remove(i)),
};
Ok(a)
}
fn flag(&self, k: &str) -> bool {
self.opts.iter().any(|(o, _)| o == k)
}
fn opt(&self, k: &str) -> Option<&str> {
self.opts.iter().rev().find(|(o, _)| o == k).and_then(|(_, v)| v.as_deref())
}
fn num(&self, k: &str) -> Result<Option<f64>, String> {
self.opt(k).map(|v| v.parse::<f64>().map_err(|_| format!("{k}: not a number: {v}"))).transpose()
}
}
/// A comp/layer reference: number → id, `-` → none (active), else a name / `#n`.
fn reference(s: &str) -> Option<Value> {
match s {
"-" | "" => None,
_ => Some(s.parse::<u64>().map(Value::from).unwrap_or_else(|_| json!(s))),
}
}
/// JSON if it parses, else the bare string.
fn value_arg(s: &str) -> Value {
serde_json::from_str(s).unwrap_or_else(|_| json!(s))
}
fn main() {
let raw: Vec<String> = std::env::args().skip(1).collect();
if raw.is_empty() || raw.iter().any(|a| a == "-h" || a == "--help") {
println!("{USAGE}");
std::process::exit(if raw.is_empty() { 2 } else { 0 });
}
let mut args = match Args::parse(raw) {
Ok(a) => a,
Err(e) => fail_usage(&e),
};
let json_out = args.flag("--json");
if args.pos.is_empty() {
fail_usage("missing subcommand");
}
let cmd = args.pos.remove(0);
match run(&cmd, &args, json_out) {
Ok(()) => {}
Err(Failure::Usage(e)) => fail_usage(&e),
Err(Failure::Error(e)) => {
if json_out {
println!("{}", json!({"error": e}));
}
eprintln!("effectcraft-cli {cmd}: {e}");
std::process::exit(1);
}
}
}
fn fail_usage(e: &str) -> ! {
eprintln!("effectcraft-cli: {e}\n{USAGE}");
std::process::exit(2)
}
enum Failure {
Usage(String),
Error(String),
}
impl From<effectcraft_automation::Error> for Failure {
fn from(e: effectcraft_automation::Error) -> Self {
Failure::Error(e.to_string())
}
}
impl From<String> for Failure {
fn from(e: String) -> Self {
Failure::Error(e)
}
}
fn usage_err<T>(m: &str) -> Result<T, Failure> {
Err(Failure::Usage(m.to_string()))
}
/// Build the backend: a bridge to the app, or a headless session with the requested project.
fn backend(args: &Args, default_demo: bool) -> Result<Backend, Failure> {
if let Some(addr) = args.opt("--bridge") {
if args.project.is_some() || args.flag("--demo") {
return usage_err("--bridge drives the running app's project; use `exec file.open` to open another");
}
return Ok(Backend::bridge(addr)?);
}
let mut b = Backend::headless(effectcraft_host::session());
if let Some(p) = &args.project {
b.exec("file.open", json!({"path": p}))?;
} else if args.flag("--demo") || (default_demo && !args.flag("--empty")) {
b.exec("file.openDemoProject", json!({}))?;
}
Ok(b)
}
/// `--save` / `--save-as` after a mutating command.
fn maybe_save(b: &mut Backend, args: &Args) -> Result<Option<String>, Failure> {
let r = if let Some(p) = args.opt("--save-as") {
b.exec("file.saveAs", json!({"path": p}))?
} else if args.flag("--save") {
b.exec("file.save", json!({})).map_err(|e| Failure::Error(format!("{e} (use --project F to save in place, or --save-as F)")))?
} else {
return Ok(None);
};
Ok(r.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| args.opt("--save-as").map(str::to_string)))
}
fn tool(b: &mut Backend, name: &str, a: Value) -> Result<Value, Failure> {
match tools::run(b, name, &a)? {
Reply::Json(v) => Ok(v),
Reply::Image { info, .. } => Ok(info),
}
}
/// Print a result: compact JSON with `--json`, else pretty JSON.
fn emit(v: &Value, json_out: bool) {
if json_out {
println!("{v}");
} else {
println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
}
}
fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
match cmd {
"render" => render(args, json_out)?,
"bench" => bench_cmd(args)?,
"info" => {
let mut b = backend(args, true)?;
let p = tool(&mut b, "get_project", json!({}))?;
let comp = tool(&mut b, "get_comp", json!({})).ok();
let info = json!({
"version": env!("CARGO_PKG_VERSION"),
"mode": if b.is_bridge() { "bridge" } else { "headless" },
"commands": effectcraft_engine::command_specs().len(),
"effects": effectcraft_engine::effects::registry().len(),
"project": p,
"activeComp": comp,
});
if json_out {
emit(&info, true);
} else {
println!(
"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)"));
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(""));
}
if let Some(c) = comp {
println!(
"active comp {} \"{}\" {}x{} @ {} fps, {} s",
c["id"],
c["name"].as_str().unwrap_or(""),
c["width"],
c["height"],
c["frameRate"],
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(""));
}
}
}
}
"commands" => {
let mut b = backend(args, false)?;
let v = tool(&mut b, "list_commands", json!({"filter": args.opt("--filter"), "enabled_only": args.flag("--enabled")}))?;
if json_out {
emit(&v, true);
} else {
for c in v.as_array().into_iter().flatten() {
println!(
"{:<36} {:<36} {:<16} {}",
c["id"].as_str().unwrap_or(""),
c["label"].as_str().unwrap_or(""),
c["shortcut"].as_str().unwrap_or(""),
c["params"].as_str().unwrap_or("")
);
}
}
}
"exec" => {
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
let params = match (args.opt("--params"), args.pos.get(1).map(String::as_str)) {
(Some(p), _) | (None, Some(p)) => serde_json::from_str(p).map_err(|e| Failure::Usage(format!("params are not valid JSON: {e}")))?,
(None, None) => json!({}),
};
let mut b = backend(args, true)?;
let r = b.exec(&id, params)?;
let saved = maybe_save(&mut b, args)?;
emit(&with_saved(r, saved), json_out);
}
"run" => {
if args.pos.is_empty() {
return usage_err("run needs <command-id> [<json>] pairs");
}
let pairs = args.pos.clone();
let mut b = backend(args, true)?;
let mut results = vec![];
let mut i = 0;
while i < pairs.len() {
let id = &pairs[i];
let params = match pairs.get(i + 1).filter(|p| p.trim_start().starts_with('{')) {
Some(p) => {
i += 1;
serde_json::from_str(p).map_err(|e| Failure::Usage(format!("{id}: params are not valid JSON: {e}")))?
}
None => json!({}),
};
i += 1;
let r = b.exec(id, params).map_err(|e| Failure::Error(format!("{id}: {e}")))?;
if !json_out {
emit(&r, false);
}
results.push(json!({"command": id, "result": r}));
}
let saved = maybe_save(&mut b, args)?;
if json_out {
emit(&with_saved(json!(results), saved), true);
}
}
"props" => {
let [comp, layer] = positional::<2>(args, "props <comp> <layer>")?;
let mut b = backend(args, true)?;
let a = json!({"comp": reference(&comp), "layer": reference(&layer), "flat": args.flag("--flat") || !json_out, "time": args.num("--time")?, "depth": args.num("--depth")?});
let v = tool(&mut b, "get_layer", a)?;
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"]);
for p in v["properties"].as_array().into_iter().flatten() {
let mut extra = String::new();
if let Some(k) = p.get("keys") {
extra += &format!(" [{k} keys]");
}
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"]);
}
}
}
"get" => {
let [comp, layer, path] = positional::<3>(args, "get <comp> <layer> <path>")?;
let mut b = backend(args, true)?;
let v = tool(&mut b, "get_property", json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "time": args.num("--time")?}))?;
emit(&v, json_out);
}
"set" => {
let expr = args.opt("--expression").map(str::to_string);
let need = if expr.is_some() { 3 } else { 4 };
if args.pos.len() < need {
return usage_err("set <comp> <layer> <path> <value> [--time S] [--expression E]");
}
let (comp, layer, path) = (args.pos[0].clone(), args.pos[1].clone(), args.pos[2].clone());
let value = args.pos.get(3).map(|v| value_arg(v));
let mut b = backend(args, true)?;
let a =
json!({"comp": reference(&comp), "layer": reference(&layer), "path": path, "value": value, "time": args.num("--time")?, "expression": expr});
let v = tool(&mut b, "set_property", a)?;
let saved = maybe_save(&mut b, args)?;
emit(&with_saved(v, saved), json_out);
}
"render-frame" | "frame" => {
let mut b = backend(args, true)?;
let comp = args.opt("--comp").and_then(reference);
let mut time = args.num("--time")?;
let mut max_side = args.num("--max-side")?.map(|m| m as u32).unwrap_or(0);
let needs_info = args.opt("--frame").is_some() || args.opt("--scale").is_some();
if needs_info {
let c = tool(&mut b, "get_comp", json!({"comp": comp}))?;
if let Some(f) = args.num("--frame")? {
time = Some(f / c["frameRate"].as_f64().unwrap_or(30.0));
}
if let Some(k) = args.num("--scale")? {
let long = c["width"].as_f64().unwrap_or(0.0).max(c["height"].as_f64().unwrap_or(0.0));
max_side = (long * k).round().max(1.0) as u32;
}
}
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 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);
}
}
"mcp" => {
let b = backend(args, false)?;
McpServer::new(b).serve_stdio().map_err(|e| e.to_string())?;
}
other => return usage_err(&format!("unknown subcommand `{other}`")),
}
Ok(())
}
fn positional<const N: usize>(args: &Args, usage: &str) -> Result<[String; N], Failure> {
if args.pos.len() < N {
return usage_err(usage);
}
Ok(std::array::from_fn(|i| args.pos[i].clone()))
}
fn with_saved(v: Value, saved: Option<String>) -> Value {
match saved {
None => v,
Some(p) => json!({"result": v, "saved": p}),
}
}
/// `render`: queue `--comp` (or the active comp) with the given settings unless `--queue`, then
/// render the queue with a progress line on stderr. Fails if any item fails.
fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
use effectcraft_engine::project::render_queue::RenderStatus;
if args.opt("--bridge").is_some() {
return usage_err("render runs headless; use `exec renderQueue.add` / `renderQueue.render` with --bridge");
}
let mut s = effectcraft_host::session();
match &args.project {
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
};
let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string());
if !args.flag("--queue") {
let Some(out) = args.opt("--out") else { return usage_err("render: --out FILE is required (or --queue)") };
let mut p = json!({"output": out});
if let Some(c) = args.opt("--comp") {
p["comp"] = json!(c);
}
let ext = std::path::Path::new(out).extension().map(|e| e.to_string_lossy().to_string());
if let Some(f) = args.opt("--format").map(str::to_string).or(ext) {
p["format"] = json!(f);
}
match (args.num("--start")?, args.num("--end")?) {
(None, None) => p["timeSpan"] = json!(if args.flag("--work-area") { "workArea" } else { "comp" }),
(a, b) => {
p["start"] = json!(a.unwrap_or(0.0));
if let Some(b) = b {
p["end"] = json!(b);
}
}
}
if let Some(v) = args.num("--fps")? {
p["frameRate"] = json!(v);
}
if let Some(r) = args.opt("--resolution").or(args.opt("--scale")) {
p["resolution"] = r.parse::<f64>().map(|v| json!(v)).unwrap_or(json!(r));
}
for (flag, key) in [("--quality", "quality"), ("--channels", "channels"), ("--prores", "proresProfile"), ("--audio", "audio")] {
if let Some(v) = args.opt(flag) {
p[key] = json!(v);
}
}
if let Some(v) = args.num("--jpeg-quality")? {
p["quality"] = json!(v);
}
if let Some(v) = args.num("--bitrate")? {
p["bitrate"] = json!(v);
}
// Render exactly this item: unqueue whatever the project's queue already holds.
for k in 0..s.project.render_queue.len() {
let _ = s.execute("renderQueue.setRender", json!({"index": k + 1, "render": false}));
}
let r = s.execute("renderQueue.add", p).map_err(err)?;
eprintln!(
"{} → {} ({}×{}, {} frames, {})",
r["compName"].as_str().unwrap_or("?"),
r["outputPath"].as_str().unwrap_or("?"),
r["width"],
r["height"],
r["frames"],
r["outputModuleSummary"].as_str().unwrap_or("")
);
}
s.execute("renderQueue.render", json!({"wait": false})).map_err(err)?;
let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
while s.is_rendering() {
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!(
"
[{}/{}] frame {}/{} {:.1}s{left}\x1b[K",
(p.items_done + 1).min(p.items_total),
p.items_total,
p.done,
p.total,
p.item_elapsed
);
}
s.poll_render();
}
s.poll_render();
if tty {
eprintln!();
}
let mut results = vec![];
let mut failed = vec![];
for it in s.project.render_queue.iter().filter(|i| i.render && i.started.is_some()) {
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));
results.push(json!({"comp": name, "output": it.last_output, "seconds": it.render_time}));
}
RenderStatus::Failed(e) => failed.push(format!("{name}: {e}")),
other => failed.push(format!("{name}: {}", other.label())),
}
}
if !failed.is_empty() {
return Err(Failure::Error(failed.join("; ")));
}
if json_out {
emit(&json!({"rendered": results}), true);
}
Ok(())
}
/// `bench`: time one frame N times without the layer cache (per-layer and per-effect breakdown),
/// and optionally `--play N` consecutive frames with and without the cache.
fn bench_cmd(args: &Args) -> Result<(), Failure> {
let mut s = effectcraft_host::session();
match &args.project {
Some(p) => s.execute("file.open", json!({"path": p})),
None => s.execute("file.openDemoProject", json!({})),
}
.map_err(|e| Failure::Error(e.to_string()))?;
if let Some(c) = args.opt("--comp") {
s.execute("comp.open", json!({"comp": c})).map_err(|e| Failure::Error(e.to_string()))?;
}
let cid = s.active_comp_id().ok_or_else(|| Failure::Error("no composition".into()))?;
let t = Tick::from_seconds_f64(args.num("--time")?.unwrap_or(3.0));
let opts = RenderOpts { scale: args.num("--scale")?.unwrap_or(1.0), ..Default::default() };
bench(&s, cid, t, opts, args.num("--n")?.unwrap_or(10.0).max(1.0) as usize);
if let Some(n) = args.num("--play")? {
bench_play(&s, cid, t, opts, n.max(1.0) as usize);
}
Ok(())
}
// ---------------------------------------------------------------- benchmark helpers
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemId;
use effectcraft_render::{LayerCache, LayerTiming, RenderOpts, Renderer};
use effectcraft_time::Tick;
fn median(v: &mut [f64]) -> f64 {
v.sort_by(f64::total_cmp);
if v.is_empty() { 0.0 } else { v[v.len() / 2] }
}
fn min(v: &[f64]) -> f64 {
v.iter().copied().fold(f64::INFINITY, f64::min)
}
/// CPU time of the whole process (all threads) in ms: a load-independent measure of work.
fn cpu_ms() -> f64 {
cpu_time::ProcessTime::try_now().map(|t| t.as_duration().as_secs_f64() * 1e3).unwrap_or(0.0)
}
/// One frame: (wall ms, CPU ms, per-layer timings).
fn render_timed(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, cache: Option<&LayerCache>) -> (f64, f64, Vec<LayerTiming>) {
let prof = std::sync::Mutex::new(Vec::<LayerTiming>::new());
let mut r = Renderer::new(&s.project, s.footage.as_ref(), opts);
r.expr = s.expr.as_deref();
r.cache = cache;
r.profile = Some(&prof);
let c0 = cpu_ms();
let t0 = std::time::Instant::now();
let img = r.comp_frame(cid, t);
let ms = t0.elapsed().as_secs_f64() * 1e3;
let cpu = cpu_ms() - c0;
std::hint::black_box(&img);
(ms, cpu, prof.into_inner().unwrap_or_default())
}
type LayerRow = (usize, String, Vec<f64>, Vec<f64>, Vec<(String, Vec<f64>)>);
/// `frame --bench N`: render the same frame N times without the layer cache (the full cost of a
/// frame) and print min/median frame time plus min per-layer and per-effect times.
fn bench(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
let mut totals = Vec::with_capacity(n);
let mut cpus = Vec::with_capacity(n);
let mut per_layer: Vec<LayerRow> = Vec::new();
for _ in 0..n {
let (ms, cpu, prof) = render_timed(s, cid, t, opts, None);
totals.push(ms);
cpus.push(cpu);
for (i, lt) in prof.into_iter().enumerate() {
if per_layer.len() <= i {
per_layer.push((lt.depth, lt.layer.clone(), vec![], vec![], vec![]));
}
let e = &mut per_layer[i];
e.2.push(lt.process_ms);
e.3.push(lt.composite_ms);
for (j, (id, ms)) in lt.effects.into_iter().enumerate() {
if e.4.len() <= j {
e.4.push((id, vec![]));
}
e.4[j].1.push(ms);
}
}
}
eprintln!(
"bench (no cache): {n} runs at scale {}: wall min {:.2} ms, median {:.2} ms; CPU (all threads) min {:.2} ms",
opts.scale,
min(&totals),
median(&mut totals),
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);
for (id, v) in fx {
eprintln!(" {:indent$} fx {id:<30} {:8.2} ms", "", min(&v), indent = d * 2);
}
}
}
/// `frame --bench-play N`: render N consecutive frames from `t` as playback does, without and
/// with the layer cache, and print per-frame times.
fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
let Some(comp) = s.project.comp(cid) else { return };
let f0 = comp.frame_rate.frame_at(t);
let cache = LayerCache::default();
for (label, c) in [("no cache", None), ("layer cache", Some(&cache))] {
let runs: Vec<(f64, f64)> = (0..n as i64)
.map(|i| {
let (ms, cpu, _) = render_timed(s, cid, comp.frame_rate.tick_of(f0 + i), opts, c);
(ms, cpu)
})
.collect();
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!(
"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),
min(&v)
);
}
let st = cache.stats();
eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
}