1use crate::clip::{Clip, ClipId, ClipType};
7use crate::error::EditResult;
8use crate::timeline::Timeline;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum TrimReason {
17 SilenceDetected,
19 SceneBoundary,
21 MotionStop,
23 AudioPeak,
25}
26
27impl TrimReason {
28 #[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#[derive(Clone, Debug)]
46pub struct TrimSuggestion {
47 pub clip_id: ClipId,
49 pub trim_point: i64,
51 pub confidence: f64,
53 pub reason: TrimReason,
55 pub is_in_point: bool,
57}
58
59impl TrimSuggestion {
60 #[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#[derive(Clone, Debug)]
85pub struct SmartTrimConfig {
86 pub silence_threshold_db: f64,
88 pub min_scene_confidence: f64,
91 pub min_silence_duration_ms: i64,
94 pub motion_threshold: f64,
97 pub min_output_confidence: f64,
99}
100
101impl SmartTrimConfig {
102 #[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 #[must_use]
116 pub fn with_silence_threshold(mut self, db: f64) -> Self {
117 self.silence_threshold_db = db;
118 self
119 }
120
121 #[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 #[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 #[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 #[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
156pub struct SmartTrimEngine {
166 pub config: SmartTrimConfig,
168}
169
170impl SmartTrimEngine {
171 #[must_use]
173 pub fn new() -> Self {
174 Self {
175 config: SmartTrimConfig::new(),
176 }
177 }
178
179 #[must_use]
181 pub fn new_with_config(config: SmartTrimConfig) -> Self {
182 Self { config }
183 }
184
185 #[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 #[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 #[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 #[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 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 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 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 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#[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 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 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}