//! `cargo xtask bench`: a repeatable render benchmark (docs/testing.md "Render benchmark", gaps.md G5). //! //! Generates deterministic projects into `target/bench/projects` with `effectcraft-cli` (engine //! commands, no committed media), renders each at Full and Half plus one single frame, and measures //! every run with `/usr/bin/time` (macOS `-l`: peak memory footprint, max RSS, instructions //! retired; Linux `-v`: max RSS; elsewhere wall time only). Prints a table and appends one JSON //! line per run to `target/bench/results.jsonl`; with `--repeat N` (N > 1) one `"kind": "summary"` //! line per key follows, whose metrics are the medians. `--compare OLD.jsonl` prints the % change. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::Instant; use serde_json::{Value, json}; const HELP: &str = "usage: cargo xtask bench [options] Renders generated scenarios with effectcraft-cli and records memory, CPU and wall time. Run it under `nice -n 10` on a busy machine; the table notes the load average of every run. --bin PATH use this effectcraft-cli binary. Without it, effectcraft-cli is built once with `cargo build --release` into target/bench (its own target dir: the first build compiles everything, later ones are incremental) --scenario a,b only these scenarios: demo, layers, precomps, footage (default: all) --project F --comp C benchmark this project's comp (name or id) instead of the scenarios --start S first frame time in seconds for --project (default 0) --frames N frames per Full / Half render (default 60; --quick: 8) --quick fewer frames, for a fast check --format F render format (default png: a numbered sequence, deleted after each run) --regen regenerate the footage media (otherwise reused when present) --repeat N run every scenario N times (default 1). Every run is appended as before, followed by one `kind: summary` row per key whose metrics are the medians (`summary` holds median, p90, min and max; p90 is the nearest rank, so it equals max for N < 10). Not allowed with --with --compare OLD.jsonl print the % change of this run against the latest matching runs in OLD. The latest row per key is compared, so a side is a median only when its summary is the latest row; each side is labelled `median of N` or `1 run` and mixing them is warned about (refused with --fail-over) --with NEW.jsonl with --compare: compare two result files, without rendering --fail-over PCT with --compare: exit 1 when a matched run got more than PCT % worse in CPU time, peak memory, max RSS or instructions retired (wall time is left out: it follows the machine's load). Requires matching successful latest runs and comparable resource measurements for every key Scenarios: demo (the built-in demo comp), layers (1080p, 100 solid/text/shape layers with Gaussian Blur, Glow, Levels, Curves, Drop Shadow and keyframes), precomps (static and animated precomps nested three deep, each used several times), footage (a generated PNG sequence and H.264 clip on several layers at 1080p, plus a 4K still). Each renders Full, Half and one frame (`frame`); `open` is the cost of loading the project alone."; // ---------------------------------------------------------------------------------------- options #[derive(Debug, Default)] struct Opts { bin: Option, scenarios: Option>, project: Option, comp: Option, start: f64, frames: Option, quick: bool, format: String, regen: bool, compare: Option, with: Option, fail_over: Option, repeat: u32, } fn parse_opts(args: &[String]) -> Result { let mut o = Opts { format: "png".into(), repeat: 1, ..Default::default() }; let mut it = args.iter(); while let Some(a) = it.next() { let mut val = |name: &str| it.next().cloned().ok_or_else(|| format!("{name} needs a value")); match a.as_str() { "--bin" => o.bin = Some(PathBuf::from(val("--bin")?)), "--scenario" | "--scenarios" => o.scenarios = Some(val("--scenario")?.split(',').map(|s| s.trim().to_string()).collect()), "--project" => o.project = Some(PathBuf::from(val("--project")?)), "--comp" => o.comp = Some(val("--comp")?), "--start" => o.start = val("--start")?.parse().map_err(|_| "--start: seconds expected")?, "--frames" => o.frames = Some(val("--frames")?.parse().map_err(|_| "--frames: a number expected")?), "--quick" => o.quick = true, "--format" => o.format = val("--format")?, "--regen" => o.regen = true, "--repeat" => o.repeat = val("--repeat")?.parse().ok().filter(|n| *n >= 1).ok_or("--repeat: a number of at least 1 expected")?, "--compare" => o.compare = Some(PathBuf::from(val("--compare")?)), "--with" => o.with = Some(PathBuf::from(val("--with")?)), "--fail-over" => { o.fail_over = Some(val("--fail-over")?.parse::().ok().filter(|p| p.is_finite() && *p >= 0.0).ok_or("--fail-over: a percentage expected")?) } other => return Err(format!("unknown option `{other}`\n\n{HELP}")), } } if o.project.is_some() != o.comp.is_some() { return Err("--project and --comp go together".into()); } if o.with.is_some() && o.compare.is_none() { return Err("--with needs --compare OLD.jsonl".into()); } if o.with.is_some() && args.iter().any(|a| a == "--repeat") { return Err("--repeat has no effect with --with (nothing is rendered)".into()); } if o.fail_over.is_some() && o.compare.is_none() { return Err("--fail-over needs --compare OLD.jsonl".into()); } if o.frames == Some(0) { return Err("--frames must be at least 1".into()); } Ok(o) } pub fn run(root: &Path, args: &[String]) -> Result<(), String> { if args.iter().any(|a| matches!(a.as_str(), "-h" | "--help" | "help")) { println!("{HELP}"); return Ok(()); } let o = parse_opts(args)?; if let (Some(old), Some(new)) = (&o.compare, &o.with) { let (old, new) = (latest_per_key(&read_jsonl(old)?), latest_per_key(&read_jsonl(new)?)); print!("{}", compare_table(&old, &new)); return check_regressions(&old, &new, o.fail_over); } let dir = root.join("target").join("bench"); std::fs::create_dir_all(dir.join("projects")).map_err(|e| format!("{}: {e}", dir.display()))?; let bin = match &o.bin { Some(b) => b.clone(), None => build_cli(root, &dir)?, }; if !bin.exists() { return Err(format!("{}: no such binary", bin.display())); } let frames = o.frames.unwrap_or(if o.quick { 8 } else { 60 }); let scenarios = match (&o.project, &o.comp) { (Some(p), Some(c)) => vec![user_project(&bin, p, c, o.start)?], _ => { let want = o.scenarios.clone().unwrap_or_else(|| SCENARIOS.iter().map(|s| s.to_string()).collect()); let mut v = Vec::new(); for name in &want { eprintln!("bench: generating {name}"); v.push(generate(&bin, &dir, name, o.regen)?); } v } }; let timer = TimeTool::detect(); let ctx = RunContext::new(root, &bin, &o); let results_path = dir.join("results.jsonl"); let mut rows = Vec::new(); for i in 1..=o.repeat { for sc in &scenarios { for kind in RUNS { if o.repeat > 1 { eprintln!("bench: {} {kind} ({i}/{})", sc.name, o.repeat); } else { eprintln!("bench: {} {kind}", sc.name); } let row = measure(&bin, &dir, sc, kind, frames, &o.format, timer, &ctx)?; append_line(&results_path, &row)?; rows.push(row); } } } print!("{}", table(&rows, timer)); let summaries = if o.repeat > 1 { summarize(&rows) } else { vec![] }; for row in &summaries { append_line(&results_path, row)?; } print!("{}", summary_table(&summaries)); println!("appended {} runs and {} summary rows to {}", rows.len(), summaries.len(), results_path.display()); if let Some(old) = &o.compare { // Summaries come after their runs, so the latest row per key is the median when present. rows.extend(summaries); let (old, new) = (latest_per_key(&read_jsonl(old)?), latest_per_key(&rows)); print!("{}", compare_table(&old, &new)); return check_regressions(&old, &new, o.fail_over); } Ok(()) } /// `cargo build -p effectcraft-cli --release` into `target/bench`. fn build_cli(root: &Path, dir: &Path) -> Result { eprintln!("bench: building effectcraft-cli (--release) into {} (one build; pass --bin to skip)", dir.display()); let st = Command::new(env!("CARGO")) .current_dir(root) .env("CARGO_TARGET_DIR", dir) .args(["build", "-p", "effectcraft-cli", "--release"]) .status() .map_err(|e| e.to_string())?; if !st.success() { return Err("building effectcraft-cli failed".into()); } Ok(dir.join("release").join(format!("effectcraft-cli{}", std::env::consts::EXE_SUFFIX))) } // -------------------------------------------------------------------------------------- scenarios const SCENARIOS: &[&str] = &["demo", "layers", "precomps", "footage"]; /// `open` loads the project only; `full` / `half` render `frames` frames; `frame` renders one. const RUNS: &[&str] = &["open", "full", "half", "frame"]; struct Scenario { name: String, /// `--demo` or `--project F`. source: Vec, comp: String, fps: f64, start: f64, } type Cmds = Vec<(&'static str, Value)>; /// A deterministic value in [0, 1) for layer `i`, stream `k`. fn rnd(i: u32, k: u32) -> f64 { let mut h = i.wrapping_mul(0x9E37_79B9) ^ k.wrapping_mul(0x85EB_CA6B); h ^= h >> 15; h = h.wrapping_mul(0x2C1B_3C6D); h ^= h >> 12; f64::from(h % 10_000) / 10_000.0 } fn hex(i: u32) -> String { let c = |k| (40.0 + rnd(i, k) * 215.0) as u8; format!("#{:02x}{:02x}{:02x}", c(11), c(12), c(13)) } fn comp(name: &str, w: u32, h: u32, duration: f64) -> (&'static str, Value) { ("comp.new", json!({"name": name, "width": w, "height": h, "frameRate": 30, "duration": duration})) } fn key(path: &str, time: f64, value: Value) -> (&'static str, Value) { ("prop.addKey", json!({"path": path, "time": time, "value": value})) } fn set(path: &str, value: Value) -> (&'static str, Value) { ("prop.set", json!({"path": path, "value": value})) } fn effect(id: &str) -> (&'static str, Value) { ("effect.apply", json!({"effect": id})) } /// Position keys from a start to an end point across `dur` seconds. fn moving(c: &mut Cmds, i: u32, w: f64, h: f64, dur: f64) { c.push(key("transform/position", 0.0, json!([rnd(i, 1) * w, rnd(i, 2) * h, 0]))); c.push(key("transform/position", dur, json!([rnd(i, 3) * w, rnd(i, 4) * h, 0]))); } /// The common effects, by layer: blur (animated), glow, levels + curves, drop shadow, blur + shadow. fn common_effects(c: &mut Cmds, i: u32) { match i % 5 { 0 => { c.push(effect("ec.blur.gaussian")); c.push(key("effects/#1/blurriness", 0.0, json!(2.0 + rnd(i, 5) * 10.0))); c.push(key("effects/#1/blurriness", 4.0, json!(10.0 + rnd(i, 6) * 20.0))); } 1 => { c.push(effect("ec.stylize.glow")); c.push(set("effects/#1/radius", json!(20.0))); c.push(set("effects/#1/threshold", json!(40.0))); } 2 => { c.push(effect("ec.color.levels")); c.push(set("effects/#1/gamma", json!(1.3))); c.push(effect("ec.color.curves")); c.push(set("effects/#2/rgb", json!("0,0 0.4,0.55 1,1"))); } 3 => { c.push(effect("ec.perspective.dropshadow")); c.push(set("effects/#1/distance", json!(12.0))); c.push(set("effects/#1/softness", json!(20.0))); } _ => { c.push(effect("ec.blur.gaussian")); c.push(set("effects/#1/blurriness", json!(6.0))); c.push(effect("ec.perspective.dropshadow")); c.push(set("effects/#2/softness", json!(10.0))); } } } /// One solid, text or shape layer (by `i % 3`) with keyframes. fn graphic_layer(c: &mut Cmds, i: u32, w: f64, h: f64, dur: f64) { match i % 3 { 0 => c.push(( "layer.newSolid", json!({"name": format!("Solid {i}"), "color": hex(i), "width": 200 + (rnd(i, 7) * 400.0) as u32, "height": 120 + (rnd(i, 8) * 300.0) as u32}), )), 1 => c.push(("layer.newText", json!({"text": format!("Layer {i}"), "size": 40 + (rnd(i, 7) * 80.0) as u32, "fill": hex(i)}))), _ => { let kind = ["rect", "ellipse", "star", "polygon", "rounded"][(i as usize / 3) % 5]; let s = 80.0 + rnd(i, 7) * 260.0; c.push(("layer.newShape", json!({"kind": kind, "size": [s, s], "fill": hex(i), "stroke": "#ffffff", "strokeWidth": 4}))); } } moving(c, i, w, h, dur); if i.is_multiple_of(2) { c.push(key("transform/rotation", 0.0, json!(0.0))); c.push(key("transform/rotation", dur, json!(rnd(i, 9) * 360.0 - 180.0))); } if i.is_multiple_of(3) { c.push(key("transform/scale", 0.0, json!([60, 60, 100]))); c.push(key("transform/scale", dur, json!([120, 120, 100]))); } if i.is_multiple_of(5) { c.push(key("transform/opacity", 0.0, json!(30.0))); c.push(key("transform/opacity", dur / 2.0, json!(100.0))); } } /// `layers`: a 1080p comp with 100 layers, the common effects and keyframes. fn layers_cmds() -> Cmds { let mut c = vec![comp("Layers", 1920, 1080, 10.0), ("layer.newSolid", json!({"name": "Background", "color": "#202838"}))]; for i in 0..100 { graphic_layer(&mut c, i, 1920.0, 1080.0, 4.0); common_effects(&mut c, i); } c.push(("layer.newAdjustment", json!({"name": "Grade"}))); c.push(effect("ec.color.curves")); c.push(set("effects/#1/rgb", json!("0,0.03 0.5,0.52 1,0.97"))); c } /// `precomps`: static and animated 960x540 tiles, a 1080p `Mid` using them, and `Main` using /// `Mid` and the tiles again (three levels deep). fn precomps_cmds() -> Cmds { let mut c = vec![comp("Static Tile", 960, 540, 10.0), ("layer.newSolid", json!({"name": "Tile BG", "color": "#334455"}))]; for i in 0..6u32 { let kind = ["rect", "ellipse", "star"][i as usize % 3]; c.push(("layer.newShape", json!({"kind": kind, "size": [140, 140], "fill": hex(i), "position": [120 + i * 140, 270]}))); } c.push(("layer.newText", json!({"text": "STATIC", "size": 90, "fill": "#ffffff"}))); c.push(effect("ec.perspective.dropshadow")); c.push(set("effects/#1/softness", json!(15.0))); c.push(comp("Anim Tile", 960, 540, 10.0)); c.push(("layer.newSolid", json!({"name": "Tile BG", "color": "#443322"}))); for i in 10..18 { graphic_layer(&mut c, i, 960.0, 540.0, 3.0); if i.is_multiple_of(4) { common_effects(&mut c, i); } } c.push(comp("Mid", 1920, 1080, 10.0)); c.push(("layer.newSolid", json!({"name": "Mid BG", "color": "#101010"}))); for (n, item) in ["Anim Tile", "Static Tile", "Anim Tile", "Static Tile"].iter().enumerate() { let (x, y) = (480 + (n % 2) * 960, 270 + (n / 2) * 540); c.push(("layer.addItem", json!({"item": item, "position": [x, y]}))); } c.push(effect("ec.stylize.glow")); c.push(set("effects/#1/radius", json!(25.0))); c.push(comp("Main", 1920, 1080, 10.0)); c.push(("layer.newSolid", json!({"name": "Main BG", "color": "#000000"}))); for (n, item) in ["Mid", "Static Tile", "Mid", "Anim Tile", "Mid", "Static Tile", "Anim Tile"].iter().enumerate() { let i = 40 + n as u32; c.push(("layer.addItem", json!({"item": item}))); c.push(set("transform/scale", json!([45, 45, 100]))); moving(&mut c, i, 1920.0, 1080.0, 5.0); if n % 2 == 0 { c.push(key("transform/rotation", 0.0, json!(-10.0))); c.push(key("transform/rotation", 5.0, json!(10.0))); } if n == 2 { c.push(effect("ec.perspective.dropshadow")); c.push(set("effects/#1/softness", json!(30.0))); } } c } /// The comps the footage media are rendered from: `Source` (1080p, animated) and `Still 4K`. fn footage_source_cmds() -> Cmds { let mut c = vec![comp("Source", 1920, 1080, 3.0), ("layer.newSolid", json!({"name": "BG", "color": "#183048"}))]; for i in 60..76 { graphic_layer(&mut c, i, 1920.0, 1080.0, 2.0); } c.push(comp("Still 4K", 3840, 2160, 1.0)); c.push(("layer.newSolid", json!({"name": "BG", "color": "#402818"}))); for i in 80..110 { graphic_layer(&mut c, i, 3840.0, 2160.0, 1.0); } c } /// `footage`: the PNG sequence and H.264 clip on several layers at 1080p, plus the 4K still. fn footage_cmds(seq_first: &Path, clip: &Path, still: &Path) -> Cmds { let import = |p: &Path, extra: Value| { let mut v = json!({"paths": [p.to_string_lossy()], "addToComp": true}); if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) { o.extend(e.clone()); } ("file.import", v) }; // Duplicate the top layer (the one just added) and select the copy (now on top). let dup = |c: &mut Cmds| { c.push(("edit.duplicate", json!({"layers": ["#1"]}))); c.push(("layer.select", json!({"layers": ["#1"]}))); }; let mut c = vec![comp("Footage", 1920, 1080, 10.0)]; c.push(import(still, json!({}))); c.push(key("transform/scale", 0.0, json!([50, 50, 100]))); c.push(key("transform/scale", 4.0, json!([70, 70, 100]))); c.push(effect("ec.color.curves")); c.push(set("effects/#1/rgb", json!("0,0 0.5,0.6 1,1"))); c.push(import(clip, json!({}))); c.push(set("transform/scale", json!([60, 60, 100]))); moving(&mut c, 200, 1920.0, 1080.0, 4.0); dup(&mut c); c.push(("layer.setBlendMode", json!({"mode": "Screen"}))); c.push(effect("ec.blur.gaussian")); c.push(set("effects/#1/blurriness", json!(12.0))); dup(&mut c); moving(&mut c, 201, 1920.0, 1080.0, 4.0); c.push(import(seq_first, json!({"sequence": true, "frameRate": 30}))); c.push(set("transform/scale", json!([50, 50, 100]))); moving(&mut c, 202, 1920.0, 1080.0, 4.0); c.push(effect("ec.color.levels")); c.push(set("effects/#1/gamma", json!(1.2))); dup(&mut c); c.push(set("transform/opacity", json!(60.0))); moving(&mut c, 203, 1920.0, 1080.0, 4.0); dup(&mut c); c.push(effect("ec.perspective.dropshadow")); moving(&mut c, 204, 1920.0, 1080.0, 4.0); c } /// Run `cmds` with `effectcraft-cli --empty run … --save-as out`. fn build_project(bin: &Path, cmds: &Cmds, out: &Path) -> Result<(), String> { let mut cmd = Command::new(bin); cmd.arg("--empty").arg("run"); for (id, params) in cmds { cmd.arg(id).arg(params.to_string()); } cmd.arg("--save-as").arg(out); cli(&mut cmd) } /// Run a CLI command quietly; on failure, the tail of its output is the error. fn cli(cmd: &mut Command) -> Result<(), String> { let out = cmd.stdin(Stdio::null()).output().map_err(|e| format!("{cmd:?}: {e}"))?; if out.status.success() { return Ok(()); } let err = String::from_utf8_lossy(&out.stderr); let tail: Vec<&str> = err.lines().rev().take(8).collect(); Err(format!("effectcraft-cli failed ({}):\n{}", out.status, tail.into_iter().rev().collect::>().join("\n"))) } fn generate(bin: &Path, dir: &Path, name: &str, regen: bool) -> Result { let projects = dir.join("projects"); let project = |file: &str, comp: &str| Scenario { name: name.into(), source: vec!["--project".into(), projects.join(file).to_string_lossy().into_owned()], comp: comp.into(), fps: 30.0, start: 0.0, }; match name { "demo" => Ok(Scenario { name: name.into(), source: vec!["--demo".into()], comp: "EffectCraft Intro".into(), fps: 30_000.0 / 1001.0, start: 0.0 }), "layers" => { build_project(bin, &layers_cmds(), &projects.join("layers.ecproj"))?; Ok(project("layers.ecproj", "Layers")) } "precomps" => { build_project(bin, &precomps_cmds(), &projects.join("precomps.ecproj"))?; Ok(project("precomps.ecproj", "Main")) } "footage" => { let media = projects.join("media"); let (seq_dir, clip, still) = (media.join("seq"), media.join("clip.mp4"), media.join("still4k.png")); let seq_first = seq_dir.join("src_00000.png"); if regen || !seq_first.exists() || !clip.exists() || !still.exists() { let _ = std::fs::remove_dir_all(&media); std::fs::create_dir_all(&seq_dir).map_err(|e| e.to_string())?; let src = projects.join("footage-source.ecproj"); build_project(bin, &footage_source_cmds(), &src)?; let render = |args: &[&str], out: &Path| cli(Command::new(bin).arg("--project").arg(&src).args(args).arg("--out").arg(out)); render(&["render", "--comp", "Source", "--format", "png", "--start", "0", "--end", "2"], &seq_dir.join("src.png"))?; render(&["render", "--comp", "Source", "--format", "h264", "--start", "0", "--end", "3"], &clip)?; render(&["render-frame", "--comp", "Still 4K", "--time", "0"], &still)?; if !seq_first.exists() { return Err(format!("{}: the sequence render wrote no first frame", seq_first.display())); } } build_project(bin, &footage_cmds(&seq_first, &clip, &still), &projects.join("footage.ecproj"))?; Ok(project("footage.ecproj", "Footage")) } other => Err(format!("unknown scenario `{other}` (demo, layers, precomps, footage)")), } } /// `--project F --comp C`: the comp's frame rate from `info --json`. fn user_project(bin: &Path, project: &Path, comp: &str, start: f64) -> Result { let out = Command::new(bin).arg("--project").arg(project).args(["info", "--json"]).output().map_err(|e| e.to_string())?; let info: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("info --json: {e}"))?; let fps = info["project"]["items"] .as_array() .into_iter() .flatten() .find(|i| i["type"] == "Composition" && (i["name"] == comp || i["id"].as_u64().is_some_and(|id| id.to_string() == comp))) .and_then(|i| i["frameRate"].as_f64()) .filter(|f| *f > 0.0) .ok_or_else(|| format!("{}: no comp `{comp}`", project.display()))?; let stem = project.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); Ok(Scenario { name: format!("{stem}/{comp}"), source: vec!["--project".into(), project.to_string_lossy().into_owned()], comp: comp.into(), fps, start }) } // ------------------------------------------------------------------------------------- measuring #[derive(Clone, Copy, Debug, PartialEq)] enum TimeTool { /// macOS `/usr/bin/time -l`. Bsd, /// GNU `/usr/bin/time -v`. Gnu, /// Wall time only. None, } impl TimeTool { fn detect() -> Self { if !Path::new("/usr/bin/time").exists() { return TimeTool::None; } if cfg!(target_os = "macos") { TimeTool::Bsd } else if cfg!(target_os = "linux") { TimeTool::Gnu } else { TimeTool::None } } } #[derive(Clone, Debug, Default, PartialEq)] struct Metrics { wall_s: f64, user_s: Option, sys_s: Option, max_rss: Option, peak_footprint: Option, instructions: Option, } /// macOS `/usr/bin/time -l` output: ` 0.33 real 0.67 user 0.15 sys`, then `