Skip to main content

oximedia_edit/
smart_trim.rs

1//! Smart / intelligent trim operations for the timeline editor.
2//!
3//! Analyses clip content to suggest optimal in/out trim points based on
4//! silence detection, scene boundaries, motion analysis, and audio peaks.
5
6use crate::clip::{Clip, ClipId, ClipType};
7use crate::error::EditResult;
8use crate::timeline::Timeline;
9
10// ─────────────────────────────────────────────────────────────────────────────
11// TrimReason
12// ─────────────────────────────────────────────────────────────────────────────
13
14/// The signal that triggered a trim suggestion.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum TrimReason {
17    /// A period of silence was detected near the trim point.
18    SilenceDetected,
19    /// A scene/shot boundary was detected near the trim point.
20    SceneBoundary,
21    /// Significant motion ceases near the trim point.
22    MotionStop,
23    /// An audio transient or peak occurs near the trim point.
24    AudioPeak,
25}
26
27impl TrimReason {
28    /// Human-readable description of the trim reason.
29    #[must_use]
30    pub fn description(&self) -> &'static str {
31        match self {
32            Self::SilenceDetected => "silence detected",
33            Self::SceneBoundary => "scene boundary detected",
34            Self::MotionStop => "motion stop detected",
35            Self::AudioPeak => "audio peak detected",
36        }
37    }
38}
39
40// ─────────────────────────────────────────────────────────────────────────────
41// TrimSuggestion
42// ─────────────────────────────────────────────────────────────────────────────
43
44/// A suggested trim point for a clip, with a confidence score and reason.
45#[derive(Clone, Debug)]
46pub struct TrimSuggestion {
47    /// The clip this suggestion applies to.
48    pub clip_id: ClipId,
49    /// Proposed timeline position for the trim (timebase units).
50    pub trim_point: i64,
51    /// Confidence score in the range `[0.0, 1.0]`.
52    pub confidence: f64,
53    /// Reason the trim was suggested.
54    pub reason: TrimReason,
55    /// `true` → this is a suggested in-point; `false` → suggested out-point.
56    pub is_in_point: bool,
57}
58
59impl TrimSuggestion {
60    /// Create a new trim suggestion.
61    #[must_use]
62    pub fn new(
63        clip_id: ClipId,
64        trim_point: i64,
65        confidence: f64,
66        reason: TrimReason,
67        is_in_point: bool,
68    ) -> Self {
69        Self {
70            clip_id,
71            trim_point,
72            confidence: confidence.clamp(0.0, 1.0),
73            reason,
74            is_in_point,
75        }
76    }
77}
78
79// ─────────────────────────────────────────────────────────────────────────────
80// SmartTrimConfig
81// ─────────────────────────────────────────────────────────────────────────────
82
83/// Tuning parameters for the smart trim engine.
84#[derive(Clone, Debug)]
85pub struct SmartTrimConfig {
86    /// Silence threshold in dBFS (default −40 dB).
87    pub silence_threshold_db: f64,
88    /// Minimum confidence required before a scene-boundary suggestion is
89    /// emitted (default 0.7).
90    pub min_scene_confidence: f64,
91    /// Minimum duration of a silence region to be considered, in timebase
92    /// units (default 100 ms).
93    pub min_silence_duration_ms: i64,
94    /// Normalised motion magnitude below which motion is considered stopped
95    /// (default 0.1).
96    pub motion_threshold: f64,
97    /// Minimum suggestion confidence to include in `analyze` results (default 0.0).
98    pub min_output_confidence: f64,
99}
100
101impl SmartTrimConfig {
102    /// Create a configuration with default values.
103    #[must_use]
104    pub fn new() -> Self {
105        Self {
106            silence_threshold_db: -40.0,
107            min_scene_confidence: 0.7,
108            min_silence_duration_ms: 100,
109            motion_threshold: 0.1,
110            min_output_confidence: 0.0,
111        }
112    }
113
114    /// Set the silence threshold.
115    #[must_use]
116    pub fn with_silence_threshold(mut self, db: f64) -> Self {
117        self.silence_threshold_db = db;
118        self
119    }
120
121    /// Set the minimum scene-detection confidence.
122    #[must_use]
123    pub fn with_min_scene_confidence(mut self, confidence: f64) -> Self {
124        self.min_scene_confidence = confidence.clamp(0.0, 1.0);
125        self
126    }
127
128    /// Set the minimum silence duration in milliseconds.
129    #[must_use]
130    pub fn with_min_silence_duration_ms(mut self, ms: i64) -> Self {
131        self.min_silence_duration_ms = ms.max(0);
132        self
133    }
134
135    /// Set the motion-stop threshold.
136    #[must_use]
137    pub fn with_motion_threshold(mut self, threshold: f64) -> Self {
138        self.motion_threshold = threshold.clamp(0.0, 1.0);
139        self
140    }
141
142    /// Only surface suggestions with at least this confidence level.
143    #[must_use]
144    pub fn with_min_output_confidence(mut self, confidence: f64) -> Self {
145        self.min_output_confidence = confidence.clamp(0.0, 1.0);
146        self
147    }
148}
149
150impl Default for SmartTrimConfig {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156// ─────────────────────────────────────────────────────────────────────────────
157// SmartTrimEngine
158// ─────────────────────────────────────────────────────────────────────────────
159
160/// Analyses clips and produces intelligent trim suggestions.
161///
162/// The analysis is based on heuristic rules derived from clip type and
163/// duration; a production implementation would replace these with signal-level
164/// analysis of the decoded media.
165pub struct SmartTrimEngine {
166    /// Tuning configuration.
167    pub config: SmartTrimConfig,
168}
169
170impl SmartTrimEngine {
171    /// Create a smart trim engine with default configuration.
172    #[must_use]
173    pub fn new() -> Self {
174        Self {
175            config: SmartTrimConfig::new(),
176        }
177    }
178
179    /// Create a smart trim engine with custom configuration.
180    #[must_use]
181    pub fn new_with_config(config: SmartTrimConfig) -> Self {
182        Self { config }
183    }
184
185    /// Analyse all clips in `timeline` and return all trim suggestions above
186    /// the configured minimum confidence.
187    #[must_use]
188    pub fn analyze(&self, timeline: &Timeline) -> Vec<TrimSuggestion> {
189        let min_conf = self.config.min_output_confidence;
190        timeline
191            .tracks
192            .iter()
193            .flat_map(|track| track.clips.iter())
194            .flat_map(|clip| self.analyze_clip(clip))
195            .filter(|s| s.confidence >= min_conf)
196            .collect()
197    }
198
199    /// Produce trim suggestions for a single clip.
200    ///
201    /// Returns suggestions based on clip type:
202    /// - **Audio** clips: silence-based in/out suggestions.
203    /// - **Video** clips: scene-boundary in-point and motion-stop out-point.
204    /// - **Subtitle** clips: no suggestions.
205    #[must_use]
206    pub fn analyze_clip(&self, clip: &Clip) -> Vec<TrimSuggestion> {
207        let duration = clip.timeline_duration;
208        if duration <= 0 {
209            return Vec::new();
210        }
211
212        match clip.clip_type {
213            ClipType::Audio => {
214                let in_point = clip.timeline_start + duration / 10;
215                let out_point = clip.timeline_end() - duration / 10;
216
217                let in_suggestion =
218                    TrimSuggestion::new(clip.id, in_point, 0.85, TrimReason::SilenceDetected, true);
219                let out_suggestion = TrimSuggestion::new(
220                    clip.id,
221                    out_point,
222                    0.80,
223                    TrimReason::SilenceDetected,
224                    false,
225                );
226                vec![in_suggestion, out_suggestion]
227            }
228
229            ClipType::Video => {
230                let in_point = clip.timeline_start + duration / 20;
231                let out_point = clip.timeline_end() - duration / 20;
232
233                let in_suggestion =
234                    TrimSuggestion::new(clip.id, in_point, 0.75, TrimReason::SceneBoundary, true);
235                let out_suggestion =
236                    TrimSuggestion::new(clip.id, out_point, 0.72, TrimReason::MotionStop, false);
237                vec![in_suggestion, out_suggestion]
238            }
239
240            ClipType::Subtitle => Vec::new(),
241        }
242    }
243
244    /// Return the highest-confidence in-point suggestion for `clip`, if any.
245    #[must_use]
246    pub fn suggest_in_point(&self, clip: &Clip) -> Option<TrimSuggestion> {
247        self.analyze_clip(clip)
248            .into_iter()
249            .filter(|s| s.is_in_point)
250            .max_by(|a, b| {
251                a.confidence
252                    .partial_cmp(&b.confidence)
253                    .unwrap_or(std::cmp::Ordering::Equal)
254            })
255    }
256
257    /// Return the highest-confidence out-point suggestion for `clip`, if any.
258    #[must_use]
259    pub fn suggest_out_point(&self, clip: &Clip) -> Option<TrimSuggestion> {
260        self.analyze_clip(clip)
261            .into_iter()
262            .filter(|s| !s.is_in_point)
263            .max_by(|a, b| {
264                a.confidence
265                    .partial_cmp(&b.confidence)
266                    .unwrap_or(std::cmp::Ordering::Equal)
267            })
268    }
269
270    /// Apply a set of trim suggestions to the timeline.
271    ///
272    /// Only suggestions with confidence ≥ 0.75 are applied.  Missing clips are
273    /// silently skipped.  Returns the number of suggestions applied.
274    pub fn apply_suggestions(
275        &self,
276        timeline: &mut Timeline,
277        suggestions: &[TrimSuggestion],
278    ) -> EditResult<usize> {
279        let mut applied = 0usize;
280
281        for suggestion in suggestions {
282            if suggestion.confidence < 0.75 {
283                continue;
284            }
285
286            // Fetch the clip; skip if not found
287            let clip = match timeline.get_clip_mut(suggestion.clip_id) {
288                Some(c) => c,
289                None => continue,
290            };
291
292            if suggestion.is_in_point {
293                // Compute new source_in relative to clip position
294                let offset = suggestion.trim_point - clip.timeline_start;
295                let new_source_in = (clip.source_in + offset)
296                    .clamp(clip.source_in, clip.source_out.saturating_sub(1));
297                clip.source_in = new_source_in;
298            } else {
299                // Compute new source_out relative to clip position
300                let offset = suggestion.trim_point - clip.timeline_start;
301                let new_source_out =
302                    (clip.source_in + offset).clamp(clip.source_in + 1, clip.source_out);
303                clip.source_out = new_source_out;
304            }
305
306            applied += 1;
307        }
308
309        Ok(applied)
310    }
311}
312
313impl Default for SmartTrimEngine {
314    fn default() -> Self {
315        Self::new()
316    }
317}
318
319// ─────────────────────────────────────────────────────────────────────────────
320// Tests
321// ─────────────────────────────────────────────────────────────────────────────
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::clip::ClipType;
327    use crate::timeline::Timeline;
328    use oximedia_core::Rational;
329
330    fn engine() -> SmartTrimEngine {
331        SmartTrimEngine::new()
332    }
333
334    fn audio_clip(id: ClipId, start: i64, duration: i64) -> Clip {
335        Clip::new(id, ClipType::Audio, start, duration)
336    }
337
338    fn video_clip(id: ClipId, start: i64, duration: i64) -> Clip {
339        Clip::new(id, ClipType::Video, start, duration)
340    }
341
342    fn subtitle_clip(id: ClipId, start: i64, duration: i64) -> Clip {
343        Clip::new(id, ClipType::Subtitle, start, duration)
344    }
345
346    #[test]
347    fn test_analyze_clip_audio_returns_two_suggestions() {
348        let clip = audio_clip(1, 0, 1000);
349        let suggestions = engine().analyze_clip(&clip);
350        assert_eq!(suggestions.len(), 2);
351        assert!(suggestions.iter().any(|s| s.is_in_point));
352        assert!(suggestions.iter().any(|s| !s.is_in_point));
353        for s in &suggestions {
354            assert_eq!(s.reason, TrimReason::SilenceDetected);
355            assert!(s.confidence > 0.0);
356        }
357    }
358
359    #[test]
360    fn test_analyze_clip_video_returns_two_suggestions() {
361        let clip = video_clip(2, 500, 2000);
362        let suggestions = engine().analyze_clip(&clip);
363        assert_eq!(suggestions.len(), 2);
364        let in_sug = suggestions
365            .iter()
366            .find(|s| s.is_in_point)
367            .expect("in-point");
368        let out_sug = suggestions
369            .iter()
370            .find(|s| !s.is_in_point)
371            .expect("out-point");
372        assert_eq!(in_sug.reason, TrimReason::SceneBoundary);
373        assert_eq!(out_sug.reason, TrimReason::MotionStop);
374    }
375
376    #[test]
377    fn test_analyze_clip_subtitle_returns_empty() {
378        let clip = subtitle_clip(3, 0, 500);
379        let suggestions = engine().analyze_clip(&clip);
380        assert!(suggestions.is_empty());
381    }
382
383    #[test]
384    fn test_suggest_in_point() {
385        let clip = audio_clip(4, 0, 1000);
386        let suggestion = engine().suggest_in_point(&clip);
387        assert!(suggestion.is_some());
388        let s = suggestion.expect("should have in-point suggestion");
389        assert!(s.is_in_point);
390        assert_eq!(s.clip_id, 4);
391    }
392
393    #[test]
394    fn test_suggest_out_point() {
395        let clip = video_clip(5, 0, 2000);
396        let suggestion = engine().suggest_out_point(&clip);
397        assert!(suggestion.is_some());
398        let s = suggestion.expect("should have out-point suggestion");
399        assert!(!s.is_in_point);
400        assert_eq!(s.clip_id, 5);
401    }
402
403    #[test]
404    fn test_apply_suggestions_returns_count() {
405        let mut timeline = Timeline::new(Rational::new(1, 1000), Rational::new(30, 1));
406        let track = timeline.add_track(crate::timeline::TrackType::Audio);
407        let clip = audio_clip(0, 0, 1000);
408        let clip_id = timeline.add_clip(track, clip).expect("add clip ok");
409
410        let engine = engine();
411        let clip_ref = timeline.get_clip(clip_id).expect("clip exists");
412        let suggestions = engine.analyze_clip(clip_ref);
413
414        let applied = engine
415            .apply_suggestions(&mut timeline, &suggestions)
416            .expect("apply_suggestions ok");
417        // Both audio suggestions have confidence >= 0.75, so 2 should be applied
418        assert_eq!(applied, 2);
419    }
420
421    #[test]
422    fn test_apply_suggestions_skips_missing_clips() {
423        let mut timeline = Timeline::new(Rational::new(1, 1000), Rational::new(30, 1));
424        let suggestion = TrimSuggestion::new(9999, 100, 0.99, TrimReason::AudioPeak, true);
425        let engine = engine();
426        let applied = engine
427            .apply_suggestions(&mut timeline, &[suggestion])
428            .expect("should not error on missing clip");
429        assert_eq!(applied, 0, "missing clips must be silently skipped");
430    }
431
432    #[test]
433    fn test_apply_suggestions_skips_low_confidence() {
434        let mut timeline = Timeline::new(Rational::new(1, 1000), Rational::new(30, 1));
435        let track = timeline.add_track(crate::timeline::TrackType::Video);
436        let clip = video_clip(0, 0, 2000);
437        let clip_id = timeline.add_clip(track, clip).expect("add clip ok");
438
439        let suggestion = TrimSuggestion::new(clip_id, 100, 0.50, TrimReason::SceneBoundary, true);
440        let engine = engine();
441        let applied = engine
442            .apply_suggestions(&mut timeline, &[suggestion])
443            .expect("apply ok");
444        assert_eq!(applied, 0, "low confidence suggestion must be skipped");
445    }
446
447    #[test]
448    fn test_analyze_all_clips_in_timeline() {
449        let mut timeline = Timeline::new(Rational::new(1, 1000), Rational::new(30, 1));
450        let v_track = timeline.add_track(crate::timeline::TrackType::Video);
451        let a_track = timeline.add_track(crate::timeline::TrackType::Audio);
452        timeline
453            .add_clip(v_track, video_clip(0, 0, 2000))
454            .expect("v ok");
455        timeline
456            .add_clip(a_track, audio_clip(0, 0, 1000))
457            .expect("a ok");
458
459        let engine = engine();
460        let suggestions = engine.analyze(&timeline);
461        // 2 video + 2 audio = 4 suggestions
462        assert_eq!(suggestions.len(), 4);
463    }
464
465    #[test]
466    fn test_config_builder() {
467        let config = SmartTrimConfig::new()
468            .with_silence_threshold(-30.0)
469            .with_min_scene_confidence(0.8)
470            .with_min_silence_duration_ms(200)
471            .with_motion_threshold(0.05)
472            .with_min_output_confidence(0.6);
473
474        let engine = SmartTrimEngine::new_with_config(config);
475        assert!((engine.config.silence_threshold_db - (-30.0)).abs() < f64::EPSILON);
476        assert!((engine.config.min_scene_confidence - 0.8).abs() < f64::EPSILON);
477        assert_eq!(engine.config.min_silence_duration_ms, 200);
478    }
479}