Skip to main content

oximedia_edit/
multicam.rs

1//! Multi-camera editing support with sync point alignment.
2//!
3//! Enables editing footage from multiple camera angles that were
4//! recording the same event. Cameras are synchronized via sync points
5//! (timecode, audio waveform, or manual markers), allowing the editor
6//! to switch between angles on a single output track.
7
8#![allow(dead_code)]
9
10use std::collections::HashMap;
11
12use crate::clip::ClipId;
13use crate::error::{EditError, EditResult};
14
15/// Unique identifier for a camera angle.
16pub type AngleId = u64;
17
18/// Unique identifier for a multi-cam clip group.
19pub type MultiCamId = u64;
20
21/// Method used to synchronize camera angles.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SyncMethod {
24    /// Synchronize by matching embedded timecode.
25    Timecode,
26    /// Synchronize by audio waveform cross-correlation.
27    AudioWaveform,
28    /// Synchronize by a manually placed marker at a known event.
29    ManualMarker,
30    /// Synchronize by a common start point (slate clap, flash, etc.).
31    CommonStart,
32}
33
34impl SyncMethod {
35    /// Returns a human-readable label for this sync method.
36    #[must_use]
37    pub fn label(self) -> &'static str {
38        match self {
39            Self::Timecode => "Timecode",
40            Self::AudioWaveform => "Audio Waveform",
41            Self::ManualMarker => "Manual Marker",
42            Self::CommonStart => "Common Start",
43        }
44    }
45}
46
47/// A sync point that aligns a camera angle to the master timeline.
48///
49/// The `source_offset` is the position in the camera's source media
50/// (in timebase units) that corresponds to `timeline_position` on the
51/// master timeline.
52#[derive(Debug, Clone, PartialEq)]
53pub struct SyncPoint {
54    /// Position on the master timeline (timebase units).
55    pub timeline_position: i64,
56    /// Corresponding position in the camera source (timebase units).
57    pub source_offset: i64,
58    /// Confidence score for automatic sync (0.0 to 1.0).
59    pub confidence: f64,
60    /// Sync method that produced this point.
61    pub method: SyncMethod,
62}
63
64impl SyncPoint {
65    /// Create a new sync point.
66    #[must_use]
67    pub fn new(
68        timeline_position: i64,
69        source_offset: i64,
70        confidence: f64,
71        method: SyncMethod,
72    ) -> Self {
73        Self {
74            timeline_position,
75            source_offset,
76            confidence: confidence.clamp(0.0, 1.0),
77            method,
78        }
79    }
80
81    /// Compute the time delta between timeline and source.
82    #[must_use]
83    pub fn offset_delta(&self) -> i64 {
84        self.timeline_position - self.source_offset
85    }
86
87    /// Returns `true` when the confidence exceeds a threshold.
88    #[must_use]
89    pub fn is_confident(&self, threshold: f64) -> bool {
90        self.confidence >= threshold
91    }
92}
93
94/// A single camera angle within a multi-cam group.
95#[derive(Debug, Clone)]
96pub struct CameraAngle {
97    /// Unique angle identifier.
98    pub id: AngleId,
99    /// Human-readable label (e.g. "Camera A", "Wide Shot").
100    pub label: String,
101    /// Clip IDs that belong to this angle (video + audio).
102    pub clips: Vec<ClipId>,
103    /// Sync points for this angle.
104    pub sync_points: Vec<SyncPoint>,
105    /// Whether this angle is currently active (selected for output).
106    pub active: bool,
107    /// The computed offset to align this angle to the master timeline.
108    /// Positive means the source starts *after* the master zero point.
109    pub alignment_offset: i64,
110}
111
112impl CameraAngle {
113    /// Create a new camera angle.
114    #[must_use]
115    pub fn new(id: AngleId, label: String) -> Self {
116        Self {
117            id,
118            label,
119            clips: Vec::new(),
120            sync_points: Vec::new(),
121            active: false,
122            alignment_offset: 0,
123        }
124    }
125
126    /// Add a clip to this angle.
127    pub fn add_clip(&mut self, clip_id: ClipId) {
128        if !self.clips.contains(&clip_id) {
129            self.clips.push(clip_id);
130        }
131    }
132
133    /// Remove a clip from this angle.
134    pub fn remove_clip(&mut self, clip_id: ClipId) -> bool {
135        if let Some(pos) = self.clips.iter().position(|&id| id == clip_id) {
136            self.clips.remove(pos);
137            true
138        } else {
139            false
140        }
141    }
142
143    /// Add a sync point.
144    pub fn add_sync_point(&mut self, point: SyncPoint) {
145        self.sync_points.push(point);
146    }
147
148    /// Compute alignment offset from sync points.
149    ///
150    /// Uses a weighted average of sync point deltas, weighted by confidence.
151    /// Returns `None` if there are no sync points.
152    #[must_use]
153    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
154    pub fn compute_alignment(&self) -> Option<i64> {
155        if self.sync_points.is_empty() {
156            return None;
157        }
158        let total_weight: f64 = self.sync_points.iter().map(|sp| sp.confidence).sum();
159        if total_weight < 1e-12 {
160            // All zero-confidence: fall back to simple average
161            let sum: i64 = self.sync_points.iter().map(SyncPoint::offset_delta).sum();
162            return Some(sum / self.sync_points.len() as i64);
163        }
164        let weighted_sum: f64 = self
165            .sync_points
166            .iter()
167            .map(|sp| sp.offset_delta() as f64 * sp.confidence)
168            .sum();
169        Some((weighted_sum / total_weight).round() as i64)
170    }
171}
172
173/// A switch point on the output track indicating when to cut to a
174/// different camera angle.
175#[derive(Debug, Clone, PartialEq)]
176pub struct AngleSwitch {
177    /// Timeline position where the switch occurs.
178    pub position: i64,
179    /// The angle to switch to.
180    pub angle_id: AngleId,
181}
182
183impl AngleSwitch {
184    /// Create a new angle switch.
185    #[must_use]
186    pub fn new(position: i64, angle_id: AngleId) -> Self {
187        Self { position, angle_id }
188    }
189}
190
191/// A multi-camera editing group.
192///
193/// Contains multiple [`CameraAngle`]s that cover the same event, plus
194/// a list of [`AngleSwitch`]es that define which angle is on-screen at
195/// each point in the output timeline.
196#[derive(Debug, Clone)]
197pub struct MultiCamGroup {
198    /// Unique multi-cam ID.
199    pub id: MultiCamId,
200    /// Human-readable name.
201    pub name: String,
202    /// Camera angles in this group.
203    pub angles: Vec<CameraAngle>,
204    /// Switch list (sorted by position).
205    pub switches: Vec<AngleSwitch>,
206    /// Next angle ID counter.
207    next_angle_id: AngleId,
208}
209
210impl MultiCamGroup {
211    /// Create a new multi-cam group.
212    #[must_use]
213    pub fn new(id: MultiCamId, name: String) -> Self {
214        Self {
215            id,
216            name,
217            angles: Vec::new(),
218            switches: Vec::new(),
219            next_angle_id: 1,
220        }
221    }
222
223    /// Add a new camera angle and return its ID.
224    pub fn add_angle(&mut self, label: String) -> AngleId {
225        let id = self.next_angle_id;
226        self.next_angle_id += 1;
227        self.angles.push(CameraAngle::new(id, label));
228        id
229    }
230
231    /// Remove a camera angle by ID.
232    pub fn remove_angle(&mut self, angle_id: AngleId) -> Option<CameraAngle> {
233        if let Some(pos) = self.angles.iter().position(|a| a.id == angle_id) {
234            let angle = self.angles.remove(pos);
235            // Remove switches that reference this angle
236            self.switches.retain(|s| s.angle_id != angle_id);
237            Some(angle)
238        } else {
239            None
240        }
241    }
242
243    /// Get an angle by ID.
244    #[must_use]
245    pub fn get_angle(&self, angle_id: AngleId) -> Option<&CameraAngle> {
246        self.angles.iter().find(|a| a.id == angle_id)
247    }
248
249    /// Get a mutable angle by ID.
250    pub fn get_angle_mut(&mut self, angle_id: AngleId) -> Option<&mut CameraAngle> {
251        self.angles.iter_mut().find(|a| a.id == angle_id)
252    }
253
254    /// Add a switch point (cut to a different angle).
255    pub fn add_switch(&mut self, position: i64, angle_id: AngleId) -> EditResult<()> {
256        if !self.angles.iter().any(|a| a.id == angle_id) {
257            return Err(EditError::InvalidEdit(format!(
258                "Angle {angle_id} not found in multi-cam group"
259            )));
260        }
261        self.switches.push(AngleSwitch::new(position, angle_id));
262        self.switches.sort_by_key(|s| s.position);
263        Ok(())
264    }
265
266    /// Remove the switch at a given position.
267    pub fn remove_switch_at(&mut self, position: i64) -> Option<AngleSwitch> {
268        if let Some(pos) = self.switches.iter().position(|s| s.position == position) {
269            Some(self.switches.remove(pos))
270        } else {
271            None
272        }
273    }
274
275    /// Get the active angle at a given timeline position.
276    ///
277    /// Finds the most recent switch at or before `position`.
278    #[must_use]
279    pub fn active_angle_at(&self, position: i64) -> Option<AngleId> {
280        self.switches
281            .iter()
282            .rev()
283            .find(|s| s.position <= position)
284            .map(|s| s.angle_id)
285    }
286
287    /// Synchronize all angles by computing their alignment offsets.
288    pub fn sync_all_angles(&mut self) {
289        for angle in &mut self.angles {
290            if let Some(offset) = angle.compute_alignment() {
291                angle.alignment_offset = offset;
292            }
293        }
294    }
295
296    /// Get the total number of angles.
297    #[must_use]
298    pub fn angle_count(&self) -> usize {
299        self.angles.len()
300    }
301
302    /// Get the total number of switches.
303    #[must_use]
304    pub fn switch_count(&self) -> usize {
305        self.switches.len()
306    }
307}
308
309/// Manager for all multi-cam groups in a project.
310#[derive(Debug, Default)]
311pub struct MultiCamManager {
312    /// All multi-cam groups.
313    groups: HashMap<MultiCamId, MultiCamGroup>,
314    /// Next group ID.
315    next_id: MultiCamId,
316}
317
318impl MultiCamManager {
319    /// Create a new manager.
320    #[must_use]
321    pub fn new() -> Self {
322        Self {
323            groups: HashMap::new(),
324            next_id: 1,
325        }
326    }
327
328    /// Create a new multi-cam group.
329    pub fn create_group(&mut self, name: String) -> MultiCamId {
330        let id = self.next_id;
331        self.next_id += 1;
332        self.groups.insert(id, MultiCamGroup::new(id, name));
333        id
334    }
335
336    /// Delete a multi-cam group.
337    pub fn delete_group(&mut self, id: MultiCamId) -> Option<MultiCamGroup> {
338        self.groups.remove(&id)
339    }
340
341    /// Get a group by ID.
342    #[must_use]
343    pub fn get_group(&self, id: MultiCamId) -> Option<&MultiCamGroup> {
344        self.groups.get(&id)
345    }
346
347    /// Get a mutable group by ID.
348    pub fn get_group_mut(&mut self, id: MultiCamId) -> Option<&mut MultiCamGroup> {
349        self.groups.get_mut(&id)
350    }
351
352    /// Get all groups.
353    #[must_use]
354    pub fn all_groups(&self) -> Vec<&MultiCamGroup> {
355        self.groups.values().collect()
356    }
357
358    /// Clear all groups.
359    pub fn clear(&mut self) {
360        self.groups.clear();
361    }
362
363    /// Get total group count.
364    #[must_use]
365    pub fn len(&self) -> usize {
366        self.groups.len()
367    }
368
369    /// Check if there are no groups.
370    #[must_use]
371    pub fn is_empty(&self) -> bool {
372        self.groups.is_empty()
373    }
374}
375
376// ─────────────────────────────────────────────────────────────────────────────
377// MultiCamSession / MultiCamEditor API
378// ─────────────────────────────────────────────────────────────────────────────
379
380/// A lightweight reference to a clip within a camera track,
381/// expressed as a millisecond-based time range.
382#[derive(Debug, Clone, PartialEq)]
383pub struct ClipRef {
384    /// The underlying clip identifier.
385    pub clip_id: ClipId,
386    /// Start of the clip reference on the camera timeline, in milliseconds.
387    pub start_ms: i64,
388    /// Duration of this reference, in milliseconds.
389    pub duration_ms: i64,
390}
391
392impl ClipRef {
393    /// Create a new clip reference.
394    #[must_use]
395    pub fn new(clip_id: ClipId, start_ms: i64, duration_ms: i64) -> Self {
396        Self {
397            clip_id,
398            start_ms,
399            duration_ms,
400        }
401    }
402
403    /// Inclusive end position of this reference (start_ms + duration_ms).
404    #[must_use]
405    pub fn end_ms(&self) -> i64 {
406        self.start_ms + self.duration_ms
407    }
408}
409
410/// A single camera track holding an ordered list of [`ClipRef`]s and a
411/// source timecode anchor.
412#[derive(Debug, Clone)]
413pub struct CameraTrack {
414    /// Human-readable or machine-generated camera identifier.
415    pub id: String,
416    /// Ordered list of clip references on this track.
417    pub clips: Vec<ClipRef>,
418    /// The timeline start position of this camera, in milliseconds.
419    /// All clip start_ms values are relative to this anchor.
420    pub timecode_start: i64,
421}
422
423impl CameraTrack {
424    /// Create a new empty camera track.
425    #[must_use]
426    pub fn new(id: impl Into<String>, timecode_start: i64) -> Self {
427        Self {
428            id: id.into(),
429            clips: Vec::new(),
430            timecode_start,
431        }
432    }
433
434    /// Append a clip reference to the track.
435    pub fn add_clip(&mut self, clip: ClipRef) {
436        self.clips.push(clip);
437    }
438
439    /// Return the clip whose time range contains `ms` (timeline-relative).
440    ///
441    /// The lookup accounts for the track's `timecode_start` offset so that
442    /// `ms` should be given in the same coordinate system as `timecode_start`.
443    #[must_use]
444    pub fn clip_at_ms(&self, ms: i64) -> Option<&ClipRef> {
445        let local_ms = ms - self.timecode_start;
446        self.clips
447            .iter()
448            .find(|c| local_ms >= c.start_ms && local_ms < c.start_ms + c.duration_ms)
449    }
450
451    /// Total number of clips on this track.
452    #[must_use]
453    pub fn clip_count(&self) -> usize {
454        self.clips.len()
455    }
456}
457
458/// A cut from one camera to another at a specific timeline position.
459#[derive(Debug, Clone, PartialEq)]
460pub struct MultiCamCut {
461    /// Position at which the cut occurs, in milliseconds.
462    pub timestamp_ms: u64,
463    /// Index (into `MultiCamSession::cameras`) of the outgoing camera.
464    pub from_camera: usize,
465    /// Index (into `MultiCamSession::cameras`) of the incoming camera.
466    pub to_camera: usize,
467}
468
469impl MultiCamCut {
470    /// Create a new multi-cam cut.
471    #[must_use]
472    pub fn new(timestamp_ms: u64, from_camera: usize, to_camera: usize) -> Self {
473        Self {
474            timestamp_ms,
475            from_camera,
476            to_camera,
477        }
478    }
479}
480
481/// A multi-camera editing session that groups camera tracks and their sync offsets.
482#[derive(Debug, Clone, Default)]
483pub struct MultiCamSession {
484    /// All camera tracks in this session, in order.
485    pub cameras: Vec<CameraTrack>,
486    /// Per-camera sync offset in milliseconds (positive = camera starts later).
487    /// The vector is grown automatically when cameras are added.
488    pub sync_offset_ms: Vec<i64>,
489}
490
491impl MultiCamSession {
492    /// Create an empty session.
493    #[must_use]
494    pub fn new() -> Self {
495        Self::default()
496    }
497
498    /// Add a camera track to the session. A zero sync offset is appended.
499    pub fn add_camera(&mut self, track: CameraTrack) {
500        self.cameras.push(track);
501        self.sync_offset_ms.push(0);
502    }
503
504    /// Override the sync offset for `camera_idx`.
505    ///
506    /// # Errors
507    ///
508    /// Returns [`EditError::InvalidEdit`] when `camera_idx` is out of range.
509    pub fn set_sync_offset(&mut self, camera_idx: usize, offset_ms: i64) -> EditResult<()> {
510        if camera_idx >= self.cameras.len() {
511            return Err(EditError::InvalidEdit(format!(
512                "Camera index {camera_idx} out of range (session has {} cameras)",
513                self.cameras.len()
514            )));
515        }
516        self.sync_offset_ms[camera_idx] = offset_ms;
517        Ok(())
518    }
519
520    /// Number of cameras in this session.
521    #[must_use]
522    pub fn camera_count(&self) -> usize {
523        self.cameras.len()
524    }
525
526    /// Return the sync-adjusted timeline position for a given camera at absolute
527    /// position `abs_ms`.  The result is the position within that camera's own
528    /// coordinate system after accounting for its sync offset.
529    #[must_use]
530    pub fn camera_local_ms(&self, camera_idx: usize, abs_ms: i64) -> i64 {
531        let offset = self.sync_offset_ms.get(camera_idx).copied().unwrap_or(0);
532        abs_ms - offset
533    }
534}
535
536/// High-level multi-camera editing controller.
537///
538/// Manages a [`MultiCamSession`] together with a sorted list of
539/// [`MultiCamCut`]s and provides timeline generation.
540#[derive(Debug)]
541pub struct MultiCamEditor {
542    /// The underlying session (cameras + sync offsets).
543    pub session: MultiCamSession,
544    /// Sorted list of cuts.
545    pub cuts: Vec<MultiCamCut>,
546}
547
548impl MultiCamEditor {
549    /// Create a new editor wrapping the given session.
550    #[must_use]
551    pub fn new(session: MultiCamSession) -> Self {
552        Self {
553            session,
554            cuts: Vec::new(),
555        }
556    }
557
558    /// Add a cut, keeping the cut list sorted by `timestamp_ms`.
559    ///
560    /// # Errors
561    ///
562    /// Returns [`EditError::InvalidEdit`] if either `from_camera` or `to_camera`
563    /// is not a valid camera index in the underlying session.
564    pub fn add_cut(&mut self, cut: MultiCamCut) -> EditResult<()> {
565        let n = self.session.camera_count();
566        if cut.from_camera >= n {
567            return Err(EditError::InvalidEdit(format!(
568                "from_camera {} is out of range (session has {n} cameras)",
569                cut.from_camera
570            )));
571        }
572        if cut.to_camera >= n {
573            return Err(EditError::InvalidEdit(format!(
574                "to_camera {} is out of range (session has {n} cameras)",
575                cut.to_camera
576            )));
577        }
578        self.cuts.push(cut);
579        self.cuts.sort_by_key(|c| c.timestamp_ms);
580        Ok(())
581    }
582
583    /// Remove the first cut whose `timestamp_ms` equals `timestamp_ms`.
584    pub fn remove_cut_at(&mut self, timestamp_ms: u64) -> Option<MultiCamCut> {
585        if let Some(pos) = self
586            .cuts
587            .iter()
588            .position(|c| c.timestamp_ms == timestamp_ms)
589        {
590            Some(self.cuts.remove(pos))
591        } else {
592            None
593        }
594    }
595
596    /// Number of cuts currently registered.
597    #[must_use]
598    pub fn cut_count(&self) -> usize {
599        self.cuts.len()
600    }
601
602    /// Generate a flat timeline as a sequence of [`ClipRef`]s.
603    ///
604    /// For each pair of consecutive cuts (or the single interval defined by the
605    /// only cut), the active camera at the *midpoint* of that interval is queried.
606    /// The resulting [`ClipRef`] spans the full interval.
607    ///
608    /// If there are no cuts, an empty vec is returned.
609    #[must_use]
610    pub fn generate_timeline(&self) -> Vec<ClipRef> {
611        if self.cuts.is_empty() {
612            return Vec::new();
613        }
614
615        let mut result = Vec::with_capacity(self.cuts.len());
616
617        for (i, cut) in self.cuts.iter().enumerate() {
618            let interval_start_ms = cut.timestamp_ms as i64;
619            let interval_end_ms = self
620                .cuts
621                .get(i + 1)
622                .map_or(interval_start_ms + 5000, |next| next.timestamp_ms as i64);
623
624            let midpoint_ms = (interval_start_ms + interval_end_ms) / 2;
625            let cam_idx = cut.to_camera;
626
627            // Apply sync offset for this camera.
628            let local_ms = self.session.camera_local_ms(cam_idx, midpoint_ms);
629
630            if let Some(cam) = self.session.cameras.get(cam_idx) {
631                // Attempt to find the clip covering local_ms (track-relative).
632                let clip_ref = cam.clip_at_ms(local_ms + cam.timecode_start);
633                let duration_ms = interval_end_ms - interval_start_ms;
634
635                if let Some(cr) = clip_ref {
636                    result.push(ClipRef::new(cr.clip_id, interval_start_ms, duration_ms));
637                } else if let Some(first_clip) = cam.clips.first() {
638                    // Fallback: use the first clip of the camera track.
639                    result.push(ClipRef::new(
640                        first_clip.clip_id,
641                        interval_start_ms,
642                        duration_ms,
643                    ));
644                }
645                // If the camera has no clips, we skip this interval.
646            }
647        }
648
649        result
650    }
651}
652
653/// Compute the cross-correlation offset (in samples) between two audio
654/// fingerprint arrays.
655///
656/// The function slides `camera_b` over `camera_a` in the lag range
657/// `[-max_lag, +max_lag]` where `max_lag = len / 4` (at least 1), and
658/// returns the lag that maximises the dot-product.
659///
660/// A positive returned lag means `camera_b` is shifted forward relative to
661/// `camera_a` by that many samples; a negative lag means it starts earlier.
662///
663/// Returns `0` when either slice is empty.
664#[must_use]
665pub fn sync_by_audio_fingerprint(camera_a: &[f32], camera_b: &[f32]) -> i64 {
666    if camera_a.is_empty() || camera_b.is_empty() {
667        return 0;
668    }
669
670    let len_a = camera_a.len();
671    let len_b = camera_b.len();
672    let window = len_a.min(len_b);
673    let max_lag = (window / 4).max(1) as i64;
674
675    let mut best_lag: i64 = 0;
676    let mut best_corr = f64::NEG_INFINITY;
677
678    for lag in -max_lag..=max_lag {
679        let mut dot: f64 = 0.0;
680        for j in 0..window {
681            let b_idx = j as i64 + lag;
682            if b_idx >= 0 && (b_idx as usize) < len_b {
683                dot += f64::from(camera_a[j]) * f64::from(camera_b[b_idx as usize]);
684            }
685        }
686        if dot > best_corr {
687            best_corr = dot;
688            best_lag = lag;
689        }
690    }
691
692    best_lag
693}
694
695// ─────────────────────────────────────────────────────────────────────────────
696// Tests
697// ─────────────────────────────────────────────────────────────────────────────
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn test_sync_method_label() {
705        assert_eq!(SyncMethod::Timecode.label(), "Timecode");
706        assert_eq!(SyncMethod::AudioWaveform.label(), "Audio Waveform");
707        assert_eq!(SyncMethod::ManualMarker.label(), "Manual Marker");
708        assert_eq!(SyncMethod::CommonStart.label(), "Common Start");
709    }
710
711    #[test]
712    fn test_sync_point_creation() {
713        let sp = SyncPoint::new(1000, 500, 0.95, SyncMethod::Timecode);
714        assert_eq!(sp.timeline_position, 1000);
715        assert_eq!(sp.source_offset, 500);
716        assert!((sp.confidence - 0.95).abs() < 1e-9);
717        assert_eq!(sp.offset_delta(), 500);
718    }
719
720    #[test]
721    fn test_sync_point_confidence_clamped() {
722        let sp = SyncPoint::new(0, 0, 1.5, SyncMethod::Timecode);
723        assert!((sp.confidence - 1.0).abs() < 1e-9);
724        let sp2 = SyncPoint::new(0, 0, -0.5, SyncMethod::Timecode);
725        assert!((sp2.confidence).abs() < 1e-9);
726    }
727
728    #[test]
729    fn test_sync_point_is_confident() {
730        let sp = SyncPoint::new(0, 0, 0.8, SyncMethod::Timecode);
731        assert!(sp.is_confident(0.5));
732        assert!(sp.is_confident(0.8));
733        assert!(!sp.is_confident(0.9));
734    }
735
736    #[test]
737    fn test_camera_angle_clips() {
738        let mut angle = CameraAngle::new(1, "Camera A".to_string());
739        angle.add_clip(10);
740        angle.add_clip(20);
741        angle.add_clip(10); // duplicate, should not add
742        assert_eq!(angle.clips.len(), 2);
743        assert!(angle.remove_clip(10));
744        assert!(!angle.remove_clip(999));
745        assert_eq!(angle.clips.len(), 1);
746    }
747
748    #[test]
749    fn test_camera_angle_compute_alignment() {
750        let mut angle = CameraAngle::new(1, "A".to_string());
751        assert!(angle.compute_alignment().is_none());
752
753        angle.add_sync_point(SyncPoint::new(1000, 500, 1.0, SyncMethod::Timecode));
754        angle.add_sync_point(SyncPoint::new(2000, 1500, 1.0, SyncMethod::Timecode));
755        // Both deltas are 500, so alignment should be 500
756        let offset = angle.compute_alignment();
757        assert_eq!(offset, Some(500));
758    }
759
760    #[test]
761    fn test_camera_angle_compute_alignment_weighted() {
762        let mut angle = CameraAngle::new(1, "A".to_string());
763        // delta=100 at high confidence, delta=200 at low confidence
764        angle.add_sync_point(SyncPoint::new(100, 0, 0.9, SyncMethod::Timecode));
765        angle.add_sync_point(SyncPoint::new(200, 0, 0.1, SyncMethod::AudioWaveform));
766        let offset = angle.compute_alignment();
767        // weighted = (100*0.9 + 200*0.1) / (0.9+0.1) = (90+20)/1.0 = 110
768        assert_eq!(offset, Some(110));
769    }
770
771    #[test]
772    fn test_multicam_group_add_remove_angle() {
773        let mut group = MultiCamGroup::new(1, "Test".to_string());
774        let a1 = group.add_angle("Camera A".to_string());
775        let a2 = group.add_angle("Camera B".to_string());
776        assert_eq!(group.angle_count(), 2);
777
778        assert!(group.get_angle(a1).is_some());
779        assert!(group.remove_angle(a2).is_some());
780        assert_eq!(group.angle_count(), 1);
781        assert!(group.remove_angle(999).is_none());
782    }
783
784    #[test]
785    fn test_multicam_group_switches() {
786        let mut group = MultiCamGroup::new(1, "Test".to_string());
787        let a1 = group.add_angle("Camera A".to_string());
788        let a2 = group.add_angle("Camera B".to_string());
789
790        assert!(group.add_switch(0, a1).is_ok());
791        assert!(group.add_switch(5000, a2).is_ok());
792        assert!(group.add_switch(10000, a1).is_ok());
793        assert_eq!(group.switch_count(), 3);
794
795        // Invalid angle
796        assert!(group.add_switch(15000, 999).is_err());
797
798        // Active angle lookup
799        assert_eq!(group.active_angle_at(2500), Some(a1));
800        assert_eq!(group.active_angle_at(5000), Some(a2));
801        assert_eq!(group.active_angle_at(7500), Some(a2));
802        assert_eq!(group.active_angle_at(10000), Some(a1));
803        assert!(group.active_angle_at(-100).is_none());
804    }
805
806    #[test]
807    fn test_multicam_group_remove_switch() {
808        let mut group = MultiCamGroup::new(1, "Test".to_string());
809        let a1 = group.add_angle("A".to_string());
810        let _ = group.add_switch(0, a1);
811        assert!(group.remove_switch_at(0).is_some());
812        assert!(group.remove_switch_at(0).is_none());
813        assert_eq!(group.switch_count(), 0);
814    }
815
816    #[test]
817    fn test_multicam_group_remove_angle_removes_switches() {
818        let mut group = MultiCamGroup::new(1, "Test".to_string());
819        let a1 = group.add_angle("A".to_string());
820        let a2 = group.add_angle("B".to_string());
821        let _ = group.add_switch(0, a1);
822        let _ = group.add_switch(5000, a2);
823        group.remove_angle(a1);
824        // Only switches for a2 should remain
825        assert_eq!(group.switch_count(), 1);
826        assert_eq!(group.switches[0].angle_id, a2);
827    }
828
829    #[test]
830    fn test_multicam_group_sync_all_angles() {
831        let mut group = MultiCamGroup::new(1, "Test".to_string());
832        let a1 = group.add_angle("A".to_string());
833        let angle = group.get_angle_mut(a1).expect("angle should exist");
834        angle.add_sync_point(SyncPoint::new(1000, 800, 1.0, SyncMethod::Timecode));
835        group.sync_all_angles();
836        assert_eq!(
837            group
838                .get_angle(a1)
839                .expect("angle should exist")
840                .alignment_offset,
841            200
842        );
843    }
844
845    #[test]
846    fn test_multicam_manager() {
847        let mut mgr = MultiCamManager::new();
848        assert!(mgr.is_empty());
849
850        let g1 = mgr.create_group("Group 1".to_string());
851        let g2 = mgr.create_group("Group 2".to_string());
852        assert_eq!(mgr.len(), 2);
853
854        assert!(mgr.get_group(g1).is_some());
855        assert!(mgr.delete_group(g2).is_some());
856        assert_eq!(mgr.len(), 1);
857
858        mgr.clear();
859        assert!(mgr.is_empty());
860    }
861
862    #[test]
863    fn test_multicam_manager_all_groups() {
864        let mut mgr = MultiCamManager::new();
865        let _ = mgr.create_group("A".to_string());
866        let _ = mgr.create_group("B".to_string());
867        assert_eq!(mgr.all_groups().len(), 2);
868    }
869
870    #[test]
871    fn test_angle_switch_creation() {
872        let sw = AngleSwitch::new(5000, 2);
873        assert_eq!(sw.position, 5000);
874        assert_eq!(sw.angle_id, 2);
875    }
876}
877
878// ─────────────────────────────────────────────────────────────────────────────
879// MultiCamSession / MultiCamEditor / sync_by_audio_fingerprint tests
880// ─────────────────────────────────────────────────────────────────────────────
881
882#[cfg(test)]
883mod multicam_session_tests {
884    use super::*;
885
886    fn make_clip_ref(id: ClipId, start_ms: i64, duration_ms: i64) -> ClipRef {
887        ClipRef::new(id, start_ms, duration_ms)
888    }
889
890    fn make_track(id: &str, timecode_start: i64, clips: Vec<ClipRef>) -> CameraTrack {
891        let mut t = CameraTrack::new(id, timecode_start);
892        for c in clips {
893            t.add_clip(c);
894        }
895        t
896    }
897
898    // ── ClipRef ──────────────────────────────────────────────────────────────
899
900    #[test]
901    fn test_clip_ref_creation() {
902        let cr = make_clip_ref(42, 1000, 500);
903        assert_eq!(cr.clip_id, 42);
904        assert_eq!(cr.start_ms, 1000);
905        assert_eq!(cr.duration_ms, 500);
906    }
907
908    #[test]
909    fn test_clip_ref_end_ms() {
910        let cr = make_clip_ref(1, 200, 300);
911        assert_eq!(cr.end_ms(), 500);
912    }
913
914    // ── CameraTrack ──────────────────────────────────────────────────────────
915
916    #[test]
917    fn test_camera_track_add_and_count() {
918        let mut t = CameraTrack::new("cam-a", 0);
919        assert_eq!(t.clip_count(), 0);
920        t.add_clip(make_clip_ref(10, 0, 1000));
921        t.add_clip(make_clip_ref(11, 1000, 1000));
922        assert_eq!(t.clip_count(), 2);
923    }
924
925    #[test]
926    fn test_camera_track_clip_at_ms_found() {
927        let clips = vec![make_clip_ref(10, 0, 1000), make_clip_ref(11, 1000, 1000)];
928        let track = make_track("cam-a", 0, clips);
929        let found = track.clip_at_ms(500);
930        assert!(found.is_some());
931        assert_eq!(found.expect("clip should exist").clip_id, 10);
932    }
933
934    #[test]
935    fn test_camera_track_clip_at_ms_second_clip() {
936        let clips = vec![make_clip_ref(10, 0, 1000), make_clip_ref(11, 1000, 1000)];
937        let track = make_track("cam-a", 0, clips);
938        let found = track.clip_at_ms(1500);
939        assert!(found.is_some());
940        assert_eq!(found.expect("clip should exist").clip_id, 11);
941    }
942
943    #[test]
944    fn test_camera_track_clip_at_ms_not_found() {
945        let clips = vec![make_clip_ref(10, 0, 1000)];
946        let track = make_track("cam-a", 0, clips);
947        assert!(track.clip_at_ms(5000).is_none());
948    }
949
950    #[test]
951    fn test_camera_track_timecode_start_offset() {
952        // Track starts at 10_000 ms. Clip covers [0, 1000) local.
953        // Absolute query at 10_500 should find the clip.
954        let clips = vec![make_clip_ref(99, 0, 1000)];
955        let track = make_track("cam-b", 10_000, clips);
956        let found = track.clip_at_ms(10_500);
957        assert!(found.is_some(), "Should find clip at abs 10_500");
958    }
959
960    // ── MultiCamCut ──────────────────────────────────────────────────────────
961
962    #[test]
963    fn test_multicam_cut_creation() {
964        let cut = MultiCamCut::new(3000, 0, 1);
965        assert_eq!(cut.timestamp_ms, 3000);
966        assert_eq!(cut.from_camera, 0);
967        assert_eq!(cut.to_camera, 1);
968    }
969
970    // ── MultiCamSession ──────────────────────────────────────────────────────
971
972    #[test]
973    fn test_session_add_camera() {
974        let mut session = MultiCamSession::new();
975        assert_eq!(session.camera_count(), 0);
976        session.add_camera(CameraTrack::new("A", 0));
977        session.add_camera(CameraTrack::new("B", 0));
978        assert_eq!(session.camera_count(), 2);
979        assert_eq!(session.sync_offset_ms.len(), 2);
980    }
981
982    #[test]
983    fn test_session_set_sync_offset_valid() {
984        let mut session = MultiCamSession::new();
985        session.add_camera(CameraTrack::new("A", 0));
986        let result = session.set_sync_offset(0, 250);
987        assert!(result.is_ok());
988        assert_eq!(session.sync_offset_ms[0], 250);
989    }
990
991    #[test]
992    fn test_session_set_sync_offset_out_of_bounds() {
993        let mut session = MultiCamSession::new();
994        session.add_camera(CameraTrack::new("A", 0));
995        let result = session.set_sync_offset(5, 100);
996        assert!(result.is_err());
997    }
998
999    #[test]
1000    fn test_session_camera_local_ms() {
1001        let mut session = MultiCamSession::new();
1002        session.add_camera(CameraTrack::new("A", 0));
1003        session.set_sync_offset(0, 500).expect("set ok");
1004        // abs_ms=2000, offset=500 → local_ms = 2000 - 500 = 1500
1005        assert_eq!(session.camera_local_ms(0, 2000), 1500);
1006    }
1007
1008    // ── MultiCamEditor ───────────────────────────────────────────────────────
1009
1010    #[test]
1011    fn test_editor_add_cut_valid() {
1012        let mut session = MultiCamSession::new();
1013        session.add_camera(CameraTrack::new("A", 0));
1014        session.add_camera(CameraTrack::new("B", 0));
1015        let mut editor = MultiCamEditor::new(session);
1016        let result = editor.add_cut(MultiCamCut::new(1000, 0, 1));
1017        assert!(result.is_ok());
1018        assert_eq!(editor.cut_count(), 1);
1019    }
1020
1021    #[test]
1022    fn test_editor_add_cut_invalid_from_camera() {
1023        let mut session = MultiCamSession::new();
1024        session.add_camera(CameraTrack::new("A", 0));
1025        let mut editor = MultiCamEditor::new(session);
1026        let result = editor.add_cut(MultiCamCut::new(1000, 5, 0));
1027        assert!(result.is_err());
1028    }
1029
1030    #[test]
1031    fn test_editor_add_cut_invalid_to_camera() {
1032        let mut session = MultiCamSession::new();
1033        session.add_camera(CameraTrack::new("A", 0));
1034        let mut editor = MultiCamEditor::new(session);
1035        let result = editor.add_cut(MultiCamCut::new(1000, 0, 5));
1036        assert!(result.is_err());
1037    }
1038
1039    #[test]
1040    fn test_editor_cuts_sorted() {
1041        let mut session = MultiCamSession::new();
1042        session.add_camera(CameraTrack::new("A", 0));
1043        session.add_camera(CameraTrack::new("B", 0));
1044        let mut editor = MultiCamEditor::new(session);
1045        editor.add_cut(MultiCamCut::new(5000, 0, 1)).expect("ok");
1046        editor.add_cut(MultiCamCut::new(1000, 1, 0)).expect("ok");
1047        editor.add_cut(MultiCamCut::new(3000, 0, 1)).expect("ok");
1048        assert_eq!(editor.cuts[0].timestamp_ms, 1000);
1049        assert_eq!(editor.cuts[1].timestamp_ms, 3000);
1050        assert_eq!(editor.cuts[2].timestamp_ms, 5000);
1051    }
1052
1053    #[test]
1054    fn test_editor_remove_cut_at_found() {
1055        let mut session = MultiCamSession::new();
1056        session.add_camera(CameraTrack::new("A", 0));
1057        session.add_camera(CameraTrack::new("B", 0));
1058        let mut editor = MultiCamEditor::new(session);
1059        editor.add_cut(MultiCamCut::new(1000, 0, 1)).expect("ok");
1060        let removed = editor.remove_cut_at(1000);
1061        assert!(removed.is_some());
1062        assert_eq!(editor.cut_count(), 0);
1063    }
1064
1065    #[test]
1066    fn test_editor_remove_cut_at_not_found() {
1067        let mut session = MultiCamSession::new();
1068        session.add_camera(CameraTrack::new("A", 0));
1069        let mut editor = MultiCamEditor::new(session);
1070        assert!(editor.remove_cut_at(9999).is_none());
1071    }
1072
1073    #[test]
1074    fn test_editor_generate_timeline_empty_cuts() {
1075        let session = MultiCamSession::new();
1076        let editor = MultiCamEditor::new(session);
1077        assert!(editor.generate_timeline().is_empty());
1078    }
1079
1080    #[test]
1081    fn test_editor_generate_timeline_single_cut() {
1082        let mut session = MultiCamSession::new();
1083        let mut cam = CameraTrack::new("A", 0);
1084        cam.add_clip(ClipRef::new(7, 0, 10_000));
1085        session.add_camera(cam);
1086        session.add_camera(CameraTrack::new("B", 0)); // dummy second cam
1087
1088        let mut editor = MultiCamEditor::new(session);
1089        editor.add_cut(MultiCamCut::new(0, 0, 0)).expect("ok");
1090
1091        let tl = editor.generate_timeline();
1092        // One cut → one output clip reference
1093        assert_eq!(tl.len(), 1);
1094        assert_eq!(tl[0].clip_id, 7);
1095    }
1096
1097    // ── sync_by_audio_fingerprint ─────────────────────────────────────────────
1098
1099    #[test]
1100    fn test_sync_empty_a() {
1101        assert_eq!(sync_by_audio_fingerprint(&[], &[1.0, 2.0, 3.0]), 0);
1102    }
1103
1104    #[test]
1105    fn test_sync_empty_b() {
1106        assert_eq!(sync_by_audio_fingerprint(&[1.0, 2.0, 3.0], &[]), 0);
1107    }
1108
1109    #[test]
1110    fn test_sync_both_empty() {
1111        assert_eq!(sync_by_audio_fingerprint(&[], &[]), 0);
1112    }
1113
1114    #[test]
1115    fn test_sync_identical_signals_zero_lag() {
1116        let signal: Vec<f32> = (0..64).map(|i| (i as f32).sin()).collect();
1117        let lag = sync_by_audio_fingerprint(&signal, &signal);
1118        assert_eq!(lag, 0, "Identical signals should have zero lag");
1119    }
1120
1121    #[test]
1122    fn test_sync_shifted_signal() {
1123        // camera_b is camera_a shifted right by 4 samples.
1124        let len = 64usize;
1125        let shift = 4i64;
1126        let base: Vec<f32> = (0..len).map(|i| ((i as f32) * 0.3).sin()).collect();
1127        let mut shifted = vec![0.0f32; len];
1128        for i in shift as usize..len {
1129            shifted[i] = base[i - shift as usize];
1130        }
1131        let lag = sync_by_audio_fingerprint(&base, &shifted);
1132        // lag == shift means camera_b is shifted forward by `shift` samples
1133        assert_eq!(lag, shift, "Expected lag={shift}, got {lag}");
1134    }
1135}