Skip to main content

oximedia_edit/
auto_edit.rs

1//! Automatic editing operations.
2//!
3//! Provides algorithms for automatic clip sequencing, audio ducking,
4//! gap removal, and beat-driven editing.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8#![allow(clippy::too_many_arguments)]
9
10use std::collections::HashMap;
11
12/// Strategy for auto-sequencing clips.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SequenceStrategy {
15    /// Place clips end-to-end with no gap.
16    BackToBack,
17    /// Insert a fixed gap (in timebase units) between clips.
18    FixedGap,
19    /// Overlap clips by a fixed amount for cross-dissolve.
20    Overlap,
21}
22
23/// Configuration for automatic sequencing.
24#[derive(Debug, Clone)]
25pub struct AutoSequenceConfig {
26    /// Sequencing strategy.
27    pub strategy: SequenceStrategy,
28    /// Gap or overlap amount in timebase units.
29    pub spacing: u64,
30    /// Whether to sort clips by source timecode before sequencing.
31    pub sort_by_timecode: bool,
32    /// Target track index to place clips on.
33    pub target_track: u32,
34}
35
36impl AutoSequenceConfig {
37    /// Create a default back-to-back configuration.
38    #[must_use]
39    pub fn back_to_back(target_track: u32) -> Self {
40        Self {
41            strategy: SequenceStrategy::BackToBack,
42            spacing: 0,
43            sort_by_timecode: false,
44            target_track,
45        }
46    }
47
48    /// Create a configuration with fixed gaps.
49    #[must_use]
50    pub fn with_gap(target_track: u32, gap: u64) -> Self {
51        Self {
52            strategy: SequenceStrategy::FixedGap,
53            spacing: gap,
54            sort_by_timecode: false,
55            target_track,
56        }
57    }
58
59    /// Create a configuration with overlap for dissolves.
60    #[must_use]
61    pub fn with_overlap(target_track: u32, overlap: u64) -> Self {
62        Self {
63            strategy: SequenceStrategy::Overlap,
64            spacing: overlap,
65            sort_by_timecode: false,
66            target_track,
67        }
68    }
69}
70
71/// A clip reference for auto-editing operations.
72#[derive(Debug, Clone)]
73pub struct AutoClip {
74    /// Clip identifier.
75    pub id: u64,
76    /// Source in-point (timebase units).
77    pub source_in: u64,
78    /// Source out-point (timebase units).
79    pub source_out: u64,
80    /// Source timecode for sorting (if available).
81    pub source_timecode: Option<u64>,
82    /// Audio level in dB (for ducking).
83    pub audio_level_db: f64,
84}
85
86impl AutoClip {
87    /// Create a new auto-clip reference.
88    #[must_use]
89    pub fn new(id: u64, source_in: u64, source_out: u64) -> Self {
90        Self {
91            id,
92            source_in,
93            source_out,
94            source_timecode: None,
95            audio_level_db: 0.0,
96        }
97    }
98
99    /// Duration in timebase units.
100    #[must_use]
101    pub fn duration(&self) -> u64 {
102        self.source_out.saturating_sub(self.source_in)
103    }
104}
105
106/// Placement result for a single clip after auto-sequencing.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct ClipPlacement {
109    /// Clip identifier.
110    pub clip_id: u64,
111    /// Timeline position (timebase units).
112    pub timeline_pos: u64,
113    /// Duration on timeline (timebase units).
114    pub duration: u64,
115    /// Target track index.
116    pub track: u32,
117}
118
119/// Compute clip placements using the given auto-sequence configuration.
120#[must_use]
121pub fn auto_sequence(clips: &[AutoClip], config: &AutoSequenceConfig) -> Vec<ClipPlacement> {
122    if clips.is_empty() {
123        return Vec::new();
124    }
125
126    let mut sorted: Vec<&AutoClip> = clips.iter().collect();
127    if config.sort_by_timecode {
128        sorted.sort_by_key(|c| c.source_timecode.unwrap_or(0));
129    }
130
131    let mut result = Vec::with_capacity(sorted.len());
132    let mut cursor: u64 = 0;
133
134    for clip in &sorted {
135        let dur = clip.duration();
136        result.push(ClipPlacement {
137            clip_id: clip.id,
138            timeline_pos: cursor,
139            duration: dur,
140            track: config.target_track,
141        });
142        match config.strategy {
143            SequenceStrategy::BackToBack => cursor += dur,
144            SequenceStrategy::FixedGap => cursor += dur + config.spacing,
145            SequenceStrategy::Overlap => cursor += dur.saturating_sub(config.spacing),
146        }
147    }
148    result
149}
150
151/// Audio ducking mode.
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum DuckMode {
154    /// Reduce music volume when dialogue is present.
155    DialogueOverMusic,
156    /// Reduce all other audio when narration is present.
157    NarrationPriority,
158}
159
160/// Configuration for auto audio-ducking.
161#[derive(Debug, Clone)]
162pub struct AutoDuckConfig {
163    /// Ducking mode.
164    pub mode: DuckMode,
165    /// Threshold in dB below which the side-chain triggers.
166    pub threshold_db: f64,
167    /// Amount of gain reduction in dB when ducking is active.
168    pub reduction_db: f64,
169    /// Attack time in milliseconds.
170    pub attack_ms: f64,
171    /// Release time in milliseconds.
172    pub release_ms: f64,
173}
174
175impl Default for AutoDuckConfig {
176    fn default() -> Self {
177        Self {
178            mode: DuckMode::DialogueOverMusic,
179            threshold_db: -20.0,
180            reduction_db: -12.0,
181            attack_ms: 50.0,
182            release_ms: 200.0,
183        }
184    }
185}
186
187impl AutoDuckConfig {
188    /// Create a new auto-duck configuration.
189    #[must_use]
190    pub fn new(mode: DuckMode) -> Self {
191        Self {
192            mode,
193            ..Default::default()
194        }
195    }
196
197    /// Compute the ducked level for a given input level.
198    #[must_use]
199    pub fn ducked_level(&self, input_db: f64) -> f64 {
200        if input_db > self.threshold_db {
201            input_db + self.reduction_db
202        } else {
203            input_db
204        }
205    }
206}
207
208/// A detected gap in the timeline.
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct TimelineGap {
211    /// Start position of the gap (timebase units).
212    pub start: u64,
213    /// End position of the gap (timebase units).
214    pub end: u64,
215    /// Track index where the gap exists.
216    pub track: u32,
217}
218
219impl TimelineGap {
220    /// Duration of the gap.
221    #[must_use]
222    pub fn duration(&self) -> u64 {
223        self.end.saturating_sub(self.start)
224    }
225}
226
227/// Detect gaps in a set of clip placements on a single track.
228#[must_use]
229pub fn detect_gaps(placements: &[ClipPlacement], track: u32) -> Vec<TimelineGap> {
230    let mut on_track: Vec<&ClipPlacement> =
231        placements.iter().filter(|p| p.track == track).collect();
232    on_track.sort_by_key(|p| p.timeline_pos);
233
234    let mut gaps = Vec::new();
235    for i in 1..on_track.len() {
236        let prev_end = on_track[i - 1].timeline_pos + on_track[i - 1].duration;
237        let curr_start = on_track[i].timeline_pos;
238        if curr_start > prev_end {
239            gaps.push(TimelineGap {
240                start: prev_end,
241                end: curr_start,
242                track,
243            });
244        }
245    }
246    gaps
247}
248
249/// Compute ripple-close offsets that would remove all gaps on a track.
250///
251/// Returns a map of `clip_id` to new timeline position.
252#[must_use]
253pub fn ripple_close_gaps(placements: &[ClipPlacement], track: u32) -> HashMap<u64, u64> {
254    let mut on_track: Vec<&ClipPlacement> =
255        placements.iter().filter(|p| p.track == track).collect();
256    on_track.sort_by_key(|p| p.timeline_pos);
257
258    let mut result = HashMap::new();
259    let mut cursor: u64 = on_track.first().map_or(0, |p| p.timeline_pos);
260    for p in &on_track {
261        result.insert(p.clip_id, cursor);
262        cursor += p.duration;
263    }
264    result
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_auto_clip_duration() {
273        let c = AutoClip::new(1, 100, 500);
274        assert_eq!(c.duration(), 400);
275    }
276
277    #[test]
278    fn test_auto_clip_zero_duration() {
279        let c = AutoClip::new(1, 500, 500);
280        assert_eq!(c.duration(), 0);
281    }
282
283    #[test]
284    fn test_sequence_back_to_back() {
285        let clips = vec![AutoClip::new(1, 0, 100), AutoClip::new(2, 0, 200)];
286        let cfg = AutoSequenceConfig::back_to_back(0);
287        let result = auto_sequence(&clips, &cfg);
288        assert_eq!(result.len(), 2);
289        assert_eq!(result[0].timeline_pos, 0);
290        assert_eq!(result[0].duration, 100);
291        assert_eq!(result[1].timeline_pos, 100);
292        assert_eq!(result[1].duration, 200);
293    }
294
295    #[test]
296    fn test_sequence_fixed_gap() {
297        let clips = vec![AutoClip::new(1, 0, 100), AutoClip::new(2, 0, 100)];
298        let cfg = AutoSequenceConfig::with_gap(0, 50);
299        let result = auto_sequence(&clips, &cfg);
300        assert_eq!(result[0].timeline_pos, 0);
301        assert_eq!(result[1].timeline_pos, 150); // 100 + 50 gap
302    }
303
304    #[test]
305    fn test_sequence_overlap() {
306        let clips = vec![AutoClip::new(1, 0, 100), AutoClip::new(2, 0, 100)];
307        let cfg = AutoSequenceConfig::with_overlap(0, 20);
308        let result = auto_sequence(&clips, &cfg);
309        assert_eq!(result[0].timeline_pos, 0);
310        assert_eq!(result[1].timeline_pos, 80); // 100 - 20 overlap
311    }
312
313    #[test]
314    fn test_sequence_empty() {
315        let cfg = AutoSequenceConfig::back_to_back(0);
316        let result = auto_sequence(&[], &cfg);
317        assert!(result.is_empty());
318    }
319
320    #[test]
321    fn test_sequence_sort_by_timecode() {
322        let mut c1 = AutoClip::new(1, 0, 100);
323        c1.source_timecode = Some(200);
324        let mut c2 = AutoClip::new(2, 0, 100);
325        c2.source_timecode = Some(100);
326        let mut cfg = AutoSequenceConfig::back_to_back(0);
327        cfg.sort_by_timecode = true;
328        let result = auto_sequence(&[c1, c2], &cfg);
329        assert_eq!(result[0].clip_id, 2); // Earlier timecode first
330        assert_eq!(result[1].clip_id, 1);
331    }
332
333    #[test]
334    fn test_duck_config_default() {
335        let cfg = AutoDuckConfig::default();
336        assert_eq!(cfg.mode, DuckMode::DialogueOverMusic);
337        assert!(cfg.threshold_db < 0.0);
338    }
339
340    #[test]
341    fn test_ducked_level_above_threshold() {
342        let cfg = AutoDuckConfig {
343            threshold_db: -20.0,
344            reduction_db: -12.0,
345            ..Default::default()
346        };
347        let ducked = cfg.ducked_level(-10.0);
348        assert!((ducked - (-22.0)).abs() < f64::EPSILON);
349    }
350
351    #[test]
352    fn test_ducked_level_below_threshold() {
353        let cfg = AutoDuckConfig::default();
354        let level = -30.0;
355        assert!((cfg.ducked_level(level) - level).abs() < f64::EPSILON);
356    }
357
358    #[test]
359    fn test_detect_gaps() {
360        let placements = vec![
361            ClipPlacement {
362                clip_id: 1,
363                timeline_pos: 0,
364                duration: 100,
365                track: 0,
366            },
367            ClipPlacement {
368                clip_id: 2,
369                timeline_pos: 150,
370                duration: 100,
371                track: 0,
372            },
373            ClipPlacement {
374                clip_id: 3,
375                timeline_pos: 250,
376                duration: 50,
377                track: 0,
378            },
379        ];
380        let gaps = detect_gaps(&placements, 0);
381        assert_eq!(gaps.len(), 1);
382        assert_eq!(gaps[0].start, 100);
383        assert_eq!(gaps[0].end, 150);
384        assert_eq!(gaps[0].duration(), 50);
385    }
386
387    #[test]
388    fn test_detect_no_gaps() {
389        let placements = vec![
390            ClipPlacement {
391                clip_id: 1,
392                timeline_pos: 0,
393                duration: 100,
394                track: 0,
395            },
396            ClipPlacement {
397                clip_id: 2,
398                timeline_pos: 100,
399                duration: 100,
400                track: 0,
401            },
402        ];
403        let gaps = detect_gaps(&placements, 0);
404        assert!(gaps.is_empty());
405    }
406
407    #[test]
408    fn test_ripple_close_gaps() {
409        let placements = vec![
410            ClipPlacement {
411                clip_id: 1,
412                timeline_pos: 0,
413                duration: 100,
414                track: 0,
415            },
416            ClipPlacement {
417                clip_id: 2,
418                timeline_pos: 200,
419                duration: 50,
420                track: 0,
421            },
422            ClipPlacement {
423                clip_id: 3,
424                timeline_pos: 400,
425                duration: 80,
426                track: 0,
427            },
428        ];
429        let new_pos = ripple_close_gaps(&placements, 0);
430        assert_eq!(*new_pos.get(&1).expect("get should succeed"), 0);
431        assert_eq!(*new_pos.get(&2).expect("get should succeed"), 100);
432        assert_eq!(*new_pos.get(&3).expect("get should succeed"), 150);
433    }
434}