#[cfg(any(not(coverage), test))] use anyhow::{Context, Result, bail}; #[cfg(not(coverage))] use serde::Serialize; #[cfg(any(not(coverage), test))] use std::path::PathBuf; #[cfg(not(coverage))] use lesavka_client::sync_probe::analyze::{ SyncAnalysisOptions, SyncAnalysisReport, SyncAnalysisVerdict, SyncCalibrationRecommendation, analyze_capture, }; #[cfg(not(coverage))] #[derive(Serialize)] struct SyncAnalyzeOutput<'a> { #[serde(flatten)] report: &'a SyncAnalysisReport, calibration: SyncCalibrationRecommendation, verdict: SyncAnalysisVerdict, } #[cfg(not(coverage))] fn main() -> Result<()> { let args = parse_args(std::env::args().skip(1))?; let report = analyze_capture(&args.capture_path, &SyncAnalysisOptions::default()) .with_context(|| format!("analyzing sync capture {}", args.capture_path.display()))?; let calibration = report.calibration_recommendation(); let verdict = report.verdict(); let human_report = format_human_report(&args.capture_path, &report, &calibration, &verdict); let output = SyncAnalyzeOutput { report: &report, calibration, verdict, }; if let Some(report_dir) = &args.report_dir { write_report_dir(report_dir, &human_report, &output)?; } if args.emit_json { println!( "{}", serde_json::to_string_pretty(&output).context("serializing JSON report")? ); } else { print!("{human_report}"); } Ok(()) } #[cfg(any(not(coverage), test))] #[derive(Debug, PartialEq, Eq)] struct AnalyzeArgs { capture_path: PathBuf, emit_json: bool, report_dir: Option, } #[cfg(any(not(coverage), test))] fn parse_args(args: I) -> Result where I: IntoIterator, S: Into, { let args = args.into_iter().map(Into::into).collect::>(); if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") { println!("Usage: lesavka-sync-analyze [--json] [--report-dir ]"); std::process::exit(0); } let mut emit_json = false; let mut report_dir = None::; let mut capture_path = None::; let mut iter = args.into_iter(); while let Some(arg) = iter.next() { if arg == "--json" { emit_json = true; continue; } if arg == "--report-dir" { let Some(dir) = iter.next() else { bail!("--report-dir requires a directory"); }; report_dir = Some(PathBuf::from(dir)); continue; } if let Some(dir) = arg.strip_prefix("--report-dir=") { if dir.is_empty() { bail!("--report-dir requires a directory"); } report_dir = Some(PathBuf::from(dir)); continue; } if capture_path.is_some() { bail!("unexpected extra argument `{arg}`"); } capture_path = Some(PathBuf::from(arg)); } let capture_path = capture_path.context("capture path is required")?; Ok(AnalyzeArgs { capture_path, emit_json, report_dir, }) } #[cfg(not(coverage))] fn format_human_report( capture_path: &std::path::Path, report: &SyncAnalysisReport, calibration: &SyncCalibrationRecommendation, verdict: &SyncAnalysisVerdict, ) -> String { format!( "\ A/V sync report for {capture} - verdict: {status} ({passed}) - verdict reason: {reason} - p95 abs skew: {p95:.1} ms - video onsets: {video_events} - audio onsets: {audio_events} - paired pulses: {paired_events} - first skew: {first_skew:+.1} ms (audio after video is positive) - last skew: {last_skew:+.1} ms - mean skew: {mean_skew:+.1} ms - median skew: {median_skew:+.1} ms - max abs skew: {max_abs:.1} ms - drift: {drift:+.1} ms - calibration ready: {cal_ready} - recommended audio offset adjust: {audio_adjust:+} us - alternative video offset adjust: {video_adjust:+} us - calibration note: {cal_note} ", capture = capture_path.display(), status = verdict.status, passed = if verdict.passed { "pass" } else { "fail" }, reason = verdict.reason, p95 = verdict.p95_abs_skew_ms, video_events = report.video_event_count, audio_events = report.audio_event_count, paired_events = report.paired_event_count, first_skew = report.first_skew_ms, last_skew = report.last_skew_ms, mean_skew = report.mean_skew_ms, median_skew = report.median_skew_ms, max_abs = report.max_abs_skew_ms, drift = report.drift_ms, cal_ready = calibration.ready, audio_adjust = calibration.recommended_audio_offset_adjust_us, video_adjust = calibration.recommended_video_offset_adjust_us, cal_note = calibration.note, ) } #[cfg(not(coverage))] fn write_report_dir( report_dir: &std::path::Path, human_report: &str, output: &SyncAnalyzeOutput<'_>, ) -> Result<()> { std::fs::create_dir_all(report_dir) .with_context(|| format!("creating report directory {}", report_dir.display()))?; std::fs::write(report_dir.join("report.txt"), human_report) .with_context(|| format!("writing {}", report_dir.join("report.txt").display()))?; std::fs::write( report_dir.join("report.json"), serde_json::to_string_pretty(output).context("serializing JSON report")?, ) .with_context(|| format!("writing {}", report_dir.join("report.json").display()))?; write_events_csv(&report_dir.join("events.csv"), output.report)?; Ok(()) } #[cfg(not(coverage))] fn write_events_csv(path: &std::path::Path, report: &SyncAnalysisReport) -> Result<()> { let mut csv = String::from("event_id,video_time_s,audio_time_s,skew_ms,confidence\n"); for event in &report.paired_events { csv.push_str(&format!( "{},{:.9},{:.9},{:.6},{:.6}\n", event.event_id, event.video_time_s, event.audio_time_s, event.skew_ms, event.confidence )); } std::fs::write(path, csv).with_context(|| format!("writing {}", path.display())) } #[cfg(coverage)] fn main() {} #[cfg(test)] mod tests { use super::parse_args; #[test] fn parse_args_accepts_capture_path_and_json_flag() { let args = parse_args(["capture.mkv", "--json"]).expect("args"); assert_eq!(args.capture_path, std::path::PathBuf::from("capture.mkv")); assert!(args.emit_json); assert_eq!(args.report_dir, None); } #[test] fn parse_args_accepts_report_dir() { let args = parse_args(["capture.mkv", "--report-dir", "/tmp/probe"]).expect("args"); assert_eq!(args.capture_path, std::path::PathBuf::from("capture.mkv")); assert_eq!( args.report_dir, Some(std::path::PathBuf::from("/tmp/probe")) ); } #[test] fn parse_args_rejects_extra_positional_arguments() { assert!(parse_args(["one.mkv", "two.mkv"]).is_err()); } #[test] fn parse_args_requires_a_capture_path() { let error = parse_args(["--json"]).expect_err("missing capture path should fail"); assert!( error.to_string().contains("capture path is required"), "unexpected error: {error:#}" ); } #[test] fn coverage_main_stub_is_non_panicking() { let _ = super::main(); } }