Skip to main content

mcraw_tui/
export.rs

1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2pub enum CodecFamily {
3    ProRes,
4    DNxHR,
5    HEVC,
6    H264,
7    AV1,
8    VP9,
9}
10
11impl CodecFamily {
12    pub fn name(&self) -> &'static str {
13        match self {
14            CodecFamily::ProRes => "ProRes",
15            CodecFamily::DNxHR => "DNxHR",
16            CodecFamily::HEVC => "HEVC",
17            CodecFamily::H264 => "H.264",
18            CodecFamily::AV1 => "AV1",
19            CodecFamily::VP9 => "VP9",
20        }
21    }
22
23    pub fn all() -> &'static [CodecFamily] {
24        &[
25            CodecFamily::ProRes,
26            CodecFamily::DNxHR,
27            CodecFamily::HEVC,
28            CodecFamily::H264,
29            CodecFamily::AV1,
30            CodecFamily::VP9,
31        ]
32    }
33
34    pub fn next(self) -> Self {
35        let all = Self::all();
36        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
37        all[(pos + 1) % all.len()]
38    }
39
40    pub fn prev(self) -> Self {
41        let all = Self::all();
42        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
43        all[(pos + all.len() - 1) % all.len()]
44    }
45
46    /// Build FFmpeg arguments:
47    /// - Codec and pixel format are determined by the family and the
48    ///   runtime-detected encoder names.
49    /// - Profile is resolved independently so the user's choice is preserved.
50    /// - Rate-control flags are appended for HEVC / H.264 / AV1.
51    pub fn to_ffmpeg_args(
52        &self,
53        hevc_encoder: &str,
54        h264_encoder: &str,
55        av1_encoder: &str,
56        prores_encoder: &str,
57        prores: ProResProfile,
58        dnxhr: DnxhrProfile,
59        hevc: HevcProfile,
60        h264: H264Profile,
61        av1: Av1Profile,
62        vp9: Vp9Profile,
63        rate_control: &RateControl,
64        is_wide_gamut: bool,
65    ) -> (String, String, Vec<String>) {
66        let mut base_codec_name: String = String::new();
67        let mut base_pix_fmt: String = String::new();
68        let mut base_extra: Vec<&'static str> = Vec::new();
69
70        match self {
71            CodecFamily::ProRes => {
72                let (profile_v, base_pix) = match prores {
73                    ProResProfile::Proxy => ("0", "yuv422p10le"),
74                    ProResProfile::LT => ("1", "yuv422p10le"),
75                    ProResProfile::Standard => ("2", "yuv422p10le"),
76                    ProResProfile::HQ => ("3", "yuv422p10le"),
77                    ProResProfile::P4444 => ("4", "yuva444p10le"),
78                    ProResProfile::XQ4444 => ("5", "yuva444p12le"),
79                };
80                let pix_fmt = match (is_wide_gamut, prores) {
81                    (true, ProResProfile::P4444 | ProResProfile::XQ4444) => base_pix,
82                    (true, _) => "gbrp10le",
83                    (false, _) => base_pix,
84                };
85                base_codec_name = prores_encoder.to_string();
86                base_pix_fmt = pix_fmt.to_string();
87                base_extra = vec!["-profile:v", profile_v];
88            }
89            CodecFamily::DNxHR => {
90                let (profile_str, pix_fmt) = match dnxhr {
91                    DnxhrProfile::SQ => ("dnxhr_sq", "yuv422p10le"),
92                    DnxhrProfile::HD => ("dnxhr_hd", "yuv422p10le"),
93                    DnxhrProfile::HDX => ("dnxhr_hdx", "yuv422p10le"),
94                    DnxhrProfile::HQX => ("dnxhr_hqx", "yuv422p10le"),
95                    DnxhrProfile::P444 => ("dnxhr_444", "yuv444p10le"),
96                };
97                base_codec_name = "dnxhd".to_string();
98                base_pix_fmt = pix_fmt.to_string();
99                base_extra = vec!["-profile:v", profile_str];
100            }
101            CodecFamily::HEVC => {
102                match hevc_encoder {
103                    "libx265" => {
104                        if is_wide_gamut {
105                            base_codec_name = "libx265".to_string();
106                            base_pix_fmt = "gbrp10le".to_string();
107                            base_extra = vec![];
108                        } else {
109                            let pix_fmt = match hevc {
110                                HevcProfile::Main10_420 => "yuv420p10le",
111                                HevcProfile::Main10_444 => "yuv444p10le",
112                            };
113                            base_codec_name = "libx265".to_string();
114                            base_pix_fmt = pix_fmt.to_string();
115                            base_extra = vec!["-preset", "slow"];
116                        }
117                    }
118                    "hevc_nvenc" => {
119                        base_codec_name = "hevc_nvenc".to_string();
120                        base_pix_fmt = "p010le".to_string();
121                        base_extra = vec!["-preset", "p6"];
122                    }
123                    "hevc_amf" => {
124                        base_codec_name = "hevc_amf".to_string();
125                        base_pix_fmt = "p010le".to_string();
126                        base_extra = vec!["-quality", "quality"];
127                    }
128                    "hevc_qsv" => {
129                        base_codec_name = "hevc_qsv".to_string();
130                        base_pix_fmt = "p010le".to_string();
131                    }
132                    "hevc_videotoolbox" => {
133                        base_codec_name = "hevc_videotoolbox".to_string();
134                        base_pix_fmt = "p010le".to_string();
135                        base_extra = vec!["-realtime", "true"];
136                    }
137                    _ => {
138                        if is_wide_gamut {
139                            base_codec_name = "libx265".to_string();
140                            base_pix_fmt = "gbrp10le".to_string();
141                            base_extra = vec!["-preset", "slow"];
142                        } else {
143                            let pix_fmt = match hevc {
144                                HevcProfile::Main10_420 => "yuv420p10le",
145                                HevcProfile::Main10_444 => "yuv444p10le",
146                            };
147                            base_codec_name = "libx265".to_string();
148                            base_pix_fmt = pix_fmt.to_string();
149                            base_extra = vec!["-pix_fmt", pix_fmt, "-preset", "slow"];
150                        }
151                    }
152                }
153            }
154            CodecFamily::H264 => {
155                if is_wide_gamut {
156                    // H264 has no 10-bit planar RGB support, and doesn't
157                    // encode wide-gamut properly. Use libx265 with gbrp10le.
158                    base_codec_name = "libx265".to_string();
159                    base_pix_fmt = "gbrp10le".to_string();
160                    base_extra = vec!["-pix_fmt", "gbrp10le"];
161                } else {
162                    match h264_encoder {
163                        "h264_nvenc" => {
164                            let (pf, ext) = match h264 {
165                                H264Profile::High10bit => ("p010le", vec!["-preset", "p6", "-profile:v", "high10"]),
166                                H264Profile::Main8bit => ("yuv420p", vec!["-preset", "p6"]),
167                            };
168                            base_codec_name = "h264_nvenc".to_string();
169                            base_pix_fmt = pf.to_string();
170                            base_extra = ext;
171                        }
172                        "h264_amf" => {
173                            let (pf, ext) = match h264 {
174                                H264Profile::High10bit => ("p010le", vec!["-quality", "quality"]),
175                                H264Profile::Main8bit => ("yuv420p", vec!["-quality", "quality"]),
176                            };
177                            base_codec_name = "h264_amf".to_string();
178                            base_pix_fmt = pf.to_string();
179                            base_extra = ext;
180                        }
181                        "h264_qsv" => {
182                            let pf = match h264 {
183                                H264Profile::High10bit => "p010le",
184                                H264Profile::Main8bit => "yuv420p",
185                            };
186                            base_codec_name = "h264_qsv".to_string();
187                            base_pix_fmt = pf.to_string();
188                        }
189                        "h264_videotoolbox" => {
190                            let pf = match h264 {
191                                H264Profile::High10bit => "p010le",
192                                H264Profile::Main8bit => "yuv420p",
193                            };
194                            base_codec_name = "h264_videotoolbox".to_string();
195                            base_pix_fmt = pf.to_string();
196                            base_extra = vec!["-realtime", "true"];
197                        }
198                        _ => {
199                            let (pf, ext) = match h264 {
200                                H264Profile::Main8bit => ("yuv420p", vec!["-preset", "slow"]),
201                                H264Profile::High10bit => ("yuv422p10le", vec!["-preset", "slow"]),
202                            };
203                            base_codec_name = "libx264".to_string();
204                            base_pix_fmt = pf.to_string();
205                            base_extra = ext;
206                        }
207                    }
208                }
209            }
210            CodecFamily::AV1 => {
211                match av1_encoder {
212                    "libsvtav1" => {
213                        base_codec_name = "libsvtav1".to_string();
214                        base_pix_fmt = match av1 {
215                            Av1Profile::Profile0_420_10bit => "yuv420p10le",
216                            Av1Profile::Profile1_444_10bit => "yuv444p10le",
217                        }.to_string();
218                        base_extra = vec!["-preset", "8"];
219                    }
220                    "av1_nvenc" => {
221                        base_codec_name = "av1_nvenc".to_string();
222                        base_pix_fmt = match av1 {
223                            Av1Profile::Profile0_420_10bit => "p010le",
224                            Av1Profile::Profile1_444_10bit => "yuv444p10le",
225                        }.to_string();
226                        base_extra = vec!["-preset", "p6"];
227                    }
228                    "av1_amf" => {
229                        base_codec_name = "av1_amf".to_string();
230                        base_pix_fmt = match av1 {
231                            Av1Profile::Profile0_420_10bit => "p010le",
232                            Av1Profile::Profile1_444_10bit => "yuv444p10le",
233                        }.to_string();
234                        base_extra = vec!["-quality", "quality"];
235                    }
236                    "av1_qsv" => {
237                        base_codec_name = "av1_qsv".to_string();
238                        base_pix_fmt = match av1 {
239                            Av1Profile::Profile0_420_10bit => "p010le",
240                            Av1Profile::Profile1_444_10bit => "yuv444p10le",
241                        }.to_string();
242                    }
243                    _ => {
244                        base_codec_name = "libsvtav1".to_string();
245                        base_pix_fmt = match av1 {
246                            Av1Profile::Profile0_420_10bit => "yuv420p10le",
247                            Av1Profile::Profile1_444_10bit => "yuv444p10le",
248                        }.to_string();
249                        base_extra = vec!["-preset", "8"];
250                    }
251                }
252            }
253            CodecFamily::VP9 => {
254                // VP9 quality / bitrate mode is fully driven by the user's
255                // rate-control choice (`-crf X -b:v 0` for CQ modes,
256                // `-b:v X -maxrate X` for bitrate modes — handled below).
257                base_codec_name = "libvpx-vp9".to_string();
258                base_pix_fmt = match vp9 {
259                    Vp9Profile::Profile2_420_10bit => "yuv420p10le".to_string(),
260                    Vp9Profile::Profile3_444_10bit => "yuv444p10le".to_string(),
261                };
262                base_extra = vec![];
263            }
264        }
265
266        // Convert static extra args to owned Strings
267        let mut extra: Vec<String> = base_extra.iter().map(|&s| s.to_string()).collect();
268
269        // Inject a scale filter for wide-gamut → YUV pixel formats.
270        // Planar RGB formats (gbrp*) bypass RGB→YUV conversion entirely,
271        // so no matrix correction is needed — the data stays in pure RGB
272        // through the entire encode pipeline.
273        // For YUV formats we force swscale to use the bt2020nc (non-constant
274        // luminance) matrix in full range so the mathematical rotation from
275        // RGB preserves 100% of the wide-gamut colorimetry. Without this,
276        // FFmpeg defaults to BT.601/BT.709 matrix coefficients which clip
277        // values outside the BT.709 gamut.
278        if is_wide_gamut && !base_pix_fmt.starts_with("gbrp") && !base_pix_fmt.starts_with("rgb") {
279            extra.push("-vf".into());
280            extra.push(format!("scale=flags=accurate_rnd+full_chroma_int:out_color_matrix=bt2020nc:out_range=full,format={}", base_pix_fmt));
281        }
282
283        // Append rate-control flags. ProRes / DNxHR ignore CRF / bitrate
284        // flags (they use the explicit `-profile:v` instead), so we skip
285        // them. Every other family — including VP9 and AV1 — honours the
286        // user's rate-control choice.
287        match self {
288            CodecFamily::HEVC => {
289                extra.extend(rate_control_args(rate_control, hevc_encoder));
290            }
291            CodecFamily::H264 => {
292                extra.extend(rate_control_args(rate_control, h264_encoder));
293            }
294            CodecFamily::AV1 => {
295                extra.extend(rate_control_args(rate_control, av1_encoder));
296            }
297            CodecFamily::VP9 => {
298                // libvpx-vp9 is always a software encoder; pass it through
299                // the same helper so Lossless / High / Standard / bitrate
300                // / Custom presets all work consistently.
301                extra.extend(rate_control_args(rate_control, "libvpx-vp9"));
302            }
303            CodecFamily::ProRes | CodecFamily::DNxHR => {}
304        }
305
306        tracing::debug!("ffmpeg args: codec={} pix_fmt={} extra={:?}",
307            base_codec_name, base_pix_fmt, extra);
308
309        (base_codec_name, base_pix_fmt, extra)
310    }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum ProResProfile {
315    Proxy,
316    LT,
317    Standard,
318    HQ,
319    P4444,
320    XQ4444,
321}
322
323impl ProResProfile {
324    pub fn name(&self) -> &'static str {
325        match self {
326            ProResProfile::Proxy => "Proxy",
327            ProResProfile::LT => "LT",
328            ProResProfile::Standard => "Standard",
329            ProResProfile::HQ => "HQ",
330            ProResProfile::P4444 => "4444",
331            ProResProfile::XQ4444 => "4444 XQ",
332        }
333    }
334
335    pub fn all() -> &'static [ProResProfile] {
336        &[
337            ProResProfile::Proxy,
338            ProResProfile::LT,
339            ProResProfile::Standard,
340            ProResProfile::HQ,
341            ProResProfile::P4444,
342            ProResProfile::XQ4444,
343        ]
344    }
345
346    pub fn next(self) -> Self {
347        let all = Self::all();
348        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
349        all[(pos + 1) % all.len()]
350    }
351
352    pub fn prev(self) -> Self {
353        let all = Self::all();
354        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
355        all[(pos + all.len() - 1) % all.len()]
356    }
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum DnxhrProfile {
361    SQ,
362    HD,
363    HDX,
364    HQX,
365    P444,
366}
367
368impl DnxhrProfile {
369    pub fn name(&self) -> &'static str {
370        match self {
371            DnxhrProfile::SQ => "SQ",
372            DnxhrProfile::HD => "HD",
373            DnxhrProfile::HDX => "HDX",
374            DnxhrProfile::HQX => "HQX",
375            DnxhrProfile::P444 => "444",
376        }
377    }
378
379    pub fn all() -> &'static [DnxhrProfile] {
380        &[DnxhrProfile::SQ, DnxhrProfile::HD, DnxhrProfile::HDX, DnxhrProfile::HQX, DnxhrProfile::P444]
381    }
382
383    pub fn next(self) -> Self {
384        let all = Self::all();
385        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
386        all[(pos + 1) % all.len()]
387    }
388
389    pub fn prev(self) -> Self {
390        let all = Self::all();
391        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
392        all[(pos + all.len() - 1) % all.len()]
393    }
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq)]
397pub enum HevcProfile {
398    Main10_420,
399    Main10_444,
400}
401
402impl HevcProfile {
403    pub fn name(&self) -> &'static str {
404        match self {
405            HevcProfile::Main10_420 => "Main 10 4:2:0",
406            HevcProfile::Main10_444 => "Main 10 4:4:4",
407        }
408    }
409
410    pub fn is_8bit(&self) -> bool {
411        false
412    }
413
414    pub fn all() -> &'static [HevcProfile] {
415        &[HevcProfile::Main10_420, HevcProfile::Main10_444]
416    }
417
418    pub fn next(self) -> Self {
419        let all = Self::all();
420        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
421        all[(pos + 1) % all.len()]
422    }
423
424    pub fn prev(self) -> Self {
425        let all = Self::all();
426        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
427        all[(pos + all.len() - 1) % all.len()]
428    }
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum H264Profile {
433    Main8bit,
434    High10bit,
435}
436
437impl H264Profile {
438    pub fn name(&self) -> &'static str {
439        match self {
440            H264Profile::Main8bit => "Main 8-bit",
441            H264Profile::High10bit => "High 10-bit",
442        }
443    }
444
445    pub fn is_8bit(&self) -> bool {
446        matches!(self, H264Profile::Main8bit)
447    }
448
449    pub fn all() -> &'static [H264Profile] {
450        &[H264Profile::Main8bit, H264Profile::High10bit]
451    }
452
453    pub fn next(self) -> Self {
454        let all = Self::all();
455        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
456        all[(pos + 1) % all.len()]
457    }
458
459    pub fn prev(self) -> Self {
460        let all = Self::all();
461        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
462        all[(pos + all.len() - 1) % all.len()]
463    }
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum Av1Profile {
468    Profile0_420_10bit,
469    Profile1_444_10bit,
470}
471
472impl Av1Profile {
473    pub fn name(&self) -> &'static str {
474        match self {
475            Av1Profile::Profile0_420_10bit => "Profile 0 4:2:0 10-bit",
476            Av1Profile::Profile1_444_10bit => "Profile 1 4:4:4 10-bit",
477        }
478    }
479
480    pub fn is_8bit(&self) -> bool {
481        false
482    }
483
484    pub fn all() -> &'static [Av1Profile] {
485        &[Av1Profile::Profile0_420_10bit, Av1Profile::Profile1_444_10bit]
486    }
487
488    pub fn next(self) -> Self {
489        let all = Self::all();
490        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
491        all[(pos + 1) % all.len()]
492    }
493
494    pub fn prev(self) -> Self {
495        let all = Self::all();
496        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
497        all[(pos + all.len() - 1) % all.len()]
498    }
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub enum Vp9Profile {
503    Profile2_420_10bit,
504    Profile3_444_10bit,
505}
506
507impl Vp9Profile {
508    pub fn name(&self) -> &'static str {
509        match self {
510            Vp9Profile::Profile2_420_10bit => "Profile 2 4:2:0 10-bit",
511            Vp9Profile::Profile3_444_10bit => "Profile 3 4:4:4 10-bit",
512        }
513    }
514
515    pub fn is_8bit(&self) -> bool {
516        false
517    }
518
519    pub fn all() -> &'static [Vp9Profile] {
520        &[Vp9Profile::Profile2_420_10bit, Vp9Profile::Profile3_444_10bit]
521    }
522
523    pub fn next(self) -> Self {
524        let all = Self::all();
525        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
526        all[(pos + 1) % all.len()]
527    }
528
529    pub fn prev(self) -> Self {
530        let all = Self::all();
531        let pos = all.iter().position(|&x| x == self).unwrap_or(0);
532        all[(pos + all.len() - 1) % all.len()]
533    }
534}
535
536// ---------------------------------------------------------------------------
537// Rate Control
538// ---------------------------------------------------------------------------
539
540/// A hybrid rate-control / constant-quality preset.
541///
542/// Quality presets (`Lossless` / `High` / `Standard`) map to `-cq` (HW) or
543/// `-crf` (SW).  Bitrate presets (`Master400M` / `Standard150M`) map to
544/// `-b:v` / `-maxrate`.  The `Custom` variant lets the user type an arbitrary
545/// FFmpeg rate-control argument.
546#[derive(Debug, Clone)]
547pub enum RateControl {
548    Lossless,
549    High,
550    Standard,
551    Master400M,
552    Standard150M,
553    Custom(String),
554}
555
556impl RateControl {
557    pub fn name(&self) -> String {
558        match self {
559            RateControl::Lossless => "Lossless".to_string(),
560            RateControl::High => "High Quality".to_string(),
561            RateControl::Standard => "Standard".to_string(),
562            RateControl::Master400M => "Master 400M".to_string(),
563            RateControl::Standard150M => "Standard 150M".to_string(),
564            RateControl::Custom(v) => {
565                if v.is_empty() {
566                    "Custom: []".to_string()
567                } else {
568                    format!("Custom: [{}]", v)
569                }
570            }
571        }
572    }
573
574    pub fn next(&self) -> Self {
575        match self {
576            RateControl::Lossless => RateControl::High,
577            RateControl::High => RateControl::Standard,
578            RateControl::Standard => RateControl::Master400M,
579            RateControl::Master400M => RateControl::Standard150M,
580            RateControl::Standard150M => RateControl::Custom(String::new()),
581            RateControl::Custom(_) => RateControl::Lossless,
582        }
583    }
584
585    pub fn prev(&self) -> Self {
586        match self {
587            RateControl::Lossless => RateControl::Custom(String::new()),
588            RateControl::High => RateControl::Lossless,
589            RateControl::Standard => RateControl::High,
590            RateControl::Master400M => RateControl::Standard,
591            RateControl::Standard150M => RateControl::Master400M,
592            RateControl::Custom(_) => RateControl::Standard150M,
593        }
594    }
595}
596
597/// Build the FFmpeg rate-control / quality arguments for a given encoder.
598///
599/// * `is_hw` — `true` for GPU-backed encoders (nvenc / amf / qsv / videotoolbox).
600/// * `encoder_name` — the FFmpeg encoder name; used to pick the right flag set.
601///
602/// Special cases:
603/// * **libvpx-vp9** and **libaom-av1** require `-b:v 0` alongside `-crf` to
604///   enable constant-quality mode. Without `-b:v 0` they treat `-crf` as a
605///   max-bitrate hint and fall back to default VBR.
606/// * **NVENC** bitrate modes get an explicit `-rc:v vbr` so the preset's
607///   default rate control (which varies by FFmpeg / driver version) doesn't
608///   silently override the requested target.
609pub fn rate_control_args(rc: &RateControl, encoder_name: &str) -> Vec<String> {
610    let is_hw = !encoder_name.starts_with("lib");
611    let is_videotoolbox = encoder_name.ends_with("_videotoolbox");
612    let is_nvenc = encoder_name.ends_with("_nvenc");
613    let needs_bv0_for_crf = matches!(encoder_name, "libvpx-vp9" | "libaom-av1");
614
615    // Helper: produce a constant-quality arg pair for the encoder.
616    let cq = |value: &str| -> Vec<String> {
617        if is_videotoolbox {
618            vec!["-quality".into(), value.into()]
619        } else if is_hw {
620            vec!["-cq".into(), value.into()]
621        } else if needs_bv0_for_crf {
622            vec!["-crf".into(), value.into(), "-b:v".into(), "0".into()]
623        } else {
624            vec!["-crf".into(), value.into()]
625        }
626    };
627
628    // Helper: produce a target-bitrate arg set.
629    let bitrate = |value: &str| -> Vec<String> {
630        let mut v = vec![
631            "-b:v".into(), value.into(),
632            "-maxrate".into(), value.into(),
633        ];
634        if is_nvenc {
635            // Pin NVENC into VBR mode so `-b:v` actually drives the encoder
636            // (the default rc depends on preset + driver, which made bitrate
637            // modes unreliable).
638            v.push("-rc:v".into());
639            v.push("vbr".into());
640        }
641        v
642    };
643
644    match rc {
645        RateControl::Lossless => {
646            if is_videotoolbox {
647                vec!["-quality".into(), "lossless".into()]
648            } else {
649                cq("16")
650            }
651        }
652        RateControl::High => {
653            if is_videotoolbox {
654                vec!["-quality".into(), "max".into()]
655            } else {
656                cq("20")
657            }
658        }
659        RateControl::Standard => {
660            if is_videotoolbox {
661                vec!["-quality".into(), "high".into()]
662            } else {
663                cq("24")
664            }
665        }
666        RateControl::Master400M => bitrate("400M"),
667        RateControl::Standard150M => bitrate("150M"),
668        RateControl::Custom(val) => {
669            if val.is_empty() {
670                return vec![];
671            }
672            let upper = val.to_uppercase();
673            if upper.ends_with('M') || upper.ends_with('K') {
674                bitrate(val)
675            } else if val.parse::<f64>().is_ok() {
676                cq(val)
677            } else {
678                // Pass the raw string directly — FFmpeg validates it.
679                vec![val.clone()]
680            }
681        }
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688
689    #[test]
690    fn rate_control_lossless_software_uses_crf() {
691        let args = rate_control_args(&RateControl::Lossless, "libx265");
692        assert_eq!(args, vec!["-crf", "16"]);
693    }
694
695    #[test]
696    fn rate_control_lossless_nvenc_uses_cq() {
697        let args = rate_control_args(&RateControl::Lossless, "hevc_nvenc");
698        assert_eq!(args, vec!["-cq", "16"]);
699    }
700
701    #[test]
702    fn rate_control_lossless_videotoolbox_uses_quality_lossless() {
703        let args = rate_control_args(&RateControl::Lossless, "hevc_videotoolbox");
704        assert_eq!(args, vec!["-quality", "lossless"]);
705    }
706
707    #[test]
708    fn rate_control_nvenc_bitrate_mode_pins_rc_to_vbr() {
709        // Regression test: NVENC bitrate modes used to silently get the
710        // preset's default rate-control mode, which made `-b:v` unreliable.
711        let args = rate_control_args(&RateControl::Master400M, "hevc_nvenc");
712        assert!(args.contains(&"-b:v".to_string()));
713        assert!(args.contains(&"-maxrate".to_string()));
714        assert!(args.contains(&"-rc:v".to_string()));
715        assert!(args.contains(&"vbr".to_string()));
716    }
717
718    #[test]
719    fn rate_control_vp9_crf_adds_bv0() {
720        // Regression test: libvpx-vp9 needs `-b:v 0` alongside `-crf`,
721        // otherwise it silently falls back to default VBR.
722        let args = rate_control_args(&RateControl::Standard, "libvpx-vp9");
723        assert!(args.contains(&"-crf".to_string()));
724        assert!(args.contains(&"24".to_string()));
725        assert!(args.contains(&"-b:v".to_string()));
726        assert!(args.contains(&"0".to_string()));
727    }
728
729    #[test]
730    fn rate_control_libaom_av1_crf_adds_bv0() {
731        let args = rate_control_args(&RateControl::High, "libaom-av1");
732        assert!(args.contains(&"-crf".to_string()));
733        assert!(args.contains(&"20".to_string()));
734        assert!(args.contains(&"-b:v".to_string()));
735        assert!(args.contains(&"0".to_string()));
736    }
737
738    #[test]
739    fn rate_control_custom_numeric_routes_to_cq() {
740        let args = rate_control_args(&RateControl::Custom("18".into()), "libx265");
741        assert_eq!(args, vec!["-crf", "18"]);
742    }
743
744    #[test]
745    fn rate_control_custom_bitrate_routes_to_bv() {
746        let args = rate_control_args(&RateControl::Custom("50M".into()), "libx265");
747        assert!(args.contains(&"-b:v".to_string()));
748        assert!(args.contains(&"50M".to_string()));
749    }
750
751    #[test]
752    fn rate_control_custom_empty_returns_empty() {
753        let args = rate_control_args(&RateControl::Custom(String::new()), "libx265");
754        assert!(args.is_empty());
755    }
756}