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;
|
2026-04-30 22:23:29 -03:00
|
|
|
#[cfg(any(not(coverage), test))]
|
|
|
|
|
use std::path::PathBuf;
|
2026-04-25 16:48:20 -03:00
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
use lesavka_client::sync_probe::analyze::{
|
2026-04-30 22:23:29 -03:00
|
|
|
SyncAnalysisOptions, SyncAnalysisReport, SyncAnalysisVerdict, SyncCalibrationRecommendation,
|
|
|
|
|
analyze_capture,
|
2026-04-25 16:48:20 -03:00
|
|
|
};
|
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-30 22:23:29 -03:00
|
|
|
verdict: SyncAnalysisVerdict,
|
2026-04-25 16:48:20 -03:00
|
|
|
}
|
2026-04-24 14:49:57 -03:00
|
|
|
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
fn main() -> Result<()> {
|
2026-04-30 22:23:29 -03:00
|
|
|
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()))?;
|
2026-04-25 16:48:20 -03:00
|
|
|
let calibration = report.calibration_recommendation();
|
2026-04-30 22:23:29 -03:00
|
|
|
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)?;
|
|
|
|
|
}
|
2026-04-24 14:49:57 -03:00
|
|
|
|
2026-04-30 22:23:29 -03:00
|
|
|
if args.emit_json {
|
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 {
|
2026-04-30 22:23:29 -03:00
|
|
|
print!("{human_report}");
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(any(not(coverage), test))]
|
2026-04-30 22:23:29 -03:00
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
|
|
|
struct AnalyzeArgs {
|
|
|
|
|
capture_path: PathBuf,
|
|
|
|
|
emit_json: bool,
|
|
|
|
|
report_dir: Option<PathBuf>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(any(not(coverage), test))]
|
|
|
|
|
fn parse_args<I, S>(args: I) -> Result<AnalyzeArgs>
|
2026-04-24 14:49:57 -03:00
|
|
|
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") {
|
2026-04-30 22:23:29 -03:00
|
|
|
println!("Usage: lesavka-sync-analyze <capture.mkv> [--json] [--report-dir <dir>]");
|
2026-04-24 14:49:57 -03:00
|
|
|
std::process::exit(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut emit_json = false;
|
2026-04-30 22:23:29 -03:00
|
|
|
let mut report_dir = None::<PathBuf>;
|
|
|
|
|
let mut capture_path = None::<PathBuf>;
|
|
|
|
|
let mut iter = args.into_iter();
|
|
|
|
|
while let Some(arg) = iter.next() {
|
2026-04-24 14:49:57 -03:00
|
|
|
if arg == "--json" {
|
|
|
|
|
emit_json = true;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-04-30 22:23:29 -03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-24 14:49:57 -03:00
|
|
|
if capture_path.is_some() {
|
|
|
|
|
bail!("unexpected extra argument `{arg}`");
|
|
|
|
|
}
|
2026-04-30 22:23:29 -03:00
|
|
|
capture_path = Some(PathBuf::from(arg));
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let capture_path = capture_path.context("capture path is required")?;
|
2026-04-30 22:23:29 -03:00
|
|
|
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()))
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(coverage)]
|
|
|
|
|
fn main() {}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::parse_args;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_args_accepts_capture_path_and_json_flag() {
|
2026-04-30 22:23:29 -03:00
|
|
|
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"))
|
|
|
|
|
);
|
2026-04-24 14:49:57 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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();
|
|
|
|
|
}
|
|
|
|
|
}
|