1use serde::{Deserialize, Serialize};
7
8#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct EditDecisionList {
13 pub video_id: String,
15 pub decisions: Vec<EditDecision>,
17}
18
19impl EditDecisionList {
20 #[must_use]
22 pub fn has_ticks(&self) -> bool {
23 self.decisions.iter().any(|d| !d.ticks.is_empty())
24 }
25
26 #[must_use]
28 pub fn tick_count(&self) -> usize {
29 self.decisions.iter().map(|d| d.ticks.len()).sum()
30 }
31}
32
33#[derive(Clone, Debug, Serialize, Deserialize)]
35pub struct EditDecision {
36 pub segment: String,
38 pub fps: u32,
40 pub sample_rate: u32,
42 pub ticks: Vec<AudioTickPlacement>,
44}
45
46#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct AudioTickPlacement {
49 pub bullet_index: usize,
51 pub visual_land_secs: f64,
53 pub audio_place_secs: f64,
55 pub peak_anticipation_ms: f64,
57 pub perceptual_lead_ms: f64,
59}
60
61#[derive(Clone, Debug)]
63pub struct AudioOnset {
64 pub time_secs: f64,
66 pub energy_db: f64,
68 pub sample_index: usize,
70}
71
72#[derive(Clone, Debug, Serialize)]
74pub struct AvSyncReport {
75 pub video_id: String,
77 pub verdict: SyncVerdict,
79 pub segments: Vec<SegmentSyncResult>,
81 pub total_ticks: usize,
83 pub matched_ticks: usize,
85 pub coverage_pct: f64,
87 pub max_delta_ms: f64,
89 pub mean_delta_ms: f64,
91}
92
93#[derive(Clone, Debug, Serialize)]
95pub struct SegmentSyncResult {
96 pub segment: String,
98 pub ticks: Vec<TickDelta>,
100 pub all_passed: bool,
102}
103
104#[derive(Clone, Debug, Serialize)]
106pub struct TickDelta {
107 pub segment: String,
109 pub bullet_index: usize,
111 pub declared_secs: f64,
113 pub actual_secs: Option<f64>,
115 pub delta_ms: Option<f64>,
117 pub passed: bool,
119}
120
121#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
123pub enum SyncVerdict {
124 Pass,
126 Fail,
128 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}