1use crate::progress::TranscodeProgress;
11use anyhow::{anyhow, Context, Result};
12use colored::Colorize;
13use std::fs;
14use std::path::{Path, PathBuf};
15use tracing::{debug, info};
16
17#[derive(Debug, Clone)]
19pub struct TranscodeOptions {
20 pub input: PathBuf,
21 pub output: PathBuf,
22 pub preset_name: Option<String>,
23 pub video_codec: Option<String>,
24 pub audio_codec: Option<String>,
25 pub video_bitrate: Option<String>,
26 pub audio_bitrate: Option<String>,
27 pub scale: Option<String>,
28 #[allow(dead_code)]
29 pub video_filter: Option<String>,
30 #[allow(dead_code)]
32 pub audio_filter: Option<String>,
33 #[allow(dead_code)]
34 pub start_time: Option<String>,
35 #[allow(dead_code)]
36 pub duration: Option<String>,
37 #[allow(dead_code)]
38 pub framerate: Option<String>,
39 pub preset: String,
40 pub two_pass: bool,
41 pub crf: Option<u32>,
42 #[allow(dead_code)]
43 pub threads: usize,
44 pub overwrite: bool,
45 #[allow(dead_code)]
46 pub resume: bool,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum VideoCodec {
52 Av1,
53 Vp9,
54 Vp8,
55}
56
57impl VideoCodec {
58 pub fn from_str(s: &str) -> Result<Self> {
60 match s.to_lowercase().as_str() {
61 "av1" | "libaom-av1" => Ok(Self::Av1),
62 "vp9" | "libvpx-vp9" => Ok(Self::Vp9),
63 "vp8" | "libvpx" => Ok(Self::Vp8),
64 _ => Err(anyhow!("Unsupported video codec: {}", s)),
65 }
66 }
67
68 pub fn name(&self) -> &'static str {
70 match self {
71 Self::Av1 => "AV1",
72 Self::Vp9 => "VP9",
73 Self::Vp8 => "VP8",
74 }
75 }
76
77 #[allow(dead_code)]
79 pub fn default_crf(&self) -> u32 {
80 match self {
81 Self::Av1 => 30, Self::Vp9 => 31, Self::Vp8 => 10, }
85 }
86
87 pub fn validate_crf(&self, crf: u32) -> Result<()> {
89 let max = match self {
90 Self::Av1 => 255,
91 Self::Vp9 | Self::Vp8 => 63,
92 };
93
94 if crf > max {
95 Err(anyhow!(
96 "CRF {} is out of range for {} (max: {})",
97 crf,
98 self.name(),
99 max
100 ))
101 } else {
102 Ok(())
103 }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum AudioCodec {
110 Opus,
111 Vorbis,
112 Flac,
113 Pcm,
114 Aac,
115 Mp3,
116}
117
118impl AudioCodec {
119 pub fn from_str(s: &str) -> Result<Self> {
121 match s.to_lowercase().as_str() {
122 "opus" | "libopus" => Ok(Self::Opus),
123 "vorbis" | "libvorbis" => Ok(Self::Vorbis),
124 "flac" => Ok(Self::Flac),
125 "pcm" | "pcm_s16le" | "pcm_s24le" | "pcm_f32le" | "wav" => Ok(Self::Pcm),
126 "aac" | "libfdk_aac" => Ok(Self::Aac),
127 "mp3" | "libmp3lame" | "lame" => Ok(Self::Mp3),
128 _ => Err(anyhow!("Unsupported audio codec: {}", s)),
129 }
130 }
131
132 pub fn name(&self) -> &'static str {
134 match self {
135 Self::Opus => "Opus",
136 Self::Vorbis => "Vorbis",
137 Self::Flac => "FLAC",
138 Self::Pcm => "PCM",
139 Self::Aac => "AAC",
140 Self::Mp3 => "MP3",
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum EncoderPreset {
148 Ultrafast,
149 Superfast,
150 Veryfast,
151 Faster,
152 Fast,
153 Medium,
154 Slow,
155 Slower,
156 Veryslow,
157}
158
159impl EncoderPreset {
160 pub fn from_str(s: &str) -> Result<Self> {
162 match s.to_lowercase().as_str() {
163 "ultrafast" => Ok(Self::Ultrafast),
164 "superfast" => Ok(Self::Superfast),
165 "veryfast" => Ok(Self::Veryfast),
166 "faster" => Ok(Self::Faster),
167 "fast" => Ok(Self::Fast),
168 "medium" => Ok(Self::Medium),
169 "slow" => Ok(Self::Slow),
170 "slower" => Ok(Self::Slower),
171 "veryslow" => Ok(Self::Veryslow),
172 _ => Err(anyhow!("Unknown preset: {}", s)),
173 }
174 }
175
176 #[allow(dead_code)]
178 pub fn speed_factor(&self) -> u32 {
179 match self {
180 Self::Ultrafast => 9,
181 Self::Superfast => 8,
182 Self::Veryfast => 7,
183 Self::Faster => 6,
184 Self::Fast => 5,
185 Self::Medium => 4,
186 Self::Slow => 3,
187 Self::Slower => 2,
188 Self::Veryslow => 1,
189 }
190 }
191}
192
193pub async fn transcode(mut options: TranscodeOptions) -> Result<()> {
195 info!("Starting transcode operation");
196 debug!("Options: {:?}", options);
197
198 if let Some(ref preset_name) = options.preset_name {
200 use crate::presets::PresetManager;
201
202 let custom_dir = PresetManager::default_custom_dir()?;
203 let manager = PresetManager::with_custom_dir(&custom_dir)?;
204 let preset = manager.get_preset(preset_name)?;
205
206 info!("Using preset: {} - {}", preset.name, preset.description);
207
208 options.video_codec = Some(preset.video.codec.clone());
210 options.audio_codec = Some(preset.audio.codec.clone());
211 options.video_bitrate = preset.video.bitrate.clone();
212 options.audio_bitrate = preset.audio.bitrate.clone();
213 options.crf = preset.video.crf;
214 options.two_pass = preset.video.two_pass;
215
216 if let Some(ref preset_name) = preset.video.preset {
217 options.preset = preset_name.clone();
218 }
219
220 if let (Some(width), Some(height)) = (preset.video.width, preset.video.height) {
222 options.scale = Some(format!("{}:{}", width, height));
223 }
224 }
225
226 validate_input(&options.input).await?;
228
229 check_output(&options.output, options.overwrite).await?;
231
232 let video_codec = parse_video_codec(&options)?;
234 let audio_codec = parse_audio_codec(&options)?;
235 let preset = EncoderPreset::from_str(&options.preset)?;
236
237 if let Some(crf) = options.crf {
239 if let Some(codec) = video_codec {
240 codec.validate_crf(crf)?;
241 }
242 }
243
244 let video_bitrate = if let Some(ref br) = options.video_bitrate {
246 Some(parse_bitrate(br)?)
247 } else {
248 None
249 };
250
251 let audio_bitrate = if let Some(ref br) = options.audio_bitrate {
252 Some(parse_bitrate(br)?)
253 } else {
254 None
255 };
256
257 let scale_dimensions = if let Some(ref scale) = options.scale {
259 Some(parse_scale(scale)?)
260 } else {
261 None
262 };
263
264 print_transcode_plan(
266 &options,
267 video_codec,
268 audio_codec,
269 preset,
270 video_bitrate,
271 audio_bitrate,
272 scale_dimensions,
273 );
274
275 if options.two_pass {
277 info!("Using two-pass encoding");
278 transcode_two_pass(
279 &options,
280 video_codec,
281 audio_codec,
282 preset,
283 video_bitrate,
284 scale_dimensions,
285 )
286 .await?;
287 } else {
288 info!("Using single-pass encoding");
289 transcode_single_pass(
290 &options,
291 video_codec,
292 audio_codec,
293 preset,
294 video_bitrate,
295 scale_dimensions,
296 )
297 .await?;
298 }
299
300 print_transcode_summary(&options.output).await?;
302
303 Ok(())
304}
305
306async fn validate_input(path: &Path) -> Result<()> {
308 if !path.exists() {
309 return Err(anyhow!("Input file does not exist: {}", path.display()));
310 }
311
312 if !path.is_file() {
313 return Err(anyhow!("Input path is not a file: {}", path.display()));
314 }
315
316 let metadata = tokio::fs::metadata(path)
317 .await
318 .context("Failed to read input file metadata")?;
319
320 if metadata.len() == 0 {
321 return Err(anyhow!("Input file is empty"));
322 }
323
324 Ok(())
325}
326
327async fn check_output(path: &Path, overwrite: bool) -> Result<()> {
329 if path.exists() {
330 if overwrite {
331 info!(
332 "Output file exists, will be overwritten: {}",
333 path.display()
334 );
335 } else {
336 return Err(anyhow!(
337 "Output file already exists: {}. Use -y to overwrite.",
338 path.display()
339 ));
340 }
341 }
342
343 if let Some(parent) = path.parent() {
345 if !parent.exists() {
346 tokio::fs::create_dir_all(parent)
347 .await
348 .context("Failed to create output directory")?;
349 }
350 }
351
352 Ok(())
353}
354
355fn parse_video_codec(options: &TranscodeOptions) -> Result<Option<VideoCodec>> {
357 if let Some(ref codec) = options.video_codec {
358 Ok(Some(VideoCodec::from_str(codec)?))
359 } else {
360 if let Some(ext) = options.output.extension() {
362 match ext.to_str() {
363 Some("webm") => Ok(Some(VideoCodec::Vp9)),
364 Some("mkv") => Ok(Some(VideoCodec::Av1)),
365 _ => Ok(None),
366 }
367 } else {
368 Ok(None)
369 }
370 }
371}
372
373fn parse_audio_codec(options: &TranscodeOptions) -> Result<Option<AudioCodec>> {
375 if let Some(ref codec) = options.audio_codec {
376 Ok(Some(AudioCodec::from_str(codec)?))
377 } else {
378 if let Some(ext) = options.output.extension() {
380 match ext.to_str() {
381 Some("webm") | Some("mkv") => Ok(Some(AudioCodec::Opus)),
382 Some("flac") => Ok(Some(AudioCodec::Flac)),
383 Some("wav") => Ok(Some(AudioCodec::Pcm)),
384 Some("mp4") | Some("m4a") => Ok(Some(AudioCodec::Aac)),
385 Some("mp3") => Ok(Some(AudioCodec::Mp3)),
386 _ => Ok(None),
387 }
388 } else {
389 Ok(None)
390 }
391 }
392}
393
394fn parse_bitrate(s: &str) -> Result<u64> {
396 let s = s.trim().to_lowercase();
397
398 if let Some(stripped) = s.strip_suffix('m') {
399 let value: f64 = stripped.parse().context("Invalid bitrate format")?;
400 Ok((value * 1_000_000.0) as u64)
401 } else if let Some(stripped) = s.strip_suffix('k') {
402 let value: f64 = stripped.parse().context("Invalid bitrate format")?;
403 Ok((value * 1_000.0) as u64)
404 } else {
405 s.parse::<u64>().context("Invalid bitrate format")
406 }
407}
408
409fn parse_scale(s: &str) -> Result<(Option<u32>, Option<u32>)> {
411 let parts: Vec<&str> = s.split(':').collect();
412 if parts.len() != 2 {
413 return Err(anyhow!("Invalid scale format. Expected 'width:height'"));
414 }
415
416 let width = if parts[0] == "-1" {
417 None
418 } else {
419 Some(parts[0].parse().context("Invalid width")?)
420 };
421
422 let height = if parts[1] == "-1" {
423 None
424 } else {
425 Some(parts[1].parse().context("Invalid height")?)
426 };
427
428 Ok((width, height))
429}
430
431#[allow(clippy::too_many_arguments)]
433fn print_transcode_plan(
434 options: &TranscodeOptions,
435 video_codec: Option<VideoCodec>,
436 audio_codec: Option<AudioCodec>,
437 preset: EncoderPreset,
438 video_bitrate: Option<u64>,
439 audio_bitrate: Option<u64>,
440 scale: Option<(Option<u32>, Option<u32>)>,
441) {
442 println!("{}", "Transcode Plan".cyan().bold());
443 println!("{}", "=".repeat(60));
444 println!("{:20} {}", "Input:", options.input.display());
445 println!("{:20} {}", "Output:", options.output.display());
446
447 if let Some(codec) = video_codec {
448 println!("{:20} {}", "Video Codec:", codec.name());
449 }
450
451 if let Some(codec) = audio_codec {
452 println!("{:20} {}", "Audio Codec:", codec.name());
453 }
454
455 println!("{:20} {:?}", "Preset:", preset);
456
457 if let Some(bitrate) = video_bitrate {
458 println!("{:20} {} bps", "Video Bitrate:", bitrate);
459 }
460
461 if let Some(bitrate) = audio_bitrate {
462 println!("{:20} {} bps", "Audio Bitrate:", bitrate);
463 }
464
465 if let Some((w, h)) = scale {
466 println!(
467 "{:20} {}x{}",
468 "Scale:",
469 w.map_or("-1".to_string(), |v| v.to_string()),
470 h.map_or("-1".to_string(), |v| v.to_string())
471 );
472 }
473
474 if options.two_pass {
475 println!("{:20} {}", "Mode:", "Two-pass".yellow());
476 }
477
478 if let Some(crf) = options.crf {
479 println!("{:20} {}", "CRF:", crf);
480 }
481
482 println!("{}", "=".repeat(60));
483 println!();
484}
485
486#[allow(dead_code)]
488async fn transcode_single_pass(
489 options: &TranscodeOptions,
490 video_codec: Option<VideoCodec>,
491 audio_codec: Option<AudioCodec>,
492 _preset: EncoderPreset,
493 video_bitrate: Option<u64>,
494 scale: Option<(Option<u32>, Option<u32>)>,
495) -> Result<()> {
496 use oximedia_transcode::TranscodePipeline;
497
498 info!("Starting single-pass encode");
499
500 let mut builder = TranscodePipeline::builder()
501 .input(options.input.clone())
502 .output(options.output.clone())
503 .track_progress(true);
504
505 if let Some(vc) = video_codec {
507 builder = builder.video_codec(vc.name().to_lowercase());
508 }
509
510 if let Some(ac) = audio_codec {
512 builder = builder.audio_codec(ac.name().to_lowercase());
513 }
514
515 if let Some(crf) = options.crf {
517 use oximedia_transcode::{QualityConfig, QualityPreset, RateControlMode};
518 let crf_u8 = u8::try_from(crf.min(255)).unwrap_or(30);
519 let qconfig = QualityConfig {
520 preset: QualityPreset::Medium,
521 rate_control: RateControlMode::Crf(crf_u8),
522 two_pass: false,
523 lookahead: None,
524 tune: None,
525 };
526 builder = builder.quality(qconfig);
527 } else if let Some(bitrate) = video_bitrate {
528 use oximedia_transcode::{QualityConfig, QualityPreset, RateControlMode};
529 let qconfig = QualityConfig {
530 preset: QualityPreset::Medium,
531 rate_control: RateControlMode::Cbr(bitrate),
532 two_pass: false,
533 lookahead: None,
534 tune: None,
535 };
536 builder = builder.quality(qconfig);
537 }
538
539 if let Some((Some(_w), Some(_h))) = scale {
541 debug!(
546 "Scale requested: {:?} — applied via pipeline codec config",
547 scale
548 );
549 }
550
551 let mut pipeline = builder
552 .build()
553 .context("Failed to build transcode pipeline")?;
554
555 let progress = TranscodeProgress::new(0);
557
558 let result = pipeline.execute().await;
559
560 progress.finish();
561
562 match result {
563 Ok(output) => {
564 info!(
565 "Single-pass encode complete: {} bytes in {:.2}s (speed {:.2}×)",
566 output.file_size, output.encoding_time, output.speed_factor
567 );
568 }
569 Err(e) => {
570 return Err(anyhow!("Transcode pipeline failed: {}", e));
571 }
572 }
573
574 Ok(())
575}
576
577#[allow(dead_code)]
579async fn transcode_two_pass(
580 options: &TranscodeOptions,
581 video_codec: Option<VideoCodec>,
582 audio_codec: Option<AudioCodec>,
583 preset: EncoderPreset,
584 video_bitrate: Option<u64>,
585 scale: Option<(Option<u32>, Option<u32>)>,
586) -> Result<()> {
587 use oximedia_transcode::{MultiPassMode, TranscodePipeline};
588
589 info!("Starting two-pass encode");
590
591 let mut builder = TranscodePipeline::builder()
592 .input(options.input.clone())
593 .output(options.output.clone())
594 .multipass(MultiPassMode::TwoPass)
595 .track_progress(true);
596
597 if let Some(vc) = video_codec {
598 builder = builder.video_codec(vc.name().to_lowercase());
599 }
600 if let Some(ac) = audio_codec {
601 builder = builder.audio_codec(ac.name().to_lowercase());
602 }
603 if let Some(bitrate) = video_bitrate {
604 use oximedia_transcode::{QualityConfig, QualityPreset, RateControlMode};
605 let qconfig = QualityConfig {
606 preset: QualityPreset::Medium,
607 rate_control: RateControlMode::Vbr {
608 target: bitrate,
609 max: bitrate + bitrate / 4,
610 },
611 two_pass: true,
612 lookahead: Some(16),
613 tune: None,
614 };
615 builder = builder.quality(qconfig);
616 }
617
618 if let Some((Some(_w), Some(_h))) = scale {
620 debug!("Two-pass scale hint: {:?}", scale);
621 }
622
623 debug!("Encoder preset: {:?}", preset);
625
626 println!("\n{}", "Two-pass transcode starting...".yellow().bold());
627
628 let mut pipeline = builder
629 .build()
630 .context("Failed to build two-pass transcode pipeline")?;
631
632 let progress = TranscodeProgress::new(0);
633 let result = pipeline.execute().await;
634 progress.finish();
635
636 match result {
637 Ok(output) => {
638 info!(
639 "Two-pass encode complete: {} bytes in {:.2}s (speed {:.2}×)",
640 output.file_size, output.encoding_time, output.speed_factor
641 );
642 }
643 Err(e) => {
644 return Err(anyhow!("Two-pass transcode pipeline failed: {}", e));
645 }
646 }
647
648 Ok(())
649}
650
651async fn print_transcode_summary(output: &Path) -> Result<()> {
653 let metadata = fs::metadata(output).context("Failed to read output file metadata")?;
654
655 println!();
656 println!("{}", "Transcode Complete".green().bold());
657 println!("{}", "=".repeat(60));
658 println!("{:20} {}", "Output File:", output.display());
659 println!(
660 "{:20} {:.2} MB",
661 "File Size:",
662 metadata.len() as f64 / 1_048_576.0
663 );
664 println!("{}", "=".repeat(60));
665
666 Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn test_parse_bitrate() {
675 assert_eq!(parse_bitrate("2M").expect("2M should parse"), 2_000_000);
676 assert_eq!(parse_bitrate("500k").expect("500k should parse"), 500_000);
677 assert_eq!(parse_bitrate("1000").expect("1000 should parse"), 1000);
678 }
679
680 #[test]
681 fn test_parse_scale() {
682 assert_eq!(
683 parse_scale("1280:720").expect("1280:720 should parse"),
684 (Some(1280), Some(720))
685 );
686 assert_eq!(
687 parse_scale("1920:-1").expect("1920:-1 should parse"),
688 (Some(1920), None)
689 );
690 assert_eq!(
691 parse_scale("-1:1080").expect("-1:1080 should parse"),
692 (None, Some(1080))
693 );
694 }
695
696 #[test]
697 fn test_video_codec_parsing() {
698 assert_eq!(
699 VideoCodec::from_str("av1").expect("av1 should parse"),
700 VideoCodec::Av1
701 );
702 assert_eq!(
703 VideoCodec::from_str("vp9").expect("vp9 should parse"),
704 VideoCodec::Vp9
705 );
706 assert_eq!(
707 VideoCodec::from_str("vp8").expect("vp8 should parse"),
708 VideoCodec::Vp8
709 );
710 assert!(VideoCodec::from_str("h264").is_err());
711 }
712
713 #[test]
714 fn test_audio_codec_parsing() {
715 assert_eq!(
716 AudioCodec::from_str("opus").expect("opus should parse"),
717 AudioCodec::Opus
718 );
719 assert_eq!(
720 AudioCodec::from_str("vorbis").expect("vorbis should parse"),
721 AudioCodec::Vorbis
722 );
723 assert_eq!(
724 AudioCodec::from_str("flac").expect("flac should parse"),
725 AudioCodec::Flac
726 );
727 assert_eq!(
728 AudioCodec::from_str("pcm").expect("pcm should parse"),
729 AudioCodec::Pcm
730 );
731 assert_eq!(
732 AudioCodec::from_str("pcm_s16le").expect("pcm_s16le should parse"),
733 AudioCodec::Pcm
734 );
735 assert_eq!(
736 AudioCodec::from_str("wav").expect("wav should parse"),
737 AudioCodec::Pcm
738 );
739 assert_eq!(
740 AudioCodec::from_str("aac").expect("aac should parse"),
741 AudioCodec::Aac
742 );
743 assert_eq!(
744 AudioCodec::from_str("libfdk_aac").expect("libfdk_aac should parse"),
745 AudioCodec::Aac
746 );
747 assert_eq!(
748 AudioCodec::from_str("mp3").expect("mp3 should parse"),
749 AudioCodec::Mp3
750 );
751 assert_eq!(
752 AudioCodec::from_str("libmp3lame").expect("libmp3lame should parse"),
753 AudioCodec::Mp3
754 );
755 assert_eq!(
756 AudioCodec::from_str("lame").expect("lame should parse"),
757 AudioCodec::Mp3
758 );
759 assert!(AudioCodec::from_str("unknown_codec").is_err());
760 }
761
762 #[test]
763 fn test_crf_validation() {
764 let av1 = VideoCodec::Av1;
765 assert!(av1.validate_crf(30).is_ok());
766 assert!(av1.validate_crf(255).is_ok());
767 assert!(av1.validate_crf(256).is_err());
768
769 let vp9 = VideoCodec::Vp9;
770 assert!(vp9.validate_crf(31).is_ok());
771 assert!(vp9.validate_crf(63).is_ok());
772 assert!(vp9.validate_crf(64).is_err());
773 }
774}