From e23b926efc3677293c06fa92f837dccdeabbe78b Mon Sep 17 00:00:00 2001 From: Elias Date: Sat, 10 Oct 2026 15:21:19 +0100 Subject: [PATCH 1/2] G5: cargo xtask bench: render benchmark of generated scenarios (peak memory, CPU, wall) with results.jsonl and --compare (cherry picked from commit 40b232d02069925e9c72677f52ee6f356495a736) (cherry picked from commit a2d89bda11b7217c83c98ab681b8f9b4a334786d) (cherry picked from commit 63951595222207b6bfd0ecde72f991628c6f12d9) (cherry picked from commit 20c2bb0dcbb14013d5da3d506e7709ba8582b00b) --- docs/gaps.md | 4 + docs/testing.md | 24 ++ xtask/src/bench.rs | 961 +++++++++++++++++++++++++++++++++++++++++++++ xtask/src/main.rs | 6 +- 4 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 xtask/src/bench.rs diff --git a/docs/gaps.md b/docs/gaps.md index e621ee87..95cf368a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -249,6 +249,10 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie - Benchmark projects at 1080p and 4K with many layers, heavy effects, long footage and nested comps; record preview frame times, RAM preview fill and render times on reference machines. + Started 10 October 2026: `cargo xtask bench` renders generated 1080p / 4K scenarios (many + layers with common effects, nested precomps, footage) at Full and Half and records peak memory, + CPU and wall time per run, with `--compare` against an earlier run (docs/testing.md, "Render + benchmark"). - Compare with After Effects on the same machine where possible. - Done when: benchmark numbers are tracked over time and regressions fail a check. diff --git a/docs/testing.md b/docs/testing.md index 4aa8514a..fca9762e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,3 +46,27 @@ cargo run --release -p effectcraft-cli -- bench --n 10 --play 30 prints per-layer and per-effect timings for one frame, and playback timings with and without the layer cache. + +### Render benchmark (`cargo xtask bench`) + +```sh +nice -n 10 cargo xtask bench --bin target/release/effectcraft-cli # or no --bin: one `--release` build into target/bench +nice -n 10 cargo xtask bench --quick --compare before.jsonl # 8 frames instead of 60, % change against an earlier run +cargo xtask bench --project F.ecproj --comp "Main" # any project's comp +``` + +measures memory, CPU and wall time of whole `effectcraft-cli` runs, so every optimisation has a +before and after number. It generates four deterministic scenarios into `target/bench/projects` +with engine commands (no committed media): `demo` (the built-in demo comp), `layers` (1080p, +100 solid, text and shape layers with Gaussian Blur, Glow, Levels, Curves, Drop Shadow and +keyframes), `precomps` (static and animated precomps nested three deep, each used several times) +and `footage` (a rendered PNG sequence and H.264 clip on several 1080p layers, plus a 4K still). +Each is opened (`open`), rendered at Full and Half (PNG sequence, deleted afterwards) and as one +frame. `/usr/bin/time -l` on macOS gives peak memory footprint, max RSS, user + sys CPU and +instructions retired; `/usr/bin/time -v` on Linux gives max RSS and CPU; elsewhere wall time only. + +It prints a table and appends one JSON line per run to `target/bench/results.jsonl` (git rev, +date, load average, scenario, settings, metrics); `--compare OLD.jsonl` prints the % change against +the latest matching runs there, `--compare OLD --with NEW` compares two files without rendering. +The table notes the 1-minute load average: on a busy machine wall time is noise, so compare +peak memory and instructions retired, and rerun when the load differs a lot. diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs new file mode 100644 index 00000000..74154ef4 --- /dev/null +++ b/xtask/src/bench.rs @@ -0,0 +1,961 @@ +//! `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`; `--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) + --compare OLD.jsonl print the % change of this run against the latest matching runs in OLD + --with NEW.jsonl with --compare: compare two result files, without rendering + +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, +} + +fn parse_opts(args: &[String]) -> Result { + let mut o = Opts { format: "png".into(), ..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, + "--compare" => o.compare = Some(PathBuf::from(val("--compare")?)), + "--with" => o.with = Some(PathBuf::from(val("--with")?)), + 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.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) = (read_jsonl(old)?, read_jsonl(new)?); + print!("{}", compare_table(&latest_per_key(&old), &latest_per_key(&new))); + return Ok(()); + } + 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 sc in &scenarios { + for kind in RUNS { + 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)); + println!("appended {} runs to {}", rows.len(), results_path.display()); + if let Some(old) = &o.compare { + let old = read_jsonl(old)?; + print!("{}", compare_table(&latest_per_key(&old), &latest_per_key(&rows))); + } + 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 `