1#![allow(dead_code)]
9
10use std::collections::HashMap;
11
12pub type ProfileId = u64;
14
15#[derive(Debug, Clone)]
17pub struct ExportProfile {
18 pub id: ProfileId,
20 pub name: String,
22 pub width: u32,
24 pub height: u32,
26 pub fps: f64,
28 pub video_codec: String,
30 pub audio_codec: String,
32 pub container: String,
34 pub include_video: bool,
36 pub include_audio: bool,
38 pub file_suffix: String,
40 pub metadata: HashMap<String, String>,
42}
43
44impl ExportProfile {
45 #[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 #[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 #[must_use]
74 pub fn with_fps(mut self, fps: f64) -> Self {
75 self.fps = fps.max(1.0);
76 self
77 }
78
79 #[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 #[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 #[must_use]
95 pub fn with_container(mut self, container: impl Into<String>) -> Self {
96 self.container = container.into();
97 self
98 }
99
100 #[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 #[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 #[must_use]
119 pub fn pixel_count(&self) -> u64 {
120 u64::from(self.width) * u64::from(self.height)
121 }
122}
123
124pub struct ProfilePresets;
126
127impl ProfilePresets {
128 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum ExportStatus {
199 Pending,
201 InProgress,
203 Completed,
205 Failed,
207 Cancelled,
209}
210
211impl ExportStatus {
212 #[must_use]
214 pub fn is_terminal(self) -> bool {
215 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
216 }
217}
218
219#[derive(Debug, Clone)]
221pub struct ExportJob {
222 pub profile: ExportProfile,
224 pub output_path: String,
226 pub status: ExportStatus,
228 pub progress: f64,
230 pub error: Option<String>,
232}
233
234impl ExportJob {
235 #[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 pub fn set_progress(&mut self, progress: f64) {
249 self.progress = progress.clamp(0.0, 1.0);
250 }
251}
252
253#[derive(Debug, Default)]
255pub struct MultiExportManager {
256 jobs: Vec<ExportJob>,
258 profiles: Vec<ExportProfile>,
260 next_profile_id: ProfileId,
262}
263
264impl MultiExportManager {
265 #[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 #[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 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 #[must_use]
297 pub fn profiles(&self) -> &[ExportProfile] {
298 &self.profiles
299 }
300
301 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 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 #[must_use]
328 pub fn jobs(&self) -> &[ExportJob] {
329 &self.jobs
330 }
331
332 pub fn get_job_mut(&mut self, index: usize) -> Option<&mut ExportJob> {
334 self.jobs.get_mut(index)
335 }
336
337 #[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 #[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 #[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 pub fn clear_jobs(&mut self) {
365 self.jobs.clear();
366 }
367
368 #[must_use]
370 pub fn job_count(&self) -> usize {
371 self.jobs.len()
372 }
373}
374
375#[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 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}