1#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8#![allow(clippy::too_many_arguments)]
9
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum PresetCategory {
15 Montage,
17 Interview,
19 Pip,
21 SplitScreen,
23 Social,
25 Trailer,
27 Custom,
29}
30
31#[derive(Debug, Clone)]
33pub struct TrackLayout {
34 pub track_index: u32,
36 pub label: String,
38 pub is_video: bool,
40 pub opacity: f64,
42 pub x_offset: f64,
44 pub y_offset: f64,
46 pub scale: f64,
48}
49
50impl TrackLayout {
51 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum PresetTransition {
99 Cut,
101 Dissolve,
103 DipToBlack,
105 WipeLeft,
107 WipeRight,
109}
110
111#[derive(Debug, Clone)]
113pub struct EditPreset {
114 pub name: String,
116 pub description: String,
118 pub category: PresetCategory,
120 pub tracks: Vec<TrackLayout>,
122 pub default_transition: PresetTransition,
124 pub transition_duration: u64,
126 pub metadata: HashMap<String, String>,
128}
129
130impl EditPreset {
131 #[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 #[must_use]
147 pub fn with_description(mut self, desc: &str) -> Self {
148 self.description = desc.to_string();
149 self
150 }
151
152 #[must_use]
154 pub fn with_track(mut self, layout: TrackLayout) -> Self {
155 self.tracks.push(layout);
156 self
157 }
158
159 #[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 pub fn set_metadata(&mut self, key: &str, value: &str) {
169 self.metadata.insert(key.to_string(), value.to_string());
170 }
171
172 #[must_use]
174 pub fn get_metadata(&self, key: &str) -> Option<&str> {
175 self.metadata.get(key).map(String::as_str)
176 }
177
178 #[must_use]
180 pub fn video_track_count(&self) -> usize {
181 self.tracks.iter().filter(|t| t.is_video).count()
182 }
183
184 #[must_use]
186 pub fn audio_track_count(&self) -> usize {
187 self.tracks.iter().filter(|t| !t.is_video).count()
188 }
189}
190
191#[derive(Debug, Clone, Default)]
193pub struct PresetLibrary {
194 presets: HashMap<String, EditPreset>,
196}
197
198impl PresetLibrary {
199 #[must_use]
201 pub fn new() -> Self {
202 Self::default()
203 }
204
205 pub fn register(&mut self, preset: EditPreset) {
207 self.presets.insert(preset.name.clone(), preset);
208 }
209
210 #[must_use]
212 pub fn get(&self, name: &str) -> Option<&EditPreset> {
213 self.presets.get(name)
214 }
215
216 pub fn remove(&mut self, name: &str) -> Option<EditPreset> {
218 self.presets.remove(name)
219 }
220
221 #[must_use]
223 pub fn names(&self) -> Vec<&str> {
224 self.presets.keys().map(String::as_str).collect()
225 }
226
227 #[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 #[must_use]
238 pub fn len(&self) -> usize {
239 self.presets.len()
240 }
241
242 #[must_use]
244 pub fn is_empty(&self) -> bool {
245 self.presets.is_empty()
246 }
247
248 #[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#[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#[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#[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#[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}