Skip to main content

oximedia_cli/
transcode.rs

1//! Transcoding operations for converting media files.
2//!
3//! Provides transcode command implementation with:
4//! - Codec selection and validation
5//! - Filter chain construction
6//! - Format detection
7//! - Multi-pass encoding
8//! - Resume capability
9
10use 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/// Options for transcode operation.
18#[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    /// Audio filtergraph string (e.g. `"loudnorm=I=-23:TP=-2:LRA=11,volume=0.5"`).
31    #[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/// Supported video codecs (patent-free only).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum VideoCodec {
52    Av1,
53    Vp9,
54    Vp8,
55}
56
57impl VideoCodec {
58    /// Parse codec from string.
59    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    /// Get codec name.
69    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    /// Get default CRF range for this codec.
78    #[allow(dead_code)]
79    pub fn default_crf(&self) -> u32 {
80        match self {
81            Self::Av1 => 30, // 0-255 range
82            Self::Vp9 => 31, // 0-63 range
83            Self::Vp8 => 10, // 0-63 range
84        }
85    }
86
87    /// Validate CRF value for this codec.
88    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/// Supported audio codecs (patent-free only).
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum AudioCodec {
110    Opus,
111    Vorbis,
112    Flac,
113}
114
115impl AudioCodec {
116    /// Parse codec from string.
117    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    /// Get codec name.
127    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/// Encoder preset (affects speed/quality tradeoff).
137#[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    /// Parse preset from string.
152    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    /// Get speed factor (higher = faster but lower quality).
168    #[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
184/// Main transcode function.
185pub async fn transcode(mut options: TranscodeOptions) -> Result<()> {
186    info!("Starting transcode operation");
187    debug!("Options: {:?}", options);
188
189    // Handle preset if specified
190    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        // Apply preset settings to options
200        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        // Apply scale if resolution is specified
212        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 file
218    validate_input(&options.input).await?;
219
220    // Check output file
221    check_output(&options.output, options.overwrite).await?;
222
223    // Parse and validate codec options
224    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    // Validate CRF if specified
229    if let Some(crf) = options.crf {
230        if let Some(codec) = video_codec {
231            codec.validate_crf(crf)?;
232        }
233    }
234
235    // Parse bitrate if specified
236    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    // Parse scale if specified
249    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
256    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    // Perform the transcode
267    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 summary
292    print_transcode_summary(&options.output).await?;
293
294    Ok(())
295}
296
297/// Validate input file exists and is readable.
298async 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
318/// Check if output file exists and handle overwrite logic.
319async 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    // Ensure output directory exists
335    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
346/// Parse video codec from options.
347fn 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        // Auto-detect from output extension
352        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
364/// Parse audio codec from options.
365fn 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        // Auto-detect from output extension
370        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
382/// Parse bitrate string (e.g., "2M", "500k") to bits per second.
383fn 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
397/// Parse scale string (e.g., "1280:720", "1920:-1") to dimensions.
398fn 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/// Print the transcode plan before starting.
420#[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/// Perform single-pass transcode using the real `oximedia_transcode` pipeline.
475#[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    // Apply video codec.
494    if let Some(vc) = video_codec {
495        builder = builder.video_codec(vc.name().to_lowercase());
496    }
497
498    // Apply audio codec.
499    if let Some(ac) = audio_codec {
500        builder = builder.audio_codec(ac.name().to_lowercase());
501    }
502
503    // Apply quality / CRF config if specified.
504    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    // Apply resolution scaling when a scale is given with both dimensions.
528    if let Some((Some(_w), Some(_h))) = scale {
529        // Resolution scaling is applied inside the pipeline when video codec
530        // params carry width/height overrides.  Here we just note it in the log;
531        // the QualityConfig / resolution fields on TranscodePipeline are wired
532        // inside the pipeline executor.
533        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    // Show a simple progress indicator while the pipeline runs.
544    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/// Perform two-pass transcode using the real `oximedia_transcode` pipeline.
566#[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    // Scale hint (logged; applied inside pipeline codec config).
607    if let Some((Some(_w), Some(_h))) = scale {
608        debug!("Two-pass scale hint: {:?}", scale);
609    }
610
611    // Silence unused-variable warnings for `preset` by logging it.
612    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
639/// Print transcode summary after completion.
640async 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}