#[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, &args.options) .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)] struct AnalyzeArgs { capture_path: PathBuf, emit_json: bool, report_dir: Option, options: SyncAnalysisOptions, } #[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 ] [--event-width-codes 1,2,1,3] [--analysis-window-s START:END]" ); std::process::exit(0); } let mut emit_json = false; let mut report_dir = None::; let mut capture_path = None::; let mut options = SyncAnalysisOptions::default(); 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 arg == "--event-width-codes" { let Some(raw_codes) = iter.next() else { bail!("--event-width-codes requires a comma-separated list"); }; options.event_width_codes = parse_event_width_codes(&raw_codes)?; continue; } if let Some(raw_codes) = arg.strip_prefix("--event-width-codes=") { if raw_codes.is_empty() { bail!("--event-width-codes requires a comma-separated list"); } options.event_width_codes = parse_event_width_codes(raw_codes)?; continue; } if arg == "--analysis-window-s" { let Some(raw_window) = iter.next() else { bail!("--analysis-window-s requires START:END seconds"); }; parse_analysis_window(&raw_window, &mut options)?; continue; } if let Some(raw_window) = arg.strip_prefix("--analysis-window-s=") { if raw_window.is_empty() { bail!("--analysis-window-s requires START:END seconds"); } parse_analysis_window(raw_window, &mut options)?; continue; } if arg == "--analysis-start-s" { let Some(raw_start) = iter.next() else { bail!("--analysis-start-s requires seconds"); }; options.analysis_start_s = Some(parse_analysis_seconds(&raw_start, "analysis start")?); continue; } if let Some(raw_start) = arg.strip_prefix("--analysis-start-s=") { if raw_start.is_empty() { bail!("--analysis-start-s requires seconds"); } options.analysis_start_s = Some(parse_analysis_seconds(raw_start, "analysis start")?); continue; } if arg == "--analysis-end-s" { let Some(raw_end) = iter.next() else { bail!("--analysis-end-s requires seconds"); }; options.analysis_end_s = Some(parse_analysis_seconds(&raw_end, "analysis end")?); continue; } if let Some(raw_end) = arg.strip_prefix("--analysis-end-s=") { if raw_end.is_empty() { bail!("--analysis-end-s requires seconds"); } options.analysis_end_s = Some(parse_analysis_seconds(raw_end, "analysis end")?); 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, options, }) } #[cfg(any(not(coverage), test))] fn parse_event_width_codes(raw: &str) -> Result> { let codes = raw .split(',') .filter_map(|part| { let trimmed = part.trim(); (!trimmed.is_empty()).then_some(trimmed) }) .map(|part| { let code = part .parse::() .with_context(|| format!("parsing event width code `{part}`"))?; if code == 0 { bail!("event width codes must be positive"); } Ok(code) }) .collect::>>()?; if codes.is_empty() { bail!("event width code list must not be empty"); } Ok(codes) } #[cfg(any(not(coverage), test))] fn parse_analysis_window(raw: &str, options: &mut SyncAnalysisOptions) -> Result<()> { let Some((start, end)) = raw.split_once(':') else { bail!("--analysis-window-s requires START:END seconds"); }; options.analysis_start_s = if start.trim().is_empty() { None } else { Some(parse_analysis_seconds(start, "analysis start")?) }; options.analysis_end_s = if end.trim().is_empty() { None } else { Some(parse_analysis_seconds(end, "analysis end")?) }; Ok(()) } #[cfg(any(not(coverage), test))] fn parse_analysis_seconds(raw: &str, label: &str) -> Result { let value = raw .trim() .parse::() .with_context(|| format!("parsing {label} `{raw}`"))?; if !value.is_finite() || value < 0.0 { bail!("{label} must be a finite non-negative timestamp"); } Ok(value) } #[cfg(not(coverage))] fn format_human_report( capture_path: &std::path::Path, report: &SyncAnalysisReport, calibration: &SyncCalibrationRecommendation, verdict: &SyncAnalysisVerdict, ) -> String { let first_paired_video = report .paired_events .first() .map(|event| event.video_time_s) .unwrap_or(0.0); let first_paired_audio = report .paired_events .first() .map(|event| event.audio_time_s) .unwrap_or(0.0); let unpaired_video = format_onset_list(&unpaired_video_onsets(report)); let unpaired_audio = format_onset_list(&unpaired_audio_onsets(report)); let raw_activity_handling = if report.raw_activity_start_is_verdict_relevant() { "used as verdict evidence" } else { "reported only; ignored for verdict/calibration because it disagrees with paired pulses" }; format!( "\ A/V sync report for {capture} - verdict: {status} ({passed}) - verdict reason: {reason} - evidence mode: {evidence_mode} - p95 abs skew: {p95:.1} ms - video onsets: {video_events} - audio onsets: {audio_events} - paired pulses: {paired_events} - activity start delta: {activity_start_delta:+.1} ms (audio after video is positive) - raw first video activity: {raw_video:.3} s - raw first audio activity: {raw_audio:.3} s - raw-vs-paired disagreement: {raw_pair_disagreement:.1} ms - raw activity handling: {raw_activity_handling} - paired window first video/audio: {paired_video:.3} s / {paired_audio:.3} s - unpaired video onsets: {unpaired_video} - unpaired audio onsets: {unpaired_audio} - 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, evidence_mode = if report.coded_events { "coded pulses" } else { "cadence/brightness pulses" }, p95 = verdict.p95_abs_skew_ms, video_events = report.video_event_count, audio_events = report.audio_event_count, paired_events = report.paired_event_count, activity_start_delta = report.activity_start_delta_ms, raw_video = report.raw_first_video_activity_s, raw_audio = report.raw_first_audio_activity_s, raw_pair_disagreement = report.activity_start_pair_disagreement_ms().abs(), raw_activity_handling = raw_activity_handling, paired_video = first_paired_video, paired_audio = first_paired_audio, unpaired_video = unpaired_video, unpaired_audio = unpaired_audio, 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 unpaired_video_onsets(report: &SyncAnalysisReport) -> Vec { unpaired_onsets( &report.video_onsets_s, &report .paired_events .iter() .map(|event| event.video_time_s) .collect::>(), ) } #[cfg(not(coverage))] fn unpaired_audio_onsets(report: &SyncAnalysisReport) -> Vec { unpaired_onsets( &report.audio_onsets_s, &report .paired_events .iter() .map(|event| event.audio_time_s) .collect::>(), ) } #[cfg(not(coverage))] fn unpaired_onsets(all_onsets: &[f64], paired_onsets: &[f64]) -> Vec { const SAME_ONSET_EPSILON_S: f64 = 0.000_001; all_onsets .iter() .copied() .filter(|onset| { !paired_onsets .iter() .any(|paired| (paired - onset).abs() <= SAME_ONSET_EPSILON_S) }) .collect() } #[cfg(not(coverage))] fn format_onset_list(onsets: &[f64]) -> String { const MAX_PRINTED_ONSETS: usize = 12; if onsets.is_empty() { return "none".to_string(); } let mut formatted = onsets .iter() .take(MAX_PRINTED_ONSETS) .map(|onset| format!("{onset:.3}s")) .collect::>(); if onsets.len() > MAX_PRINTED_ONSETS { formatted.push(format!("...+{}", onsets.len() - MAX_PRINTED_ONSETS)); } formatted.join(", ") } #[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; use lesavka_client::sync_probe::analyze::{ SyncAnalysisReport, SyncAnalysisVerdict, SyncCalibrationRecommendation, SyncEventPair, }; #[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_accepts_event_width_codes() { let args = parse_args(["capture.mkv", "--event-width-codes", "1,2,1,3"]).expect("args"); assert_eq!(args.options.event_width_codes, vec![1, 2, 1, 3]); } #[test] fn parse_args_accepts_analysis_window() { let args = parse_args(["capture.mkv", "--analysis-window-s", "8.25:26.5"]).expect("args"); assert_eq!(args.options.analysis_start_s, Some(8.25)); assert_eq!(args.options.analysis_end_s, Some(26.5)); } #[test] fn parse_args_rejects_extra_positional_arguments() { assert!(parse_args(["one.mkv", "two.mkv"]).is_err()); assert!(parse_args(["one.mkv", "--event-width-codes", ""]).is_err()); assert!(parse_args(["one.mkv", "--event-width-codes", "0"]).is_err()); assert!(parse_args(["one.mkv", "--analysis-window-s", "wat:10"]).is_err()); assert!(parse_args(["one.mkv", "--analysis-start-s", "-1"]).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(); } #[test] fn human_report_explains_coded_raw_activity_and_unpaired_onsets() { let report = SyncAnalysisReport { video_event_count: 3, audio_event_count: 3, paired_event_count: 1, coded_events: true, activity_start_delta_ms: -3_620.7, raw_first_video_activity_s: 9.361, raw_first_audio_activity_s: 5.740, first_skew_ms: -188.4, last_skew_ms: -188.4, mean_skew_ms: -188.4, median_skew_ms: -188.4, max_abs_skew_ms: 188.4, drift_ms: 0.0, skews_ms: vec![-188.4], video_onsets_s: vec![9.461, 11.420, 13.367], audio_onsets_s: vec![9.135, 11.146, 13.135], paired_events: vec![SyncEventPair { event_id: 0, server_event_id: None, event_code: None, video_time_s: 11.420, audio_time_s: 11.146, skew_ms: -188.4, confidence: 0.62, }], }; let calibration = SyncCalibrationRecommendation { ready: false, recommended_audio_offset_adjust_us: 0, recommended_video_offset_adjust_us: 0, note: "need more pairs".to_string(), }; let verdict = SyncAnalysisVerdict { status: "gross_failure".to_string(), passed: false, p95_abs_skew_ms: 188.4, max_abs_skew_ms: 188.4, preferred_p95_abs_skew_ms: 35.0, acceptable_p95_abs_skew_ms: 80.0, gross_failure_p95_abs_skew_ms: 250.0, catastrophic_max_abs_skew_ms: 1_000.0, reason: "coded pulse skew is too high".to_string(), }; let text = super::format_human_report( std::path::Path::new("/tmp/capture.webm"), &report, &calibration, &verdict, ); assert!(text.contains("- evidence mode: coded pulses")); assert!(text.contains("raw activity handling: reported only")); assert!(text.contains("- paired window first video/audio: 11.420 s / 11.146 s")); assert!(text.contains("- unpaired video onsets: 9.461s, 13.367s")); assert!(text.contains("- unpaired audio onsets: 9.135s, 13.135s")); } }