Skip to main content

oximedia_edit/
edit_preset.rs

1//! Edit presets and templates for common editing patterns.
2//!
3//! Provides reusable editing templates such as montage, interview cut,
4//! picture-in-picture, and split-screen layouts.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8#![allow(clippy::too_many_arguments)]
9
10use std::collections::HashMap;
11
12/// Category of editing preset.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum PresetCategory {
15    /// Montage / highlight reel style.
16    Montage,
17    /// Interview / talking-head cut.
18    Interview,
19    /// Picture-in-picture layout.
20    Pip,
21    /// Split-screen layout.
22    SplitScreen,
23    /// Social media format (vertical, square, etc.).
24    Social,
25    /// Trailer / promo style.
26    Trailer,
27    /// Custom / user-defined.
28    Custom,
29}
30
31/// A single track layout entry within a preset.
32#[derive(Debug, Clone)]
33pub struct TrackLayout {
34    /// Track index.
35    pub track_index: u32,
36    /// Track label (e.g., "A-Roll", "B-Roll", "Music").
37    pub label: String,
38    /// Whether this is a video track (false = audio).
39    pub is_video: bool,
40    /// Default opacity (0.0 to 1.0, video only).
41    pub opacity: f64,
42    /// Position X offset (normalised 0.0..1.0, video only).
43    pub x_offset: f64,
44    /// Position Y offset (normalised 0.0..1.0, video only).
45    pub y_offset: f64,
46    /// Scale factor (1.0 = full size).
47    pub scale: f64,
48}
49
50impl TrackLayout {
51    /// Create a full-screen video track layout.
52    #[must_use]
53    pub fn video(track_index: u32, label: &str) -> Self {
54        Self {
55            track_index,
56            label: label.to_string(),
57            is_video: true,
58            opacity: 1.0,
59            x_offset: 0.0,
60            y_offset: 0.0,
61            scale: 1.0,
62        }
63    }
64
65    /// Create an audio track layout.
66    #[must_use]
67    pub fn audio(track_index: u32, label: &str) -> Self {
68        Self {
69            track_index,
70            label: label.to_string(),
71            is_video: false,
72            opacity: 1.0,
73            x_offset: 0.0,
74            y_offset: 0.0,
75            scale: 1.0,
76        }
77    }
78
79    /// Set position and scale for PIP / split-screen.
80    #[must_use]
81    pub fn with_transform(mut self, x: f64, y: f64, scale: f64) -> Self {
82        self.x_offset = x;
83        self.y_offset = y;
84        self.scale = scale;
85        self
86    }
87
88    /// Set opacity.
89    #[must_use]
90    pub fn with_opacity(mut self, opacity: f64) -> Self {
91        self.opacity = opacity.clamp(0.0, 1.0);
92        self
93    }
94}
95
96/// Transition style to apply between clips in a preset.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum PresetTransition {
99    /// Hard cut (no transition).
100    Cut,
101    /// Cross-dissolve.
102    Dissolve,
103    /// Dip to black.
104    DipToBlack,
105    /// Wipe left-to-right.
106    WipeLeft,
107    /// Wipe right-to-left.
108    WipeRight,
109}
110
111/// An editing preset / template.
112#[derive(Debug, Clone)]
113pub struct EditPreset {
114    /// Unique name.
115    pub name: String,
116    /// Human-readable description.
117    pub description: String,
118    /// Category.
119    pub category: PresetCategory,
120    /// Track layouts.
121    pub tracks: Vec<TrackLayout>,
122    /// Default transition between clips.
123    pub default_transition: PresetTransition,
124    /// Default transition duration in timebase units.
125    pub transition_duration: u64,
126    /// Custom metadata (key-value pairs).
127    pub metadata: HashMap<String, String>,
128}
129
130impl EditPreset {
131    /// Create a new preset.
132    #[must_use]
133    pub fn new(name: &str, category: PresetCategory) -> Self {
134        Self {
135            name: name.to_string(),
136            description: String::new(),
137            category,
138            tracks: Vec::new(),
139            default_transition: PresetTransition::Cut,
140            transition_duration: 0,
141            metadata: HashMap::new(),
142        }
143    }
144
145    /// Set description.
146    #[must_use]
147    pub fn with_description(mut self, desc: &str) -> Self {
148        self.description = desc.to_string();
149        self
150    }
151
152    /// Add a track layout.
153    #[must_use]
154    pub fn with_track(mut self, layout: TrackLayout) -> Self {
155        self.tracks.push(layout);
156        self
157    }
158
159    /// Set default transition.
160    #[must_use]
161    pub fn with_transition(mut self, transition: PresetTransition, duration: u64) -> Self {
162        self.default_transition = transition;
163        self.transition_duration = duration;
164        self
165    }
166
167    /// Add a metadata entry.
168    pub fn set_metadata(&mut self, key: &str, value: &str) {
169        self.metadata.insert(key.to_string(), value.to_string());
170    }
171
172    /// Get a metadata entry.
173    #[must_use]
174    pub fn get_metadata(&self, key: &str) -> Option<&str> {
175        self.metadata.get(key).map(String::as_str)
176    }
177
178    /// Number of video tracks.
179    #[must_use]
180    pub fn video_track_count(&self) -> usize {
181        self.tracks.iter().filter(|t| t.is_video).count()
182    }
183
184    /// Number of audio tracks.
185    #[must_use]
186    pub fn audio_track_count(&self) -> usize {
187        self.tracks.iter().filter(|t| !t.is_video).count()
188    }
189}
190
191/// Library of editing presets.
192#[derive(Debug, Clone, Default)]
193pub struct PresetLibrary {
194    /// Presets keyed by name.
195    presets: HashMap<String, EditPreset>,
196}
197
198impl PresetLibrary {
199    /// Create a new empty library.
200    #[must_use]
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    /// Register a preset.
206    pub fn register(&mut self, preset: EditPreset) {
207        self.presets.insert(preset.name.clone(), preset);
208    }
209
210    /// Get a preset by name.
211    #[must_use]
212    pub fn get(&self, name: &str) -> Option<&EditPreset> {
213        self.presets.get(name)
214    }
215
216    /// Remove a preset.
217    pub fn remove(&mut self, name: &str) -> Option<EditPreset> {
218        self.presets.remove(name)
219    }
220
221    /// List all preset names.
222    #[must_use]
223    pub fn names(&self) -> Vec<&str> {
224        self.presets.keys().map(String::as_str).collect()
225    }
226
227    /// List presets filtered by category.
228    #[must_use]
229    pub fn by_category(&self, category: PresetCategory) -> Vec<&EditPreset> {
230        self.presets
231            .values()
232            .filter(|p| p.category == category)
233            .collect()
234    }
235
236    /// Number of presets.
237    #[must_use]
238    pub fn len(&self) -> usize {
239        self.presets.len()
240    }
241
242    /// Whether the library is empty.
243    #[must_use]
244    pub fn is_empty(&self) -> bool {
245        self.presets.is_empty()
246    }
247
248    /// Create a library pre-loaded with built-in presets.
249    #[must_use]
250    pub fn with_builtins() -> Self {
251        let mut lib = Self::new();
252        lib.register(builtin_montage());
253        lib.register(builtin_interview());
254        lib.register(builtin_pip());
255        lib.register(builtin_split_screen());
256        lib
257    }
258}
259
260/// Built-in montage preset.
261#[must_use]
262fn builtin_montage() -> EditPreset {
263    EditPreset::new("montage", PresetCategory::Montage)
264        .with_description("Fast-paced montage with dissolves")
265        .with_track(TrackLayout::video(0, "B-Roll"))
266        .with_track(TrackLayout::audio(1, "Music"))
267        .with_transition(PresetTransition::Dissolve, 500)
268}
269
270/// Built-in interview preset.
271#[must_use]
272fn builtin_interview() -> EditPreset {
273    EditPreset::new("interview", PresetCategory::Interview)
274        .with_description("Two-camera interview with hard cuts")
275        .with_track(TrackLayout::video(0, "Cam A"))
276        .with_track(TrackLayout::video(1, "Cam B"))
277        .with_track(TrackLayout::audio(2, "Lav Mic"))
278        .with_transition(PresetTransition::Cut, 0)
279}
280
281/// Built-in picture-in-picture preset.
282#[must_use]
283fn builtin_pip() -> EditPreset {
284    EditPreset::new("pip", PresetCategory::Pip)
285        .with_description("Full-screen main with small overlay")
286        .with_track(TrackLayout::video(0, "Main"))
287        .with_track(
288            TrackLayout::video(1, "PIP")
289                .with_transform(0.7, 0.7, 0.25)
290                .with_opacity(0.95),
291        )
292        .with_track(TrackLayout::audio(2, "Audio"))
293}
294
295/// Built-in split-screen preset.
296#[must_use]
297fn builtin_split_screen() -> EditPreset {
298    EditPreset::new("split_screen", PresetCategory::SplitScreen)
299        .with_description("Side-by-side 50/50 split")
300        .with_track(TrackLayout::video(0, "Left").with_transform(0.0, 0.0, 0.5))
301        .with_track(TrackLayout::video(1, "Right").with_transform(0.5, 0.0, 0.5))
302        .with_track(TrackLayout::audio(2, "Audio"))
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    #[test]
310    fn test_track_layout_video() {
311        let t = TrackLayout::video(0, "Main");
312        assert!(t.is_video);
313        assert!((t.opacity - 1.0).abs() < f64::EPSILON);
314        assert!((t.scale - 1.0).abs() < f64::EPSILON);
315    }
316
317    #[test]
318    fn test_track_layout_audio() {
319        let t = TrackLayout::audio(1, "Music");
320        assert!(!t.is_video);
321        assert_eq!(t.label, "Music");
322    }
323
324    #[test]
325    fn test_track_layout_transform() {
326        let t = TrackLayout::video(0, "PIP").with_transform(0.7, 0.7, 0.25);
327        assert!((t.x_offset - 0.7).abs() < f64::EPSILON);
328        assert!((t.scale - 0.25).abs() < f64::EPSILON);
329    }
330
331    #[test]
332    fn test_track_layout_opacity_clamped() {
333        let t = TrackLayout::video(0, "V").with_opacity(1.5);
334        assert!((t.opacity - 1.0).abs() < f64::EPSILON);
335        let t2 = TrackLayout::video(0, "V").with_opacity(-0.5);
336        assert!(t2.opacity.abs() < f64::EPSILON);
337    }
338
339    #[test]
340    fn test_preset_new() {
341        let p = EditPreset::new("test", PresetCategory::Custom);
342        assert_eq!(p.name, "test");
343        assert_eq!(p.category, PresetCategory::Custom);
344        assert!(p.tracks.is_empty());
345    }
346
347    #[test]
348    fn test_preset_builder() {
349        let p = EditPreset::new("p", PresetCategory::Montage)
350            .with_description("desc")
351            .with_track(TrackLayout::video(0, "V"))
352            .with_track(TrackLayout::audio(1, "A"))
353            .with_transition(PresetTransition::Dissolve, 1000);
354        assert_eq!(p.description, "desc");
355        assert_eq!(p.video_track_count(), 1);
356        assert_eq!(p.audio_track_count(), 1);
357        assert_eq!(p.default_transition, PresetTransition::Dissolve);
358        assert_eq!(p.transition_duration, 1000);
359    }
360
361    #[test]
362    fn test_preset_metadata() {
363        let mut p = EditPreset::new("p", PresetCategory::Social);
364        p.set_metadata("platform", "instagram");
365        assert_eq!(p.get_metadata("platform"), Some("instagram"));
366        assert_eq!(p.get_metadata("missing"), None);
367    }
368
369    #[test]
370    fn test_library_empty() {
371        let lib = PresetLibrary::new();
372        assert!(lib.is_empty());
373        assert_eq!(lib.len(), 0);
374    }
375
376    #[test]
377    fn test_library_register_get() {
378        let mut lib = PresetLibrary::new();
379        lib.register(EditPreset::new("my_preset", PresetCategory::Custom));
380        assert_eq!(lib.len(), 1);
381        assert!(lib.get("my_preset").is_some());
382        assert!(lib.get("nonexistent").is_none());
383    }
384
385    #[test]
386    fn test_library_remove() {
387        let mut lib = PresetLibrary::new();
388        lib.register(EditPreset::new("x", PresetCategory::Trailer));
389        assert!(lib.remove("x").is_some());
390        assert!(lib.is_empty());
391    }
392
393    #[test]
394    fn test_library_by_category() {
395        let mut lib = PresetLibrary::new();
396        lib.register(EditPreset::new("a", PresetCategory::Montage));
397        lib.register(EditPreset::new("b", PresetCategory::Interview));
398        lib.register(EditPreset::new("c", PresetCategory::Montage));
399        let montages = lib.by_category(PresetCategory::Montage);
400        assert_eq!(montages.len(), 2);
401    }
402
403    #[test]
404    fn test_library_builtins() {
405        let lib = PresetLibrary::with_builtins();
406        assert_eq!(lib.len(), 4);
407        assert!(lib.get("montage").is_some());
408        assert!(lib.get("interview").is_some());
409        assert!(lib.get("pip").is_some());
410        assert!(lib.get("split_screen").is_some());
411    }
412
413    #[test]
414    fn test_builtin_montage() {
415        let p = builtin_montage();
416        assert_eq!(p.category, PresetCategory::Montage);
417        assert_eq!(p.default_transition, PresetTransition::Dissolve);
418        assert_eq!(p.video_track_count(), 1);
419    }
420
421    #[test]
422    fn test_builtin_pip() {
423        let p = builtin_pip();
424        assert_eq!(p.video_track_count(), 2);
425        let pip_track = &p.tracks[1];
426        assert!((pip_track.scale - 0.25).abs() < f64::EPSILON);
427    }
428}