diff --git a/Cargo.toml b/Cargo.toml index 34665da..03561f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/revelo-util", "crates/revelo-cdylib", "crates/revelo-wasm", + "crates/revelo-bench", ] [workspace.package] diff --git a/crates/revelo-bench/Cargo.toml b/crates/revelo-bench/Cargo.toml new file mode 100644 index 0000000..f9d2cff --- /dev/null +++ b/crates/revelo-bench/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "revelo-bench" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[[bin]] +name = "revelo-bench" +path = "src/main.rs" + +[dependencies] +base64 = "0.22" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tempfile = "3" +tungstenite = { version = "0.24", default-features = false, features = ["handshake"] } +ureq = { version = "2", default-features = false, features = ["json"] } + +[lints] +workspace = true diff --git a/crates/revelo-bench/src/cdp.rs b/crates/revelo-bench/src/cdp.rs new file mode 100644 index 0000000..db2fb14 --- /dev/null +++ b/crates/revelo-bench/src/cdp.rs @@ -0,0 +1,219 @@ +use std::fs; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use base64::Engine; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tungstenite::{Message, connect}; + +use crate::util::{Result, err, which}; + +pub(crate) fn capture_html( + html: &Path, + output: &Path, + selector: &str, + chrome_path: Option<&Path>, +) -> Result<()> { + let browser = resolve_browser(chrome_path)?; + let port = free_port()?; + let user_data = tempfile::tempdir()?; + let mut child = launch_browser(&browser, port, &user_data)?; + let result = capture_with_child(html, output, selector, port); + let _ = child.kill(); + let _ = child.wait(); + result +} + +fn resolve_browser(chrome_path: Option<&Path>) -> Result { + if let Some(path) = chrome_path { + if path.is_file() { + return Ok(path.to_path_buf()); + } + return Err(err(format!("configured chrome path does not exist: {}", path.display()))); + } + for candidate in browser_candidates() { + if candidate.is_file() { + return Ok(candidate); + } + } + for binary in ["google-chrome", "chromium", "chromium-browser", "chrome", "msedge"] { + if let Some(path) = which(binary) { + return Ok(path); + } + } + Err(err("Chrome/Chromium/Edge not found; pass --chrome-path to render PNG")) +} + +fn browser_candidates() -> Vec { + vec![ + PathBuf::from("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"), + PathBuf::from("/Applications/Chromium.app/Contents/MacOS/Chromium"), + PathBuf::from("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"), + ] +} + +fn free_port() -> Result { + Ok(TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port()) +} + +fn launch_browser(browser: &Path, port: u16, user_data: &TempDir) -> Result { + Ok(Command::new(browser) + .arg("--headless=new") + .arg("--disable-gpu") + .arg("--hide-scrollbars") + .arg(format!("--remote-debugging-port={port}")) + .arg(format!("--user-data-dir={}", user_data.path().display())) + .arg("about:blank") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?) +} + +fn capture_with_child(html: &Path, output: &Path, selector: &str, port: u16) -> Result<()> { + let version = wait_for_json(&format!("http://127.0.0.1:{port}/json/version"))?; + let browser_ws = version + .get("webSocketDebuggerUrl") + .and_then(Value::as_str) + .ok_or_else(|| err("Chrome did not expose webSocketDebuggerUrl"))?; + let target = new_page(port, html)?; + let page_ws = target + .get("webSocketDebuggerUrl") + .and_then(Value::as_str) + .ok_or_else(|| err("Chrome target did not expose webSocketDebuggerUrl"))?; + let _browser_ws = browser_ws; + capture_page(page_ws, output, selector) +} + +fn wait_for_json(url: &str) -> Result { + let started = Instant::now(); + loop { + match ureq::get(url).call() { + Ok(response) => return Ok(response.into_json()?), + Err(_) if started.elapsed() < Duration::from_secs(5) => { + thread::sleep(Duration::from_millis(50)); + } + Err(error) => return Err(err(format!("Chrome CDP endpoint unavailable: {error}"))), + } + } +} + +fn new_page(port: u16, html: &Path) -> Result { + let url = file_url(&html.canonicalize()?); + let endpoint = format!("http://127.0.0.1:{port}/json/new?{url}"); + match ureq::put(&endpoint).call() { + Ok(response) => Ok(response.into_json()?), + Err(error) => Err(err(format!("failed to create Chrome target: {error}"))), + } +} + +fn file_url(path: &Path) -> String { + let raw = path.to_string_lossy(); + format!("file://{}", percent_encode_path(&raw)) +} + +fn percent_encode_path(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { + encoded.push(byte as char) + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +fn capture_page(ws_url: &str, output: &Path, selector: &str) -> Result<()> { + let (mut socket, _) = connect(ws_url) + .map_err(|error| err(format!("failed to connect to Chrome CDP websocket: {error}")))?; + let mut cdp = CdpSession { next_id: 1 }; + cdp.call(&mut socket, "Page.enable", json!({}))?; + cdp.call(&mut socket, "Runtime.enable", json!({}))?; + cdp.call(&mut socket, "Runtime.evaluate", json!({"expression": "document.fonts ? document.fonts.ready : Promise.resolve()", "awaitPromise": true}))?; + let metrics = cdp.call( + &mut socket, + "Runtime.evaluate", + json!({ + "expression": format!( + r#"(function() {{ + const el = document.querySelector({selector:?}) || document.documentElement; + const rect = el.getBoundingClientRect(); + return {{x: Math.floor(rect.x), y: Math.floor(rect.y), width: Math.ceil(rect.width), height: Math.ceil(rect.height)}}; + }})()"# + ), + "returnByValue": true + }), + )?; + let value = metrics + .pointer("/result/result/value") + .ok_or_else(|| err("Chrome did not return capture metrics"))?; + let width = value.get("width").and_then(Value::as_u64).unwrap_or(1200).max(1); + let height = value.get("height").and_then(Value::as_u64).unwrap_or(800).max(1); + cdp.call( + &mut socket, + "Emulation.setDeviceMetricsOverride", + json!({"width": width, "height": height, "deviceScaleFactor": 1, "mobile": false}), + )?; + let screenshot = cdp.call( + &mut socket, + "Page.captureScreenshot", + json!({"format": "png", "fromSurface": true, "captureBeyondViewport": true}), + )?; + let data = screenshot + .pointer("/result/data") + .and_then(Value::as_str) + .ok_or_else(|| err("Chrome did not return screenshot data"))?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|error| err(format!("invalid Chrome screenshot base64: {error}")))?; + fs::write(output, bytes)?; + Ok(()) +} + +struct CdpSession { + next_id: u64, +} + +impl CdpSession { + fn call( + &mut self, + socket: &mut tungstenite::WebSocket< + tungstenite::stream::MaybeTlsStream, + >, + method: &str, + params: Value, + ) -> Result { + let id = self.next_id; + self.next_id += 1; + socket + .send(Message::Text(json!({"id": id, "method": method, "params": params}).to_string())) + .map_err(|error| err(format!("Chrome CDP send failed: {error}")))?; + loop { + let message = + socket.read().map_err(|error| err(format!("Chrome CDP read failed: {error}")))?; + if let Message::Text(text) = message { + let value: Value = serde_json::from_str(&text)?; + if value.get("id").and_then(Value::as_u64) == Some(id) { + if let Some(error) = value.get("error") { + return Err(err(format!("Chrome CDP method {method} failed: {error}"))); + } + return Ok(value); + } + } + } + } +} + +pub(crate) fn self_test() -> Result<()> { + assert!(resolve_browser(Some(Path::new("/definitely/missing/chrome"))).is_err()); + assert_eq!( + file_url(Path::new("/tmp/Revelo Bench/#table?.html")), + "file:///tmp/Revelo%20Bench/%23table%3F.html" + ); + Ok(()) +} diff --git a/crates/revelo-bench/src/compare.rs b/crates/revelo-bench/src/compare.rs new file mode 100644 index 0000000..da271f5 --- /dev/null +++ b/crates/revelo-bench/src/compare.rs @@ -0,0 +1,489 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use clap::Args; +use serde_json::{Value, json}; + +use crate::config::{BenchCase, Manifest, validate_manifest}; +use crate::fixtures; +use crate::render; +use crate::util::{ + Result, command_text, err, relative_tool_path, repo_root, resolve_repo_path, safe_id, which, +}; + +#[derive(Debug, Args)] +pub(crate) struct CompareArgs { + #[arg(long)] + pub(crate) manifest: PathBuf, + #[arg(long, default_value = "target/perf-investigation")] + pub(crate) out_dir: PathBuf, + #[arg(long, default_value = "target/perf-fixtures")] + pub(crate) fixture_dir: PathBuf, + #[arg(long)] + pub(crate) run_id: Option, + #[arg(long)] + pub(crate) warmups: Option, + #[arg(long)] + pub(crate) runs: Option, + #[arg(long, default_value = "all")] + pub(crate) probe_export: String, + #[arg(long, default_value = "sink")] + pub(crate) probe_output_target: String, + #[arg(long)] + pub(crate) table_config: Option, + #[arg(long)] + pub(crate) no_render_table: bool, + #[arg(long)] + pub(crate) render_png: bool, + #[arg(long)] + pub(crate) chrome_path: Option, + #[arg(long)] + pub(crate) no_build: bool, + #[arg(long)] + pub(crate) skip_mediainfo: bool, + #[arg(long)] + pub(crate) skip_ffprobe: bool, +} + +#[derive(Debug)] +pub(crate) struct CompareRun { + pub(crate) results_path: PathBuf, + pub(crate) run_dir: PathBuf, + pub(crate) run_id: String, +} + +pub(crate) fn run(args: CompareArgs) -> Result { + Ok(run_compare(args)?.results_path) +} + +pub(crate) fn run_compare(args: CompareArgs) -> Result { + let manifest = Manifest::load(&args.manifest)?; + validate_manifest(&manifest)?; + let run_id = args.run_id.clone().unwrap_or_else(timestamp_run_id); + let run_dir = args.out_dir.join(&run_id); + let fixture_dir = args.fixture_dir.join(&run_id); + fs::create_dir_all(&run_dir)?; + fs::create_dir_all(&fixture_dir)?; + + if !args.no_build { + let mut build = Command::new("cargo"); + build.args([ + "build", + "-p", + "revelo-cli", + "--bin", + "revelo", + "--example", + "perf_probe", + "--release", + ]); + crate::util::run_checked(&mut build)?; + } + + let tools = Tools::resolve(&args)?; + let revelo_versions = resolve_revelo_versions(&manifest)?; + let warmups = args.warmups.or(manifest.settings.warmups).unwrap_or(2); + let runs = args.runs.or(manifest.settings.runs).unwrap_or(10); + let render_png = args.render_png || manifest.settings.render_png.unwrap_or(false); + + let context = RunContext { + args: &args, + tools: &tools, + revelo_versions: &revelo_versions, + warmups, + runs, + run_dir: &run_dir, + fixture_dir: &fixture_dir, + }; + let mut cases = Vec::new(); + for case in &manifest.cases { + cases.push(run_case(case, &context)?); + } + + let output = json!({ + "schema": "revelo_bench_compare_v1", + "run_id": run_id, + "repo": repo_metadata(), + "environment": environment_snapshot(), + "tools": tools.versions(), + "revelo_versions": sanitize_revelo_versions(&revelo_versions), + "measurement_backend": { + "name": "hyperfine", + "version": tool_version(tools.hyperfine.as_path(), &["--version"]), + "required": true + }, + "settings": { + "warmups": warmups, + "runs": runs, + "probe_export": args.probe_export, + "probe_output_target": args.probe_output_target, + "fixture_dir": args.fixture_dir.display().to_string() + }, + "cases": cases + }); + + let results_path = run_dir.join("results.json"); + fs::write(&results_path, serde_json::to_string_pretty(&output)? + "\n")?; + if !args.no_render_table { + let render_args = render::RenderArgs { + results: results_path.clone(), + output: Some(run_dir.join("benchmark-table.html")), + config: args.table_config, + render_png, + chrome_path: args.chrome_path, + selector: None, + }; + let _ = render::run(render_args)?; + } + Ok(CompareRun { results_path, run_dir, run_id }) +} + +struct RunContext<'a> { + args: &'a CompareArgs, + tools: &'a Tools, + revelo_versions: &'a [ResolvedReveloVersion], + warmups: u32, + runs: u32, + run_dir: &'a Path, + fixture_dir: &'a Path, +} + +fn run_case(case: &BenchCase, context: &RunContext<'_>) -> Result { + let path = resolve_case_path(case, context.fixture_dir)?; + if !path.exists() { + return Err(err(format!("missing case path for {}: {}", case.label, path.display()))); + } + let mut measurements = serde_json::Map::new(); + for version in context.revelo_versions { + let command = vec![version.path.display().to_string(), path.display().to_string()]; + measurements.insert( + version.id.clone(), + measure_command( + context.tools, + &version.id, + &command, + context.warmups, + context.runs, + context.run_dir, + &case.label, + )?, + ); + } + + let mut probe_cmd = vec![ + context.tools.perf_probe.display().to_string(), + "--path".to_owned(), + path.display().to_string(), + "--label".to_owned(), + case.label.clone(), + "--export".to_owned(), + context.args.probe_export.clone(), + "--output-target".to_owned(), + context.args.probe_output_target.clone(), + ]; + if context.args.probe_output_target == "file" { + probe_cmd.extend([ + "--output-file".to_owned(), + context + .run_dir + .join(format!("{}.probe-output", safe_id(&case.label))) + .display() + .to_string(), + ]); + } + let probe_process = measure_command( + context.tools, + "revelo_perf_probe", + &probe_cmd, + context.warmups, + context.runs, + context.run_dir, + &case.label, + )?; + measurements.insert( + "revelo_perf_probe".to_owned(), + json!({ + "process_ms": probe_process, + "records": collect_probe_records(&probe_cmd, context.runs)?, + "records_diagnostic_only": true + }), + ); + + if let Some(mediainfo) = &context.tools.mediainfo { + let command = vec![mediainfo.display().to_string(), path.display().to_string()]; + measurements.insert( + "mediainfo".to_owned(), + measure_command( + context.tools, + "mediainfo", + &command, + context.warmups, + context.runs, + context.run_dir, + &case.label, + )?, + ); + } + if let Some(ffprobe) = &context.tools.ffprobe { + let command = vec![ + ffprobe.display().to_string(), + "-v".to_owned(), + "error".to_owned(), + "-show_format".to_owned(), + "-show_streams".to_owned(), + path.display().to_string(), + ]; + measurements.insert( + "ffprobe".to_owned(), + measure_command( + context.tools, + "ffprobe", + &command, + context.warmups, + context.runs, + context.run_dir, + &case.label, + )?, + ); + } + + Ok(json!({ + "id": case.id.clone().unwrap_or_else(|| safe_id(&case.label)), + "label": case.label, + "class": case.class_name, + "format": case.format.clone().or_else(|| case.container.clone()).unwrap_or_default(), + "container": case.container.clone().or_else(|| case.format.clone()).unwrap_or_default(), + "codec": case.codec.clone().unwrap_or_default(), + "layout": case.layout.clone().unwrap_or_default(), + "source": case.source.clone().unwrap_or_default(), + "size_bytes": path.metadata()?.len(), + "synthetic_kind": case.synthetic.as_ref().map(|synthetic| synthetic.kind.clone()).unwrap_or_default(), + "measurements": measurements + })) +} + +fn resolve_case_path(case: &BenchCase, fixture_dir: &Path) -> Result { + if case.synthetic.is_some() { + fixtures::generate_case_fixture(case, fixture_dir) + } else { + let path = + case.path.as_ref().ok_or_else(|| err(format!("case {} missing path", case.label)))?; + if path.is_absolute() { Ok(path.clone()) } else { Ok(repo_root()?.join(path)) } + } +} + +fn measure_command( + tools: &Tools, + name: &str, + command: &[String], + warmups: u32, + runs: u32, + run_dir: &Path, + label: &str, +) -> Result { + let export_path = run_dir.join(format!("{}-{}.hyperfine.json", safe_id(label), safe_id(name))); + let shell_command = shell_join(command); + let output = Command::new(&tools.hyperfine) + .args(["--warmup", &warmups.to_string(), "--runs", &runs.to_string(), "--export-json"]) + .arg(&export_path) + .args(["--command-name", name]) + .arg(&shell_command) + .output()?; + if !output.status.success() { + let message = String::from_utf8_lossy(&output.stderr); + return Err(err(format!("hyperfine failed for {name}: {}", message.trim()))); + } + let data: Value = serde_json::from_str(&fs::read_to_string(&export_path)?)?; + let _ = fs::remove_file(&export_path); + let result = data + .get("results") + .and_then(Value::as_array) + .and_then(|results| results.first()) + .ok_or_else(|| err(format!("hyperfine produced no results for {name}")))?; + Ok(summarize_hyperfine_result(result)) +} + +fn summarize_hyperfine_result(result: &Value) -> Value { + let times = result + .get("times") + .and_then(Value::as_array) + .map(|times| { + times.iter().filter_map(Value::as_f64).map(|value| value * 1000.0).collect::>() + }) + .unwrap_or_default(); + json!({ + "runs": times.len(), + "mean_ms": seconds_to_ms(result.get("mean")), + "median_ms": seconds_to_ms(result.get("median")), + "min_ms": seconds_to_ms(result.get("min")), + "max_ms": seconds_to_ms(result.get("max")), + "stddev_ms": seconds_to_ms(result.get("stddev")), + "samples_ms": times + }) +} + +fn seconds_to_ms(value: Option<&Value>) -> Option { + value.and_then(Value::as_f64).map(|value| value * 1000.0) +} + +fn collect_probe_records(command: &[String], runs: u32) -> Result> { + let mut records = Vec::new(); + for _ in 0..runs { + let output = Command::new(&command[0]).args(&command[1..]).output()?; + if !output.status.success() { + return Err(err("perf_probe command failed")); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let line = stdout.lines().last().ok_or_else(|| err("perf_probe produced no output"))?; + let mut record: Value = serde_json::from_str(line)?; + if let Some(object) = record.as_object_mut() { + object.remove("path"); + } + records.push(record); + } + Ok(records) +} + +#[derive(Debug)] +struct Tools { + hyperfine: PathBuf, + revelo: PathBuf, + perf_probe: PathBuf, + mediainfo: Option, + ffprobe: Option, +} + +impl Tools { + fn resolve(args: &CompareArgs) -> Result { + let root = repo_root()?; + let hyperfine = which("hyperfine").ok_or_else(|| { + err("hyperfine is required for bench-compare; install it with `brew install hyperfine`") + })?; + let revelo = root.join("target/release/revelo"); + let perf_probe = root.join("target/release/examples/perf_probe"); + if !perf_probe.exists() { + return Err(err(format!("missing built tool: {}", perf_probe.display()))); + } + let mediainfo = if args.skip_mediainfo { + None + } else { + Some( + which("mediainfo") + .ok_or_else(|| err("mediainfo is required unless --skip-mediainfo is used"))?, + ) + }; + let ffprobe = if args.skip_ffprobe { + None + } else { + Some( + which("ffprobe") + .ok_or_else(|| err("ffprobe is required unless --skip-ffprobe is used"))?, + ) + }; + Ok(Self { hyperfine, revelo, perf_probe, mediainfo, ffprobe }) + } + + fn versions(&self) -> Value { + json!({ + "hyperfine": tool_version(self.hyperfine.as_path(), &["--version"]), + "revelo": {"available": self.revelo.exists(), "path": relative_tool_path(&self.revelo)}, + "perf_probe": {"available": self.perf_probe.exists(), "path": relative_tool_path(&self.perf_probe)}, + "mediainfo": self.mediainfo.as_ref().and_then(|path| tool_version(path, &["--Version"])), + "ffprobe": self.ffprobe.as_ref().and_then(|path| tool_version(path, &["-version"])) + }) + } +} + +#[derive(Debug, Clone)] +struct ResolvedReveloVersion { + id: String, + label: String, + path: PathBuf, +} + +fn resolve_revelo_versions(manifest: &Manifest) -> Result> { + if manifest.revelo_versions.is_empty() { + return Ok(vec![ResolvedReveloVersion { + id: "revelo_cli_text".to_owned(), + label: "Revelo".to_owned(), + path: repo_root()?.join("target/release/revelo"), + }]); + } + manifest + .revelo_versions + .iter() + .map(|version| { + let path = resolve_repo_path(&version.path)?; + if !path.exists() { + return Err(err(format!( + "missing Revelo version binary for {}: {}", + version.id, + path.display() + ))); + } + Ok(ResolvedReveloVersion { id: version.id.clone(), label: version.label.clone(), path }) + }) + .collect() +} + +fn sanitize_revelo_versions(versions: &[ResolvedReveloVersion]) -> Vec { + versions + .iter() + .map(|version| { + json!({ + "id": version.id, + "label": version.label, + "path": relative_tool_path(&version.path) + }) + }) + .collect() +} + +fn repo_metadata() -> Value { + json!({ + "branch": command_text("git", &["branch", "--show-current"]), + "commit": command_text("git", &["rev-parse", "--short", "HEAD"]) + }) +} + +fn environment_snapshot() -> Value { + json!({ + "machine": command_text("uname", &["-m"]), + "system": command_text("uname", &["-s"]), + "release": command_text("uname", &["-r"]), + "processor": command_text("sysctl", &["-n", "machdep.cpu.brand_string"]), + "cpu_count": std::thread::available_parallelism().ok().map(usize::from), + "sw_vers": command_text("sw_vers", &[]), + "rustc": command_text("rustc", &["--version", "--verbose"]), + "cargo": command_text("cargo", &["--version", "--verbose"]) + }) +} + +fn tool_version(path: &Path, args: &[&str]) -> Option { + command_text(path.as_os_str(), args) + .and_then(|text| text.lines().find(|line| !line.trim().is_empty()).map(ToOwned::to_owned)) +} + +fn timestamp_run_id() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + format!("run-{seconds}") +} + +fn shell_join(command: &[String]) -> String { + command + .iter() + .map(|part| { + if part.chars().all(|char| char.is_ascii_alphanumeric() || "-_./:=+".contains(char)) { + part.clone() + } else { + format!("'{}'", part.replace('\'', "'\\''")) + } + }) + .collect::>() + .join(" ") +} diff --git a/crates/revelo-bench/src/config.rs b/crates/revelo-bench/src/config.rs new file mode 100644 index 0000000..863b685 --- /dev/null +++ b/crates/revelo-bench/src/config.rs @@ -0,0 +1,287 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::fixtures; +use crate::util::{Result, err}; + +const MANIFEST_SCHEMA: &str = "revelo_perf_manifest_v2"; +const TABLE_CONFIG_SCHEMA: &str = "revelo_benchmark_table_config_v1"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Manifest { + pub(crate) schema: String, + #[serde(default)] + pub(crate) settings: ManifestSettings, + #[serde(default)] + pub(crate) revelo_versions: Vec, + pub(crate) cases: Vec, +} + +impl Manifest { + pub(crate) fn load(path: &Path) -> Result { + Ok(serde_json::from_str(&fs::read_to_string(path)?)?) + } +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManifestSettings { + pub(crate) warmups: Option, + pub(crate) runs: Option, + pub(crate) render_png: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ReveloVersion { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) path: PathBuf, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct BenchCase { + pub(crate) id: Option, + pub(crate) label: String, + #[serde(rename = "class")] + pub(crate) class_name: String, + pub(crate) format: Option, + pub(crate) container: Option, + pub(crate) codec: Option, + pub(crate) layout: Option, + pub(crate) source: Option, + pub(crate) path: Option, + pub(crate) synthetic: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SyntheticCase { + pub(crate) kind: String, + pub(crate) size_bytes: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct TableConfig { + pub(crate) schema: Option, + pub(crate) title: Option, + pub(crate) caption: Option, + pub(crate) capture_id: Option, + pub(crate) footer_note: Option, + pub(crate) columns: Option>, + #[serde(default)] + pub(crate) column_labels: std::collections::BTreeMap, + pub(crate) show_size_column: Option, + pub(crate) sections: Option>, + pub(crate) latency_tiers_ms: Option>, +} + +impl TableConfig { + pub(crate) fn load(path: Option<&Path>) -> Result { + match path { + Some(path) => { + let config: Self = serde_json::from_str(&fs::read_to_string(path)?)?; + validate_table_config(&config)?; + Ok(config) + } + None => Ok(Self::default()), + } + } + + pub(crate) fn capture_id(&self) -> &str { + self.capture_id.as_deref().unwrap_or("benchmark-table-capture") + } +} + +impl Default for TableConfig { + fn default() -> Self { + Self { + schema: Some(TABLE_CONFIG_SCHEMA.to_owned()), + title: Some("Revelo benchmark comparison".to_owned()), + caption: None, + capture_id: Some("benchmark-table-capture".to_owned()), + footer_note: Some( + "Values are median milliseconds. Generated fixtures are parser-oriented sparse files; real rows come from the local manifest." + .to_owned(), + ), + columns: None, + column_labels: Default::default(), + show_size_column: Some(true), + sections: Some(vec![ + TableSection::class("synthetic", "Generated fixtures"), + TableSection::class("real", "Real media"), + ]), + latency_tiers_ms: Some(default_latency_tiers()), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct TableSection { + pub(crate) id: Option, + pub(crate) label: Option, + #[serde(rename = "match", default)] + pub(crate) match_: std::collections::BTreeMap, +} + +impl TableSection { + fn class(id: &str, label: &str) -> Self { + Self { + id: Some(id.to_owned()), + label: Some(label.to_owned()), + match_: [("class".to_owned(), serde_json::Value::String(id.to_owned()))].into(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LatencyTier { + pub(crate) name: String, + pub(crate) label: String, + pub(crate) max: Option, + #[serde(rename = "class")] + pub(crate) class_name: String, +} + +fn default_latency_tiers() -> Vec { + [ + ("instant", "under 10 ms", Some(10.0), "instant"), + ("very_fast", "10-30 ms", Some(30.0), "very-fast"), + ("fast", "30-80 ms", Some(80.0), "fast"), + ("medium", "80-150 ms", Some(150.0), "medium"), + ("slow", "150-500 ms", Some(500.0), "slow"), + ("very_slow", "over 500 ms", None, "very-slow"), + ] + .into_iter() + .map(|(name, label, max, class_name)| LatencyTier { + name: name.to_owned(), + label: label.to_owned(), + max, + class_name: class_name.to_owned(), + }) + .collect() +} + +pub(crate) fn validate_manifest(manifest: &Manifest) -> Result<()> { + if manifest.schema != MANIFEST_SCHEMA { + return Err(err(format!("manifest schema must be {MANIFEST_SCHEMA}"))); + } + if manifest.cases.is_empty() { + return Err(err("manifest must contain non-empty cases list")); + } + let mut case_ids = HashSet::new(); + for case in &manifest.cases { + if !matches!(case.class_name.as_str(), "synthetic" | "real") { + return Err(err(format!("case {} class must be real or synthetic", case.label))); + } + match (&case.path, &case.synthetic) { + (Some(_), None) if case.class_name == "real" => {} + (None, Some(synthetic)) if case.class_name == "synthetic" => { + if synthetic.size_bytes < fixtures::MIN_SIZE { + return Err(err(format!( + "case {} size_bytes must be >= {}", + case.label, + fixtures::MIN_SIZE + ))); + } + if !fixtures::is_supported_kind(&synthetic.kind) { + return Err(err(format!( + "case {} unsupported synthetic kind {}; supported: {}", + case.label, + synthetic.kind, + fixtures::supported_kinds().join(", ") + ))); + } + } + _ => { + return Err(err(format!( + "case {} must use exactly one of path or synthetic, matching class", + case.label + ))); + } + } + if let Some(id) = &case.id + && !case_ids.insert(id.clone()) + { + return Err(err(format!("duplicate case id: {id}"))); + } + } + validate_revelo_versions(&manifest.revelo_versions) +} + +fn validate_revelo_versions(versions: &[ReveloVersion]) -> Result<()> { + let reserved = ["mediainfo", "ffprobe", "revelo_perf_probe"]; + let mut seen = HashSet::new(); + for version in versions { + if version.id.is_empty() + || !version + .id + .chars() + .all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_')) + { + return Err(err( + "revelo_versions id must contain only letters, numbers, dash or underscore", + )); + } + if reserved.contains(&version.id.as_str()) { + return Err(err(format!("revelo_versions id {} is reserved", version.id))); + } + if !seen.insert(version.id.clone()) { + return Err(err(format!("duplicate revelo_versions id: {}", version.id))); + } + } + Ok(()) +} + +fn validate_table_config(config: &TableConfig) -> Result<()> { + if config.schema.as_deref().unwrap_or(TABLE_CONFIG_SCHEMA) != TABLE_CONFIG_SCHEMA { + return Err(err(format!("table config schema must be {TABLE_CONFIG_SCHEMA}"))); + } + if let Some(tiers) = &config.latency_tiers_ms { + if tiers.is_empty() { + return Err(err("table config latency_tiers_ms must be non-empty")); + } + for tier in tiers { + if tier.name.is_empty() || tier.label.is_empty() || tier.class_name.is_empty() { + return Err(err("latency tier name, label and class must be non-empty")); + } + } + } + Ok(()) +} + +pub(crate) fn self_test() -> Result<()> { + let valid = r#"{ + "schema": "revelo_perf_manifest_v2", + "settings": {"warmups": 1, "runs": 2}, + "cases": [{ + "id": "fixture", + "label": "Fixture", + "class": "synthetic", + "container": "MP4", + "synthetic": {"kind": "mp4_snv2_tail", "size_bytes": 4096} + }] + }"#; + let manifest: Manifest = serde_json::from_str(valid)?; + validate_manifest(&manifest)?; + let invalid = r#"{ + "schema": "revelo_perf_manifest_v2", + "cases": [{ + "label": "Bad", + "class": "real", + "path": "sample.mp4", + "synthetic": {"kind": "mp4_snv2_tail", "size_bytes": 4096} + }] + }"#; + let manifest: Manifest = serde_json::from_str(invalid)?; + assert!(validate_manifest(&manifest).is_err()); + Ok(()) +} diff --git a/crates/revelo-bench/src/evidence.rs b/crates/revelo-bench/src/evidence.rs new file mode 100644 index 0000000..c8e676b --- /dev/null +++ b/crates/revelo-bench/src/evidence.rs @@ -0,0 +1,73 @@ +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use clap::Args; +use serde_json::json; + +use crate::compare::{self, CompareArgs}; +use crate::util::Result; + +#[derive(Debug, Args)] +pub(crate) struct EvidenceArgs { + #[arg(long)] + pub(crate) manifest: PathBuf, + #[arg(long, default_value = "target/perf-investigation")] + pub(crate) out_dir: PathBuf, + #[arg(long, default_value = "target/perf-fixtures")] + pub(crate) fixture_dir: PathBuf, + #[arg(long)] + pub(crate) run_id: Option, + #[arg(long, default_value = "scripts/perf/table.config.example.json")] + pub(crate) table_config: PathBuf, + #[arg(long)] + pub(crate) warmups: Option, + #[arg(long)] + pub(crate) runs: Option, + #[arg(long)] + pub(crate) chrome_path: Option, +} + +pub(crate) fn run(args: EvidenceArgs) -> Result { + let run_id = args.run_id.unwrap_or_else(timestamp_run_id); + let compare = compare::run_compare(CompareArgs { + manifest: args.manifest.clone(), + out_dir: args.out_dir.clone(), + fixture_dir: args.fixture_dir, + run_id: Some(run_id.clone()), + warmups: args.warmups, + runs: args.runs, + probe_export: "all".to_owned(), + probe_output_target: "sink".to_owned(), + table_config: Some(args.table_config), + no_render_table: false, + render_png: true, + chrome_path: args.chrome_path, + no_build: false, + skip_mediainfo: false, + skip_ffprobe: false, + })?; + let summary_path = compare.run_dir.join("evidence-summary.json"); + fs::write( + &summary_path, + serde_json::to_string_pretty(&json!({ + "schema": "revelo_benchmark_evidence_v1", + "run_id": compare.run_id, + "manifest": args.manifest, + "outputs": [ + {"name": "bench_compare", "output": compare.results_path}, + {"name": "render_table", "output": compare.run_dir.join("benchmark-table.html")}, + {"name": "capture_table", "output": compare.run_dir.join("benchmark-table.png")} + ] + }))? + "\n", + )?; + Ok(summary_path) +} + +fn timestamp_run_id() -> String { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default(); + format!("run-{seconds}") +} diff --git a/crates/revelo-bench/src/fixtures.rs b/crates/revelo-bench/src/fixtures.rs new file mode 100644 index 0000000..2b43ef4 --- /dev/null +++ b/crates/revelo-bench/src/fixtures.rs @@ -0,0 +1,1254 @@ +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde::Serialize; + +use crate::config::{BenchCase, Manifest}; +use crate::util::{Result, err, run_checked, safe_id, which}; + +pub(crate) const MIN_SIZE: u64 = 4096; + +#[derive(Debug, Serialize)] +pub(crate) struct FixtureList { + pub(crate) fixtures: Vec, +} + +#[derive(Debug, Serialize)] +pub(crate) struct FixtureRecord { + pub(crate) id: String, + pub(crate) path: String, + pub(crate) size_bytes: u64, +} + +#[derive(Debug, Clone)] +struct FfmpegFixture { + extension: &'static str, + command: Vec<&'static str>, + major_brand: Option<&'static [u8; 4]>, +} + +pub(crate) fn generate_manifest_fixtures( + manifest: &Manifest, + out_dir: &Path, +) -> Result { + let mut fixtures = Vec::new(); + for case in &manifest.cases { + if case.synthetic.is_some() { + let path = generate_case_fixture(case, out_dir)?; + fixtures.push(FixtureRecord { + id: case.id.clone().unwrap_or_else(|| safe_id(&case.label)), + size_bytes: path.metadata()?.len(), + path: path.display().to_string(), + }); + } + } + Ok(FixtureList { fixtures }) +} + +pub(crate) fn generate_case_fixture(case: &BenchCase, out_dir: &Path) -> Result { + let synthetic = case + .synthetic + .as_ref() + .ok_or_else(|| err(format!("case {} missing synthetic object", case.label)))?; + if synthetic.size_bytes < MIN_SIZE { + return Err(err(format!("synthetic case {} size must be at least {MIN_SIZE}", case.label))); + } + let extension = extension_for_kind(&synthetic.kind) + .ok_or_else(|| err(format!("unsupported synthetic fixture kind {}", synthetic.kind)))?; + fs::create_dir_all(out_dir)?; + let case_id = case.id.clone().unwrap_or_else(|| safe_id(&case.label)); + let path = out_dir.join(format!("{}.{}", safe_id(&case_id), extension)); + generate_kind(&synthetic.kind, &path, synthetic.size_bytes)?; + Ok(path) +} + +pub(crate) fn is_supported_kind(kind: &str) -> bool { + extension_for_kind(kind).is_some() +} + +pub(crate) fn supported_kinds() -> Vec { + let mut kinds = sparse_extensions() + .keys() + .chain(ffmpeg_fixtures().keys()) + .map(|value| (*value).to_owned()) + .collect::>(); + kinds.sort(); + kinds +} + +fn generate_kind(kind: &str, path: &Path, size_bytes: u64) -> Result<()> { + match kind { + "mp4_moov_front" => { + write_mp4(path, size_bytes, b"isom", &[b"isom", b"iso2", b"mp41"], false) + } + "mp4_moov_tail" => write_mp4(path, size_bytes, b"isom", &[b"isom", b"iso2", b"mp41"], true), + "mp4_snv2_tail" => { + write_mp4(path, size_bytes, b"SNV2", &[b"SNV2", b"isom", b"iso2", b"mp42"], true) + } + "mov_moov_tail" => write_mp4(path, size_bytes, b"qt ", &[b"qt "], true), + "fragmented_mp4" => generate_fragmented_mp4(path, size_bytes), + "webm_sparse" => generate_ebml_sparse(path, size_bytes, b"webm"), + "mkv_sparse" => generate_ebml_sparse(path, size_bytes, b"matroska"), + "wav_list_id3_data" => generate_wav(path, size_bytes, WavOptions::default()), + "wav_192khz_data" => generate_wav( + path, + size_bytes, + WavOptions { sample_rate: 192_000, ..Default::default() }, + ), + "wav_9ch_data" => { + generate_wav(path, size_bytes, WavOptions { channels: 9, ..Default::default() }) + } + "bwf_data" => { + generate_wav(path, size_bytes, WavOptions { bext: true, ..Default::default() }) + } + "rf64_ds64_data" => { + generate_wav(path, size_bytes, WavOptions { rf64: true, ..Default::default() }) + } + "aiff_ssnd" => generate_aiff(path, size_bytes, false, *b"NONE"), + "aifc_ssnd" => generate_aiff(path, size_bytes, true, *b"NONE"), + "aifc_mace3_ssnd" => generate_aiff(path, size_bytes, true, *b"MAC3"), + "flac_large" => generate_flac(path, size_bytes), + "mp3_id3_large" => generate_mp3(path, size_bytes), + "ogg_large" => generate_ogg(path, size_bytes), + "mpeg_ts_large" => generate_mpeg_ts(path, size_bytes), + "mpeg_ps_large" => generate_mpeg_ps(path, size_bytes), + "avi_large" => generate_ffmpeg(path, size_bytes, &avi_large_fixture()), + other => { + let fixtures = ffmpeg_fixtures(); + let fixture = fixtures + .get(other) + .ok_or_else(|| err(format!("unsupported synthetic fixture kind {other}")))?; + generate_ffmpeg(path, size_bytes, fixture) + } + } +} + +fn extension_for_kind(kind: &str) -> Option<&'static str> { + sparse_extensions() + .get(kind) + .copied() + .or_else(|| ffmpeg_fixtures().get(kind).map(|fixture| fixture.extension)) +} + +fn sparse_extensions() -> BTreeMap<&'static str, &'static str> { + [ + ("mp4_moov_front", "mp4"), + ("mp4_moov_tail", "mp4"), + ("mp4_snv2_tail", "mp4"), + ("mov_moov_tail", "mov"), + ("fragmented_mp4", "mp4"), + ("webm_sparse", "webm"), + ("mkv_sparse", "mkv"), + ("wav_list_id3_data", "wav"), + ("wav_192khz_data", "wav"), + ("wav_9ch_data", "wav"), + ("bwf_data", "wav"), + ("rf64_ds64_data", "wav"), + ("aiff_ssnd", "aiff"), + ("aifc_ssnd", "aifc"), + ("aifc_mace3_ssnd", "aifc"), + ("flac_large", "flac"), + ("mp3_id3_large", "mp3"), + ("ogg_large", "ogg"), + ("mpeg_ts_large", "ts"), + ("mpeg_ps_large", "vob"), + ("avi_large", "avi"), + ] + .into() +} + +fn write_exact_size( + path: &Path, + size_bytes: u64, + writer: impl FnOnce(&mut File, u64) -> Result<()>, +) -> Result<()> { + let mut file = File::create(path)?; + writer(&mut file, size_bytes)?; + let position = file.stream_position()?; + if position > size_bytes { + return Err(err(format!("fixture writer exceeded target size for {}", path.display()))); + } + file.set_len(size_bytes)?; + Ok(()) +} + +fn write_box(name: &[u8; 4], payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(payload.len() + 8); + out.extend_from_slice(&(payload.len() as u32 + 8).to_be_bytes()); + out.extend_from_slice(name); + out.extend_from_slice(payload); + out +} + +fn full_box(name: &[u8; 4], version: u8, flags: u32, payload: &[u8]) -> Vec { + let mut full = vec![version]; + full.extend_from_slice(&(flags & 0x00ff_ffff).to_be_bytes()[1..]); + full.extend_from_slice(payload); + write_box(name, &full) +} + +fn make_ftyp(major: &[u8; 4], brands: &[&[u8; 4]]) -> Vec { + let mut payload = Vec::new(); + payload.extend_from_slice(major); + payload.extend_from_slice(&0x200u32.to_be_bytes()); + for brand in brands { + payload.extend_from_slice(*brand); + } + write_box(b"ftyp", &payload) +} + +fn make_moov() -> Vec { + let mut payload = vec![0; 8]; + payload.extend_from_slice(&1000u32.to_be_bytes()); + payload.extend_from_slice(&1000u32.to_be_bytes()); + payload.extend_from_slice(&0x0001_0000u32.to_be_bytes()); + payload.extend_from_slice(&0x0100u16.to_be_bytes()); + payload.extend_from_slice(&[0; 10]); + payload.extend_from_slice(&[ + 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0x40, 0, 0, 0, + ]); + payload.extend_from_slice(&[0; 24]); + payload.extend_from_slice(&2u32.to_be_bytes()); + write_box(b"moov", &full_box(b"mvhd", 0, 0, &payload)) +} + +fn write_mp4( + path: &Path, + size_bytes: u64, + major: &[u8; 4], + brands: &[&[u8; 4]], + moov_tail: bool, +) -> Result<()> { + let ftyp = make_ftyp(major, brands); + let moov = make_moov(); + write_exact_size(path, size_bytes, |file, target| { + file.write_all(&ftyp)?; + if moov_tail { + let mdat_size = target + .checked_sub(ftyp.len() as u64 + moov.len() as u64) + .ok_or_else(|| err("target MP4 size too small"))?; + if mdat_size < 8 { + return Err(err("target MP4 size too small")); + } + file.write_all(&(mdat_size as u32).to_be_bytes())?; + file.write_all(b"mdat")?; + file.seek(SeekFrom::Start(target - moov.len() as u64))?; + file.write_all(&moov)?; + } else { + file.write_all(&moov)?; + let mdat_size = target + .checked_sub(ftyp.len() as u64 + moov.len() as u64) + .ok_or_else(|| err("target MP4 size too small"))?; + if mdat_size < 8 { + return Err(err("target MP4 size too small")); + } + file.write_all(&(mdat_size as u32).to_be_bytes())?; + file.write_all(b"mdat")?; + } + Ok(()) + }) +} + +fn generate_fragmented_mp4(path: &Path, size_bytes: u64) -> Result<()> { + let ftyp = make_ftyp(b"iso6", &[b"iso6", b"mp41", b"dash"]); + let moov = make_moov(); + let moof = write_box(b"moof", &write_box(b"mfhd", b"\x00\x00\x00\x00\x00\x00\x00\x01")); + write_exact_size(path, size_bytes, |file, target| { + file.write_all(&ftyp)?; + file.write_all(&moov)?; + file.write_all(&moof)?; + let mdat_size = target + .checked_sub(file.stream_position()?) + .ok_or_else(|| err("target fragmented MP4 size too small"))?; + if mdat_size < 8 { + return Err(err("target fragmented MP4 size too small")); + } + file.write_all(&(mdat_size as u32).to_be_bytes())?; + file.write_all(b"mdat")?; + Ok(()) + }) +} + +#[derive(Clone, Copy)] +struct WavOptions { + rf64: bool, + bext: bool, + channels: u16, + sample_rate: u32, + bits_per_sample: u16, +} + +impl Default for WavOptions { + fn default() -> Self { + Self { rf64: false, bext: false, channels: 2, sample_rate: 48_000, bits_per_sample: 16 } + } +} + +fn riff_chunk(name: &[u8; 4], payload: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(name); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(payload); + if payload.len() % 2 == 1 { + out.push(0); + } + out +} + +fn generate_wav(path: &Path, size_bytes: u64, options: WavOptions) -> Result<()> { + let block_align = options.channels * options.bits_per_sample / 8; + let byte_rate = options.sample_rate * u32::from(block_align); + let mut fmt_payload = Vec::new(); + fmt_payload.extend_from_slice(&1u16.to_le_bytes()); + fmt_payload.extend_from_slice(&options.channels.to_le_bytes()); + fmt_payload.extend_from_slice(&options.sample_rate.to_le_bytes()); + fmt_payload.extend_from_slice(&byte_rate.to_le_bytes()); + fmt_payload.extend_from_slice(&block_align.to_le_bytes()); + fmt_payload.extend_from_slice(&options.bits_per_sample.to_le_bytes()); + let fmt = riff_chunk(b"fmt ", &fmt_payload); + let list = riff_chunk(b"LIST", b"INFOINAM\x08\x00\x00\x00fixture\x00"); + let id3 = + riff_chunk(b"ID3 ", b"ID3\x04\x00\x00\x00\x00\x00\x10\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"); + let bext = if options.bext { riff_chunk(b"bext", &vec![0; 602]) } else { Vec::new() }; + write_exact_size(path, size_bytes, |file, target| { + if options.rf64 { + file.write_all(b"RF64\xff\xff\xff\xffWAVE")?; + let data_bytes = target.saturating_sub( + 12 + 36 + + fmt.len() as u64 + + bext.len() as u64 + + list.len() as u64 + + id3.len() as u64 + + 8, + ); + let mut ds64_payload = Vec::new(); + ds64_payload.extend_from_slice(&(target - 8).to_le_bytes()); + ds64_payload.extend_from_slice(&data_bytes.to_le_bytes()); + ds64_payload.extend_from_slice(&0u64.to_le_bytes()); + ds64_payload.extend_from_slice(&0u32.to_le_bytes()); + file.write_all(&riff_chunk(b"ds64", &ds64_payload))?; + file.write_all(&fmt)?; + file.write_all(&bext)?; + file.write_all(&list)?; + file.write_all(&id3)?; + file.write_all(b"data\xff\xff\xff\xff")?; + } else { + file.write_all(b"RIFF\xff\xff\xff\xffWAVE")?; + file.write_all(&fmt)?; + file.write_all(&bext)?; + file.write_all(&list)?; + file.write_all(&id3)?; + file.write_all(b"data")?; + let data_size = target.saturating_sub(file.stream_position()? + 4); + file.write_all(&(data_size as u32).to_le_bytes())?; + } + Ok(()) + }) +} + +fn aiff_chunk(name: &[u8; 4], payload: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(name); + out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + out.extend_from_slice(payload); + if payload.len() % 2 == 1 { + out.push(0); + } + out +} + +fn generate_aiff( + path: &Path, + size_bytes: u64, + compressed: bool, + compression_type: [u8; 4], +) -> Result<()> { + let mut common_payload = + b"\x00\x02\x00\x00\x00\x01\x00\x10@\x0e\xac\x44\x00\x00\x00\x00\x00\x00".to_vec(); + if compressed { + common_payload.extend_from_slice(&compression_type); + common_payload.extend_from_slice(b"\x0enot compressed"); + } + let common = aiff_chunk(b"COMM", &common_payload); + let name = aiff_chunk(b"NAME", b"fixture"); + let id3 = + aiff_chunk(b"ID3 ", b"ID3\x04\x00\x00\x00\x00\x00\x10\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"); + write_exact_size(path, size_bytes, |file, target| { + let header_size = 12 + common.len() as u64 + name.len() as u64 + id3.len() as u64 + 16; + let ssnd_payload_size = + target.checked_sub(header_size).ok_or_else(|| err("target AIFF size too small"))?; + file.write_all(b"FORM")?; + file.write_all(&((target - 8) as u32).to_be_bytes())?; + file.write_all(if compressed { b"AIFC" } else { b"AIFF" })?; + file.write_all(&common)?; + file.write_all(&name)?; + file.write_all(&id3)?; + file.write_all(&((ssnd_payload_size + 16) as u32).to_be_bytes())?; + file.write_all(b"SSND")?; + file.write_all(&0u32.to_be_bytes())?; + file.write_all(&0u32.to_be_bytes())?; + Ok(()) + }) +} + +fn generate_flac(path: &Path, size_bytes: u64) -> Result<()> { + write_exact_size(path, size_bytes, |file, target| { + file.write_all(b"fLaC")?; + let audio_size = target.saturating_sub(42).max(4); + let streaminfo = flac_streaminfo(audio_size); + file.write_all(&[0x80])?; + file.write_all(&(streaminfo.len() as u32).to_be_bytes()[1..])?; + file.write_all(&streaminfo)?; + Ok(()) + }) +} + +fn flac_streaminfo(audio_size: u64) -> Vec { + let samples = (audio_size / 4).max(1); + let mut packed = 0u128; + packed |= 48_000u128 << (3 + 5 + 36); + packed |= 1u128 << (5 + 36); + packed |= 15u128 << 36; + packed |= u128::from(samples) & ((1u128 << 36) - 1); + let mut out = Vec::new(); + out.extend_from_slice(b"\x00\x10\x10\x00\x00\x00\x00\x00\x00\x00"); + out.extend_from_slice(&packed.to_be_bytes()[8..]); + out.extend_from_slice(&[0; 16]); + out +} + +fn generate_mp3(path: &Path, size_bytes: u64) -> Result<()> { + let tag_payload = b"TIT2\x00\x00\x00\x08\x00\x00fixture\x00"; + let tag_size = tag_payload.len(); + let syncsafe = [ + ((tag_size >> 21) & 0x7f) as u8, + ((tag_size >> 14) & 0x7f) as u8, + ((tag_size >> 7) & 0x7f) as u8, + (tag_size & 0x7f) as u8, + ]; + write_exact_size(path, size_bytes, |file, _| { + file.write_all(b"ID3\x04\x00\x00")?; + file.write_all(&syncsafe)?; + file.write_all(tag_payload)?; + file.write_all(b"\xff\xfb\x90\x64")?; + Ok(()) + }) +} + +fn generate_ogg(path: &Path, size_bytes: u64) -> Result<()> { + let opus_head = [ + b"OpusHead".as_slice(), + &[1, 2], + &312u16.to_le_bytes(), + &48_000u32.to_le_bytes(), + &0u16.to_le_bytes(), + &[0], + ] + .concat(); + let opus_tags = [b"OpusTags".as_slice(), &0u32.to_le_bytes(), &0u32.to_le_bytes()].concat(); + let header = [ + ogg_page(&opus_head, 2, 0, 0), + ogg_page(&opus_tags, 0, 1, 0), + ogg_page(b"\xfc\xff\xfe", 4, 2, 960), + ] + .concat(); + write_exact_size(path, size_bytes, |file, _| { + file.write_all(&header)?; + Ok(()) + }) +} + +fn ogg_page(packet: &[u8], header_type: u8, sequence: u32, granule_position: u64) -> Vec { + let mut segments = Vec::new(); + let mut remaining = packet.len(); + while remaining >= 255 { + segments.push(255); + remaining -= 255; + } + segments.push(remaining as u8); + let mut page = Vec::new(); + page.extend_from_slice(b"OggS"); + page.extend_from_slice(&[0, header_type]); + page.extend_from_slice(&granule_position.to_le_bytes()); + page.extend_from_slice(&1u32.to_le_bytes()); + page.extend_from_slice(&sequence.to_le_bytes()); + page.extend_from_slice(&0u32.to_le_bytes()); + page.push(segments.len() as u8); + page.extend_from_slice(&segments); + page.extend_from_slice(packet); + let crc = ogg_crc(&page); + page[22..26].copy_from_slice(&crc.to_le_bytes()); + page +} + +fn ogg_crc(data: &[u8]) -> u32 { + let mut crc = 0u32; + for byte in data { + crc ^= u32::from(*byte) << 24; + for _ in 0..8 { + if crc & 0x8000_0000 != 0 { + crc = (crc << 1) ^ 0x04c1_1db7; + } else { + crc <<= 1; + } + } + } + crc +} + +fn generate_mpeg_ts(path: &Path, size_bytes: u64) -> Result<()> { + let packet = [vec![0x47, 0x40, 0x00, 0x10], vec![0xff; 184]].concat(); + write_exact_size(path, size_bytes, |file, target| { + for _ in 0..std::cmp::min(16, target / packet.len() as u64) { + file.write_all(&packet)?; + } + Ok(()) + }) +} + +fn generate_mpeg_ps(path: &Path, size_bytes: u64) -> Result<()> { + write_exact_size(path, size_bytes, |file, _| { + file.write_all(b"\x00\x00\x01\xba\x44\x00\x04\x00\x04\x01\x89\xc3\xf8")?; + Ok(()) + }) +} + +fn generate_ebml_sparse(path: &Path, size_bytes: u64, doc_type: &[u8]) -> Result<()> { + let mut header_payload = Vec::new(); + header_payload.extend_from_slice(&hex("4286810142F7810142F2810442F38108")); + header_payload.extend_from_slice(&hex("4282")); + header_payload.extend_from_slice(&vint_size(doc_type.len())?); + header_payload.extend_from_slice(doc_type); + let mut header = hex("1A45DFA3"); + header.extend_from_slice(&vint_size(header_payload.len())?); + header.extend_from_slice(&header_payload); + write_exact_size(path, size_bytes, |file, _| { + file.write_all(&header)?; + file.write_all(&hex("1853806701FFFFFFFFFFFFFF"))?; + file.write_all(&hex("1549A966842AD7B1810F"))?; + file.write_all(&hex("1F43B67501FFFFFFFFFFFFFF"))?; + Ok(()) + }) +} + +fn vint_size(size: usize) -> Result> { + if size < 0x7f { + Ok(vec![0x80 | size as u8]) + } else if size < 0x3fff { + Ok(vec![0x40 | (size >> 8) as u8, size as u8]) + } else { + Err(err(format!("EBML fixture size field too large: {size}"))) + } +} + +fn hex(value: &str) -> Vec { + value + .as_bytes() + .chunks(2) + .map(|chunk| u8::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap()) + .collect() +} + +fn generate_ffmpeg(path: &Path, size_bytes: u64, fixture: &FfmpegFixture) -> Result<()> { + let ffmpeg = which("ffmpeg") + .ok_or_else(|| err("ffmpeg is required to generate this synthetic fixture"))?; + let tmp = path.with_file_name(format!( + "{}.tmp.{}", + path.file_stem().and_then(|value| value.to_str()).unwrap_or("fixture"), + path.extension().and_then(|value| value.to_str()).unwrap_or("media") + )); + let _ = fs::remove_file(path); + let _ = fs::remove_file(&tmp); + let mut command = Command::new(ffmpeg); + command.args(["-hide_banner", "-loglevel", "error", "-y"]).args(&fixture.command).arg(&tmp); + run_checked(&mut command)?; + if tmp.metadata()?.len() > size_bytes { + return Err(err(format!("ffmpeg fixture exceeded target size for {}", path.display()))); + } + truncate_sparse(&tmp, size_bytes)?; + if let Some(brand) = fixture.major_brand { + patch_major_brand(&tmp, brand)?; + } + fs::rename(&tmp, path)?; + Ok(()) +} + +fn truncate_sparse(path: &Path, size_bytes: u64) -> Result<()> { + OpenOptions::new().write(true).open(path)?.set_len(size_bytes)?; + Ok(()) +} + +fn patch_major_brand(path: &Path, brand: &[u8; 4]) -> Result<()> { + use std::io::Read; + let mut file = OpenOptions::new().read(true).write(true).open(path)?; + let mut header = [0; 16]; + file.read_exact(&mut header)?; + if &header[4..8] != b"ftyp" { + return Err(err(format!("{} does not start with an ftyp box", path.display()))); + } + file.seek(SeekFrom::Start(8))?; + file.write_all(brand)?; + Ok(()) +} + +fn audio_base(source: &'static str) -> Vec<&'static str> { + vec!["-f", "lavfi", "-i", source, "-t", "2"] +} + +fn video_base() -> Vec<&'static str> { + vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=640x360:rate=30", + "-f", + "lavfi", + "-i", + "sine=frequency=1000:sample_rate=48000", + "-t", + "2", + "-shortest", + ] +} + +fn with_metadata(mut args: Vec<&'static str>) -> Vec<&'static str> { + args.extend([ + "-metadata", + "title=Revelo benchmark fixture", + "-metadata", + "comment=generated public benchmark fixture", + ]); + args +} + +fn fixture(extension: &'static str, command: Vec<&'static str>) -> FfmpegFixture { + FfmpegFixture { extension, command, major_brand: None } +} + +fn brand_fixture( + extension: &'static str, + command: Vec<&'static str>, + brand: &'static [u8; 4], +) -> FfmpegFixture { + FfmpegFixture { extension, command, major_brand: Some(brand) } +} + +fn avi_large_fixture() -> FfmpegFixture { + fixture( + "avi", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "mpeg4", + "-q:v", + "5", + "-c:a", + "libmp3lame", + "-b:a", + "128k", + "-f", + "avi", + ], + ] + .concat(), + ), + ) +} + +fn ffmpeg_fixtures() -> BTreeMap<&'static str, FfmpegFixture> { + let mp4_h264 = || { + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "96k", + "-movflags", + "+faststart", + ], + ] + .concat(), + ) + }; + [ + ("ffmpeg_mp4_avc_faststart", fixture("mp4", mp4_h264())), + ("ffmpeg_mp4_snv2_faststart", brand_fixture("mp4", mp4_h264(), b"SNV2")), + ( + "ffmpeg_mp4_hevc10_faststart", + fixture( + "mp4", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "libx265", + "-preset", + "ultrafast", + "-x265-params", + "log-level=error", + "-pix_fmt", + "yuv420p10le", + "-c:a", + "aac", + "-b:a", + "96k", + "-movflags", + "+faststart", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_mp4_av1_faststart", + fixture( + "mp4", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "libsvtav1", + "-preset", + "13", + "-crf", + "45", + "-pix_fmt", + "yuv420p10le", + "-c:a", + "aac", + "-b:a", + "96k", + "-movflags", + "+faststart", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_mp4_aac_audio", + fixture( + "m4a", + with_metadata( + [ + audio_base("sine=frequency=1000:sample_rate=48000"), + vec!["-c:a", "aac", "-b:a", "128k", "-f", "mp4"], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_mp4_tail", + fixture( + "mp4", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "96k", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_mov_mpeg4_pcm", + fixture( + "mov", + with_metadata( + [ + video_base(), + vec!["-c:v", "mpeg4", "-q:v", "5", "-c:a", "pcm_s16be", "-f", "mov"], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_fragmented_mp4", + fixture( + "mp4", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-movflags", + "frag_keyframe+empty_moov+default_base_moof", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_mkv_h264_aac", + fixture( + "mkv", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-c:a", + "aac", + "-f", + "matroska", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_mkv_ffv1_flac", + fixture( + "mkv", + with_metadata( + [ + video_base(), + vec!["-c:v", "ffv1", "-level", "3", "-c:a", "flac", "-f", "matroska"], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_webm_vp8_opus", + fixture( + "webm", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "libvpx", + "-deadline", + "realtime", + "-cpu-used", + "8", + "-b:v", + "500k", + "-c:a", + "libopus", + "-b:a", + "64k", + "-f", + "webm", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_webm_av1_opus", + fixture( + "webm", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "libsvtav1", + "-preset", + "13", + "-crf", + "45", + "-pix_fmt", + "yuv420p10le", + "-c:a", + "libopus", + "-b:a", + "64k", + "-f", + "webm", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_flv_nellymoser", + fixture( + "flv", + with_metadata( + [ + audio_base("sine=frequency=1000:sample_rate=44100"), + vec!["-c:a", "nellymoser", "-f", "flv"], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_asf_wma", + fixture( + "asf", + with_metadata( + [ + audio_base("sine=frequency=1000:sample_rate=48000"), + vec!["-c:a", "wmav2", "-f", "asf"], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_flac", + fixture( + "flac", + with_metadata( + [audio_base("anoisesrc=r=48000:a=0.25"), vec!["-c:a", "flac", "-f", "flac"]] + .concat(), + ), + ), + ), + ( + "ffmpeg_mp3", + fixture( + "mp3", + with_metadata( + [ + audio_base("anoisesrc=r=48000:a=0.25"), + vec![ + "-c:a", + "libmp3lame", + "-b:a", + "320k", + "-write_id3v2", + "1", + "-id3v2_version", + "3", + "-f", + "mp3", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_ogg_vorbis", + fixture( + "ogg", + with_metadata( + [ + audio_base("anullsrc=r=48000:cl=stereo"), + vec!["-strict", "-2", "-c:a", "vorbis", "-q:a", "5", "-f", "ogg"], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_ogg_opus", + fixture( + "ogg", + with_metadata( + [ + audio_base("anullsrc=r=48000:cl=stereo"), + vec!["-c:a", "libopus", "-b:a", "128k", "-f", "ogg"], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_ogg_flac", + fixture( + "oga", + with_metadata( + [audio_base("anoisesrc=r=48000:a=0.25"), vec!["-c:a", "flac", "-f", "ogg"]] + .concat(), + ), + ), + ), + ( + "ffmpeg_mpeg_ts", + fixture( + "ts", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "mpeg2video", + "-b:v", + "2M", + "-c:a", + "mp2", + "-b:a", + "128k", + "-f", + "mpegts", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_mpeg_ts_ac3", + fixture( + "ts", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "mpeg2video", + "-b:v", + "2M", + "-c:a", + "ac3", + "-b:a", + "192k", + "-f", + "mpegts", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_m2ts", + fixture( + "m2ts", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "mpeg2video", + "-b:v", + "2M", + "-c:a", + "mp2", + "-b:a", + "128k", + "-mpegts_m2ts_mode", + "1", + "-f", + "mpegts", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_mpeg_ps", + fixture( + "mpg", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "mpeg2video", + "-b:v", + "2M", + "-c:a", + "mp2", + "-b:a", + "128k", + "-f", + "mpeg", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_mpeg_ps_ac3", + fixture( + "mpg", + with_metadata( + [ + video_base(), + vec![ + "-c:v", + "mpeg2video", + "-b:v", + "2M", + "-c:a", + "ac3", + "-b:a", + "192k", + "-f", + "mpeg", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_vob", + fixture( + "vob", + with_metadata( + [ + vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=720x480:rate=30000/1001", + "-f", + "lavfi", + "-i", + "sine=frequency=1000:sample_rate=48000", + "-t", + "2", + "-shortest", + ], + vec![ + "-c:v", + "mpeg2video", + "-b:v", + "4M", + "-c:a", + "mp2", + "-b:a", + "192k", + "-f", + "vob", + ], + ] + .concat(), + ), + ), + ), + ("ffmpeg_avi_mpeg4_mp3", avi_large_fixture()), + ( + "ffmpeg_avi_mpeg4_wma", + fixture( + "avi", + with_metadata( + [ + video_base(), + vec![ + "-c:v", "mpeg4", "-q:v", "5", "-c:a", "wmav2", "-b:a", "128k", "-f", + "avi", + ], + ] + .concat(), + ), + ), + ), + ( + "ffmpeg_raw_aac", + fixture( + "aac", + [ + audio_base("sine=frequency=1000:sample_rate=48000"), + vec!["-c:a", "aac", "-b:a", "128k", "-f", "adts"], + ] + .concat(), + ), + ), + ( + "ffmpeg_raw_ac3", + fixture( + "ac3", + [ + audio_base("sine=frequency=1000:sample_rate=48000"), + vec!["-c:a", "ac3", "-b:a", "192k", "-f", "ac3"], + ] + .concat(), + ), + ), + ( + "ffmpeg_raw_eac3", + fixture( + "eac3", + [ + audio_base("sine=frequency=1000:sample_rate=48000"), + vec!["-c:a", "eac3", "-b:a", "192k", "-f", "eac3"], + ] + .concat(), + ), + ), + ( + "ffmpeg_opus", + fixture( + "opus", + with_metadata( + [ + audio_base("sine=frequency=1000:sample_rate=48000"), + vec!["-c:a", "libopus", "-b:a", "64k", "-f", "opus"], + ] + .concat(), + ), + ), + ), + ] + .into() +} + +pub(crate) fn self_test() -> Result<()> { + let temp = tempfile::tempdir()?; + for kind in ["mp4_snv2_tail", "webm_sparse", "mkv_sparse", "flac_large", "ogg_large"] { + let case = BenchCase { + id: Some(format!("self-test-{kind}")), + label: format!("self test {kind}"), + class_name: "synthetic".to_owned(), + format: None, + container: None, + codec: None, + layout: None, + source: None, + path: None, + synthetic: Some(crate::config::SyntheticCase { + kind: kind.to_owned(), + size_bytes: 8 * 1024 * 1024, + }), + }; + let path = generate_case_fixture(&case, temp.path())?; + assert_eq!(path.metadata()?.len(), 8 * 1024 * 1024); + } + Ok(()) +} diff --git a/crates/revelo-bench/src/main.rs b/crates/revelo-bench/src/main.rs new file mode 100644 index 0000000..5c8940c --- /dev/null +++ b/crates/revelo-bench/src/main.rs @@ -0,0 +1,82 @@ +mod cdp; +mod compare; +mod config; +mod evidence; +mod fixtures; +mod render; +mod util; + +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command(name = "revelo-bench")] +#[command(about = "Developer-only benchmark tooling for Revelo")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Generate synthetic benchmark fixtures from a manifest. + GenerateFixtures { + #[arg(long)] + manifest: PathBuf, + #[arg(long, default_value = "target/perf-fixtures")] + out_dir: PathBuf, + }, + /// Run Hyperfine comparisons from a manifest and write sanitized JSON. + Compare(compare::CompareArgs), + /// Render benchmark HTML and optionally capture a PNG through Chrome CDP. + RenderTable(render::RenderArgs), + /// Run compare plus table rendering with one shared run id. + Evidence(evidence::EvidenceArgs), + /// Run lightweight self-tests for benchmark tooling. + SelfTest, +} + +fn main() { + if let Err(error) = run() { + eprintln!("error: {error}"); + std::process::exit(1); + } +} + +fn run() -> util::Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::GenerateFixtures { manifest, out_dir } => { + let manifest = config::Manifest::load(&manifest)?; + config::validate_manifest(&manifest)?; + let fixtures = fixtures::generate_manifest_fixtures(&manifest, &out_dir)?; + println!("{}", serde_json::to_string_pretty(&fixtures)?); + } + Command::Compare(args) => { + let path = compare::run(args)?; + println!("{}", path.display()); + } + Command::RenderTable(args) => { + let output = render::run(args)?; + println!("{}", output.html.display()); + if let Some(png) = output.png { + println!("{}", png.display()); + } + } + Command::Evidence(args) => { + let path = evidence::run(args)?; + println!("{}", path.display()); + } + Command::SelfTest => self_test()?, + } + Ok(()) +} + +fn self_test() -> util::Result<()> { + config::self_test()?; + fixtures::self_test()?; + render::self_test()?; + cdp::self_test()?; + Ok(()) +} diff --git a/crates/revelo-bench/src/render.rs b/crates/revelo-bench/src/render.rs new file mode 100644 index 0000000..15ceb7e --- /dev/null +++ b/crates/revelo-bench/src/render.rs @@ -0,0 +1,379 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; + +use clap::Args; +use serde_json::Value; + +use crate::cdp; +use crate::config::{LatencyTier, TableConfig}; +use crate::util::{Result, err}; + +#[derive(Debug, Args)] +pub(crate) struct RenderArgs { + #[arg(long)] + pub(crate) results: PathBuf, + #[arg(long)] + pub(crate) output: Option, + #[arg(long)] + pub(crate) config: Option, + #[arg(long)] + pub(crate) render_png: bool, + #[arg(long)] + pub(crate) chrome_path: Option, + #[arg(long)] + pub(crate) selector: Option, +} + +#[derive(Debug)] +pub(crate) struct RenderOutput { + pub(crate) html: PathBuf, + pub(crate) png: Option, +} + +pub(crate) fn run(args: RenderArgs) -> Result { + let results: Value = serde_json::from_str(&fs::read_to_string(&args.results)?)?; + let config = TableConfig::load(args.config.as_deref())?; + let output = args.output.unwrap_or_else(|| args.results.with_file_name("benchmark-table.html")); + let css_path = output.with_file_name("benchmark-table.css"); + fs::create_dir_all(output.parent().ok_or_else(|| err("output path has no parent"))?)?; + fs::write(&css_path, stylesheet())?; + fs::write(&output, render_html(&results, &config)?)?; + + let mut png = None; + if args.render_png { + let png_path = output.with_extension("png"); + let selector = args.selector.unwrap_or_else(|| format!("#{}", config.capture_id())); + match cdp::capture_html(&output, &png_path, &selector, args.chrome_path.as_deref()) { + Ok(()) => { + write_png_status(&args.results, "generated", None)?; + png = Some(png_path); + } + Err(error) => { + write_png_status(&args.results, "skipped", Some(&error.to_string()))?; + eprintln!("warning: PNG capture skipped: {error}"); + } + } + } + Ok(RenderOutput { html: output, png }) +} + +fn write_png_status( + results_path: &std::path::Path, + status: &str, + reason: Option<&str>, +) -> Result<()> { + let mut results: Value = serde_json::from_str(&fs::read_to_string(results_path)?)?; + if let Some(object) = results.as_object_mut() { + object.insert( + "table_png".to_owned(), + serde_json::json!({ + "status": status, + "reason": reason + }), + ); + } + fs::write(results_path, serde_json::to_string_pretty(&results)? + "\n")?; + Ok(()) +} + +fn render_html(results: &Value, config: &TableConfig) -> Result { + let cases = results + .get("cases") + .and_then(Value::as_array) + .ok_or_else(|| err("results JSON missing cases array"))?; + let columns = columns(results, config); + let sections = config.sections.clone().unwrap_or_default(); + let title = config.title.as_deref().unwrap_or("Revelo benchmark comparison"); + let caption = config.caption.clone().unwrap_or_else(|| { + let run_id = results.get("run_id").and_then(Value::as_str).unwrap_or("unknown"); + let commit = results.pointer("/repo/commit").and_then(Value::as_str).unwrap_or("unknown"); + format!("Run {run_id}; commit {commit}") + }); + + let mut html = String::new(); + html.push_str(""); + html.push_str(""); + html.push_str(&format!("{}", escape(title))); + html.push_str(""); + html.push_str(""); + html.push_str(&format!( + "

