Skip to main content

viser_ffmpeg/
color.rs

1//! Bit depth, pixel format, and HDR color metadata helpers.
2
3use crate::{Codec, CodecFamily, EncoderBackend, Hdr10Metadata, StreamInfo};
4
5/// Snapshot of source video color characteristics for encode preservation.
6#[derive(Debug, Clone, Default, PartialEq, Eq)]
7pub struct SourceFormat {
8    /// Preferred output pixel format (e.g. `yuv420p10le`).
9    pub pix_fmt: String,
10    /// Effective bit depth (8, 10, 12, or 16).
11    pub bit_depth: u8,
12    /// Color primaries from probe (e.g. `bt2020`).
13    pub color_primaries: String,
14    /// Color transfer from probe (e.g. `smpte2084`).
15    pub color_transfer: String,
16    /// Color matrix / space from probe.
17    pub color_space: String,
18    /// Whether the stream carries HDR signaling.
19    pub is_hdr: bool,
20    /// HDR10 static metadata (mastering display + MaxCLL/MaxFALL), when probed.
21    pub hdr10: Option<Hdr10Metadata>,
22}
23
24impl SourceFormat {
25    /// Builds a format snapshot from a probed video stream.
26    pub fn from_stream(stream: &StreamInfo) -> Self {
27        let bit_depth = bit_depth(stream);
28        let pix_fmt = if stream.pix_fmt.is_empty() {
29            yuv420p_for_depth(bit_depth).to_string()
30        } else {
31            stream.pix_fmt.clone()
32        };
33        Self {
34            pix_fmt,
35            bit_depth,
36            color_primaries: stream.color_primaries.clone(),
37            color_transfer: stream.color_transfer.clone(),
38            color_space: stream.color_space.clone(),
39            is_hdr: stream.is_hdr(),
40            hdr10: None,
41        }
42    }
43
44    /// Returns `true` when the source should be encoded at more than 8 bits per sample.
45    pub fn is_high_bit_depth(&self) -> bool {
46        self.bit_depth > 8
47    }
48
49    /// Probes and attaches HDR10 static metadata (mastering display + MaxCLL)
50    /// when the source is HDR, so it can be re-signalled on the encode.
51    ///
52    /// A no-op for SDR sources. Probe failures are swallowed (best-effort): a
53    /// missing mastering-display block degrades to colour-primary signalling
54    /// rather than failing the encode.
55    pub async fn enrich_hdr10(mut self, path: &str) -> Self {
56        if self.is_hdr {
57            if let Ok(Some(md)) = crate::probe_hdr10_metadata(path).await {
58                self.hdr10 = Some(md);
59            }
60        }
61        self
62    }
63}
64
65/// Returns the effective bit depth of a video stream.
66pub fn bit_depth(stream: &StreamInfo) -> u8 {
67    if stream.bits_per_raw_sample >= 10 {
68        return stream.bits_per_raw_sample.clamp(8, 16) as u8;
69    }
70    if stream.pix_fmt.contains("16") {
71        return 16;
72    }
73    if stream.pix_fmt.contains("12") {
74        return 12;
75    }
76    if stream.pix_fmt.contains("10") {
77        return 10;
78    }
79    8
80}
81
82/// Default 4:2:0 pixel format for a given bit depth.
83pub fn yuv420p_for_depth(depth: u8) -> &'static str {
84    match depth {
85        10 => "yuv420p10le",
86        12 => "yuv420p12le",
87        16 => "yuv420p16le",
88        _ => "yuv420p",
89    }
90}
91
92/// PSNR peak value for a given bit depth.
93pub fn psnr_peak(depth: u8) -> f64 {
94    match depth {
95        10 => 1023.0,
96        12 => 4095.0,
97        16 => 65535.0,
98        _ => 255.0,
99    }
100}
101
102/// Returns whether a codec can encode at the requested bit depth.
103///
104/// Hardware encoders (NVENC, QSV, AMF, VideoToolbox) generally support 10-bit
105/// for HEVC and AV1, but not for H.264 AVC. VAAPI and VP9 have their own
106/// constraints. Returns `false` for unknown or unsupported combinations.
107pub fn codec_supports_bit_depth(codec: Codec, depth: u8) -> bool {
108    if depth <= 8 {
109        return true;
110    }
111    match codec {
112        // Software
113        Codec::X264 | Codec::X265 | Codec::SvtAv1 | Codec::Vp9 => true,
114        // Hardware encoders: HEVC and AV1 backends support 10-bit; H.264 backends do not.
115        Codec::NvencH265 | Codec::QsvH265 | Codec::AmfH265 | Codec::VideoToolboxH265 => true,
116        Codec::NvencAv1 | Codec::QsvAv1 | Codec::AmfAv1 => true,
117        Codec::VaapiH265 | Codec::VaapiAv1 => true,
118        // H.264 HW and VideoToolbox AV1 are 8-bit only.
119        Codec::NvencH264
120        | Codec::QsvH264
121        | Codec::AmfH264
122        | Codec::VideoToolboxH264
123        | Codec::VaapiH264 => false,
124    }
125}
126
127/// Returns the hardware-appropriate high-bit-depth pixel format for a given
128/// codec and source format, or `None` when the backend does not support high
129/// bit depth (or the format is handled elsewhere, e.g. via VAAPI hwupload).
130fn hw_pix_fmt(format: &SourceFormat, codec: Codec) -> Option<String> {
131    if !format.is_high_bit_depth() {
132        return None;
133    }
134    // Hardware encoders typically use the 10-bit 4:2:0 p010le format.
135    // NVENC, QSV, AMF, and VideoToolbox all accept this for HEVC/AV1.
136    // We accept yuv420p10le sources and map to p010le where possible.
137    match codec.backend() {
138        EncoderBackend::Nvenc
139        | EncoderBackend::Qsv
140        | EncoderBackend::Amf
141        | EncoderBackend::VideoToolbox => {
142            if format.bit_depth == 10 {
143                Some("p010le".into())
144            } else {
145                None
146            }
147        }
148        EncoderBackend::Vaapi => None, // pix_fmt is set via hwupload filter
149        EncoderBackend::Software => None,
150    }
151}
152
153/// FFmpeg output arguments that preserve source bit depth and HDR metadata.
154pub fn encode_color_args(codec: Codec, format: &SourceFormat) -> Vec<String> {
155    let mut args = Vec::new();
156
157    if format.is_high_bit_depth() && codec_supports_bit_depth(codec, format.bit_depth) {
158        if codec.is_software() {
159            args.extend(["-pix_fmt".into(), format.pix_fmt.clone()]);
160            match codec {
161                Codec::X264 => args.extend(["-profile:v".into(), "high10".into()]),
162                Codec::X265 => args.extend(["-x265-params".into(), x265_params(format)]),
163                Codec::SvtAv1 => {}
164                _ => {}
165            }
166        } else if let Some(pix) = hw_pix_fmt(format, codec) {
167            args.extend(["-pix_fmt".into(), pix]);
168            // Hardware HEVC and AV1 encoders need an explicit main10 profile.
169            if matches!(codec.family(), CodecFamily::H265 | CodecFamily::Av1) {
170                args.extend(["-profile:v".into(), "main10".into()]);
171            }
172        }
173    }
174
175    if format.is_hdr {
176        append_color_metadata(&mut args, format);
177        match codec {
178            Codec::X265 => merge_x265_color_params(&mut args, format),
179            Codec::SvtAv1 => {
180                let params = svtav1_hdr_params(format);
181                if !params.is_empty() {
182                    // A second `-svtav1-params` may be added by the rate-control
183                    // builder; `coalesce_svtav1_params` merges them before the
184                    // encode runs (the last flag would otherwise win outright).
185                    args.extend(["-svtav1-params".into(), params]);
186                }
187            }
188            _ => add_hdr_bsf(&mut args, codec.family(), format),
189        }
190    }
191
192    args
193}
194
195/// Injects HDR10 static metadata (mastering-display + max-cll) via a
196/// codec-family bitstream filter. This works with ANY encoder (including
197/// hardware) because it operates on the encoded bitstream after encoding,
198/// before muxing.
199///
200/// Supported codec families:
201/// - `H265` → `hevc_metadata` bitstream filter
202/// - `H264` → `h264_metadata` bitstream filter
203/// - `Av1` / `Vp9` → not yet supported (falls back to `-color_*` tags only)
204fn add_hdr_bsf(args: &mut Vec<String>, family: CodecFamily, format: &SourceFormat) {
205    let Some(hdr10) = &format.hdr10 else {
206        return;
207    };
208    if hdr10.is_empty() {
209        return;
210    }
211
212    let bsf_name = match family {
213        CodecFamily::H265 => "hevc_metadata",
214        CodecFamily::H264 => "h264_metadata",
215        // av1_metadata does not support mastering_display/max_cll options;
216        // VP9 has no equivalent bitstream filter.
217        _ => return,
218    };
219
220    let mut params = Vec::new();
221    if let Some(display) = &hdr10.mastering_display {
222        params.push(format!("mastering_display=\"{}\"", display.to_x265_string()));
223    }
224    if let Some(max_cll) = hdr10.max_cll {
225        let max_fall = hdr10.max_fall.unwrap_or(0);
226        params.push(format!("max_cll={max_cll},{max_fall}"));
227    }
228    if !params.is_empty() {
229        args.extend(["-bsf".into(), format!("{}={}", bsf_name, params.join(":"))]);
230    }
231}
232
233fn append_color_metadata(args: &mut Vec<String>, format: &SourceFormat) {
234    if !format.color_primaries.is_empty() {
235        args.extend(["-color_primaries".into(), format.color_primaries.clone()]);
236    }
237    if !format.color_transfer.is_empty() {
238        args.extend(["-color_trc".into(), format.color_transfer.clone()]);
239    }
240    if !format.color_space.is_empty() {
241        args.extend(["-colorspace".into(), format.color_space.clone()]);
242    }
243}
244
245fn x265_params(format: &SourceFormat) -> String {
246    let mut parts = Vec::new();
247    if format.bit_depth > 8 {
248        parts.push("profile=main10".into());
249    }
250    if format.is_hdr {
251        if !format.color_primaries.is_empty() {
252            parts.push(format!("colorprim={}", format.color_primaries));
253        }
254        if !format.color_transfer.is_empty() {
255            parts.push(format!("transfer={}", format.color_transfer));
256        }
257        if !format.color_space.is_empty() {
258            parts.push(format!("colormatrix={}", format.color_space));
259        }
260        if let Some(hdr10) = &format.hdr10 {
261            if let Some(display) = &hdr10.mastering_display {
262                parts.push(format!("master-display={}", display.to_x265_string()));
263            }
264            if let Some(max_cll) = hdr10.max_cll {
265                // x265 expects "MaxCLL,MaxFALL"; MaxFALL defaults to 0 when absent.
266                parts.push(format!("max-cll={},{}", max_cll, hdr10.max_fall.unwrap_or(0)));
267            }
268        }
269    }
270    parts.join(":")
271}
272
273/// SVT-AV1 `-svtav1-params` HDR10 static-metadata fragment (without the flag).
274///
275/// SVT-AV1 shares x265's `mastering-display` grammar but spells content light
276/// `content-light=MaxCLL,MaxFALL`. Colour primaries/transfer/matrix are carried
277/// by the standard `-color_*` options [`append_color_metadata`] emits.
278fn svtav1_hdr_params(format: &SourceFormat) -> String {
279    let mut parts = Vec::new();
280    if let Some(hdr10) = &format.hdr10 {
281        if let Some(display) = &hdr10.mastering_display {
282            parts.push(format!("mastering-display={}", display.to_svtav1_string()));
283        }
284        if let Some(max_cll) = hdr10.max_cll {
285            parts.push(format!("content-light={},{}", max_cll, hdr10.max_fall.unwrap_or(0)));
286        }
287    }
288    parts.join(":")
289}
290
291fn merge_x265_color_params(args: &mut Vec<String>, format: &SourceFormat) {
292    let color = x265_params(format);
293    if color.is_empty() {
294        return;
295    }
296    if let Some(idx) = args.iter().position(|a| a == "-x265-params") {
297        let existing = args.get(idx + 1).cloned().unwrap_or_default();
298        let merged = if existing.is_empty() { color } else { format!("{existing}:{color}") };
299        args[idx + 1] = merged;
300    } else {
301        args.extend(["-x265-params".into(), color]);
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::StreamInfo;
309
310    fn base_stream() -> StreamInfo {
311        StreamInfo {
312            index: 0,
313            codec_name: "h264".into(),
314            codec_long_name: String::new(),
315            codec_type: "video".into(),
316            profile: String::new(),
317            width: 1920,
318            height: 1080,
319            pix_fmt: "yuv420p".into(),
320            level: 0,
321            field_order: String::new(),
322            color_range: String::new(),
323            color_space: "bt709".into(),
324            color_transfer: "bt709".into(),
325            color_primaries: "bt709".into(),
326            duration: 0.0,
327            bit_rate: 0,
328            nb_frames: 0,
329            r_frame_rate: "24/1".into(),
330            avg_frame_rate: "24/1".into(),
331            sample_rate: 0,
332            channels: 0,
333            channel_layout: String::new(),
334            bits_per_raw_sample: 8,
335        }
336    }
337
338    #[test]
339    fn test_bit_depth_from_pix_fmt() {
340        let mut stream = base_stream();
341        stream.pix_fmt = "yuv420p10le".into();
342        assert_eq!(bit_depth(&stream), 10);
343    }
344
345    #[test]
346    fn test_source_format_high_bit_depth() {
347        let mut stream = base_stream();
348        stream.pix_fmt = "yuv420p10le".into();
349        let format = SourceFormat::from_stream(&stream);
350        assert_eq!(format.bit_depth, 10);
351        assert_eq!(format.pix_fmt, "yuv420p10le");
352        assert!(format.is_high_bit_depth());
353    }
354
355    #[test]
356    fn test_encode_color_args_x265_10bit_hdr() {
357        let mut stream = base_stream();
358        stream.pix_fmt = "yuv420p10le".into();
359        stream.color_transfer = "smpte2084".into();
360        stream.color_primaries = "bt2020".into();
361        stream.color_space = "bt2020nc".into();
362        let format = SourceFormat::from_stream(&stream);
363        let args = encode_color_args(Codec::X265, &format);
364        assert!(args.windows(2).any(|w| w[0] == "-pix_fmt" && w[1] == "yuv420p10le"));
365        assert!(args.iter().any(|a| a.contains("profile=main10")));
366        assert!(args.iter().any(|a| a.contains("transfer=smpte2084")));
367    }
368
369    #[test]
370    fn test_psnr_peak_scaling() {
371        assert_eq!(psnr_peak(8), 255.0);
372        assert_eq!(psnr_peak(10), 1023.0);
373    }
374
375    #[test]
376    fn test_encode_color_args_emits_hdr10_metadata() {
377        use crate::{Hdr10Metadata, MasteringDisplay};
378        let mut stream = base_stream();
379        stream.pix_fmt = "yuv420p10le".into();
380        stream.color_transfer = "smpte2084".into();
381        stream.color_primaries = "bt2020".into();
382        stream.color_space = "bt2020nc".into();
383        let mut format = SourceFormat::from_stream(&stream);
384        format.hdr10 = Some(Hdr10Metadata {
385            mastering_display: Some(MasteringDisplay {
386                green_x: 13250,
387                green_y: 34500,
388                blue_x: 7500,
389                blue_y: 3000,
390                red_x: 34000,
391                red_y: 16000,
392                white_x: 15635,
393                white_y: 16450,
394                max_luminance: 10_000_000,
395                min_luminance: 50,
396            }),
397            max_cll: Some(1000),
398            max_fall: Some(400),
399        });
400        let args = encode_color_args(Codec::X265, &format);
401        let params = args
402            .windows(2)
403            .find(|w| w[0] == "-x265-params")
404            .map(|w| w[1].clone())
405            .expect("x265-params present");
406        assert!(
407            params.contains("master-display=G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,50)"),
408            "got: {params}"
409        );
410        assert!(params.contains("max-cll=1000,400"), "got: {params}");
411    }
412
413    #[test]
414    fn test_encode_color_args_svtav1_hdr10_metadata() {
415        use crate::{Hdr10Metadata, MasteringDisplay};
416        let mut stream = base_stream();
417        stream.pix_fmt = "yuv420p10le".into();
418        stream.color_transfer = "smpte2084".into();
419        stream.color_primaries = "bt2020".into();
420        stream.color_space = "bt2020nc".into();
421        let mut format = SourceFormat::from_stream(&stream);
422        format.hdr10 = Some(Hdr10Metadata {
423            mastering_display: Some(MasteringDisplay {
424                green_x: 13250,
425                green_y: 34500,
426                blue_x: 7500,
427                blue_y: 3000,
428                red_x: 34000,
429                red_y: 16000,
430                white_x: 15635,
431                white_y: 16450,
432                max_luminance: 10_000_000,
433                min_luminance: 50,
434            }),
435            max_cll: Some(1000),
436            max_fall: Some(400),
437        });
438        let args = encode_color_args(Codec::SvtAv1, &format);
439        let params = args
440            .windows(2)
441            .find(|w| w[0] == "-svtav1-params")
442            .map(|w| w[1].clone())
443            .expect("svtav1-params present");
444        assert!(
445            params.contains("mastering-display=G(0.265,0.69)B(0.15,0.06)R(0.68,0.32)WP(0.3127,0.329)L(1000,0.005)"),
446            "got: {params}"
447        );
448        assert!(params.contains("content-light=1000,400"), "got: {params}");
449        // AV1 carries colour primaries/transfer via the standard -color_* options.
450        assert!(args.windows(2).any(|w| w[0] == "-color_trc" && w[1] == "smpte2084"));
451    }
452
453    #[test]
454    fn test_encode_color_args_no_hdr10_when_sdr() {
455        let mut stream = base_stream();
456        stream.pix_fmt = "yuv420p10le".into();
457        let format = SourceFormat::from_stream(&stream);
458        let args = encode_color_args(Codec::X265, &format);
459        assert!(!args.iter().any(|a| a.contains("master-display")));
460        assert!(!args.iter().any(|a| a.contains("max-cll")));
461    }
462
463    // ── hw_pix_fmt ──
464
465    #[test]
466    fn test_hw_pix_fmt_nvenc_10bit() {
467        let mut stream = base_stream();
468        stream.pix_fmt = "yuv420p10le".into();
469        stream.bits_per_raw_sample = 10;
470        let format = SourceFormat::from_stream(&stream);
471        assert_eq!(hw_pix_fmt(&format, Codec::NvencH265), Some("p010le".into()));
472        assert_eq!(hw_pix_fmt(&format, Codec::NvencAv1), Some("p010le".into()));
473    }
474
475    #[test]
476    fn test_hw_pix_fmt_8bit_shows_none() {
477        let format = SourceFormat::from_stream(&base_stream());
478        assert_eq!(hw_pix_fmt(&format, Codec::NvencH265), None);
479        assert_eq!(hw_pix_fmt(&format, Codec::QsvH265), None);
480        assert_eq!(hw_pix_fmt(&format, Codec::VaapiH265), None);
481    }
482
483    #[test]
484    fn test_hw_pix_fmt_vaapi_returns_none() {
485        let mut stream = base_stream();
486        stream.pix_fmt = "yuv420p10le".into();
487        stream.bits_per_raw_sample = 10;
488        let format = SourceFormat::from_stream(&stream);
489        assert_eq!(hw_pix_fmt(&format, Codec::VaapiH265), None);
490    }
491
492    #[test]
493    fn test_hw_pix_fmt_amf_10bit() {
494        let mut stream = base_stream();
495        stream.pix_fmt = "yuv420p10le".into();
496        stream.bits_per_raw_sample = 10;
497        let format = SourceFormat::from_stream(&stream);
498        assert_eq!(hw_pix_fmt(&format, Codec::AmfH265), Some("p010le".into()));
499    }
500
501    #[test]
502    fn test_hw_pix_fmt_qsv_10bit() {
503        let mut stream = base_stream();
504        stream.pix_fmt = "yuv420p10le".into();
505        stream.bits_per_raw_sample = 10;
506        let format = SourceFormat::from_stream(&stream);
507        assert_eq!(hw_pix_fmt(&format, Codec::QsvH265), Some("p010le".into()));
508    }
509
510    #[test]
511    fn test_hw_pix_fmt_videotoolbox_10bit() {
512        let mut stream = base_stream();
513        stream.pix_fmt = "yuv420p10le".into();
514        stream.bits_per_raw_sample = 10;
515        let format = SourceFormat::from_stream(&stream);
516        assert_eq!(hw_pix_fmt(&format, Codec::VideoToolboxH265), Some("p010le".into()));
517    }
518
519    // ── HW encoder high bit depth in encode_color_args ──
520
521    #[test]
522    fn test_encode_color_args_nvenc_high_bit_depth_pix_fmt() {
523        let mut stream = base_stream();
524        stream.pix_fmt = "yuv420p10le".into();
525        stream.bits_per_raw_sample = 10;
526        let format = SourceFormat::from_stream(&stream);
527        let args = encode_color_args(Codec::NvencH265, &format);
528        assert!(
529            args.windows(2).any(|w| w[0] == "-pix_fmt" && w[1] == "p010le"),
530            "expected -pix_fmt p010le for NVENC 10-bit, got {args:?}"
531        );
532    }
533
534    #[test]
535    fn test_encode_color_args_nvenc_hdr10_sets_color_and_bsf() {
536        use crate::{Hdr10Metadata, MasteringDisplay};
537        let mut stream = base_stream();
538        stream.pix_fmt = "yuv420p10le".into();
539        stream.bits_per_raw_sample = 10;
540        stream.color_transfer = "smpte2084".into();
541        stream.color_primaries = "bt2020".into();
542        stream.color_space = "bt2020nc".into();
543        let mut format = SourceFormat::from_stream(&stream);
544        format.hdr10 = Some(Hdr10Metadata {
545            mastering_display: Some(MasteringDisplay {
546                green_x: 13250,
547                green_y: 34500,
548                blue_x: 7500,
549                blue_y: 3000,
550                red_x: 34000,
551                red_y: 16000,
552                white_x: 15635,
553                white_y: 16450,
554                max_luminance: 10_000_000,
555                min_luminance: 50,
556            }),
557            max_cll: Some(1000),
558            max_fall: Some(400),
559        });
560        let args = encode_color_args(Codec::NvencH265, &format);
561        // Must set the HDR color tags.
562        assert!(args.windows(2).any(|w| w[0] == "-color_trc" && w[1] == "smpte2084"));
563        // Must set the pixel format for 10-bit.
564        assert!(args.windows(2).any(|w| w[0] == "-pix_fmt" && w[1] == "p010le"));
565        // Must inject HDR metadata via hevc_metadata bitstream filter.
566        let bsf_idx = args.iter().position(|a| a == "-bsf").expect("missing -bsf");
567        let bsf_val = &args[bsf_idx + 1];
568        assert!(
569            bsf_val.starts_with("hevc_metadata="),
570            "expected hevc_metadata BSF, got: {bsf_val}"
571        );
572        assert!(bsf_val.contains("mastering_display="));
573        assert!(bsf_val.contains("max_cll=1000,400"));
574    }
575
576    #[test]
577    fn test_encode_color_args_amf_high_bit_depth_pix_fmt() {
578        let mut stream = base_stream();
579        stream.pix_fmt = "yuv420p10le".into();
580        stream.bits_per_raw_sample = 10;
581        let format = SourceFormat::from_stream(&stream);
582        let args = encode_color_args(Codec::AmfH265, &format);
583        assert!(
584            args.windows(2).any(|w| w[0] == "-pix_fmt" && w[1] == "p010le"),
585            "expected -pix_fmt p010le for AMF 10-bit, got {args:?}"
586        );
587    }
588
589    #[test]
590    fn test_encode_color_args_qsv_high_bit_depth_pix_fmt() {
591        let mut stream = base_stream();
592        stream.pix_fmt = "yuv420p10le".into();
593        stream.bits_per_raw_sample = 10;
594        let format = SourceFormat::from_stream(&stream);
595        let args = encode_color_args(Codec::QsvH265, &format);
596        assert!(
597            args.windows(2).any(|w| w[0] == "-pix_fmt" && w[1] == "p010le"),
598            "expected -pix_fmt p010le for QSV 10-bit, got {args:?}"
599        );
600    }
601
602    #[test]
603    fn test_encode_color_args_videotoolbox_high_bit_depth_pix_fmt() {
604        let mut stream = base_stream();
605        stream.pix_fmt = "yuv420p10le".into();
606        stream.bits_per_raw_sample = 10;
607        let format = SourceFormat::from_stream(&stream);
608        let args = encode_color_args(Codec::VideoToolboxH265, &format);
609        assert!(
610            args.windows(2).any(|w| w[0] == "-pix_fmt" && w[1] == "p010le"),
611            "expected -pix_fmt p010le for VideoToolbox 10-bit, got {args:?}"
612        );
613    }
614
615    #[test]
616    fn test_encode_color_args_nvenc_h264_8bit_does_not_set_p010() {
617        // H.264 NVENC is 8-bit only; must not set p010le.
618        let mut stream = base_stream();
619        stream.pix_fmt = "yuv420p10le".into();
620        stream.bits_per_raw_sample = 10;
621        let format = SourceFormat::from_stream(&stream);
622        let args = encode_color_args(Codec::NvencH264, &format);
623        assert!(!args.windows(2).any(|w| w == ["-pix_fmt", "p010le"]));
624        assert!(args.is_empty(), "expected no color args for H.264 NVENC 10-bit: {args:?}");
625    }
626
627    #[test]
628    fn test_encode_color_args_adds_profile_for_hw_hevc_10bit() {
629        let mut stream = base_stream();
630        stream.pix_fmt = "yuv420p10le".into();
631        stream.bits_per_raw_sample = 10;
632        let format = SourceFormat::from_stream(&stream);
633        for codec in &[Codec::NvencH265, Codec::QsvH265, Codec::AmfH265, Codec::VideoToolboxH265] {
634            let args = encode_color_args(*codec, &format);
635            assert!(
636                args.windows(2).any(|w| w == ["-profile:v", "main10"]),
637                "{codec:?}: expected -profile:v main10, got {args:?}"
638            );
639        }
640    }
641
642    #[test]
643    fn test_encode_color_args_svtav1_hdr10_uses_svtav1_params_not_bsf() {
644        use crate::{Hdr10Metadata, MasteringDisplay};
645        let mut stream = base_stream();
646        stream.pix_fmt = "yuv420p10le".into();
647        stream.bits_per_raw_sample = 10;
648        stream.color_transfer = "smpte2084".into();
649        stream.color_primaries = "bt2020".into();
650        stream.color_space = "bt2020nc".into();
651        let mut format = SourceFormat::from_stream(&stream);
652        format.hdr10 = Some(Hdr10Metadata {
653            mastering_display: Some(MasteringDisplay {
654                green_x: 13250,
655                green_y: 34500,
656                blue_x: 7500,
657                blue_y: 3000,
658                red_x: 34000,
659                red_y: 16000,
660                white_x: 15635,
661                white_y: 16450,
662                max_luminance: 10_000_000,
663                min_luminance: 50,
664            }),
665            max_cll: Some(1000),
666            max_fall: Some(400),
667        });
668        let args = encode_color_args(Codec::SvtAv1, &format);
669        // SVT-AV1 must NOT get a bitstream filter; it uses -svtav1-params instead.
670        assert!(!args.iter().any(|a| a == "-bsf"), "SVT-AV1 should not use BSF: {args:?}");
671        assert!(
672            args.windows(2).any(|w| w[0] == "-svtav1-params"),
673            "SVT-AV1 should use -svtav1-params: {args:?}"
674        );
675    }
676
677    #[test]
678    fn test_encode_color_args_x265_hdr10_uses_x265_params_not_bsf() {
679        use crate::{Hdr10Metadata, MasteringDisplay};
680        let mut stream = base_stream();
681        stream.pix_fmt = "yuv420p10le".into();
682        stream.bits_per_raw_sample = 10;
683        stream.color_transfer = "smpte2084".into();
684        stream.color_primaries = "bt2020".into();
685        stream.color_space = "bt2020nc".into();
686        let mut format = SourceFormat::from_stream(&stream);
687        format.hdr10 = Some(Hdr10Metadata {
688            mastering_display: Some(MasteringDisplay {
689                green_x: 13250,
690                green_y: 34500,
691                blue_x: 7500,
692                blue_y: 3000,
693                red_x: 34000,
694                red_y: 16000,
695                white_x: 15635,
696                white_y: 16450,
697                max_luminance: 10_000_000,
698                min_luminance: 50,
699            }),
700            max_cll: Some(1000),
701            max_fall: Some(400),
702        });
703        let args = encode_color_args(Codec::X265, &format);
704        assert!(!args.iter().any(|a| a == "-bsf"), "x265 should not use BSF: {args:?}");
705        assert!(
706            args.windows(2).any(|w| w[0] == "-x265-params"),
707            "x265 should use -x265-params: {args:?}"
708        );
709    }
710
711    #[test]
712    fn test_encode_color_args_nvenc_av1_no_bsf() {
713        // AV1 hardware encoders don't get a bitstream filter because
714        // av1_metadata does not support mastering_display/max_cll.
715        let mut stream = base_stream();
716        stream.pix_fmt = "yuv420p10le".into();
717        stream.bits_per_raw_sample = 10;
718        stream.color_transfer = "smpte2084".into();
719        stream.color_primaries = "bt2020".into();
720        stream.color_space = "bt2020nc".into();
721        let format = SourceFormat::from_stream(&stream);
722        let args = encode_color_args(Codec::NvencAv1, &format);
723        assert!(!args.iter().any(|a| a == "-bsf"), "AV1 should not use BSF: {args:?}");
724        // Color tags must still be present.
725        assert!(args.windows(2).any(|w| w[0] == "-color_trc" && w[1] == "smpte2084"));
726    }
727
728    // ── codec_supports_bit_depth ──
729
730    #[test]
731    fn test_codec_supports_bit_depth_10bit_hw() {
732        assert!(codec_supports_bit_depth(Codec::NvencH265, 10));
733        assert!(codec_supports_bit_depth(Codec::QsvH265, 10));
734        assert!(codec_supports_bit_depth(Codec::AmfH265, 10));
735        assert!(codec_supports_bit_depth(Codec::VideoToolboxH265, 10));
736        assert!(codec_supports_bit_depth(Codec::VaapiH265, 10));
737        assert!(codec_supports_bit_depth(Codec::NvencAv1, 10));
738        assert!(codec_supports_bit_depth(Codec::QsvAv1, 10));
739        assert!(codec_supports_bit_depth(Codec::AmfAv1, 10));
740        assert!(codec_supports_bit_depth(Codec::VaapiAv1, 10));
741    }
742
743    #[test]
744    fn test_codec_supports_bit_depth_h264_hw_8bit_only() {
745        assert!(!codec_supports_bit_depth(Codec::NvencH264, 10));
746        assert!(!codec_supports_bit_depth(Codec::QsvH264, 10));
747        assert!(!codec_supports_bit_depth(Codec::AmfH264, 10));
748        assert!(!codec_supports_bit_depth(Codec::VideoToolboxH264, 10));
749        assert!(!codec_supports_bit_depth(Codec::VaapiH264, 10));
750    }
751}