use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EditDecisionList {
pub video_id: String,
pub decisions: Vec<EditDecision>,
}
impl EditDecisionList {
#[must_use]
pub fn has_ticks(&self) -> bool {
self.decisions.iter().any(|d| !d.ticks.is_empty())
}
#[must_use]
pub fn tick_count(&self) -> usize {
self.decisions.iter().map(|d| d.ticks.len()).sum()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EditDecision {
pub segment: String,
pub fps: u32,
pub sample_rate: u32,
pub ticks: Vec<AudioTickPlacement>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AudioTickPlacement {
pub bullet_index: usize,
pub visual_land_secs: f64,
pub audio_place_secs: f64,
pub peak_anticipation_ms: f64,
pub perceptual_lead_ms: f64,
}
#[derive(Clone, Debug)]
pub struct AudioOnset {
pub time_secs: f64,
pub energy_db: f64,
pub sample_index: usize,
}
#[derive(Clone, Debug, Serialize)]
pub struct AvSyncReport {
pub video_id: String,
pub verdict: SyncVerdict,
pub segments: Vec<SegmentSyncResult>,
pub total_ticks: usize,
pub matched_ticks: usize,
pub coverage_pct: f64,
pub max_delta_ms: f64,
pub mean_delta_ms: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct SegmentSyncResult {
pub segment: String,
pub ticks: Vec<TickDelta>,
pub all_passed: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct TickDelta {
pub segment: String,
pub bullet_index: usize,
pub declared_secs: f64,
pub actual_secs: Option<f64>,
pub delta_ms: Option<f64>,
pub passed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub enum SyncVerdict {
Pass,
Fail,
NoTicks,
}
impl std::fmt::Display for SyncVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pass => write!(f, "PASS"),
Self::Fail => write!(f, "FAIL"),
Self::NoTicks => write!(f, "NO TICKS"),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn sample_edl() -> EditDecisionList {
EditDecisionList {
video_id: "demo-bench".to_string(),
decisions: vec![
EditDecision {
segment: "P2-key_terms".to_string(),
fps: 24,
sample_rate: 48000,
ticks: vec![
AudioTickPlacement {
bullet_index: 0,
visual_land_secs: 1.700,
audio_place_secs: 1.658,
peak_anticipation_ms: 0.0,
perceptual_lead_ms: 41.667,
},
AudioTickPlacement {
bullet_index: 1,
visual_land_secs: 2.400,
audio_place_secs: 2.358,
peak_anticipation_ms: 0.0,
perceptual_lead_ms: 41.667,
},
],
},
EditDecision {
segment: "P4-reflection".to_string(),
fps: 24,
sample_rate: 48000,
ticks: vec![],
},
],
}
}
#[test]
fn test_edl_has_ticks() {
let edl = sample_edl();
assert!(edl.has_ticks());
}
#[test]
fn test_edl_no_ticks() {
let edl = EditDecisionList {
video_id: "empty".to_string(),
decisions: vec![EditDecision {
segment: "seg".to_string(),
fps: 24,
sample_rate: 48000,
ticks: vec![],
}],
};
assert!(!edl.has_ticks());
}
#[test]
fn test_edl_tick_count() {
let edl = sample_edl();
assert_eq!(edl.tick_count(), 2);
}
#[test]
fn test_edl_tick_count_empty() {
let edl = EditDecisionList {
video_id: "empty".to_string(),
decisions: vec![],
};
assert_eq!(edl.tick_count(), 0);
}
#[test]
fn test_edl_json_roundtrip() {
let edl = sample_edl();
let json = serde_json::to_string_pretty(&edl).unwrap();
let parsed: EditDecisionList = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.video_id, "demo-bench");
assert_eq!(parsed.decisions.len(), 2);
assert_eq!(parsed.decisions[0].ticks.len(), 2);
assert!((parsed.decisions[0].ticks[0].visual_land_secs - 1.700).abs() < f64::EPSILON);
}
#[test]
fn test_edl_deserialize_from_rmedia_format() {
let json = r#"{
"video_id": "test-video",
"decisions": [{
"segment": "P2-key_terms",
"fps": 24,
"sample_rate": 48000,
"ticks": [{
"bullet_index": 0,
"visual_land_secs": 1.7,
"audio_place_secs": 1.658,
"peak_anticipation_ms": 0.0,
"perceptual_lead_ms": 41.667
}]
}]
}"#;
let edl: EditDecisionList = serde_json::from_str(json).unwrap();
assert_eq!(edl.video_id, "test-video");
assert_eq!(edl.decisions[0].ticks[0].bullet_index, 0);
}
#[test]
fn test_sync_verdict_display() {
assert_eq!(SyncVerdict::Pass.to_string(), "PASS");
assert_eq!(SyncVerdict::Fail.to_string(), "FAIL");
assert_eq!(SyncVerdict::NoTicks.to_string(), "NO TICKS");
}
#[test]
fn test_sync_verdict_equality() {
assert_eq!(SyncVerdict::Pass, SyncVerdict::Pass);
assert_ne!(SyncVerdict::Pass, SyncVerdict::Fail);
}
#[test]
fn test_audio_onset_creation() {
let onset = AudioOnset {
time_secs: 1.5,
energy_db: -20.0,
sample_index: 72000,
};
assert!((onset.time_secs - 1.5).abs() < f64::EPSILON);
assert_eq!(onset.sample_index, 72000);
}
#[test]
fn test_tick_delta_passed() {
let delta = TickDelta {
segment: "seg".to_string(),
bullet_index: 0,
declared_secs: 1.7,
actual_secs: Some(1.71),
delta_ms: Some(10.0),
passed: true,
};
assert!(delta.passed);
assert!((delta.delta_ms.unwrap() - 10.0).abs() < f64::EPSILON);
}
#[test]
fn test_tick_delta_no_match() {
let delta = TickDelta {
segment: "seg".to_string(),
bullet_index: 0,
declared_secs: 1.7,
actual_secs: None,
delta_ms: None,
passed: false,
};
assert!(!delta.passed);
assert!(delta.actual_secs.is_none());
}
#[test]
fn test_av_sync_report_serialization() {
let report = AvSyncReport {
video_id: "test".to_string(),
verdict: SyncVerdict::Pass,
segments: vec![],
total_ticks: 3,
matched_ticks: 3,
coverage_pct: 100.0,
max_delta_ms: 5.0,
mean_delta_ms: 3.0,
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"verdict\":\"Pass\""));
assert!(json.contains("\"total_ticks\":3"));
}
#[test]
fn test_segment_sync_result() {
let result = SegmentSyncResult {
segment: "P2-key_terms".to_string(),
ticks: vec![],
all_passed: true,
};
assert!(result.all_passed);
assert_eq!(result.segment, "P2-key_terms");
}
#[test]
fn test_edit_decision_clone() {
let decision = EditDecision {
segment: "test".to_string(),
fps: 24,
sample_rate: 48000,
ticks: vec![],
};
let cloned = decision;
assert_eq!(cloned.segment, "test");
assert_eq!(cloned.fps, 24);
}
#[test]
fn test_audio_tick_placement_clone() {
let tick = AudioTickPlacement {
bullet_index: 0,
visual_land_secs: 1.7,
audio_place_secs: 1.658,
peak_anticipation_ms: 0.0,
perceptual_lead_ms: 41.667,
};
let cloned = tick;
assert_eq!(cloned.bullet_index, 0);
assert!((cloned.perceptual_lead_ms - 41.667).abs() < f64::EPSILON);
}
}