Skip to main content

oximedia_edit/
multi_export.rs

1//! Multi-format export from a single timeline.
2//!
3//! Allows exporting the same timeline to multiple resolutions, codecs,
4//! and formats in a single batch operation. Commonly used for
5//! delivering to different platforms (e.g., YouTube 4K, Twitter 720p,
6//! Instagram 1080x1080 square).
7
8#![allow(dead_code)]
9
10use std::collections::HashMap;
11
12/// Unique identifier for an export profile.
13pub type ProfileId = u64;
14
15/// A preset defining resolution, codec, and format for one export target.
16#[derive(Debug, Clone)]
17pub struct ExportProfile {
18    /// Unique ID.
19    pub id: ProfileId,
20    /// Human-readable name (e.g. "YouTube 4K", "Twitter 720p").
21    pub name: String,
22    /// Output width in pixels.
23    pub width: u32,
24    /// Output height in pixels.
25    pub height: u32,
26    /// Frame rate.
27    pub fps: f64,
28    /// Video codec preset label (e.g. "av1-crf28").
29    pub video_codec: String,
30    /// Audio codec preset label (e.g. "opus-128k").
31    pub audio_codec: String,
32    /// Container format (e.g. "webm", "mkv", "mp4").
33    pub container: String,
34    /// Whether to include video.
35    pub include_video: bool,
36    /// Whether to include audio.
37    pub include_audio: bool,
38    /// Output file suffix (appended before extension).
39    pub file_suffix: String,
40    /// Custom metadata fields.
41    pub metadata: HashMap<String, String>,
42}
43
44impl ExportProfile {
45    /// Create a new export profile.
46    #[must_use]
47    pub fn new(id: ProfileId, name: impl Into<String>) -> Self {
48        Self {
49            id,
50            name: name.into(),
51            width: 1920,
52            height: 1080,
53            fps: 30.0,
54            video_codec: "av1".to_string(),
55            audio_codec: "opus".to_string(),
56            container: "webm".to_string(),
57            include_video: true,
58            include_audio: true,
59            file_suffix: String::new(),
60            metadata: HashMap::new(),
61        }
62    }
63
64    /// Set resolution.
65    #[must_use]
66    pub fn with_resolution(mut self, width: u32, height: u32) -> Self {
67        self.width = width;
68        self.height = height;
69        self
70    }
71
72    /// Set frame rate.
73    #[must_use]
74    pub fn with_fps(mut self, fps: f64) -> Self {
75        self.fps = fps.max(1.0);
76        self
77    }
78
79    /// Set video codec.
80    #[must_use]
81    pub fn with_video_codec(mut self, codec: impl Into<String>) -> Self {
82        self.video_codec = codec.into();
83        self
84    }
85
86    /// Set audio codec.
87    #[must_use]
88    pub fn with_audio_codec(mut self, codec: impl Into<String>) -> Self {
89        self.audio_codec = codec.into();
90        self
91    }
92
93    /// Set container format.
94    #[must_use]
95    pub fn with_container(mut self, container: impl Into<String>) -> Self {
96        self.container = container.into();
97        self
98    }
99
100    /// Set file suffix.
101    #[must_use]
102    pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
103        self.file_suffix = suffix.into();
104        self
105    }
106
107    /// Returns the aspect ratio as a float.
108    #[must_use]
109    #[allow(clippy::cast_precision_loss)]
110    pub fn aspect_ratio(&self) -> f64 {
111        if self.height == 0 {
112            return 0.0;
113        }
114        self.width as f64 / self.height as f64
115    }
116
117    /// Returns the pixel count (width * height).
118    #[must_use]
119    pub fn pixel_count(&self) -> u64 {
120        u64::from(self.width) * u64::from(self.height)
121    }
122}
123
124/// Standard export profile presets.
125pub struct ProfilePresets;
126
127impl ProfilePresets {
128    /// YouTube 4K preset.
129    #[must_use]
130    pub fn youtube_4k(id: ProfileId) -> ExportProfile {
131        ExportProfile::new(id, "YouTube 4K")
132            .with_resolution(3840, 2160)
133            .with_fps(60.0)
134            .with_video_codec("av1-crf28")
135            .with_audio_codec("opus-256k")
136            .with_suffix("_yt4k")
137    }
138
139    /// YouTube 1080p preset.
140    #[must_use]
141    pub fn youtube_1080p(id: ProfileId) -> ExportProfile {
142        ExportProfile::new(id, "YouTube 1080p")
143            .with_resolution(1920, 1080)
144            .with_fps(30.0)
145            .with_video_codec("av1-crf32")
146            .with_audio_codec("opus-128k")
147            .with_suffix("_yt1080")
148    }
149
150    /// Twitter/X 720p preset.
151    #[must_use]
152    pub fn twitter_720p(id: ProfileId) -> ExportProfile {
153        ExportProfile::new(id, "Twitter 720p")
154            .with_resolution(1280, 720)
155            .with_fps(30.0)
156            .with_video_codec("vp9-crf36")
157            .with_audio_codec("opus-96k")
158            .with_suffix("_tw720")
159    }
160
161    /// Instagram square (1080x1080) preset.
162    #[must_use]
163    pub fn instagram_square(id: ProfileId) -> ExportProfile {
164        ExportProfile::new(id, "Instagram Square")
165            .with_resolution(1080, 1080)
166            .with_fps(30.0)
167            .with_video_codec("av1-crf32")
168            .with_audio_codec("opus-128k")
169            .with_suffix("_ig_sq")
170    }
171
172    /// Audio-only preset.
173    #[must_use]
174    pub fn audio_only(id: ProfileId) -> ExportProfile {
175        let mut profile = ExportProfile::new(id, "Audio Only")
176            .with_audio_codec("opus-256k")
177            .with_suffix("_audio");
178        profile.include_video = false;
179        profile.container = "ogg".to_string();
180        profile
181    }
182
183    /// Archive quality preset.
184    #[must_use]
185    pub fn archive(id: ProfileId) -> ExportProfile {
186        ExportProfile::new(id, "Archive")
187            .with_resolution(3840, 2160)
188            .with_fps(60.0)
189            .with_video_codec("ffv1")
190            .with_audio_codec("flac")
191            .with_container("mkv")
192            .with_suffix("_archive")
193    }
194}
195
196/// Status of one export in a batch.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum ExportStatus {
199    /// Waiting to start.
200    Pending,
201    /// Currently exporting.
202    InProgress,
203    /// Successfully completed.
204    Completed,
205    /// Export failed.
206    Failed,
207    /// Export was cancelled.
208    Cancelled,
209}
210
211impl ExportStatus {
212    /// Returns `true` if the export is in a terminal state.
213    #[must_use]
214    pub fn is_terminal(self) -> bool {
215        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
216    }
217}
218
219/// A single export job in a multi-export batch.
220#[derive(Debug, Clone)]
221pub struct ExportJob {
222    /// Export profile.
223    pub profile: ExportProfile,
224    /// Output file path.
225    pub output_path: String,
226    /// Status.
227    pub status: ExportStatus,
228    /// Progress (0.0 to 1.0).
229    pub progress: f64,
230    /// Error message (if failed).
231    pub error: Option<String>,
232}
233
234impl ExportJob {
235    /// Create a new export job.
236    #[must_use]
237    pub fn new(profile: ExportProfile, output_path: String) -> Self {
238        Self {
239            profile,
240            output_path,
241            status: ExportStatus::Pending,
242            progress: 0.0,
243            error: None,
244        }
245    }
246
247    /// Set the progress (clamped to 0.0-1.0).
248    pub fn set_progress(&mut self, progress: f64) {
249        self.progress = progress.clamp(0.0, 1.0);
250    }
251}
252
253/// Batch multi-format export manager.
254#[derive(Debug, Default)]
255pub struct MultiExportManager {
256    /// Export jobs.
257    jobs: Vec<ExportJob>,
258    /// Available profiles.
259    profiles: Vec<ExportProfile>,
260    /// Next profile ID.
261    next_profile_id: ProfileId,
262}
263
264impl MultiExportManager {
265    /// Create a new multi-export manager.
266    #[must_use]
267    pub fn new() -> Self {
268        Self {
269            jobs: Vec::new(),
270            profiles: Vec::new(),
271            next_profile_id: 1,
272        }
273    }
274
275    /// Create a manager with standard presets.
276    #[must_use]
277    pub fn with_standard_presets() -> Self {
278        let mut mgr = Self::new();
279        mgr.add_profile(ProfilePresets::youtube_4k(mgr.next_profile_id));
280        mgr.add_profile(ProfilePresets::youtube_1080p(mgr.next_profile_id));
281        mgr.add_profile(ProfilePresets::twitter_720p(mgr.next_profile_id));
282        mgr.add_profile(ProfilePresets::instagram_square(mgr.next_profile_id));
283        mgr.add_profile(ProfilePresets::audio_only(mgr.next_profile_id));
284        mgr.add_profile(ProfilePresets::archive(mgr.next_profile_id));
285        mgr
286    }
287
288    /// Add an export profile.
289    pub fn add_profile(&mut self, mut profile: ExportProfile) {
290        profile.id = self.next_profile_id;
291        self.next_profile_id += 1;
292        self.profiles.push(profile);
293    }
294
295    /// Get all profiles.
296    #[must_use]
297    pub fn profiles(&self) -> &[ExportProfile] {
298        &self.profiles
299    }
300
301    /// Queue an export job for a given profile.
302    pub fn queue_export(&mut self, profile_id: ProfileId, base_output: &str) -> Option<usize> {
303        let profile = self.profiles.iter().find(|p| p.id == profile_id)?.clone();
304        let output_path = format!(
305            "{}{}.{}",
306            base_output, profile.file_suffix, profile.container
307        );
308        let job = ExportJob::new(profile, output_path);
309        let index = self.jobs.len();
310        self.jobs.push(job);
311        Some(index)
312    }
313
314    /// Queue exports for all profiles.
315    pub fn queue_all(&mut self, base_output: &str) -> Vec<usize> {
316        let profile_ids: Vec<ProfileId> = self.profiles.iter().map(|p| p.id).collect();
317        let mut indices = Vec::new();
318        for id in profile_ids {
319            if let Some(idx) = self.queue_export(id, base_output) {
320                indices.push(idx);
321            }
322        }
323        indices
324    }
325
326    /// Get all queued jobs.
327    #[must_use]
328    pub fn jobs(&self) -> &[ExportJob] {
329        &self.jobs
330    }
331
332    /// Get a mutable job by index.
333    pub fn get_job_mut(&mut self, index: usize) -> Option<&mut ExportJob> {
334        self.jobs.get_mut(index)
335    }
336
337    /// Get the overall progress across all jobs.
338    #[must_use]
339    #[allow(clippy::cast_precision_loss)]
340    pub fn overall_progress(&self) -> f64 {
341        if self.jobs.is_empty() {
342            return 0.0;
343        }
344        let total: f64 = self.jobs.iter().map(|j| j.progress).sum();
345        total / self.jobs.len() as f64
346    }
347
348    /// Get count of completed jobs.
349    #[must_use]
350    pub fn completed_count(&self) -> usize {
351        self.jobs
352            .iter()
353            .filter(|j| j.status == ExportStatus::Completed)
354            .count()
355    }
356
357    /// Check if all jobs are done (terminal state).
358    #[must_use]
359    pub fn all_done(&self) -> bool {
360        !self.jobs.is_empty() && self.jobs.iter().all(|j| j.status.is_terminal())
361    }
362
363    /// Clear all jobs.
364    pub fn clear_jobs(&mut self) {
365        self.jobs.clear();
366    }
367
368    /// Get total job count.
369    #[must_use]
370    pub fn job_count(&self) -> usize {
371        self.jobs.len()
372    }
373}
374
375// ─────────────────────────────────────────────────────────────────────────────
376// Tests
377// ─────────────────────────────────────────────────────────────────────────────
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn test_export_profile_defaults() {
385        let p = ExportProfile::new(1, "Test");
386        assert_eq!(p.width, 1920);
387        assert_eq!(p.height, 1080);
388        assert!((p.fps - 30.0).abs() < 1e-9);
389    }
390
391    #[test]
392    fn test_export_profile_aspect_ratio() {
393        let p = ExportProfile::new(1, "HD").with_resolution(1920, 1080);
394        let ar = p.aspect_ratio();
395        assert!((ar - 16.0 / 9.0).abs() < 0.01);
396    }
397
398    #[test]
399    fn test_export_profile_pixel_count() {
400        let p = ExportProfile::new(1, "4K").with_resolution(3840, 2160);
401        assert_eq!(p.pixel_count(), 3840 * 2160);
402    }
403
404    #[test]
405    fn test_export_profile_zero_height() {
406        let p = ExportProfile::new(1, "Bad").with_resolution(100, 0);
407        assert!((p.aspect_ratio()).abs() < 1e-9);
408    }
409
410    #[test]
411    fn test_standard_presets() {
412        let yt4k = ProfilePresets::youtube_4k(1);
413        assert_eq!(yt4k.width, 3840);
414        assert_eq!(yt4k.height, 2160);
415
416        let tw = ProfilePresets::twitter_720p(2);
417        assert_eq!(tw.width, 1280);
418        assert_eq!(tw.height, 720);
419
420        let ig = ProfilePresets::instagram_square(3);
421        assert_eq!(ig.width, 1080);
422        assert_eq!(ig.height, 1080);
423
424        let audio = ProfilePresets::audio_only(4);
425        assert!(!audio.include_video);
426
427        let archive = ProfilePresets::archive(5);
428        assert_eq!(archive.container, "mkv");
429    }
430
431    #[test]
432    fn test_export_status() {
433        assert!(ExportStatus::Completed.is_terminal());
434        assert!(ExportStatus::Failed.is_terminal());
435        assert!(ExportStatus::Cancelled.is_terminal());
436        assert!(!ExportStatus::Pending.is_terminal());
437        assert!(!ExportStatus::InProgress.is_terminal());
438    }
439
440    #[test]
441    fn test_export_job_progress() {
442        let profile = ExportProfile::new(1, "Test");
443        let mut job = ExportJob::new(profile, "/out/test.webm".to_string());
444        assert!((job.progress).abs() < 1e-9);
445        job.set_progress(0.5);
446        assert!((job.progress - 0.5).abs() < 1e-9);
447        job.set_progress(1.5);
448        assert!((job.progress - 1.0).abs() < 1e-9);
449    }
450
451    #[test]
452    fn test_multi_export_manager() {
453        let mut mgr = MultiExportManager::new();
454        mgr.add_profile(ProfilePresets::youtube_1080p(0));
455        mgr.add_profile(ProfilePresets::twitter_720p(0));
456        assert_eq!(mgr.profiles().len(), 2);
457    }
458
459    #[test]
460    fn test_queue_export() {
461        let mut mgr = MultiExportManager::with_standard_presets();
462        let profile_id = mgr.profiles()[0].id;
463        let idx = mgr.queue_export(profile_id, "/out/video");
464        assert!(idx.is_some());
465        assert_eq!(mgr.job_count(), 1);
466
467        // Non-existent profile
468        assert!(mgr.queue_export(999, "/out/video").is_none());
469    }
470
471    #[test]
472    fn test_queue_all() {
473        let mut mgr = MultiExportManager::with_standard_presets();
474        let indices = mgr.queue_all("/out/project");
475        assert_eq!(indices.len(), mgr.profiles().len());
476        assert_eq!(mgr.job_count(), mgr.profiles().len());
477    }
478
479    #[test]
480    fn test_overall_progress() {
481        let mut mgr = MultiExportManager::new();
482        assert!((mgr.overall_progress()).abs() < 1e-9);
483
484        mgr.add_profile(ExportProfile::new(0, "A"));
485        mgr.add_profile(ExportProfile::new(0, "B"));
486        mgr.queue_all("/out/test");
487
488        mgr.get_job_mut(0).expect("job exists").set_progress(0.5);
489        mgr.get_job_mut(1).expect("job exists").set_progress(1.0);
490        assert!((mgr.overall_progress() - 0.75).abs() < 1e-9);
491    }
492
493    #[test]
494    fn test_all_done() {
495        let mut mgr = MultiExportManager::new();
496        mgr.add_profile(ExportProfile::new(0, "A"));
497        mgr.queue_all("/out/test");
498        assert!(!mgr.all_done());
499
500        mgr.get_job_mut(0).expect("job exists").status = ExportStatus::Completed;
501        assert!(mgr.all_done());
502    }
503
504    #[test]
505    fn test_completed_count() {
506        let mut mgr = MultiExportManager::new();
507        mgr.add_profile(ExportProfile::new(0, "A"));
508        mgr.add_profile(ExportProfile::new(0, "B"));
509        mgr.queue_all("/out/test");
510
511        assert_eq!(mgr.completed_count(), 0);
512        mgr.get_job_mut(0).expect("job exists").status = ExportStatus::Completed;
513        assert_eq!(mgr.completed_count(), 1);
514    }
515
516    #[test]
517    fn test_clear_jobs() {
518        let mut mgr = MultiExportManager::new();
519        mgr.add_profile(ExportProfile::new(0, "A"));
520        mgr.queue_all("/out/test");
521        mgr.clear_jobs();
522        assert_eq!(mgr.job_count(), 0);
523    }
524}