2026-04-24 14:49:57 -03:00
|
|
|
#[cfg(any(not(coverage), test))]
|
|
|
|
|
use anyhow::{Context, Result, bail};
|
2026-04-25 16:48:20 -03:00
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
use lesavka_client::sync_probe::analyze::{
|
|
|
|
|
SyncAnalysisOptions, SyncAnalysisReport, SyncCalibrationRecommendation, analyze_capture,
|
|
|
|
|
};
|
2026-04-24 14:49:57 -03:00
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
2026-04-25 16:48:20 -03:00
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct SyncAnalyzeOutput<'a> {
|
|
|
|
|
#[serde(flatten)]
|
|
|
|
|
report: &'a SyncAnalysisReport,
|
|
|
|
|
calibration: SyncCalibrationRecommendation,
|
|
|
|
|
}
|
2026-04-24 14:49:57 -03:00
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn main() -> Result<()> {
|
|
|
|
|
let (capture_path, emit_json) = parse_args(std::env::args().skip(1))?;
|
|
|
|
|
let report = analyze_capture(&capture_path, &SyncAnalysisOptions::default())
|
|
|
|
|
.with_context(|| format!("analyzing sync capture {}", capture_path.display()))?;
|
2026-04-25 16:48:20 -03:00
|
|
|
let calibration = report.calibration_recommendation();
|
2026-04-24 14:49:57 -03:00
|
|
|
|
|
|
|
|
if emit_json {
|
2026-04-25 16:48:20 -03:00
|
|
|
let output = SyncAnalyzeOutput {
|
|
|
|
|
report: &report,
|
|
|
|
|
calibration,
|
|
|
|
|
};
|
2026-04-24 14:49:57 -03:00
|
|
|
println!(
|
|
|
|
|
"{}",
|
2026-04-25 16:48:20 -03:00
|
|
|
serde_json::to_string_pretty(&output).context("serializing JSON report")?
|
2026-04-24 14:49:57 -03:00
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
println!("A/V sync report for {}", capture_path.display());
|
|
|
|
|
println!("- video onsets: {}", report.video_event_count);
|
|
|
|
|
println!("- audio onsets: {}", report.audio_event_count);
|
|
|
|
|
println!("- paired pulses: {}", report.paired_event_count);
|
|
|
|
|
println!(
|
|
|
|
|
"- first skew: {:+.1} ms (audio after video is positive)",
|
|
|
|
|
report.first_skew_ms
|
|
|
|
|
);
|
|
|
|
|
println!("- last skew: {:+.1} ms", report.last_skew_ms);
|
|
|
|
|
println!("- mean skew: {:+.1} ms", report.mean_skew_ms);
|
|
|
|
|
println!("- median skew: {:+.1} ms", report.median_skew_ms);
|
|
|
|
|
println!("- max abs skew: {:.1} ms", report.max_abs_skew_ms);
|
|
|
|
|
println!("- drift: {:+.1} ms", report.drift_ms);
|
2026-04-25 16:48:20 -03:00
|
|
|
println!("- calibration ready: {}", calibration.ready);
|
|
|
|
|
println!(
|
|
|
|
|
"- recommended audio offset adjust: {:+} us",
|
|
|
|
|
calibration.recommended_audio_offset_adjust_us
|
|
|
|
|
);
|
|
|
|
|
println!(
|
|
|
|
|
"- alternative video offset adjust: {:+} us",
|
|
|
|
|
calibration.recommended_video_offset_adjust_us
|
|
|
|
|
);
|
|
|
|
|
println!("- calibration note: {}", calibration.note);
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(any(not(coverage), test))]
|
|
|
|
|
fn parse_args<I, S>(args: I) -> Result<(std::path::PathBuf, bool)>
|
|
|
|
|
where
|
|
|
|
|
I: IntoIterator<Item = S>,
|
|
|
|
|
S: Into<String>,
|
|
|
|
|
{
|
|
|
|
|
let args = args.into_iter().map(Into::into).collect::<Vec<_>>();
|
|
|
|
|
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
|
|
|
|
|
println!("Usage: lesavka-sync-analyze <capture.mkv> [--json]");
|
|
|
|
|
std::process::exit(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut emit_json = false;
|
|
|
|
|
let mut capture_path = None::<std::path::PathBuf>;
|
|
|
|
|
for arg in args {
|
|
|
|
|
if arg == "--json" {
|
|
|
|
|
emit_json = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if capture_path.is_some() {
|
|
|
|
|
bail!("unexpected extra argument `{arg}`");
|
|
|
|
|
}
|
|
|
|
|
capture_path = Some(std::path::PathBuf::from(arg));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let capture_path = capture_path.context("capture path is required")?;
|
|
|
|
|
Ok((capture_path, emit_json))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn main() {}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::parse_args;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_args_accepts_capture_path_and_json_flag() {
|
|
|
|
|
let (path, json) = parse_args(["capture.mkv", "--json"]).expect("args");
|
|
|
|
|
assert_eq!(path, std::path::PathBuf::from("capture.mkv"));
|
|
|
|
|
assert!(json);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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();
|
|
|
|
|
}
|
|
|
|
|
}
|