{}

{}

", + escape(config.capture_id()), + escape(title), + escape(&caption) + )); + + if sections.is_empty() { + html.push_str(&render_table( + "Results", + cases.iter().collect::>().as_slice(), + &columns, + config, + )); + } else { + let mut matched_count = 0usize; + for section in sections { + let label = section.label.as_deref().unwrap_or("Results"); + let matched = cases + .iter() + .filter(|case| matches_section(case, §ion.match_)) + .collect::>(); + if !matched.is_empty() { + matched_count += matched.len(); + html.push_str(&render_table(label, &matched, &columns, config)); + } + } + if matched_count != cases.len() { + return Err(err(format!( + "table config sections matched {matched_count} of {} cases", + cases.len() + ))); + } + } + if let Some(note) = &config.footer_note { + html.push_str(&format!("
{}
", escape(note))); + } + html.push_str("
"); + Ok(html) +} + +fn render_table(label: &str, cases: &[&Value], columns: &[String], config: &TableConfig) -> String { + let mut html = String::new(); + html.push_str(&format!("

{}

", escape(label))); + html.push_str(""); + if config.show_size_column.unwrap_or(true) { + html.push_str(""); + } + for column in columns { + html.push_str(&format!( + "", + escape(config.column_labels.get(column).map_or(column, String::as_str)) + )); + } + html.push_str(""); + for case in cases { + html.push_str(""); + let label = case.get("label").and_then(Value::as_str).unwrap_or("case"); + html.push_str(""); + if config.show_size_column.unwrap_or(true) { + html.push_str(&format!( + "", + escape(&format_bytes(case.get("size_bytes").and_then(Value::as_u64).unwrap_or(0))) + )); + } + for column in columns { + let measurement = + case.get("measurements").and_then(|measurements| measurements.get(column)); + html.push_str(&render_measurement(measurement, config)); + } + html.push_str(""); + } + html.push_str("
CaseSize{}
"); + html.push_str(&format!("{}", escape(label))); + let subtitle = subtitle(case); + if !subtitle.is_empty() { + html.push_str(&format!("{}", escape(&subtitle))); + } + html.push_str("{}
"); + html +} + +fn render_measurement(measurement: Option<&Value>, config: &TableConfig) -> String { + let median = measurement + .and_then(|value| value.get("median_ms").or_else(|| value.pointer("/process_ms/median_ms"))) + .and_then(Value::as_f64); + match median { + Some(value) => format!( + "{:.1}", + tier_class(value, config), + value + ), + None => "n/a".to_owned(), + } +} + +fn tier_class(value: f64, config: &TableConfig) -> String { + let tiers = config.latency_tiers_ms.clone().unwrap_or_default(); + for LatencyTier { max, class_name, .. } in tiers { + if max.is_none_or(|limit| value < limit) { + return class_name; + } + } + "missing".to_owned() +} + +fn columns(results: &Value, config: &TableConfig) -> Vec { + if let Some(columns) = &config.columns { + return columns.clone(); + } + let preferred = [ + "revelo_0_4_6", + "revelo_0_5_0", + "revelo_0_5_1", + "revelo_pr5", + "revelo_branch", + "revelo_cli_text", + "mediainfo", + "ffprobe", + ]; + let mut observed = BTreeSet::new(); + if let Some(cases) = results.get("cases").and_then(Value::as_array) { + for case in cases { + if let Some(measurements) = case.get("measurements").and_then(Value::as_object) { + observed.extend(measurements.keys().cloned()); + } + } + } + let mut columns = preferred + .iter() + .filter(|column| observed.contains(**column)) + .map(|column| (*column).to_owned()) + .collect::>(); + for column in observed { + if !columns.contains(&column) && column != "revelo_perf_probe" { + columns.push(column); + } + } + columns +} + +fn matches_section(case: &Value, expected: &std::collections::BTreeMap) -> bool { + expected.iter().all(|(key, expected_value)| match expected_value { + Value::String(expected) => case.get(key).and_then(Value::as_str) == Some(expected.as_str()), + Value::Array(values) => values + .iter() + .filter_map(Value::as_str) + .any(|expected| case.get(key).and_then(Value::as_str) == Some(expected)), + _ => false, + }) +} + +fn subtitle(case: &Value) -> String { + let mut parts = Vec::new(); + for key in ["source", "layout", "codec"] { + if let Some(value) = case.get(key).and_then(Value::as_str) + && !value.is_empty() + && !parts.iter().any(|part| part == value) + { + parts.push(value.to_owned()); + } + } + parts.join(" · ") +} + +fn format_bytes(bytes: u64) -> String { + const MIB: f64 = 1024.0 * 1024.0; + if bytes == 0 { "n/a".to_owned() } else { format!("{:.1} MiB", bytes as f64 / MIB) } +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +fn stylesheet() -> &'static str { + r#" +:root { + color-scheme: dark; + --bg: #050506; + --panel: #0b0d10; + --line: #20252b; + --text: #f4f6f8; + --muted: #9ba7b4; +} +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.35 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +.benchmark-shell { + width: max-content; + min-width: 1200px; + padding: 24px; + background: var(--bg); +} +header { margin-bottom: 18px; } +h1 { margin: 0 0 6px; font-size: 26px; font-weight: 720; letter-spacing: 0; } +p, footer { color: var(--muted); margin: 0; } +.bench-section { margin-top: 18px; } +h2 { margin: 0 0 8px; font-size: 18px; } +table { + border-collapse: separate; + border-spacing: 0; + background: var(--panel); + table-layout: auto; +} +th, td { + padding: 8px 10px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; + white-space: nowrap; +} +th { + color: #d9e0e7; + font-size: 12px; + text-transform: uppercase; + letter-spacing: .04em; + background: #11151a; +} +.case-cell { min-width: 440px; max-width: 620px; } +.case-cell strong { display: block; font-size: 15px; } +.case-cell span { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; } +.size-col { min-width: 86px; color: #c6d0da; } +.metric-col { min-width: 112px; text-align: right; } +.metric { text-align: right; font-variant-numeric: tabular-nums; font-size: 17px; font-weight: 700; } +.metric span { display: block; } +.instant { background: #123b2a; color: #d7ffe8; } +.very-fast { background: #17492d; color: #e4ffdc; } +.fast { background: #4b4617; color: #fff5c4; } +.medium { background: #5b3516; color: #ffe1c0; } +.slow { background: #5e231d; color: #ffd0c8; } +.very-slow { background: #581d2c; color: #ffd0dd; } +.missing { background: #161a1f; color: #708090; } +footer { margin-top: 16px; font-size: 12px; } +"# +} + +pub(crate) fn self_test() -> Result<()> { + let temp = tempfile::tempdir()?; + let results = temp.path().join("results.json"); + fs::write( + &results, + r#"{"run_id":"self","repo":{"commit":"abc"},"cases":[{"label":"MP4 / AVC","class":"synthetic","size_bytes":1048576,"source":"generated","measurements":{"revelo_cli_text":{"median_ms":3.2},"mediainfo":{"median_ms":11.0}}}]}"#, + )?; + let output = temp.path().join("table.html"); + let rendered = run(RenderArgs { + results, + output: Some(output.clone()), + config: None, + render_png: false, + chrome_path: None, + selector: None, + })?; + assert_eq!(rendered.html, output); + assert!(output.read_to_string()?.contains("MP4 / AVC")); + let unmatched = serde_json::json!({ + "cases": [{"label": "Other", "class": "unknown", "measurements": {}}] + }); + assert!(render_html(&unmatched, &TableConfig::default()).is_err()); + Ok(()) +} + +trait ReadToString { + fn read_to_string(&self) -> Result; +} + +impl ReadToString for std::path::Path { + fn read_to_string(&self) -> Result { + Ok(fs::read_to_string(self)?) + } +} + +impl ReadToString for PathBuf { + fn read_to_string(&self) -> Result { + self.as_path().read_to_string() + } +} diff --git a/crates/revelo-bench/src/util.rs b/crates/revelo-bench/src/util.rs new file mode 100644 index 0000000..5b2e49d --- /dev/null +++ b/crates/revelo-bench/src/util.rs @@ -0,0 +1,99 @@ +use std::ffi::OsStr; +use std::fmt::{Display, Formatter}; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Command; + +pub(crate) type Result = std::result::Result; + +#[derive(Debug)] +pub(crate) enum BenchError { + Io(io::Error), + Json(serde_json::Error), + Message(String), +} + +impl Display for BenchError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => write!(formatter, "{error}"), + Self::Json(error) => write!(formatter, "{error}"), + Self::Message(message) => formatter.write_str(message), + } + } +} + +impl std::error::Error for BenchError {} + +impl From for BenchError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for BenchError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +pub(crate) fn err(message: impl Into) -> BenchError { + BenchError::Message(message.into()) +} + +pub(crate) fn repo_root() -> Result { + let output = Command::new("git").args(["rev-parse", "--show-toplevel"]).output()?; + if !output.status.success() { + return Err(err("failed to resolve repository root with git")); + } + Ok(PathBuf::from(String::from_utf8_lossy(&output.stdout).trim())) +} + +pub(crate) fn safe_id(value: &str) -> String { + let id: String = value + .chars() + .map( + |char| { + if char.is_ascii_alphanumeric() || matches!(char, '-' | '_') { char } else { '-' } + }, + ) + .collect(); + let trimmed = id.trim_matches('-'); + if trimmed.is_empty() { "case".to_owned() } else { trimmed.to_owned() } +} + +pub(crate) fn which(binary: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).map(|dir| dir.join(binary)).find(|candidate| candidate.is_file()) +} + +pub(crate) fn command_text(program: impl AsRef, args: &[&str]) -> Option { + let output = Command::new(program).args(args).output().ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).trim().to_owned()) +} + +pub(crate) fn run_checked(command: &mut Command) -> Result<()> { + let status = command.status()?; + if status.success() { + Ok(()) + } else { + Err(err(format!("command failed with status {status}: {command:?}"))) + } +} + +pub(crate) fn resolve_repo_path(path: &Path) -> Result { + if path.is_absolute() { Ok(path.to_path_buf()) } else { Ok(repo_root()?.join(path)) } +} + +pub(crate) fn relative_tool_path(path: &Path) -> String { + match repo_root().ok().and_then(|root| path.strip_prefix(root).ok().map(Path::to_path_buf)) { + Some(relative) => relative.display().to_string(), + None => path + .file_name() + .and_then(OsStr::to_str) + .map_or_else(|| path.display().to_string(), ToOwned::to_owned), + } +} diff --git a/crates/revelo-cli/examples/perf_probe.rs b/crates/revelo-cli/examples/perf_probe.rs new file mode 100644 index 0000000..87a0d75 --- /dev/null +++ b/crates/revelo-cli/examples/perf_probe.rs @@ -0,0 +1,739 @@ +use std::env; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process; +use std::time::{Duration, Instant, UNIX_EPOCH}; + +use revelo_core::computed_fields::fill_computed_fields; +use revelo_core::multi_file::find_duplicate_streams; +use revelo_core::{ + AccessStats, FileAnalyze, FileLevelInfo, ReadBackend, StreamKind, fill_file_level_fields, +}; +use revelo_dispatcher::table; +use revelo_export::{to_json, to_summary, to_text}; +use revelo_parsers_tag::parse_tags; + +const LARGE_INPUT_RAW_READ_LIMIT: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExportMode { + None, + Text, + Json, + Summary, + All, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputTarget { + Sink, + File, + Stdout, +} + +#[derive(Debug)] +struct Config { + path: PathBuf, + label: String, + export_mode: ExportMode, + output_target: OutputTarget, + output_file: Option, + demux: String, + trace: String, + skip_tags: bool, + include_path: bool, + fixed_local_offset_secs: Option, +} + +#[derive(Debug)] +struct Phase { + name: &'static str, + duration: Duration, + access_stats: Option, + bytes: Option, +} + +impl Phase { + fn new(name: &'static str, duration: Duration) -> Self { + Self { name, duration, access_stats: None, bytes: None } + } + + fn with_access(name: &'static str, duration: Duration, access_stats: AccessStats) -> Self { + Self { name, duration, access_stats: Some(access_stats), bytes: None } + } + + fn with_bytes(name: &'static str, duration: Duration, bytes: usize) -> Self { + Self { name, duration, access_stats: None, bytes: Some(bytes) } + } +} + +#[derive(Debug)] +struct DetectionProbe { + parser: Option bool>, + parser_index: Option, + candidates_run: usize, + access_stats: AccessStats, +} + +fn main() -> process::ExitCode { + let config = match parse_args(env::args().skip(1)) { + Ok(config) => config, + Err(message) => { + eprintln!("{message}"); + print_usage(); + return process::ExitCode::from(2); + } + }; + + match run_probe(&config) { + Ok(line) => { + println!("{line}"); + process::ExitCode::SUCCESS + } + Err(message) => { + eprintln!("{message}"); + process::ExitCode::from(1) + } + } +} + +fn run_probe(config: &Config) -> Result { + let mut phases = Vec::new(); + let start_total = Instant::now(); + + let start = Instant::now(); + let metadata = fs::metadata(&config.path).map_err(|err| format!("metadata: {err}"))?; + let file = fs::File::open(&config.path).map_err(|err| format!("open: {err}"))?; + // SAFETY: the file is opened read-only and this probe never mutates it. + let mmap = unsafe { memmap2::Mmap::map(&file) }.map_err(|err| format!("mmap: {err}"))?; + phases.push(Phase::new("open_mmap", start.elapsed())); + + let bytes = mmap.as_ref(); + let source_len = bytes.len(); + + let start = Instant::now(); + let detection = detect_with_probe(bytes); + phases.push(Phase::with_access("detect", start.elapsed(), detection.access_stats)); + let Some(parser) = detection.parser else { + return Err(format!("{}: no parser matched ({source_len} bytes)", config.path.display())); + }; + + let mut fa = FileAnalyze::from_backend(ReadBackend::from(&mmap)); + fa.set_option("demux", &config.demux); + fa.set_option("trace_level", &config.trace); + + let start = Instant::now(); + if !parser(&mut fa) { + return Err(format!("{}: selected parser failed", config.path.display())); + } + phases.push(Phase::with_access("parse_container", start.elapsed(), fa.access_stats())); + + let start = Instant::now(); + let local_offset_secs = match config.fixed_local_offset_secs { + Some(offset) => offset, + None => local_offset_seconds(), + }; + phases.push(Phase::new("local_offset", start.elapsed())); + + let modified_unix_secs = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64); + let info = FileLevelInfo { + file_size: metadata.len(), + extension: config.path.extension().and_then(|s| s.to_str()), + modified_unix_secs, + local_offset_secs, + }; + + let start = Instant::now(); + fill_file_level_fields(&mut fa, &info); + phases.push(Phase::new("file_level_fields", start.elapsed())); + + let start = Instant::now(); + fill_computed_fields(fa.streams_mut()); + phases.push(Phase::new("computed_fields", start.elapsed())); + + let start = Instant::now(); + fa.duplicate_indices = find_duplicate_streams(fa.streams()); + phases.push(Phase::new("duplicate_streams", start.elapsed())); + + if !config.skip_tags { + let before = fa.access_stats(); + let start = Instant::now(); + let _ = parse_tags(&mut fa); + let tag_stats = delta_stats(before, fa.access_stats()); + phases.push(Phase::with_access("parse_tags", start.elapsed(), tag_stats)); + } + + let mut export_results = Vec::new(); + for mode in selected_export_modes(config.export_mode) { + let start = Instant::now(); + let output = render_export(mode, fa.streams(), &config.path); + phases.push(Phase::with_bytes(export_phase_name(mode), start.elapsed(), output.len())); + + let start = Instant::now(); + emit_output(config, mode, output.as_bytes())?; + phases.push(Phase::with_bytes( + output_phase_name(config.output_target), + start.elapsed(), + output.len(), + )); + export_results.push((export_mode_name(mode), output.len())); + } + + let total_duration = start_total.elapsed(); + let format = fa + .retrieve(StreamKind::General, 0, "Format") + .map(|z| z.as_str().to_owned()) + .unwrap_or_default(); + let record = ProbeRecord { + label: &config.label, + path: &config.path, + include_path: config.include_path, + source_len, + parser_index: detection.parser_index, + candidates_run: detection.candidates_run, + format: &format, + total_duration, + phases: &phases, + export_results: &export_results, + }; + Ok(record.to_json_line()) +} + +fn detect_with_probe(bytes: &[u8]) -> DetectionProbe { + let mut combined = AccessStats::default(); + let mut candidates_run = 0; + let limit = bytes.len().gt(&LARGE_INPUT_RAW_READ_LIMIT).then_some(LARGE_INPUT_RAW_READ_LIMIT); + + for (index, parser) in table().into_iter().enumerate() { + let mut fa = if let Some(limit) = limit { + FileAnalyze::with_raw_read_limit(bytes, limit) + } else { + FileAnalyze::new(bytes) + }; + let matched = parser(&mut fa); + candidates_run += 1; + combined = add_stats(combined, fa.access_stats()); + if matched { + return DetectionProbe { + parser: Some(parser), + parser_index: Some(index), + candidates_run, + access_stats: combined, + }; + } + } + + DetectionProbe { parser: None, parser_index: None, candidates_run, access_stats: combined } +} + +fn selected_export_modes(mode: ExportMode) -> Vec { + match mode { + ExportMode::None => Vec::new(), + ExportMode::Text => vec![ExportMode::Text], + ExportMode::Json => vec![ExportMode::Json], + ExportMode::Summary => vec![ExportMode::Summary], + ExportMode::All => vec![ExportMode::Text, ExportMode::Json, ExportMode::Summary], + } +} + +fn render_export(mode: ExportMode, streams: &revelo_core::StreamCollection, path: &Path) -> String { + let path = path.to_string_lossy(); + match mode { + ExportMode::Text => to_text(streams, &path), + ExportMode::Json => to_json(streams, &path), + ExportMode::Summary => to_summary(streams, &path), + ExportMode::None | ExportMode::All => String::new(), + } +} + +fn emit_output(config: &Config, mode: ExportMode, bytes: &[u8]) -> Result<(), String> { + match config.output_target { + OutputTarget::Sink => { + io::sink().write_all(bytes).map_err(|err| format!("sink output: {err}"))?; + } + OutputTarget::Stdout => { + io::stdout().write_all(bytes).map_err(|err| format!("stdout output: {err}"))?; + } + OutputTarget::File => { + let path = output_file_for_mode(config, mode)?; + fs::write(&path, bytes).map_err(|err| format!("write {}: {err}", path.display()))?; + } + } + Ok(()) +} + +fn output_file_for_mode(config: &Config, mode: ExportMode) -> Result { + let Some(path) = &config.output_file else { + return Err("--output-target file requires --output-file".to_string()); + }; + if config.export_mode == ExportMode::All { + let mut path = path.clone(); + let extension = export_mode_name(mode); + path.set_extension(extension); + Ok(path) + } else { + Ok(path.clone()) + } +} + +fn local_offset_seconds() -> i64 { + #[cfg(unix)] + if let Some(offset) = local_offset_seconds_unix() { + return offset; + } + local_offset_seconds_from_date() +} + +fn local_offset_seconds_from_date() -> i64 { + let Ok(out) = process::Command::new("date").arg("+%z").output() else { + return 0; + }; + let s = String::from_utf8_lossy(&out.stdout); + let s = s.trim(); + if s.len() < 5 { + return 0; + } + let sign = if s.starts_with('-') { -1 } else { 1 }; + let hh: i64 = s[1..3].parse().unwrap_or(0); + let mm: i64 = s[3..5].parse().unwrap_or(0); + sign * (hh * 3600 + mm * 60) +} + +#[cfg(unix)] +fn local_offset_seconds_unix() -> Option { + use std::os::raw::{c_char, c_int, c_long}; + + type TimeT = i64; + + #[repr(C)] + struct Tm { + tm_sec: c_int, + tm_min: c_int, + tm_hour: c_int, + tm_mday: c_int, + tm_mon: c_int, + tm_year: c_int, + tm_wday: c_int, + tm_yday: c_int, + tm_isdst: c_int, + tm_gmtoff: c_long, + tm_zone: *const c_char, + } + + unsafe extern "C" { + fn time(tloc: *mut TimeT) -> TimeT; + fn localtime_r(timep: *const TimeT, result: *mut Tm) -> *mut Tm; + } + + // SAFETY: `time` accepts null for return-only use. `localtime_r` writes + // into a stack `Tm` matching the Unix C layout used by this diagnostic. + unsafe { + let now = time(std::ptr::null_mut()); + if now == -1 { + return None; + } + let mut tm = std::mem::zeroed::(); + if localtime_r(&now, &mut tm).is_null() { + return None; + } + Some(tm.tm_gmtoff as i64) + } +} + +fn add_stats(a: AccessStats, b: AccessStats) -> AccessStats { + AccessStats { + peek_raw_calls: a.peek_raw_calls.saturating_add(b.peek_raw_calls), + read_raw_calls: a.read_raw_calls.saturating_add(b.read_raw_calls), + peek_raw_at_calls: a.peek_raw_at_calls.saturating_add(b.peek_raw_at_calls), + bytes_requested: a.bytes_requested.saturating_add(b.bytes_requested), + bytes_returned: a.bytes_returned.saturating_add(b.bytes_returned), + max_request_len: a.max_request_len.max(b.max_request_len), + } +} + +fn delta_stats(before: AccessStats, after: AccessStats) -> AccessStats { + AccessStats { + peek_raw_calls: after.peek_raw_calls.saturating_sub(before.peek_raw_calls), + read_raw_calls: after.read_raw_calls.saturating_sub(before.read_raw_calls), + peek_raw_at_calls: after.peek_raw_at_calls.saturating_sub(before.peek_raw_at_calls), + bytes_requested: after.bytes_requested.saturating_sub(before.bytes_requested), + bytes_returned: after.bytes_returned.saturating_sub(before.bytes_returned), + max_request_len: after.max_request_len.max(before.max_request_len), + } +} + +struct ProbeRecord<'a> { + label: &'a str, + path: &'a Path, + include_path: bool, + source_len: usize, + parser_index: Option, + candidates_run: usize, + format: &'a str, + total_duration: Duration, + phases: &'a [Phase], + export_results: &'a [(&'static str, usize)], +} + +impl ProbeRecord<'_> { + fn to_json_line(&self) -> String { + let mut out = String::new(); + out.push('{'); + push_json_string_field(&mut out, "schema", "revelo_perf_probe_v1", true); + push_json_string_field(&mut out, "label", self.label, false); + if self.include_path { + push_json_string_field(&mut out, "path", &self.path.to_string_lossy(), false); + } + push_json_u64_field(&mut out, "source_len", self.source_len as u64, false); + match self.parser_index { + Some(index) => push_json_u64_field(&mut out, "parser_index", index as u64, false), + None => out.push_str(",\"parser_index\":null"), + } + push_json_u64_field(&mut out, "candidates_run", self.candidates_run as u64, false); + push_json_string_field(&mut out, "format", self.format, false); + push_json_f64_field(&mut out, "total_ms", duration_ms(self.total_duration), false); + out.push_str(",\"phases\":["); + for (index, phase) in self.phases.iter().enumerate() { + if index > 0 { + out.push(','); + } + push_phase_json(&mut out, phase); + } + out.push(']'); + out.push_str(",\"exports\":["); + for (index, (mode, bytes)) in self.export_results.iter().enumerate() { + if index > 0 { + out.push(','); + } + out.push('{'); + push_json_string_field(&mut out, "mode", mode, true); + push_json_u64_field(&mut out, "bytes", *bytes as u64, false); + out.push('}'); + } + out.push_str("]}"); + out + } +} + +fn push_phase_json(out: &mut String, phase: &Phase) { + out.push('{'); + push_json_string_field(out, "name", phase.name, true); + push_json_f64_field(out, "ms", duration_ms(phase.duration), false); + if let Some(bytes) = phase.bytes { + push_json_u64_field(out, "bytes", bytes as u64, false); + } + if let Some(stats) = phase.access_stats { + out.push_str(",\"access_stats\":{"); + push_json_u64_field(out, "peek_raw_calls", stats.peek_raw_calls, true); + push_json_u64_field(out, "read_raw_calls", stats.read_raw_calls, false); + push_json_u64_field(out, "peek_raw_at_calls", stats.peek_raw_at_calls, false); + push_json_u64_field(out, "bytes_requested", stats.bytes_requested, false); + push_json_u64_field(out, "bytes_returned", stats.bytes_returned, false); + push_json_u64_field(out, "max_request_len", stats.max_request_len as u64, false); + out.push('}'); + } + out.push('}'); +} + +fn duration_ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1000.0 +} + +fn push_json_string_field(out: &mut String, key: &str, value: &str, first: bool) { + if !first { + out.push(','); + } + out.push('"'); + out.push_str(key); + out.push_str("\":\""); + out.push_str(&json_escape(value)); + out.push('"'); +} + +fn push_json_u64_field(out: &mut String, key: &str, value: u64, first: bool) { + if !first { + out.push(','); + } + out.push('"'); + out.push_str(key); + out.push_str("\":"); + out.push_str(&value.to_string()); +} + +fn push_json_f64_field(out: &mut String, key: &str, value: f64, first: bool) { + if !first { + out.push(','); + } + out.push('"'); + out.push_str(key); + out.push_str("\":"); + out.push_str(&format!("{value:.6}")); +} + +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '\\' => out.push_str(r"\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +fn parse_args(args: impl IntoIterator) -> Result { + let mut path = None; + let mut label = None; + let mut export_mode = ExportMode::Text; + let mut output_target = OutputTarget::Sink; + let mut output_file = None; + let mut demux = "frame".to_string(); + let mut trace = "0".to_string(); + let mut skip_tags = false; + let mut include_path = false; + let mut fixed_local_offset_secs = None; + + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--path" => path = Some(next_arg(&mut args, "--path")?.into()), + "--label" => label = Some(next_arg(&mut args, "--label")?), + "--export" => export_mode = parse_export_mode(&next_arg(&mut args, "--export")?)?, + "--output-target" => { + output_target = parse_output_target(&next_arg(&mut args, "--output-target")?)?; + } + "--output-file" => output_file = Some(next_arg(&mut args, "--output-file")?.into()), + "--demux" => demux = next_arg(&mut args, "--demux")?, + "--trace" => trace = next_arg(&mut args, "--trace")?, + "--skip-tags" => skip_tags = true, + "--include-path" => include_path = true, + "--fixed-local-offset-secs" => { + let raw = next_arg(&mut args, "--fixed-local-offset-secs")?; + fixed_local_offset_secs = + Some(raw.parse().map_err(|_| format!("invalid offset: {raw}"))?); + } + "--help" | "-h" => return Err(String::new()), + _ if arg.starts_with("--") => return Err(format!("unknown argument: {arg}")), + _ if path.is_none() => path = Some(arg.into()), + _ => return Err(format!("unexpected positional argument: {arg}")), + } + } + + let path: PathBuf = path.ok_or_else(|| "--path is required".to_string())?; + let label = label.unwrap_or_else(|| { + path.file_name().and_then(|s| s.to_str()).unwrap_or("input").to_string() + }); + + Ok(Config { + path, + label, + export_mode, + output_target, + output_file, + demux, + trace, + skip_tags, + include_path, + fixed_local_offset_secs, + }) +} + +fn next_arg(args: &mut impl Iterator, flag: &str) -> Result { + args.next().ok_or_else(|| format!("{flag} requires a value")) +} + +fn parse_export_mode(value: &str) -> Result { + match value { + "none" => Ok(ExportMode::None), + "text" => Ok(ExportMode::Text), + "json" => Ok(ExportMode::Json), + "summary" => Ok(ExportMode::Summary), + "all" => Ok(ExportMode::All), + _ => Err(format!("invalid --export value: {value}")), + } +} + +fn parse_output_target(value: &str) -> Result { + match value { + "sink" => Ok(OutputTarget::Sink), + "file" => Ok(OutputTarget::File), + "stdout" => Ok(OutputTarget::Stdout), + _ => Err(format!("invalid --output-target value: {value}")), + } +} + +fn export_phase_name(mode: ExportMode) -> &'static str { + match mode { + ExportMode::Text => "export_text", + ExportMode::Json => "export_json", + ExportMode::Summary => "export_summary", + ExportMode::None | ExportMode::All => "export_none", + } +} + +fn output_phase_name(target: OutputTarget) -> &'static str { + match target { + OutputTarget::Sink => "output_sink", + OutputTarget::File => "output_file", + OutputTarget::Stdout => "output_stdout", + } +} + +fn export_mode_name(mode: ExportMode) -> &'static str { + match mode { + ExportMode::None => "none", + ExportMode::Text => "text", + ExportMode::Json => "json", + ExportMode::Summary => "summary", + ExportMode::All => "all", + } +} + +fn print_usage() { + eprintln!( + "usage: perf_probe --path FILE [--label LABEL] [--export none|text|json|summary|all] \\ + [--output-target sink|file|stdout] [--output-file FILE] [--skip-tags] \\ + [--include-path] [--fixed-local-offset-secs N]" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_escape_handles_control_characters() { + assert_eq!(json_escape("a\"b\\c\n"), "a\\\"b\\\\c\\n"); + } + + #[test] + fn record_json_contains_stable_top_level_fields() { + let phase = Phase::with_access( + "detect", + Duration::from_micros(1500), + AccessStats { + peek_raw_calls: 1, + read_raw_calls: 2, + peek_raw_at_calls: 3, + bytes_requested: 4, + bytes_returned: 5, + max_request_len: 6, + }, + ); + let record = ProbeRecord { + label: "fixture", + path: Path::new("fixture.mp4"), + include_path: false, + source_len: 42, + parser_index: Some(10), + candidates_run: 11, + format: "MPEG-4", + total_duration: Duration::from_millis(2), + phases: &[phase], + export_results: &[("json", 123)], + }; + let line = record.to_json_line(); + assert!(line.contains("\"schema\":\"revelo_perf_probe_v1\""), "{line}"); + assert!(line.contains("\"parser_index\":10"), "{line}"); + assert!(line.contains("\"bytes_requested\":4"), "{line}"); + } + + #[test] + fn record_json_omits_private_path_by_default() { + let record = ProbeRecord { + label: "public-label", + path: Path::new("local-only-fixture/sample.mp4"), + include_path: false, + source_len: 42, + parser_index: Some(10), + candidates_run: 11, + format: "MPEG-4", + total_duration: Duration::from_millis(2), + phases: &[], + export_results: &[], + }; + let line = record.to_json_line(); + assert!(!line.contains("\"path\""), "{line}"); + assert!(!line.contains("local-only-fixture/sample.mp4"), "{line}"); + } + + #[test] + fn record_json_includes_path_only_when_requested() { + let record = ProbeRecord { + label: "public-label", + path: Path::new("local-only-fixture/sample.mp4"), + include_path: true, + source_len: 42, + parser_index: Some(10), + candidates_run: 11, + format: "MPEG-4", + total_duration: Duration::from_millis(2), + phases: &[], + export_results: &[], + }; + let line = record.to_json_line(); + assert!(line.contains("\"path\":\"local-only-fixture/sample.mp4\""), "{line}"); + } + + #[test] + fn access_stats_delta_is_saturating() { + let before = AccessStats { + peek_raw_calls: 1, + read_raw_calls: 1, + peek_raw_at_calls: 1, + bytes_requested: 10, + bytes_returned: 10, + max_request_len: 100, + }; + let after = AccessStats { + peek_raw_calls: 3, + read_raw_calls: 1, + peek_raw_at_calls: 4, + bytes_requested: 40, + bytes_returned: 35, + max_request_len: 80, + }; + let delta = delta_stats(before, after); + assert_eq!(delta.peek_raw_calls, 2); + assert_eq!(delta.bytes_requested, 30); + assert_eq!(delta.max_request_len, 100); + } + + #[test] + fn args_do_not_require_private_paths() { + let config = parse_args([ + "--path".to_string(), + "sample.mp4".to_string(), + "--label".to_string(), + "public-label".to_string(), + "--export".to_string(), + "all".to_string(), + ]) + .expect("args"); + assert_eq!(config.path, PathBuf::from("sample.mp4")); + assert_eq!(config.label, "public-label"); + assert_eq!(config.export_mode, ExportMode::All); + assert!(!config.include_path); + } + + #[test] + fn include_path_arg_is_explicit_opt_in() { + let config = parse_args([ + "--path".to_string(), + "sample.mp4".to_string(), + "--include-path".to_string(), + ]) + .expect("args"); + assert!(config.include_path); + } +} diff --git a/crates/revelo-cli/src/main.rs b/crates/revelo-cli/src/main.rs index bc0f9dc..aa88203 100644 --- a/crates/revelo-cli/src/main.rs +++ b/crates/revelo-cli/src/main.rs @@ -243,9 +243,16 @@ fn format_text_output(text: &str, add_version: bool, add_timestamp: bool) -> Str header + text } -/// Local timezone offset in seconds east of UTC, via `date +%z` -/// (e.g. "+1000" → 36000). Used for the `_Local` date variant. +/// Local timezone offset in seconds east of UTC, for the `_Local` date variant. fn local_offset_seconds() -> i64 { + #[cfg(unix)] + if let Some(offset) = local_offset_seconds_unix() { + return offset; + } + local_offset_seconds_from_date() +} + +fn local_offset_seconds_from_date() -> i64 { let Ok(out) = process::Command::new("date").arg("+%z").output() else { return 0; }; @@ -259,3 +266,45 @@ fn local_offset_seconds() -> i64 { let mm: i64 = s[3..5].parse().unwrap_or(0); sign * (hh * 3600 + mm * 60) } + +#[cfg(unix)] +fn local_offset_seconds_unix() -> Option { + use std::os::raw::{c_char, c_int, c_long}; + + type TimeT = i64; + + #[repr(C)] + struct Tm { + tm_sec: c_int, + tm_min: c_int, + tm_hour: c_int, + tm_mday: c_int, + tm_mon: c_int, + tm_year: c_int, + tm_wday: c_int, + tm_yday: c_int, + tm_isdst: c_int, + tm_gmtoff: c_long, + tm_zone: *const c_char, + } + + unsafe extern "C" { + fn time(tloc: *mut TimeT) -> TimeT; + fn localtime_r(timep: *const TimeT, result: *mut Tm) -> *mut Tm; + } + + // SAFETY: `time` accepts a null pointer when the caller only needs the + // returned timestamp. `localtime_r` writes into a stack-allocated `Tm` + // with the platform C layout used by Unix targets supported here. + unsafe { + let now = time(std::ptr::null_mut()); + if now == -1 { + return None; + } + let mut tm = std::mem::zeroed::(); + if localtime_r(&now, &mut tm).is_null() { + return None; + } + Some(tm.tm_gmtoff as i64) + } +} diff --git a/crates/revelo-diff/Cargo.toml b/crates/revelo-diff/Cargo.toml index 46ea2d2..be7338a 100644 --- a/crates/revelo-diff/Cargo.toml +++ b/crates/revelo-diff/Cargo.toml @@ -20,3 +20,4 @@ workspace = true revelo-core = { path = "../revelo-core", version = "0.5.1" } revelo-dispatcher = { path = "../revelo-dispatcher", version = "0.5.1" } revelo-export = { path = "../revelo-export", version = "0.5.1" } +revelo-parsers-tag = { path = "../revelo-parsers-tag", version = "0.5.1" } diff --git a/crates/revelo-diff/src/main.rs b/crates/revelo-diff/src/main.rs index d0550b0..49d1486 100644 --- a/crates/revelo-diff/src/main.rs +++ b/crates/revelo-diff/src/main.rs @@ -4,9 +4,12 @@ use std::path::Path; use std::process::{Command, ExitCode}; use std::time::UNIX_EPOCH; +use revelo_core::computed_fields::fill_computed_fields; +use revelo_core::multi_file::find_duplicate_streams; use revelo_core::{FileAnalyze, FileLevelInfo, fill_file_level_fields}; use revelo_dispatcher::detect; use revelo_export::to_xml; +use revelo_parsers_tag::parse_tags; fn main() -> ExitCode { let mut args: Vec = env::args().skip(1).collect(); @@ -166,14 +169,23 @@ fn run_rust_engine(path: &str) -> Result { local_offset_secs: local_offset_seconds(), }; fill_file_level_fields(&mut fa, &info); + fill_computed_fields(fa.streams_mut()); + fa.duplicate_indices = find_duplicate_streams(fa.streams()); + let _ = parse_tags(&mut fa); Ok(to_xml(fa.streams(), path)) } -/// Detect the local timezone offset in seconds via shelling out to -/// `date +%z` (e.g. "+1000" → 36000). Cheap, macOS/Linux compatible, -/// and the harness is a dev tool so the shell-out is acceptable. +/// Detect the local timezone offset in seconds east of UTC. fn local_offset_seconds() -> i64 { + #[cfg(unix)] + if let Some(offset) = local_offset_seconds_unix() { + return offset; + } + local_offset_seconds_from_date() +} + +fn local_offset_seconds_from_date() -> i64 { let Ok(out) = Command::new("date").arg("+%z").output() else { return 0; }; @@ -188,6 +200,47 @@ fn local_offset_seconds() -> i64 { sign * (hh * 3600 + mm * 60) } +#[cfg(unix)] +fn local_offset_seconds_unix() -> Option { + use std::os::raw::{c_char, c_int, c_long}; + + type TimeT = i64; + + #[repr(C)] + struct Tm { + tm_sec: c_int, + tm_min: c_int, + tm_hour: c_int, + tm_mday: c_int, + tm_mon: c_int, + tm_year: c_int, + tm_wday: c_int, + tm_yday: c_int, + tm_isdst: c_int, + tm_gmtoff: c_long, + tm_zone: *const c_char, + } + + unsafe extern "C" { + fn time(tloc: *mut TimeT) -> TimeT; + fn localtime_r(timep: *const TimeT, result: *mut Tm) -> *mut Tm; + } + + // SAFETY: `time` accepts null for return-only use. `localtime_r` writes + // into a stack `Tm` matching the Unix C layout used by this dev harness. + unsafe { + let now = time(std::ptr::null_mut()); + if now == -1 { + return None; + } + let mut tm = std::mem::zeroed::(); + if localtime_r(&now, &mut tm).is_null() { + return None; + } + Some(tm.tm_gmtoff as i64) + } +} + enum LineDiff<'a> { Common, OnlyOracle(&'a str), diff --git a/crates/revelo-dispatcher/Cargo.toml b/crates/revelo-dispatcher/Cargo.toml index b284a88..a463aba 100644 --- a/crates/revelo-dispatcher/Cargo.toml +++ b/crates/revelo-dispatcher/Cargo.toml @@ -19,7 +19,7 @@ parallel = ["dep:rayon"] [dependencies] rayon = { workspace = true, optional = true } -revelo-core = { path = "../revelo-core", version = "0.5.1" } +revelo-core = { path = "../revelo-core", version = "0.5.1", default-features = false } revelo-parsers-archive = { path = "../revelo-parsers-archive", version = "0.5.1" } revelo-parsers-audio = { path = "../revelo-parsers-audio", version = "0.5.1" } revelo-parsers-container = { path = "../revelo-parsers-container", version = "0.5.1" } diff --git a/crates/revelo-parsers-archive/Cargo.toml b/crates/revelo-parsers-archive/Cargo.toml index b951c09..3a08750 100644 --- a/crates/revelo-parsers-archive/Cargo.toml +++ b/crates/revelo-parsers-archive/Cargo.toml @@ -18,5 +18,5 @@ path = "src/lib.rs" workspace = true [dependencies] -revelo-core = { path = "../revelo-core", version = "0.5.1" } +revelo-core = { path = "../revelo-core", version = "0.5.1", default-features = false } revelo-util = { path = "../revelo-util", version = "0.5.1" } diff --git a/crates/revelo-parsers-audio/Cargo.toml b/crates/revelo-parsers-audio/Cargo.toml index 77abac5..ede7dbe 100644 --- a/crates/revelo-parsers-audio/Cargo.toml +++ b/crates/revelo-parsers-audio/Cargo.toml @@ -18,5 +18,5 @@ path = "src/lib.rs" workspace = true [dependencies] -revelo-core = { path = "../revelo-core", version = "0.5.1" } +revelo-core = { path = "../revelo-core", version = "0.5.1", default-features = false } revelo-util = { path = "../revelo-util", version = "0.5.1" } diff --git a/crates/revelo-parsers-container/Cargo.toml b/crates/revelo-parsers-container/Cargo.toml index 80129d9..49ada89 100644 --- a/crates/revelo-parsers-container/Cargo.toml +++ b/crates/revelo-parsers-container/Cargo.toml @@ -18,6 +18,6 @@ path = "src/lib.rs" workspace = true [dependencies] -revelo-core = { path = "../revelo-core", version = "0.5.1" } +revelo-core = { path = "../revelo-core", version = "0.5.1", default-features = false } revelo-parsers-video = { path = "../revelo-parsers-video", version = "0.5.1" } revelo-util = { path = "../revelo-util", version = "0.5.1" } diff --git a/crates/revelo-parsers-image/Cargo.toml b/crates/revelo-parsers-image/Cargo.toml index 64c2da1..78b2937 100644 --- a/crates/revelo-parsers-image/Cargo.toml +++ b/crates/revelo-parsers-image/Cargo.toml @@ -18,5 +18,5 @@ path = "src/lib.rs" workspace = true [dependencies] -revelo-core = { path = "../revelo-core", version = "0.5.1" } +revelo-core = { path = "../revelo-core", version = "0.5.1", default-features = false } revelo-util = { path = "../revelo-util", version = "0.5.1" } diff --git a/crates/revelo-parsers-tag/Cargo.toml b/crates/revelo-parsers-tag/Cargo.toml index a6e856b..a67879f 100644 --- a/crates/revelo-parsers-tag/Cargo.toml +++ b/crates/revelo-parsers-tag/Cargo.toml @@ -26,7 +26,7 @@ workspace = true exiftool-tables = ["dep:revelo-exiftool-tables"] [dependencies] -revelo-core = { path = "../revelo-core", version = "0.5.1" } +revelo-core = { path = "../revelo-core", version = "0.5.1", default-features = false } revelo-util = { path = "../revelo-util", version = "0.5.1" } revelo-exiftool-tables = { path = "../revelo-exiftool-tables", version = "0.5.1", optional = true } flate2 = "1" diff --git a/crates/revelo-parsers-tag/src/tags.rs b/crates/revelo-parsers-tag/src/tags.rs index 307f5e7..bcba7ee 100644 --- a/crates/revelo-parsers-tag/src/tags.rs +++ b/crates/revelo-parsers-tag/src/tags.rs @@ -544,6 +544,52 @@ mod tests { assert!(stats.max_request_len <= EMBEDDED_METADATA_SCAN_LIMIT, "{stats:?}"); assert!(stats.bytes_returned < 64 * 1024 * 1024, "{stats:?}"); } + + #[test] + fn parse_tags_skips_image_metadata_scans_for_known_containers() { + let buf = vec![0u8; EMBEDDED_METADATA_SCAN_LIMIT + 1024]; + let mut fa = FileAnalyze::new(&buf); + fa.stream_prepare(StreamKind::General); + fa.set_field(StreamKind::General, 0, "Format", "MPEG-4"); + + assert!(parse_tags(&mut fa)); + + let stats = fa.access_stats(); + assert!(stats.bytes_returned < 1024, "{stats:?}"); + assert!(stats.max_request_len <= 128, "{stats:?}"); + } + + #[test] + fn parse_tags_keeps_embedded_metadata_for_image_formats() { + let buf = + br#"prefix xmpmeta Camera suffix"#; + let mut fa = FileAnalyze::new(buf); + fa.stream_prepare(StreamKind::General); + fa.set_field(StreamKind::General, 0, "Format", "JPEG"); + + assert!(parse_tags(&mut fa)); + + assert_eq!( + fa.retrieve(StreamKind::Xmp, 0, "Encoded_Application").map(|z| z.as_str()), + Some("Camera") + ); + } + + #[test] + fn parse_tags_keeps_embedded_metadata_for_unlisted_formats() { + let buf = + br#"prefix xmpmeta Camera suffix"#; + let mut fa = FileAnalyze::new(buf); + fa.stream_prepare(StreamKind::General); + fa.set_field(StreamKind::General, 0, "Format", "PDF"); + + assert!(parse_tags(&mut fa)); + + assert_eq!( + fa.retrieve(StreamKind::Xmp, 0, "Encoded_Application").map(|z| z.as_str()), + Some("Camera") + ); + } } // ---------- EXIF ---------- @@ -2717,20 +2763,87 @@ pub fn parse_jpeg_com(fa: &mut FileAnalyze) -> bool { // ---------- Update parse_tags ---------- +fn general_format<'fa, 'src>(fa: &'fa FileAnalyze<'src>) -> Option<&'fa str> { + fa.retrieve(StreamKind::General, 0, "Format").map(|z| z.as_str()) +} + +fn should_scan_tail_audio_tags(fa: &FileAnalyze) -> bool { + let Some(format) = general_format(fa) else { + return true; + }; + if fa.stream_count(StreamKind::Video) > 0 || fa.stream_count(StreamKind::Image) > 0 { + return false; + } + fa.stream_count(StreamKind::Audio) > 0 + || matches!( + format, + "MPEG Audio" + | "Monkey's Audio" + | "Musepack" + | "WavPack" + | "True Audio" + | "FLAC" + | "Ogg" + ) +} + +fn should_scan_embedded_metadata(fa: &FileAnalyze) -> bool { + if fa.stream_count(StreamKind::Image) > 0 + || fa.stream_count(StreamKind::Exif) > 0 + || fa.stream_count(StreamKind::Iptc) > 0 + || fa.stream_count(StreamKind::Xmp) > 0 + || fa.stream_count(StreamKind::Icc) > 0 + || fa.stream_count(StreamKind::C2pa) > 0 + || fa.stream_count(StreamKind::MakerNotes) > 0 + { + return true; + } + + if fa.stream_count(StreamKind::Video) > 0 || fa.stream_count(StreamKind::Audio) > 0 { + return false; + } + + let Some(format) = general_format(fa) else { + return true; + }; + + !matches!( + format, + "MPEG-4" + | "QuickTime" + | "Matroska" + | "WebM" + | "AVI" + | "MPEG-TS" + | "MPEG-PS" + | "BDAV" + | "Ogg" + | "FLAC" + | "MPEG Audio" + | "Wave" + | "AIFF" + | "RF64" + ) +} + pub fn parse_tags(fa: &mut FileAnalyze) -> bool { let _ = parse_id3v1(fa); let _ = parse_id3v2(fa); - let _ = parse_ape_tag(fa); - let _ = parse_lyrics3(fa); - let _ = parse_exif(fa); - let _ = parse_xmp(fa); - let _ = parse_icc(fa); - let _ = parse_c2pa(fa); - let _ = parse_iim(fa); - let _ = parse_property_list(fa); - let _ = parse_spherical_video(fa); - let _ = parse_jpeg_com(fa); - let _ = parse_png_text(fa); + if should_scan_tail_audio_tags(fa) { + let _ = parse_ape_tag(fa); + let _ = parse_lyrics3(fa); + } + if should_scan_embedded_metadata(fa) { + let _ = parse_exif(fa); + let _ = parse_xmp(fa); + let _ = parse_icc(fa); + let _ = parse_c2pa(fa); + let _ = parse_iim(fa); + let _ = parse_property_list(fa); + let _ = parse_spherical_video(fa); + let _ = parse_jpeg_com(fa); + let _ = parse_png_text(fa); + } infer_raw_format(fa); true } diff --git a/crates/revelo-parsers-text/Cargo.toml b/crates/revelo-parsers-text/Cargo.toml index 96b7aed..0ac3ca3 100644 --- a/crates/revelo-parsers-text/Cargo.toml +++ b/crates/revelo-parsers-text/Cargo.toml @@ -18,5 +18,5 @@ path = "src/lib.rs" workspace = true [dependencies] -revelo-core = { path = "../revelo-core", version = "0.5.1" } +revelo-core = { path = "../revelo-core", version = "0.5.1", default-features = false } revelo-util = { path = "../revelo-util", version = "0.5.1" } diff --git a/crates/revelo-parsers-video/Cargo.toml b/crates/revelo-parsers-video/Cargo.toml index 1d7c5b0..56a1d38 100644 --- a/crates/revelo-parsers-video/Cargo.toml +++ b/crates/revelo-parsers-video/Cargo.toml @@ -18,5 +18,5 @@ path = "src/lib.rs" workspace = true [dependencies] -revelo-core = { path = "../revelo-core", version = "0.5.1" } +revelo-core = { path = "../revelo-core", version = "0.5.1", default-features = false } revelo-util = { path = "../revelo-util", version = "0.5.1" } diff --git a/crates/revelo/Cargo.toml b/crates/revelo/Cargo.toml index 978674e..ca313c1 100644 --- a/crates/revelo/Cargo.toml +++ b/crates/revelo/Cargo.toml @@ -22,8 +22,8 @@ mmap = ["dep:memmap2", "revelo-core/mmap"] parallel = ["revelo-dispatcher/parallel"] [dependencies] -revelo-core = { path = "../revelo-core", version = "0.5.1" } -revelo-dispatcher = { path = "../revelo-dispatcher", version = "0.5.1" } +revelo-core = { path = "../revelo-core", version = "0.5.1", default-features = false } +revelo-dispatcher = { path = "../revelo-dispatcher", version = "0.5.1", default-features = false } revelo-parsers-tag = { path = "../revelo-parsers-tag", version = "0.5.1" } memmap2 = { version = "0.9", optional = true } diff --git a/justfile b/justfile index 5882719..00827dc 100644 --- a/justfile +++ b/justfile @@ -27,6 +27,34 @@ bench: bench-report: bench open target/criterion/report/index.html +# Run cross-tool benchmark comparison from a local manifest. +bench-compare manifest table_config="scripts/perf/table.config.example.json": + cargo run -p revelo-bench -- compare --manifest "{{manifest}}" --table-config "{{table_config}}" --render-png + +# Run cross-tool benchmark comparison and stop after sanitized JSON. +bench-compare-json manifest: + cargo run -p revelo-bench -- compare --manifest "{{manifest}}" --no-render-table + +# Render the standalone comparison table from existing JSON. +bench-compare-table results table_config="scripts/perf/table.config.example.json": + cargo run -p revelo-bench -- render-table --results "{{results}}" --output "{{replace(results, "results.json", "benchmark-table.html")}}" --config "{{table_config}}" + +# Render the standalone comparison table from existing JSON. +bench-evidence-table results table_config="scripts/perf/table.config.example.json": + cargo run -p revelo-bench -- render-table --results "{{results}}" --output "{{replace(results, "results.json", "benchmark-table.html")}}" --config "{{table_config}}" + +# Capture the standalone comparison table as PNG when Chrome/Chromium is available. +bench-compare-png results table_config="scripts/perf/table.config.example.json": + cargo run -p revelo-bench -- render-table --results "{{results}}" --output "{{replace(results, "results.json", "benchmark-table.html")}}" --config "{{table_config}}" --render-png + +# Check that the WASM crate still builds for the browser target. +bench-wasm: + RUSTC="$(rustup which rustc)" cargo check -p revelo-wasm --target wasm32-unknown-unknown + +# Run benchmark compare and table rendering with one shared run id. +bench-evidence manifest table_config="scripts/perf/table.config.example.json": + cargo run -p revelo-bench -- evidence --manifest "{{manifest}}" --table-config "{{table_config}}" + # Build the WASM package (requires: wasm-pack, rustup target add wasm32-unknown-unknown) wasm: wasm-pack build crates/revelo-wasm --release diff --git a/scripts/perf/README.md b/scripts/perf/README.md new file mode 100644 index 0000000..8a49b17 --- /dev/null +++ b/scripts/perf/README.md @@ -0,0 +1,186 @@ +# Performance Comparison Config + +This directory contains configuration for the Rust-native `revelo-bench` +developer tool. It intentionally does not contain Python, Node, Playwright or +browser automation scripts. + +Generated benchmark outputs are written under `target/perf-investigation/` and +generated media fixtures under `target/perf-fixtures/`; neither directory should +be committed. + +Required external tools for the full comparison: + +- `hyperfine` +- `mediainfo` +- `ffmpeg` +- `ffprobe` + +The WASM check additionally requires: + +- a rustup-managed toolchain for this repository; +- `rustup target add wasm32-unknown-unknown`. + +PNG table capture additionally needs an installed Chrome-compatible browser: + +- Google Chrome +- Chromium +- Microsoft Edge + +`revelo-bench` discovers common browser paths and `PATH` binaries, or accepts +`--chrome-path`. If no browser is available, JSON and HTML are still generated +and `results.json` records `table_png.status = "skipped"`. + +## Entry Points + +Run the full comparison from a manifest: + +```sh +just bench-compare scripts/perf/manifest.example.json +``` + +Run the larger generated-only fixture matrix: + +```sh +just bench-compare scripts/perf/manifest.extended.example.json +``` + +Write only sanitized JSON: + +```sh +just bench-compare-json path/to/local-manifest.json +``` + +Regenerate HTML from existing JSON: + +```sh +just bench-compare-table \ + target/perf-investigation//results.json +``` + +Regenerate HTML and PNG from existing JSON: + +```sh +just bench-compare-png \ + target/perf-investigation//results.json +``` + +Run the evidence pipeline with one shared run id: + +```sh +just bench-evidence path/to/local-manifest.json +``` + +Check the WASM feature graph: + +```sh +just bench-wasm +``` + +Strict MediaInfo parity checks are intentionally separate from this performance +suite. Use the existing Rust `revelo-diff` XML harness when changing parser +output semantics. + +## Manifest Schema + +The manifest schema is `revelo_perf_manifest_v2`. Each case must contain +exactly one source: + +- `synthetic`: generated by `revelo-bench generate-fixtures`; +- `path`: real local media, usually from a private or public corpus checkout. + +Generated-only example: + +```json +{ + "schema": "revelo_perf_manifest_v2", + "settings": { "warmups": 2, "runs": 10 }, + "cases": [ + { + "id": "synthetic-mp4-snv2-tail-389m", + "label": "MP4 / SNV2 / moov tail / 389 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "H.264", + "layout": "SNV2 brand, moov tail, sparse mdat", + "source": "generated", + "synthetic": { + "kind": "mp4_snv2_tail", + "size_bytes": 407896064 + } + } + ] +} +``` + +Real media example: + +```json +{ + "schema": "revelo_perf_manifest_v2", + "settings": { "warmups": 2, "runs": 10 }, + "cases": [ + { + "id": "real-mp4-avc-local", + "label": "MP4 / AVC / real local sample", + "class": "real", + "container": "MP4", + "codec": "H.264", + "layout": "real media", + "source": "local private or public corpus label", + "path": "local-media/not-committed/real-mp4-avc.mp4" + } + ] +} +``` + +The manifest can compare multiple Revelo binaries in the same run: + +```json +{ + "schema": "revelo_perf_manifest_v2", + "revelo_versions": [ + { "id": "revelo_0_4_6", "label": "Revelo 0.4.6", "path": "target/perf-tools/revelo-0.4.6" }, + { "id": "revelo_0_5_0", "label": "Revelo 0.5.0", "path": "target/perf-tools/revelo-0.5.0" }, + { "id": "revelo_0_5_1", "label": "Revelo 0.5.1", "path": "target/perf-tools/revelo-0.5.1" }, + { "id": "revelo_branch", "label": "Revelo branch", "path": "target/release/revelo" } + ], + "cases": [ + { + "id": "synthetic-mp4-snv2-tail-389m", + "label": "MP4 / SNV2 / moov tail / 389 MiB", + "class": "synthetic", + "container": "MP4", + "synthetic": { "kind": "mp4_snv2_tail", "size_bytes": 407896064 } + } + ] +} +``` + +## Outputs + +Default output layout: + +```text +target/perf-investigation// + results.json + benchmark-table.html + benchmark-table.css + benchmark-table.png # optional, when Chrome/Chromium/Edge is available + +target/perf-fixtures// + +``` + +`results.json` records the measurement backend and environment: + +```json +{ + "measurement_backend": { + "name": "hyperfine", + "required": true + } +} +``` + +Real-media manifests may contain private paths. Sanitized outputs keep labels, +sizes, tool versions and aggregate timings, but not local manifest paths. diff --git a/scripts/perf/manifest.example.json b/scripts/perf/manifest.example.json new file mode 100644 index 0000000..65d9d9a --- /dev/null +++ b/scripts/perf/manifest.example.json @@ -0,0 +1,36 @@ +{ + "schema": "revelo_perf_manifest_v2", + "settings": { + "warmups": 2, + "runs": 10, + "render_png": false + }, + "cases": [ + { + "id": "synthetic-mp4-snv2-tail-389m", + "label": "MP4 / SNV2 / moov tail / 389 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "H.264", + "layout": "SNV2 brand, moov tail, sparse mdat", + "source": "generated", + "synthetic": { + "kind": "mp4_snv2_tail", + "size_bytes": 407896064 + } + }, + { + "id": "synthetic-wav-list-id3-512m", + "label": "WAV / PCM / LIST+ID3 / 512 MiB", + "class": "synthetic", + "container": "WAV", + "codec": "PCM", + "layout": "LIST metadata, ID3 metadata, sparse data chunk", + "source": "generated", + "synthetic": { + "kind": "wav_list_id3_data", + "size_bytes": 536870912 + } + } + ] +} diff --git a/scripts/perf/manifest.extended.example.json b/scripts/perf/manifest.extended.example.json new file mode 100644 index 0000000..0024d25 --- /dev/null +++ b/scripts/perf/manifest.extended.example.json @@ -0,0 +1,412 @@ +{ + "schema": "revelo_perf_manifest_v2", + "settings": { + "warmups": 2, + "runs": 10, + "render_png": false + }, + "revelo_versions": [ + { + "id": "revelo_0_4_6", + "label": "Revelo 0.4.6", + "path": "target/perf-tools/revelo-0.4.6" + }, + { + "id": "revelo_0_5_0", + "label": "Revelo 0.5.0", + "path": "target/perf-tools/revelo-0.5.0" + }, + { + "id": "revelo_0_5_1", + "label": "Revelo 0.5.1", + "path": "target/perf-tools/revelo-0.5.1" + }, + { + "id": "revelo_branch", + "label": "This PR", + "path": "target/release/revelo" + } + ], + "cases": [ + { + "id": "synthetic-mp4-snv2-tail-389m", + "label": "MP4 / SNV2 / moov tail / 389 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "H.264", + "layout": "SNV2 brand, moov tail, sparse mdat", + "source": "generated sparse fixture", + "synthetic": { "kind": "mp4_snv2_tail", "size_bytes": 407896064 } + }, + { + "id": "synthetic-mp4-avc-faststart-256m", + "label": "MP4 / H.264 + AAC / faststart / 256 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "H.264 + AAC", + "layout": "ffmpeg-generated headers, sparse media tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mp4_avc_faststart", "size_bytes": 268435456 } + }, + { + "id": "synthetic-mp4-snv2-faststart-256m", + "label": "MP4 / SNV2 H.264 + AAC / faststart / 256 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "H.264 + AAC", + "layout": "ffmpeg-generated headers, patched SNV2 brand", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mp4_snv2_faststart", "size_bytes": 268435456 } + }, + { + "id": "synthetic-mp4-hevc10-faststart-192m", + "label": "MP4 / HEVC 10-bit + AAC / faststart / 192 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "HEVC 10-bit + AAC", + "layout": "ffmpeg-generated headers, sparse media tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mp4_hevc10_faststart", "size_bytes": 201326592 } + }, + { + "id": "synthetic-mp4-av1-faststart-192m", + "label": "MP4 / AV1 10-bit + AAC / faststart / 192 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "AV1 10-bit + AAC", + "layout": "ffmpeg-generated headers, sparse media tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mp4_av1_faststart", "size_bytes": 201326592 } + }, + { + "id": "synthetic-mp4-aac-audio-192m", + "label": "MP4 / AAC / audio-only / 192 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "AAC", + "layout": "audio-only M4A, sparse media tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mp4_aac_audio", "size_bytes": 201326592 } + }, + { + "id": "synthetic-mp4-tail-256m", + "label": "MP4 / H.264 + AAC / moov tail / 256 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "H.264 + AAC", + "layout": "ffmpeg-generated moov tail, sparse media tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mp4_tail", "size_bytes": 268435456 } + }, + { + "id": "synthetic-mov-pcm-192m", + "label": "MOV / MPEG-4 Video + PCM / 192 MiB", + "class": "synthetic", + "container": "MOV", + "codec": "MPEG-4 Video + PCM", + "layout": "QuickTime MOV, sparse media tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mov_mpeg4_pcm", "size_bytes": 201326592 } + }, + { + "id": "synthetic-fmp4-192m", + "label": "MP4 / H.264 + AAC / fragmented / 192 MiB", + "class": "synthetic", + "container": "MP4", + "codec": "H.264 + AAC", + "layout": "fragmented MP4, sparse media tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_fragmented_mp4", "size_bytes": 201326592 } + }, + { + "id": "synthetic-mkv-h264-aac-256m", + "label": "MKV / H.264 + AAC / Matroska / 256 MiB", + "class": "synthetic", + "container": "MKV", + "codec": "H.264 + AAC", + "layout": "ffmpeg-generated Matroska headers, sparse cluster tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mkv_h264_aac", "size_bytes": 268435456 } + }, + { + "id": "synthetic-mkv-ffv1-flac-192m", + "label": "MKV / FFV1 + FLAC / archival / 192 MiB", + "class": "synthetic", + "container": "MKV", + "codec": "FFV1 + FLAC", + "layout": "ffmpeg-generated Matroska headers, sparse cluster tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mkv_ffv1_flac", "size_bytes": 201326592 } + }, + { + "id": "synthetic-webm-vp8-opus-192m", + "label": "WebM / VP8 + Opus / 192 MiB", + "class": "synthetic", + "container": "WebM", + "codec": "VP8 + Opus", + "layout": "ffmpeg-generated WebM headers, sparse cluster tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_webm_vp8_opus", "size_bytes": 201326592 } + }, + { + "id": "synthetic-webm-av1-opus-192m", + "label": "WebM / AV1 10-bit + Opus / 192 MiB", + "class": "synthetic", + "container": "WebM", + "codec": "AV1 10-bit + Opus", + "layout": "ffmpeg-generated WebM headers, sparse cluster tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_webm_av1_opus", "size_bytes": 201326592 } + }, + { + "id": "synthetic-flv-nellymoser-192m", + "label": "FLV / NellyMoser / 192 MiB", + "class": "synthetic", + "container": "FLV", + "codec": "NellyMoser", + "layout": "ffmpeg-generated FLV audio, sparse media tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_flv_nellymoser", "size_bytes": 201326592 } + }, + { + "id": "synthetic-asf-wma-192m", + "label": "ASF / WMA / 192 MiB", + "class": "synthetic", + "container": "ASF", + "codec": "WMA", + "layout": "ffmpeg-generated ASF audio, sparse media tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_asf_wma", "size_bytes": 201326592 } + }, + { + "id": "synthetic-wav-list-id3-512m", + "label": "WAV / PCM / LIST + ID3 / 512 MiB", + "class": "synthetic", + "container": "WAV", + "codec": "PCM", + "layout": "LIST metadata, ID3 metadata, sparse data chunk", + "source": "generated sparse fixture", + "synthetic": { "kind": "wav_list_id3_data", "size_bytes": 536870912 } + }, + { + "id": "synthetic-wav-192khz-256m", + "label": "WAV / PCM 192 kHz / 256 MiB", + "class": "synthetic", + "container": "WAV", + "codec": "PCM", + "layout": "high sample-rate RIFF WAV, sparse data chunk", + "source": "generated sparse fixture", + "synthetic": { "kind": "wav_192khz_data", "size_bytes": 268435456 } + }, + { + "id": "synthetic-wav-9ch-256m", + "label": "WAV / PCM 9-channel / 256 MiB", + "class": "synthetic", + "container": "WAV", + "codec": "PCM", + "layout": "multi-channel RIFF WAV, sparse data chunk", + "source": "generated sparse fixture", + "synthetic": { "kind": "wav_9ch_data", "size_bytes": 268435456 } + }, + { + "id": "synthetic-bwf-192m", + "label": "BWF / PCM / bext + LIST + ID3 / 192 MiB", + "class": "synthetic", + "container": "WAV", + "codec": "PCM", + "layout": "Broadcast-WAV bext metadata, sparse data chunk", + "source": "generated sparse fixture", + "synthetic": { "kind": "bwf_data", "size_bytes": 201326592 } + }, + { + "id": "synthetic-rf64-192m", + "label": "RF64 / PCM / ds64 / 192 MiB", + "class": "synthetic", + "container": "RF64", + "codec": "PCM", + "layout": "RF64 ds64 metadata, sparse data chunk", + "source": "generated sparse fixture", + "synthetic": { "kind": "rf64_ds64_data", "size_bytes": 201326592 } + }, + { + "id": "synthetic-aiff-192m", + "label": "AIFF / PCM / SSND / 192 MiB", + "class": "synthetic", + "container": "AIFF", + "codec": "PCM", + "layout": "AIFF metadata, sparse SSND", + "source": "generated sparse fixture", + "synthetic": { "kind": "aiff_ssnd", "size_bytes": 201326592 } + }, + { + "id": "synthetic-aifc-mace3-192m", + "label": "AIFF-C / MACE3 / SSND / 192 MiB", + "class": "synthetic", + "container": "AIFF-C", + "codec": "MACE3", + "layout": "AIFF-C metadata, sparse SSND", + "source": "generated sparse fixture", + "synthetic": { "kind": "aifc_mace3_ssnd", "size_bytes": 201326592 } + }, + { + "id": "synthetic-flac-ffmpeg-192m", + "label": "FLAC / FLAC / native / 192 MiB", + "class": "synthetic", + "container": "FLAC", + "codec": "FLAC", + "layout": "ffmpeg-generated FLAC headers, sparse audio tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_flac", "size_bytes": 201326592 } + }, + { + "id": "synthetic-mp3-id3-192m", + "label": "MP3 / MPEG Audio / ID3v2 / 192 MiB", + "class": "synthetic", + "container": "MP3", + "codec": "MPEG Audio", + "layout": "ffmpeg-generated ID3 and MP3 frames, sparse audio tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mp3", "size_bytes": 201326592 } + }, + { + "id": "synthetic-ogg-vorbis-192m", + "label": "Ogg / Vorbis / 192 MiB", + "class": "synthetic", + "container": "Ogg", + "codec": "Vorbis", + "layout": "ffmpeg-generated Ogg pages, sparse audio tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_ogg_vorbis", "size_bytes": 201326592 } + }, + { + "id": "synthetic-ogg-opus-192m", + "label": "Ogg / Opus / 192 MiB", + "class": "synthetic", + "container": "Ogg", + "codec": "Opus", + "layout": "ffmpeg-generated Ogg pages, sparse audio tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_ogg_opus", "size_bytes": 201326592 } + }, + { + "id": "synthetic-ogg-flac-192m", + "label": "Ogg / FLAC / 192 MiB", + "class": "synthetic", + "container": "Ogg", + "codec": "FLAC", + "layout": "ffmpeg-generated Ogg/FLAC pages, sparse audio tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_ogg_flac", "size_bytes": 201326592 } + }, + { + "id": "synthetic-mpeg-ts-mp2-512m", + "label": "MPEG-TS / MPEG-2 + MP2 / 512 MiB", + "class": "synthetic", + "container": "MPEG-TS", + "codec": "MPEG-2 Video + MP2", + "layout": "ffmpeg-generated transport stream, sparse tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mpeg_ts", "size_bytes": 536870912 } + }, + { + "id": "synthetic-mpeg-ts-ac3-512m", + "label": "MPEG-TS / MPEG-2 + AC-3 / 512 MiB", + "class": "synthetic", + "container": "MPEG-TS", + "codec": "MPEG-2 Video + AC-3", + "layout": "ffmpeg-generated transport stream, sparse tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mpeg_ts_ac3", "size_bytes": 536870912 } + }, + { + "id": "synthetic-m2ts-512m", + "label": "M2TS / MPEG-2 + MP2 / 512 MiB", + "class": "synthetic", + "container": "M2TS", + "codec": "MPEG-2 Video + MP2", + "layout": "ffmpeg-generated M2TS packets, sparse tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_m2ts", "size_bytes": 536870912 } + }, + { + "id": "synthetic-mpeg-ps-mp2-512m", + "label": "MPEG-PS / MPEG-2 + MP2 / 512 MiB", + "class": "synthetic", + "container": "MPEG-PS", + "codec": "MPEG-2 Video + MP2", + "layout": "ffmpeg-generated program stream, sparse tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_mpeg_ps", "size_bytes": 536870912 } + }, + { + "id": "synthetic-vob-512m", + "label": "VOB / MPEG-2 + MP2 / 512 MiB", + "class": "synthetic", + "container": "VOB", + "codec": "MPEG-2 Video + MP2", + "layout": "ffmpeg-generated VOB, sparse tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_vob", "size_bytes": 536870912 } + }, + { + "id": "synthetic-avi-mpeg4-mp3-512m", + "label": "AVI / MPEG-4 + MP3 / RIFF / 512 MiB", + "class": "synthetic", + "container": "AVI", + "codec": "MPEG-4 Video + MP3", + "layout": "ffmpeg-generated RIFF AVI, sparse movi tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_avi_mpeg4_mp3", "size_bytes": 536870912 } + }, + { + "id": "synthetic-avi-mpeg4-wma-512m", + "label": "AVI / MPEG-4 + WMA / RIFF / 512 MiB", + "class": "synthetic", + "container": "AVI", + "codec": "MPEG-4 Video + WMA", + "layout": "ffmpeg-generated RIFF AVI, sparse movi tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_avi_mpeg4_wma", "size_bytes": 536870912 } + }, + { + "id": "synthetic-raw-aac-192m", + "label": "AAC / ADTS / elementary stream / 192 MiB", + "class": "synthetic", + "container": "AAC", + "codec": "AAC", + "layout": "ffmpeg-generated ADTS frames, sparse tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_raw_aac", "size_bytes": 201326592 } + }, + { + "id": "synthetic-raw-ac3-192m", + "label": "AC-3 / elementary stream / 192 MiB", + "class": "synthetic", + "container": "AC-3", + "codec": "AC-3", + "layout": "ffmpeg-generated elementary stream, sparse tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_raw_ac3", "size_bytes": 201326592 } + }, + { + "id": "synthetic-raw-eac3-192m", + "label": "E-AC-3 / elementary stream / 192 MiB", + "class": "synthetic", + "container": "E-AC-3", + "codec": "E-AC-3", + "layout": "ffmpeg-generated elementary stream, sparse tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_raw_eac3", "size_bytes": 201326592 } + }, + { + "id": "synthetic-opus-standalone-192m", + "label": "Opus / standalone / 192 MiB", + "class": "synthetic", + "container": "Opus", + "codec": "Opus", + "layout": "ffmpeg-generated standalone Opus, sparse tail", + "source": "generated ffmpeg fixture", + "synthetic": { "kind": "ffmpeg_opus", "size_bytes": 201326592 } + } + ] +} diff --git a/scripts/perf/table.config.example.json b/scripts/perf/table.config.example.json new file mode 100644 index 0000000..4c429a3 --- /dev/null +++ b/scripts/perf/table.config.example.json @@ -0,0 +1,71 @@ +{ + "schema": "revelo_benchmark_table_config_v1", + "title": "Revelo benchmark comparison", + "capture_id": "benchmark-table-capture", + "footer_note": "Values are median milliseconds. Generated fixtures are parser-oriented sparse files; real rows come from the local manifest.", + "show_size_column": true, + "column_labels": { + "revelo_0_4_6": "Revelo 0.4.6", + "revelo_0_5_0": "Revelo 0.5.0", + "revelo_0_5_1": "Revelo 0.5.1", + "revelo_pr5": "Revelo PR #5", + "revelo_branch": "This PR", + "revelo_cli_text": "Revelo", + "mediainfo": "MediaInfo", + "ffprobe": "ffprobe" + }, + "sections": [ + { + "id": "synthetic", + "label": "Generated fixtures", + "match": { + "class": "synthetic" + } + }, + { + "id": "real", + "label": "Real media", + "match": { + "class": "real" + } + } + ], + "latency_tiers_ms": [ + { + "name": "instant", + "label": "under 10 ms", + "max": 10, + "class": "instant" + }, + { + "name": "very_fast", + "label": "10-30 ms", + "max": 30, + "class": "very-fast" + }, + { + "name": "fast", + "label": "30-80 ms", + "max": 80, + "class": "fast" + }, + { + "name": "medium", + "label": "80-150 ms", + "max": 150, + "class": "medium" + }, + { + "name": "slow", + "label": "150-500 ms", + "max": 500, + "class": "slow" + }, + { + "name": "very_slow", + "label": "over 500 ms", + "max": null, + "class": "very-slow" + } + ] +}