Skip to main content

oximedia_edit/
timeline.rs

1//! Timeline and track structures.
2//!
3//! The timeline is a multi-track structure containing video, audio, and subtitle clips.
4
5use oximedia_core::Rational;
6use std::collections::{HashMap, HashSet};
7
8use crate::clip::{Clip, ClipId, ClipSelection, ClipType};
9use crate::error::{EditError, EditResult};
10use crate::group::{GroupManager, LinkManager};
11use crate::magnetic_snap::{MagneticSnapConfig, MagneticSnapEngine};
12use crate::marker::{InOutPoints, MarkerManager, RegionManager};
13use crate::transition::TransitionManager;
14
15/// A multi-track timeline.
16#[derive(Debug)]
17pub struct Timeline {
18    /// Timeline tracks.
19    pub tracks: Vec<Track>,
20    /// Timeline timebase (e.g., 1/1000 for milliseconds).
21    pub timebase: Rational,
22    /// Timeline frame rate (for video).
23    pub frame_rate: Rational,
24    /// Timeline duration (in timebase units).
25    pub duration: i64,
26    /// Current playhead position.
27    pub playhead: i64,
28    /// Clip selection.
29    pub selection: ClipSelection,
30    /// Transition manager.
31    pub transitions: TransitionManager,
32    /// Marker manager.
33    pub markers: MarkerManager,
34    /// Region manager.
35    pub regions: RegionManager,
36    /// In/Out points.
37    pub in_out: InOutPoints,
38    /// Group manager.
39    pub groups: GroupManager,
40    /// Link manager.
41    pub links: LinkManager,
42    /// Next clip ID.
43    pub next_clip_id: u64,
44    /// Clip lookup by ID.
45    pub clip_map: HashMap<ClipId, (usize, usize)>, // (track_index, clip_index)
46    /// Optional magnetic snap engine.
47    pub snap_engine: Option<MagneticSnapEngine>,
48}
49
50impl Timeline {
51    /// Create a new timeline.
52    #[must_use]
53    pub fn new(timebase: Rational, frame_rate: Rational) -> Self {
54        Self {
55            tracks: Vec::new(),
56            timebase,
57            frame_rate,
58            duration: 0,
59            playhead: 0,
60            selection: ClipSelection::new(),
61            transitions: TransitionManager::new(),
62            markers: MarkerManager::new(),
63            regions: RegionManager::new(),
64            in_out: InOutPoints::new(),
65            groups: GroupManager::new(),
66            links: LinkManager::new(),
67            next_clip_id: 1,
68            clip_map: HashMap::new(),
69            snap_engine: None,
70        }
71    }
72
73    /// Enable magnetic snapping with the given configuration.
74    #[must_use]
75    pub fn with_magnetic_snap(mut self, config: MagneticSnapConfig) -> Self {
76        self.snap_engine = Some(MagneticSnapEngine::new(config));
77        self
78    }
79
80    /// Create a timeline with default settings (1ms timebase, 30fps).
81    #[must_use]
82    pub fn default_settings() -> Self {
83        Self::new(Rational::new(1, 1000), Rational::new(30, 1))
84    }
85
86    /// Add a new track.
87    pub fn add_track(&mut self, track_type: TrackType) -> usize {
88        let index = self.tracks.len();
89        self.tracks.push(Track::new(index, track_type));
90        index
91    }
92
93    /// Remove a track by index.
94    pub fn remove_track(&mut self, index: usize) -> EditResult<Track> {
95        if index >= self.tracks.len() {
96            return Err(EditError::InvalidTrackIndex(index, self.tracks.len()));
97        }
98
99        // Remove clips from this track from the clip map
100        let track = &self.tracks[index];
101        for clip in &track.clips {
102            self.clip_map.remove(&clip.id);
103        }
104
105        let track = self.tracks.remove(index);
106
107        // Update track indices
108        for (i, t) in self.tracks.iter_mut().enumerate() {
109            t.index = i;
110        }
111
112        // Update clip map indices
113        self.rebuild_clip_map();
114
115        Ok(track)
116    }
117
118    /// Get a track by index.
119    #[must_use]
120    pub fn get_track(&self, index: usize) -> Option<&Track> {
121        self.tracks.get(index)
122    }
123
124    /// Get a mutable track by index.
125    pub fn get_track_mut(&mut self, index: usize) -> Option<&mut Track> {
126        self.tracks.get_mut(index)
127    }
128
129    /// Add a clip to a track.
130    ///
131    /// Returns [`EditError::TrackTypeMismatch`] when the clip's type does not
132    /// match the track type (e.g., adding an audio clip to a video track).
133    pub fn add_clip(&mut self, track_index: usize, mut clip: Clip) -> EditResult<ClipId> {
134        if track_index >= self.tracks.len() {
135            return Err(EditError::InvalidTrackIndex(track_index, self.tracks.len()));
136        }
137
138        // Enforce clip type matches track type.
139        let track_type = self.tracks[track_index].track_type;
140        if !track_type.matches_clip(clip.clip_type) {
141            return Err(EditError::TrackTypeMismatch {
142                expected: track_type.expected_clip_type(),
143                got: clip.clip_type,
144            });
145        }
146
147        // Assign clip ID
148        clip.id = self.next_clip_id;
149        self.next_clip_id += 1;
150        let clip_id = clip.id;
151
152        // Check for overlaps
153        let track = &self.tracks[track_index];
154        for existing in &track.clips {
155            if existing.overlaps(clip.timeline_start, clip.timeline_end()) {
156                return Err(EditError::ClipOverlap(clip.timeline_start, track_index));
157            }
158        }
159
160        // Add clip to track
161        let track = &mut self.tracks[track_index];
162        track.clips.push(clip);
163        track.sort_clips();
164
165        // Update clip map
166        let clip_index = track
167            .clips
168            .iter()
169            .position(|c| c.id == clip_id)
170            .ok_or_else(|| {
171                EditError::InvalidEdit("clip was just inserted but not found".to_string())
172            })?;
173        self.clip_map.insert(clip_id, (track_index, clip_index));
174
175        // Update timeline duration
176        self.update_duration();
177
178        Ok(clip_id)
179    }
180
181    /// Remove a clip by ID.
182    ///
183    /// **Link policy:** linked clips are preserved on delete; only the link
184    /// association is removed.  The linked clip remains in its track at its
185    /// current position.
186    pub fn remove_clip(&mut self, clip_id: ClipId) -> EditResult<Clip> {
187        let (track_index, _) = self
188            .clip_map
189            .get(&clip_id)
190            .copied()
191            .ok_or(EditError::ClipNotFound(clip_id))?;
192
193        let track = &mut self.tracks[track_index];
194        let clip_index = track
195            .clips
196            .iter()
197            .position(|c| c.id == clip_id)
198            .ok_or(EditError::ClipNotFound(clip_id))?;
199
200        let clip = track.clips.remove(clip_index);
201        self.clip_map.remove(&clip_id);
202
203        // Remove link associations for the deleted clip without cascading the
204        // delete to linked clips.
205        self.links.remove_clip_links(clip_id);
206
207        self.rebuild_clip_map();
208        self.update_duration();
209
210        Ok(clip)
211    }
212
213    /// Get a clip by ID.
214    #[must_use]
215    pub fn get_clip(&self, clip_id: ClipId) -> Option<&Clip> {
216        let (track_index, clip_index) = self.clip_map.get(&clip_id)?;
217        self.tracks.get(*track_index)?.clips.get(*clip_index)
218    }
219
220    /// Get a mutable clip by ID.
221    pub fn get_clip_mut(&mut self, clip_id: ClipId) -> Option<&mut Clip> {
222        let (track_index, clip_index) = self.clip_map.get(&clip_id).copied()?;
223        self.tracks.get_mut(track_index)?.clips.get_mut(clip_index)
224    }
225
226    /// Move a clip to a new position on the timeline.
227    ///
228    /// If magnetic snapping is enabled, the requested position is adjusted to
229    /// the nearest snap target before the move is applied.
230    ///
231    /// All clips linked to `clip_id` are moved by the same delta (cascade).
232    ///
233    /// The operation is **atomic**: if any clip in the cascade would overlap an
234    /// existing clip, the entire batch is rolled back and an error is returned.
235    pub fn move_clip(&mut self, clip_id: ClipId, requested_start: i64) -> EditResult<()> {
236        // --- 1. Verify primary clip exists and record old position. ---
237        let old_start = self
238            .get_clip(clip_id)
239            .ok_or(EditError::ClipNotFound(clip_id))?
240            .timeline_start;
241
242        // --- 2. Apply magnetic snap (primary clip excluded from its own snap targets). ---
243        let snapped_start = if let Some(ref engine) = self.snap_engine {
244            // Build a temporary engine whose config excludes the moving clip so it
245            // doesn't snap to its own edges.
246            let mut cfg = engine.config.clone();
247            if !cfg.excluded_clips.contains(&clip_id) {
248                cfg.excluded_clips.push(clip_id);
249            }
250            let transient = MagneticSnapEngine::new(cfg);
251            let result = transient.snap_on_timeline(requested_start, self);
252            if result.snapped {
253                result.position
254            } else {
255                requested_start
256            }
257        } else {
258            requested_start
259        };
260
261        let delta = snapped_start - old_start;
262        if delta == 0 {
263            return Ok(());
264        }
265
266        // --- 3. Collect all clips in the linked cascade (BFS, cycle-safe). ---
267        // Each entry: (clip_id, old_start, new_start).
268        let mut pending: Vec<(ClipId, i64, i64)> = Vec::new();
269        let mut visited: HashSet<ClipId> = HashSet::new();
270        let mut queue: Vec<ClipId> = vec![clip_id];
271        visited.insert(clip_id);
272
273        while let Some(id) = queue.pop() {
274            let this_old = if id == clip_id {
275                old_start
276            } else {
277                // Must exist (we only enqueue from known links).
278                match self.get_clip(id) {
279                    Some(c) => c.timeline_start,
280                    None => continue,
281                }
282            };
283            pending.push((id, this_old, this_old + delta));
284
285            // Traverse active links (all types).
286            let linked: Vec<ClipId> = self
287                .links
288                .get_clip_links(id)
289                .into_iter()
290                .filter(|l| l.active)
291                .filter_map(|l| l.other_clip(id))
292                .collect();
293            for linked_id in linked {
294                if visited.insert(linked_id) {
295                    queue.push(linked_id);
296                }
297            }
298        }
299
300        // --- 4. Validate: check that no proposed move produces an overlap. ---
301        // Build a map of new positions for in-batch clips so we can treat them
302        // at their destination when checking against one another.
303        let batch_new: HashMap<ClipId, i64> = pending.iter().map(|&(id, _, ns)| (id, ns)).collect();
304
305        for &(moving_id, _, new_s) in &pending {
306            let (track_idx, dur) = {
307                let (ti, _) = self
308                    .clip_map
309                    .get(&moving_id)
310                    .copied()
311                    .ok_or(EditError::ClipNotFound(moving_id))?;
312                let dur = self.tracks[ti]
313                    .clips
314                    .iter()
315                    .find(|c| c.id == moving_id)
316                    .map(|c| c.timeline_duration)
317                    .ok_or(EditError::ClipNotFound(moving_id))?;
318                (ti, dur)
319            };
320            let new_end = new_s + dur;
321
322            for existing in &self.tracks[track_idx].clips {
323                if existing.id == moving_id {
324                    continue;
325                }
326                // Use the batch-new position for in-batch clips.
327                let ex_start = batch_new
328                    .get(&existing.id)
329                    .copied()
330                    .unwrap_or(existing.timeline_start);
331                let ex_end = ex_start + existing.timeline_duration;
332                // Overlap when intervals are not disjoint.
333                if !(new_end <= ex_start || new_s >= ex_end) {
334                    return Err(EditError::ClipOverlap(new_s, track_idx));
335                }
336            }
337        }
338
339        // --- 5. Apply all moves (no rollback needed; validation already passed). ---
340        for &(id, _, new_s) in &pending {
341            if let Some(clip) = self.get_clip_mut(id) {
342                clip.timeline_start = new_s;
343            }
344        }
345        // Re-sort all affected tracks and rebuild the clip map once.
346        let affected_tracks: HashSet<usize> = pending
347            .iter()
348            .filter_map(|&(id, _, _)| self.clip_map.get(&id).map(|&(ti, _)| ti))
349            .collect();
350        for ti in affected_tracks {
351            self.tracks[ti].sort_clips();
352        }
353        self.rebuild_clip_map();
354        self.update_duration();
355
356        Ok(())
357    }
358
359    /// Move a clip to a different track.
360    pub fn move_clip_to_track(&mut self, clip_id: ClipId, target_track: usize) -> EditResult<()> {
361        if target_track >= self.tracks.len() {
362            return Err(EditError::InvalidTrackIndex(
363                target_track,
364                self.tracks.len(),
365            ));
366        }
367
368        let clip = self.remove_clip(clip_id)?;
369        self.add_clip(target_track, clip)?;
370        Ok(())
371    }
372
373    /// Get all clips at a specific timeline position.
374    #[must_use]
375    pub fn get_clips_at(&self, position: i64) -> Vec<(usize, &Clip)> {
376        let mut result = Vec::new();
377        for track in &self.tracks {
378            if let Some(clip) = track.get_clip_at(position) {
379                result.push((track.index, clip));
380            }
381        }
382        result
383    }
384
385    /// Get all clips in a time range.
386    #[must_use]
387    pub fn get_clips_in_range(&self, start: i64, end: i64) -> Vec<(usize, &Clip)> {
388        let mut result = Vec::new();
389        for track in &self.tracks {
390            for clip in &track.clips {
391                if clip.overlaps(start, end) {
392                    result.push((track.index, clip));
393                }
394            }
395        }
396        result
397    }
398
399    /// Rebuild the clip map from scratch.
400    pub fn rebuild_clip_map(&mut self) {
401        self.clip_map.clear();
402        for (track_index, track) in self.tracks.iter().enumerate() {
403            for (clip_index, clip) in track.clips.iter().enumerate() {
404                self.clip_map.insert(clip.id, (track_index, clip_index));
405            }
406        }
407    }
408
409    /// Update timeline duration based on clips.
410    fn update_duration(&mut self) {
411        let mut max_end = 0i64;
412        for track in &self.tracks {
413            if let Some(clip) = track.clips.last() {
414                max_end = max_end.max(clip.timeline_end());
415            }
416        }
417        self.duration = max_end;
418    }
419
420    /// Set playhead position.
421    pub fn set_playhead(&mut self, position: i64) {
422        self.playhead = position.clamp(0, self.duration);
423    }
424
425    /// Move playhead forward by delta.
426    pub fn move_playhead(&mut self, delta: i64) {
427        self.set_playhead(self.playhead + delta);
428    }
429
430    /// Seek to start of timeline.
431    pub fn seek_to_start(&mut self) {
432        self.playhead = 0;
433    }
434
435    /// Seek to end of timeline.
436    pub fn seek_to_end(&mut self) {
437        self.playhead = self.duration;
438    }
439
440    /// Get timeline duration in seconds.
441    #[must_use]
442    pub fn duration_seconds(&self) -> f64 {
443        let timestamp = oximedia_core::Timestamp::new(self.duration, self.timebase);
444        timestamp.to_seconds()
445    }
446
447    /// Get video tracks.
448    #[must_use]
449    pub fn video_tracks(&self) -> Vec<&Track> {
450        self.tracks
451            .iter()
452            .filter(|t| matches!(t.track_type, TrackType::Video))
453            .collect()
454    }
455
456    /// Get audio tracks.
457    #[must_use]
458    pub fn audio_tracks(&self) -> Vec<&Track> {
459        self.tracks
460            .iter()
461            .filter(|t| matches!(t.track_type, TrackType::Audio))
462            .collect()
463    }
464
465    /// Get subtitle tracks.
466    #[must_use]
467    pub fn subtitle_tracks(&self) -> Vec<&Track> {
468        self.tracks
469            .iter()
470            .filter(|t| matches!(t.track_type, TrackType::Subtitle))
471            .collect()
472    }
473
474    /// Get total number of clips.
475    #[must_use]
476    pub fn clip_count(&self) -> usize {
477        self.tracks.iter().map(|t| t.clips.len()).sum()
478    }
479
480    /// Clear all tracks and clips.
481    pub fn clear(&mut self) {
482        self.tracks.clear();
483        self.clip_map.clear();
484        self.selection.clear();
485        self.transitions.clear();
486        self.markers.clear();
487        self.regions.clear();
488        self.in_out.clear();
489        self.groups.clear();
490        self.links.clear();
491        self.playhead = 0;
492        self.duration = 0;
493        self.next_clip_id = 1;
494    }
495}
496
497impl Default for Timeline {
498    fn default() -> Self {
499        Self::default_settings()
500    }
501}
502
503/// A track in the timeline.
504#[derive(Clone, Debug)]
505pub struct Track {
506    /// Track index.
507    pub index: usize,
508    /// Track type (video, audio, or subtitle).
509    pub track_type: TrackType,
510    /// Clips in this track (sorted by start time).
511    pub clips: Vec<Clip>,
512    /// Track is muted.
513    pub muted: bool,
514    /// Track is soloed.
515    pub solo: bool,
516    /// Track is locked (cannot be edited).
517    pub locked: bool,
518    /// Track height (for UI).
519    pub height: u32,
520    /// Track name.
521    pub name: Option<String>,
522    /// Track color (for UI).
523    pub color: Option<String>,
524}
525
526impl Track {
527    /// Create a new track.
528    #[must_use]
529    pub fn new(index: usize, track_type: TrackType) -> Self {
530        Self {
531            index,
532            track_type,
533            clips: Vec::new(),
534            muted: false,
535            solo: false,
536            locked: false,
537            height: 60,
538            name: None,
539            color: None,
540        }
541    }
542
543    /// Sort clips by timeline start position.
544    pub fn sort_clips(&mut self) {
545        self.clips.sort_by_key(|c| c.timeline_start);
546    }
547
548    /// Get clip at a specific timeline position.
549    #[must_use]
550    pub fn get_clip_at(&self, position: i64) -> Option<&Clip> {
551        self.clips.iter().find(|c| c.contains(position))
552    }
553
554    /// Get mutable clip at a specific timeline position.
555    pub fn get_clip_at_mut(&mut self, position: i64) -> Option<&mut Clip> {
556        self.clips.iter_mut().find(|c| c.contains(position))
557    }
558
559    /// Get all clips in a time range.
560    #[must_use]
561    pub fn get_clips_in_range(&self, start: i64, end: i64) -> Vec<&Clip> {
562        self.clips
563            .iter()
564            .filter(|c| c.overlaps(start, end))
565            .collect()
566    }
567
568    /// Check if this is a video track.
569    #[must_use]
570    pub fn is_video(&self) -> bool {
571        matches!(self.track_type, TrackType::Video)
572    }
573
574    /// Check if this is an audio track.
575    #[must_use]
576    pub fn is_audio(&self) -> bool {
577        matches!(self.track_type, TrackType::Audio)
578    }
579
580    /// Check if this is a subtitle track.
581    #[must_use]
582    pub fn is_subtitle(&self) -> bool {
583        matches!(self.track_type, TrackType::Subtitle)
584    }
585
586    /// Get track duration (end of last clip).
587    #[must_use]
588    pub fn duration(&self) -> i64 {
589        self.clips.last().map_or(0, super::clip::Clip::timeline_end)
590    }
591
592    /// Check if track is empty.
593    #[must_use]
594    pub fn is_empty(&self) -> bool {
595        self.clips.is_empty()
596    }
597
598    /// Get clip count.
599    #[must_use]
600    pub fn clip_count(&self) -> usize {
601        self.clips.len()
602    }
603}
604
605/// Type of track.
606#[derive(Clone, Copy, Debug, PartialEq, Eq)]
607pub enum TrackType {
608    /// Video track.
609    Video,
610    /// Audio track.
611    Audio,
612    /// Subtitle track.
613    Subtitle,
614}
615
616impl TrackType {
617    /// Check if this track type matches a clip type.
618    #[must_use]
619    pub fn matches_clip(&self, clip_type: ClipType) -> bool {
620        matches!(
621            (self, clip_type),
622            (Self::Video, ClipType::Video)
623                | (Self::Audio, ClipType::Audio)
624                | (Self::Subtitle, ClipType::Subtitle)
625        )
626    }
627
628    /// Return the clip type that this track type accepts.
629    #[must_use]
630    pub fn expected_clip_type(&self) -> ClipType {
631        match self {
632            Self::Video => ClipType::Video,
633            Self::Audio => ClipType::Audio,
634            Self::Subtitle => ClipType::Subtitle,
635        }
636    }
637}
638
639/// Playback state of the timeline.
640#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
641pub enum PlaybackState {
642    /// Timeline is stopped.
643    #[default]
644    Stopped,
645    /// Timeline is playing.
646    Playing,
647    /// Timeline is paused.
648    Paused,
649    /// Timeline is seeking.
650    Seeking,
651}
652
653/// Timeline configuration.
654#[derive(Clone, Debug)]
655pub struct TimelineConfig {
656    /// Timeline timebase.
657    pub timebase: Rational,
658    /// Video frame rate.
659    pub frame_rate: Rational,
660    /// Video width.
661    pub width: u32,
662    /// Video height.
663    pub height: u32,
664    /// Audio sample rate.
665    pub sample_rate: u32,
666    /// Audio channels.
667    pub channels: u32,
668}
669
670impl Default for TimelineConfig {
671    fn default() -> Self {
672        Self {
673            timebase: Rational::new(1, 1000),
674            frame_rate: Rational::new(30, 1),
675            width: 1920,
676            height: 1080,
677            sample_rate: 48000,
678            channels: 2,
679        }
680    }
681}
682
683impl TimelineConfig {
684    /// Create a configuration for 1080p 30fps.
685    #[must_use]
686    pub fn hd_1080p_30() -> Self {
687        Self {
688            width: 1920,
689            height: 1080,
690            frame_rate: Rational::new(30, 1),
691            ..Default::default()
692        }
693    }
694
695    /// Create a configuration for 1080p 60fps.
696    #[must_use]
697    pub fn hd_1080p_60() -> Self {
698        Self {
699            width: 1920,
700            height: 1080,
701            frame_rate: Rational::new(60, 1),
702            ..Default::default()
703        }
704    }
705
706    /// Create a configuration for 4K 30fps.
707    #[must_use]
708    pub fn uhd_4k_30() -> Self {
709        Self {
710            width: 3840,
711            height: 2160,
712            frame_rate: Rational::new(30, 1),
713            ..Default::default()
714        }
715    }
716
717    /// Create a configuration for 4K 60fps.
718    #[must_use]
719    pub fn uhd_4k_60() -> Self {
720        Self {
721            width: 3840,
722            height: 2160,
723            frame_rate: Rational::new(60, 1),
724            ..Default::default()
725        }
726    }
727}