Skip to main content

jugar_probar/av_sync/
types.rs

1//! EDL (Edit Decision List) types and AV sync report structures.
2//!
3//! These types mirror rmedia's EDL output format with `Deserialize` added.
4//! No rmedia dependency -- probar reads the JSON wire format independently.
5
6use serde::{Deserialize, Serialize};
7
8/// Edit decision list for a rendered video.
9///
10/// Contains all audio tick placements organized by segment.
11#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct EditDecisionList {
13    /// Unique identifier for the rendered video
14    pub video_id: String,
15    /// One entry per segment containing audio ticks
16    pub decisions: Vec<EditDecision>,
17}
18
19impl EditDecisionList {
20    /// Returns true if any decision has tick placements.
21    #[must_use]
22    pub fn has_ticks(&self) -> bool {
23        self.decisions.iter().any(|d| !d.ticks.is_empty())
24    }
25
26    /// Total number of ticks across all segments.
27    #[must_use]
28    pub fn tick_count(&self) -> usize {
29        self.decisions.iter().map(|d| d.ticks.len()).sum()
30    }
31}
32
33/// A single segment's edit decisions.
34#[derive(Clone, Debug, Serialize, Deserialize)]
35pub struct EditDecision {
36    /// Segment name (e.g., "P2-key_terms")
37    pub segment: String,
38    /// Frame rate of the rendered video
39    pub fps: u32,
40    /// Audio sample rate (typically 48000 Hz)
41    pub sample_rate: u32,
42    /// Audio tick placements within this segment
43    pub ticks: Vec<AudioTickPlacement>,
44}
45
46/// Placement of an audio tick relative to a visual event.
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct AudioTickPlacement {
49    /// Index of the bullet in the drop sequence
50    pub bullet_index: usize,
51    /// Time (seconds) when the bullet visually lands on screen
52    pub visual_land_secs: f64,
53    /// Time (seconds) when the audio tick should start playing
54    pub audio_place_secs: f64,
55    /// Milliseconds of audio anticipation (currently always 0.0 for drop sounds)
56    pub peak_anticipation_ms: f64,
57    /// Milliseconds of perceptual lead time
58    pub perceptual_lead_ms: f64,
59}
60
61/// Detected audio onset from waveform analysis.
62#[derive(Clone, Debug)]
63pub struct AudioOnset {
64    /// Time in seconds where the onset was detected
65    pub time_secs: f64,
66    /// Energy level in dB at the onset
67    pub energy_db: f64,
68    /// Sample index in the PCM stream
69    pub sample_index: usize,
70}
71
72/// Result of comparing EDL declarations against actual audio.
73#[derive(Clone, Debug, Serialize)]
74pub struct AvSyncReport {
75    /// Video identifier from EDL
76    pub video_id: String,
77    /// Overall verdict
78    pub verdict: SyncVerdict,
79    /// Per-segment results
80    pub segments: Vec<SegmentSyncResult>,
81    /// Total ticks declared in EDL
82    pub total_ticks: usize,
83    /// Number of ticks matched within tolerance
84    pub matched_ticks: usize,
85    /// Percentage of ticks that passed (0.0-100.0)
86    pub coverage_pct: f64,
87    /// Maximum absolute delta in milliseconds
88    pub max_delta_ms: f64,
89    /// Mean absolute delta in milliseconds
90    pub mean_delta_ms: f64,
91}
92
93/// Per-segment sync verification results.
94#[derive(Clone, Debug, Serialize)]
95pub struct SegmentSyncResult {
96    /// Segment name
97    pub segment: String,
98    /// Per-tick deltas
99    pub ticks: Vec<TickDelta>,
100    /// Whether all ticks in this segment passed
101    pub all_passed: bool,
102}
103
104/// Delta between declared and actual tick timing.
105#[derive(Clone, Debug, Serialize)]
106pub struct TickDelta {
107    /// Segment name (for display)
108    pub segment: String,
109    /// Bullet index from EDL
110    pub bullet_index: usize,
111    /// Declared audio placement time (seconds)
112    pub declared_secs: f64,
113    /// Actual detected onset time (seconds), None if no match found
114    pub actual_secs: Option<f64>,
115    /// Delta in milliseconds (actual - declared), None if no match
116    pub delta_ms: Option<f64>,
117    /// Whether this tick passed the tolerance check
118    pub passed: bool,
119}
120
121/// Overall sync verdict.
122#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
123pub enum SyncVerdict {
124    /// All ticks within tolerance
125    Pass,
126    /// One or more ticks exceed tolerance
127    Fail,
128    /// No ticks found in EDL
129    NoTicks,
130}
131
132impl std::fmt::Display for SyncVerdict {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        match self {
135            Self::Pass => write!(f, "PASS"),
136            Self::Fail => write!(f, "FAIL"),
137            Self::NoTicks => write!(f, "NO TICKS"),
138        }
139    }
140}
141
142#[cfg(test)]
143#[allow(clippy::unwrap_used, clippy::expect_used)]
144mod tests {
145    use super::*;
146
147    fn sample_edl() -> EditDecisionList {
148        EditDecisionList {
149            video_id: "demo-bench".to_string(),
150            decisions: vec![
151                EditDecision {
152                    segment: "P2-key_terms".to_string(),
153                    fps: 24,
154                    sample_rate: 48000,
155                    ticks: vec![
156                        AudioTickPlacement {
157                            bullet_index: 0,
158                            visual_land_secs: 1.700,
159                            audio_place_secs: 1.658,
160                            peak_anticipation_ms: 0.0,
161                            perceptual_lead_ms: 41.667,
162                        },
163                        AudioTickPlacement {
164                            bullet_index: 1,
165                            visual_land_secs: 2.400,
166                            audio_place_secs: 2.358,
167                            peak_anticipation_ms: 0.0,
168                            perceptual_lead_ms: 41.667,
169                        },
170                    ],
171                },
172                EditDecision {
173                    segment: "P4-reflection".to_string(),
174                    fps: 24,
175                    sample_rate: 48000,
176                    ticks: vec![],
177                },
178            ],
179        }
180    }
181
182    #[test]
183    fn test_edl_has_ticks() {
184        let edl = sample_edl();
185        assert!(edl.has_ticks());
186    }
187
188    #[test]
189    fn test_edl_no_ticks() {
190        let edl = EditDecisionList {
191            video_id: "empty".to_string(),
192            decisions: vec![EditDecision {
193                segment: "seg".to_string(),
194                fps: 24,
195                sample_rate: 48000,
196                ticks: vec![],
197            }],
198        };
199        assert!(!edl.has_ticks());
200    }
201
202    #[test]
203    fn test_edl_tick_count() {
204        let edl = sample_edl();
205        assert_eq!(edl.tick_count(), 2);
206    }
207
208    #[test]
209    fn test_edl_tick_count_empty() {
210        let edl = EditDecisionList {
211            video_id: "empty".to_string(),
212            decisions: vec![],
213        };
214        assert_eq!(edl.tick_count(), 0);
215    }
216
217    #[test]
218    fn test_edl_json_roundtrip() {
219        let edl = sample_edl();
220        let json = serde_json::to_string_pretty(&edl).unwrap();
221        let parsed: EditDecisionList = serde_json::from_str(&json).unwrap();
222        assert_eq!(parsed.video_id, "demo-bench");
223        assert_eq!(parsed.decisions.len(), 2);
224        assert_eq!(parsed.decisions[0].ticks.len(), 2);
225        assert!((parsed.decisions[0].ticks[0].visual_land_secs - 1.700).abs() < f64::EPSILON);
226    }
227
228    #[test]
229    fn test_edl_deserialize_from_rmedia_format() {
230        let json = r#"{
231            "video_id": "test-video",
232            "decisions": [{
233                "segment": "P2-key_terms",
234                "fps": 24,
235                "sample_rate": 48000,
236                "ticks": [{
237                    "bullet_index": 0,
238                    "visual_land_secs": 1.7,
239                    "audio_place_secs": 1.658,
240                    "peak_anticipation_ms": 0.0,
241                    "perceptual_lead_ms": 41.667
242                }]
243            }]
244        }"#;
245        let edl: EditDecisionList = serde_json::from_str(json).unwrap();
246        assert_eq!(edl.video_id, "test-video");
247        assert_eq!(edl.decisions[0].ticks[0].bullet_index, 0);
248    }
249
250    #[test]
251    fn test_sync_verdict_display() {
252        assert_eq!(SyncVerdict::Pass.to_string(), "PASS");
253        assert_eq!(SyncVerdict::Fail.to_string(), "FAIL");
254        assert_eq!(SyncVerdict::NoTicks.to_string(), "NO TICKS");
255    }
256
257    #[test]
258    fn test_sync_verdict_equality() {
259        assert_eq!(SyncVerdict::Pass, SyncVerdict::Pass);
260        assert_ne!(SyncVerdict::Pass, SyncVerdict::Fail);
261    }
262
263    #[test]
264    fn test_audio_onset_creation() {
265        let onset = AudioOnset {
266            time_secs: 1.5,
267            energy_db: -20.0,
268            sample_index: 72000,
269        };
270        assert!((onset.time_secs - 1.5).abs() < f64::EPSILON);
271        assert_eq!(onset.sample_index, 72000);
272    }
273
274    #[test]
275    fn test_tick_delta_passed() {
276        let delta = TickDelta {
277            segment: "seg".to_string(),
278            bullet_index: 0,
279            declared_secs: 1.7,
280            actual_secs: Some(1.71),
281            delta_ms: Some(10.0),
282            passed: true,
283        };
284        assert!(delta.passed);
285        assert!((delta.delta_ms.unwrap() - 10.0).abs() < f64::EPSILON);
286    }
287
288    #[test]
289    fn test_tick_delta_no_match() {
290        let delta = TickDelta {
291            segment: "seg".to_string(),
292            bullet_index: 0,
293            declared_secs: 1.7,
294            actual_secs: None,
295            delta_ms: None,
296            passed: false,
297        };
298        assert!(!delta.passed);
299        assert!(delta.actual_secs.is_none());
300    }
301
302    #[test]
303    fn test_av_sync_report_serialization() {
304        let report = AvSyncReport {
305            video_id: "test".to_string(),
306            verdict: SyncVerdict::Pass,
307            segments: vec![],
308            total_ticks: 3,
309            matched_ticks: 3,
310            coverage_pct: 100.0,
311            max_delta_ms: 5.0,
312            mean_delta_ms: 3.0,
313        };
314        let json = serde_json::to_string(&report).unwrap();
315        assert!(json.contains("\"verdict\":\"Pass\""));
316        assert!(json.contains("\"total_ticks\":3"));
317    }
318
319    #[test]
320    fn test_segment_sync_result() {
321        let result = SegmentSyncResult {
322            segment: "P2-key_terms".to_string(),
323            ticks: vec![],
324            all_passed: true,
325        };
326        assert!(result.all_passed);
327        assert_eq!(result.segment, "P2-key_terms");
328    }
329
330    #[test]
331    fn test_edit_decision_clone() {
332        let decision = EditDecision {
333            segment: "test".to_string(),
334            fps: 24,
335            sample_rate: 48000,
336            ticks: vec![],
337        };
338        let cloned = decision;
339        assert_eq!(cloned.segment, "test");
340        assert_eq!(cloned.fps, 24);
341    }
342
343    #[test]
344    fn test_audio_tick_placement_clone() {
345        let tick = AudioTickPlacement {
346            bullet_index: 0,
347            visual_land_secs: 1.7,
348            audio_place_secs: 1.658,
349            peak_anticipation_ms: 0.0,
350            perceptual_lead_ms: 41.667,
351        };
352        let cloned = tick;
353        assert_eq!(cloned.bullet_index, 0);
354        assert!((cloned.perceptual_lead_ms - 41.667).abs() < f64::EPSILON);
355    }
356}