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