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