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.
108#[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    /// Parse codec from string.
120    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    /// Get codec name.
133    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/// Encoder preset (affects speed/quality tradeoff).
146#[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    /// Parse preset from string.
161    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    /// Get speed factor (higher = faster but lower quality).
177    #[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
193/// Main transcode function.
194pub async fn transcode(mut options: TranscodeOptions) -> Result<()> {
195    info!("Starting transcode operation");
196    debug!("Options: {:?}", options);
197
198    // Handle preset if specified
199    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        // Apply preset settings to options
209        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        // Apply scale if resolution is specified
221        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 file
227    validate_input(&options.input).await?;
228
229    // Check output file
230    check_output(&options.output, options.overwrite).await?;
231
232    // Parse and validate codec options
233    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    // Validate CRF if specified
238    if let Some(crf) = options.crf {
239        if let Some(codec) = video_codec {
240            codec.validate_crf(crf)?;
241        }
242    }
243
244    // Parse bitrate if specified
245    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    // Parse scale if specified
258    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
265    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    // Perform the transcode
276    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 summary
301    print_transcode_summary(&options.output).await?;
302
303    Ok(())
304}
305
306/// Validate input file exists and is readable.
307async 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
327/// Check if output file exists and handle overwrite logic.
328async 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    // Ensure output directory exists
344    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
355/// Parse video codec from options.
356fn 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        // Auto-detect from output extension
361        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
373/// Parse audio codec from options.
374fn 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        // Auto-detect from output extension
379        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
394/// Parse bitrate string (e.g., "2M", "500k") to bits per second.
395fn 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
409/// Parse scale string (e.g., "1280:720", "1920:-1") to dimensions.
410fn 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/// Print the transcode plan before starting.
432#[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/// Perform single-pass transcode using the real `oximedia_transcode` pipeline.
487#[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    // Apply video codec.
506    if let Some(vc) = video_codec {
507        builder = builder.video_codec(vc.name().to_lowercase());
508    }
509
510    // Apply audio codec.
511    if let Some(ac) = audio_codec {
512        builder = builder.audio_codec(ac.name().to_lowercase());
513    }
514
515    // Apply quality / CRF config if specified.
516    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    // Apply resolution scaling when a scale is given with both dimensions.
540    if let Some((Some(_w), Some(_h))) = scale {
541        // Resolution scaling is applied inside the pipeline when video codec
542        // params carry width/height overrides.  Here we just note it in the log;
543        // the QualityConfig / resolution fields on TranscodePipeline are wired
544        // inside the pipeline executor.
545        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    // Show a simple progress indicator while the pipeline runs.
556    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/// Perform two-pass transcode using the real `oximedia_transcode` pipeline.
578#[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    // Scale hint (logged; applied inside pipeline codec config).
619    if let Some((Some(_w), Some(_h))) = scale {
620        debug!("Two-pass scale hint: {:?}", scale);
621    }
622
623    // Silence unused-variable warnings for `preset` by logging it.
624    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
651/// Print transcode summary after completion.
652async 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}