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::{ProgressFormat, 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    /// Progress output format for this transcode operation.
48    #[allow(dead_code)]
49    pub progress_format: ProgressFormat,
50}
51
52/// Supported video codecs (patent-free only).
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum VideoCodec {
55    Av1,
56    Vp9,
57    Vp8,
58}
59
60impl VideoCodec {
61    /// Parse codec from string.
62    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    /// Get codec name.
72    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    /// Get default CRF range for this codec.
81    #[allow(dead_code)]
82    pub fn default_crf(&self) -> u32 {
83        match self {
84            Self::Av1 => 30, // 0-255 range
85            Self::Vp9 => 31, // 0-63 range
86            Self::Vp8 => 10, // 0-63 range
87        }
88    }
89
90    /// Validate CRF value for this codec.
91    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/// Supported audio codecs.
111#[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    /// Parse codec from string.
123    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    /// Get codec name.
136    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/// Encoder preset (affects speed/quality tradeoff).
149#[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    /// Parse preset from string.
164    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    /// Get speed factor (higher = faster but lower quality).
180    #[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
196/// Main transcode function.
197pub async fn transcode(mut options: TranscodeOptions) -> Result<()> {
198    info!("Starting transcode operation");
199    debug!("Options: {:?}", options);
200
201    // Handle preset if specified
202    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        // Apply preset settings to options
212        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        // Apply scale if resolution is specified
224        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 file
230    validate_input(&options.input).await?;
231
232    // Check output file
233    check_output(&options.output, options.overwrite).await?;
234
235    // Parse and validate codec options
236    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    // Validate CRF if specified
241    if let Some(crf) = options.crf {
242        if let Some(codec) = video_codec {
243            codec.validate_crf(crf)?;
244        }
245    }
246
247    // Parse bitrate if specified
248    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    // Parse scale if specified
261    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
268    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    // Perform the transcode
279    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 summary
304    print_transcode_summary(&options.output).await?;
305
306    Ok(())
307}
308
309/// Validate input file exists and is readable.
310async 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
330/// Check if output file exists and handle overwrite logic.
331async 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    // Ensure output directory exists
347    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
358/// Parse video codec from options.
359fn 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        // Auto-detect from output extension
364        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
376/// Parse audio codec from options.
377fn 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        // Auto-detect from output extension
382        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
397/// Parse bitrate string (e.g., "2M", "500k") to bits per second.
398fn 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
412/// Parse scale string (e.g., "1280:720", "1920:-1") to dimensions.
413fn 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/// Print the transcode plan before starting.
435#[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/// Perform single-pass transcode using the real `oximedia_transcode` pipeline.
490#[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    // Apply video codec.
509    if let Some(vc) = video_codec {
510        builder = builder.video_codec(vc.name().to_lowercase());
511    }
512
513    // Apply audio codec.
514    if let Some(ac) = audio_codec {
515        builder = builder.audio_codec(ac.name().to_lowercase());
516    }
517
518    // Apply quality / CRF config if specified.
519    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    // Apply resolution scaling when a scale is given with both dimensions.
543    if let Some((Some(_w), Some(_h))) = scale {
544        // Resolution scaling is applied inside the pipeline when video codec
545        // params carry width/height overrides.  Here we just note it in the log;
546        // the QualityConfig / resolution fields on TranscodePipeline are wired
547        // inside the pipeline executor.
548        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    // Show a simple progress indicator while the pipeline runs.
559    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/// Perform two-pass transcode using the real `oximedia_transcode` pipeline.
581#[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    // Scale hint (logged; applied inside pipeline codec config).
622    if let Some((Some(_w), Some(_h))) = scale {
623        debug!("Two-pass scale hint: {:?}", scale);
624    }
625
626    // Silence unused-variable warnings for `preset` by logging it.
627    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
654/// Print transcode summary after completion.
655async 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}