Skip to main content

oximedia_transcode/
hdr_passthrough.rs

1//! HDR metadata passthrough and conversion for the transcode pipeline.
2//!
3//! Supports HDR10 (static metadata via SMPTE ST 2086 + CTA-861.3),
4//! HLG (Hybrid Log-Gamma, ITU-R BT.2100), and a Dolby Vision profile
5//! descriptor.  Metadata can be passed through unchanged, converted
6//! between compatible transfer-function families, or stripped.
7//!
8//! # Transfer function compatibility
9//!
10//! ```text
11//!   HDR10 (PQ/ST2084) <──> HLG (BT.2100)   ← approximate inverse-OOTF path
12//!   HDR10 ──> SDR (BT.709)                  ← tone-map; PQ → BT.1886
13//!   HLG   ──> SDR (BT.709)                  ← HLG OOTF collapse
14//! ```
15//!
16//! Full pixel-level tone-mapping is provided by `oximedia-hdr`; this module
17//! handles the **metadata** side: mastering-display descriptors, content-light
18//! levels, and transfer-function flags embedded in the bitstream.
19
20#![allow(clippy::cast_precision_loss)]
21#![allow(clippy::cast_possible_truncation)]
22
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26// ─── Error ────────────────────────────────────────────────────────────────────
27
28/// Errors that can occur during HDR metadata handling.
29#[derive(Debug, Clone, Error)]
30pub enum HdrError {
31    /// The requested conversion between transfer functions is not supported.
32    #[error("Unsupported HDR conversion: {from:?} → {to:?}")]
33    UnsupportedConversion {
34        /// Source transfer function.
35        from: TransferFunction,
36        /// Target transfer function.
37        to: TransferFunction,
38    },
39
40    /// A required metadata field is absent.
41    #[error("Missing HDR metadata field: {0}")]
42    MissingField(String),
43
44    /// A numeric value is outside the valid range for its field.
45    #[error("HDR field '{field}' value {value} is out of range [{min}, {max}]")]
46    OutOfRange {
47        /// Field name.
48        field: String,
49        /// Supplied value.
50        value: f64,
51        /// Minimum allowed.
52        min: f64,
53        /// Maximum allowed.
54        max: f64,
55    },
56}
57
58// ─── Transfer function ────────────────────────────────────────────────────────
59
60/// Video transfer function (EOTF / OETF).
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub enum TransferFunction {
63    /// ITU-R BT.709 / BT.1886 (standard SDR).
64    Bt709,
65    /// SMPTE ST 2084 (Perceptual Quantizer, used by HDR10 and Dolby Vision).
66    Pq,
67    /// Hybrid Log-Gamma (ITU-R BT.2100).
68    Hlg,
69    /// Gamma 2.2 (legacy / JPEG / sRGB approximation).
70    Gamma22,
71    /// Linear light (no gamma).
72    Linear,
73    /// Unspecified / unknown.
74    Unspecified,
75}
76
77impl TransferFunction {
78    /// Returns the ITU-T H.273 / ISO/IEC 23091-2 transfer characteristics code.
79    #[must_use]
80    pub fn h273_code(self) -> u8 {
81        match self {
82            Self::Bt709 => 1,
83            Self::Gamma22 => 4,
84            Self::Linear => 8,
85            Self::Pq => 16,
86            Self::Hlg => 18,
87            Self::Unspecified => 2,
88        }
89    }
90
91    /// Returns `true` if this is an HDR transfer function (PQ or HLG).
92    #[must_use]
93    pub fn is_hdr(self) -> bool {
94        matches!(self, Self::Pq | Self::Hlg)
95    }
96
97    /// Returns `true` if this transfer function uses a wide colour gamut (BT.2020).
98    #[must_use]
99    pub fn is_wide_gamut(self) -> bool {
100        matches!(self, Self::Pq | Self::Hlg)
101    }
102}
103
104// ─── Colour primaries ─────────────────────────────────────────────────────────
105
106/// Video colour primaries (H.273 table 2).
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108pub enum ColourPrimaries {
109    /// BT.709 / sRGB.
110    Bt709,
111    /// BT.2020 (HDR10, HLG).
112    Bt2020,
113    /// Display P3 (DCI-P3 with D65 white point).
114    DisplayP3,
115    /// Unspecified.
116    Unspecified,
117}
118
119impl ColourPrimaries {
120    /// Returns the H.273 colour primaries code.
121    #[must_use]
122    pub fn h273_code(self) -> u8 {
123        match self {
124            Self::Bt709 => 1,
125            Self::Unspecified => 2,
126            Self::DisplayP3 => 12,
127            Self::Bt2020 => 9,
128        }
129    }
130}
131
132// ─── SMPTE ST 2086 mastering display ─────────────────────────────────────────
133
134/// Mastering display colour volume (SMPTE ST 2086).
135///
136/// All chromaticity coordinates are in the range [0, 1]; luminance values
137/// are in candelas per square metre (cd/m²).
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct MasteringDisplay {
140    /// Red chromaticity x coordinate.
141    pub red_x: f64,
142    /// Red chromaticity y coordinate.
143    pub red_y: f64,
144    /// Green chromaticity x coordinate.
145    pub green_x: f64,
146    /// Green chromaticity y coordinate.
147    pub green_y: f64,
148    /// Blue chromaticity x coordinate.
149    pub blue_x: f64,
150    /// Blue chromaticity y coordinate.
151    pub blue_y: f64,
152    /// White point x coordinate.
153    pub white_x: f64,
154    /// White point y coordinate.
155    pub white_y: f64,
156    /// Maximum display mastering luminance (cd/m²).
157    pub max_luminance: f64,
158    /// Minimum display mastering luminance (cd/m²).
159    pub min_luminance: f64,
160}
161
162impl MasteringDisplay {
163    /// Creates a mastering display descriptor for a standard P3 D65 reference monitor
164    /// (typical HDR10 grade suite, 1000 nit peak).
165    #[must_use]
166    pub fn p3_d65_1000nit() -> Self {
167        Self {
168            red_x: 0.680,
169            red_y: 0.320,
170            green_x: 0.265,
171            green_y: 0.690,
172            blue_x: 0.150,
173            blue_y: 0.060,
174            white_x: 0.3127,
175            white_y: 0.3290,
176            max_luminance: 1000.0,
177            min_luminance: 0.0050,
178        }
179    }
180
181    /// Creates a mastering display descriptor for BT.2020 at 4000 nit.
182    #[must_use]
183    pub fn bt2020_4000nit() -> Self {
184        Self {
185            red_x: 0.708,
186            red_y: 0.292,
187            green_x: 0.170,
188            green_y: 0.797,
189            blue_x: 0.131,
190            blue_y: 0.046,
191            white_x: 0.3127,
192            white_y: 0.3290,
193            max_luminance: 4000.0,
194            min_luminance: 0.005,
195        }
196    }
197
198    /// Validates all fields are within legal ranges.
199    ///
200    /// # Errors
201    ///
202    /// Returns `HdrError::OutOfRange` if any chromaticity or luminance value is out of range.
203    pub fn validate(&self) -> Result<(), HdrError> {
204        let check_chroma = |name: &str, v: f64| {
205            if v < 0.0 || v > 1.0 {
206                Err(HdrError::OutOfRange {
207                    field: name.to_string(),
208                    value: v,
209                    min: 0.0,
210                    max: 1.0,
211                })
212            } else {
213                Ok(())
214            }
215        };
216        check_chroma("red_x", self.red_x)?;
217        check_chroma("red_y", self.red_y)?;
218        check_chroma("green_x", self.green_x)?;
219        check_chroma("green_y", self.green_y)?;
220        check_chroma("blue_x", self.blue_x)?;
221        check_chroma("blue_y", self.blue_y)?;
222        check_chroma("white_x", self.white_x)?;
223        check_chroma("white_y", self.white_y)?;
224
225        if self.max_luminance <= 0.0 {
226            return Err(HdrError::OutOfRange {
227                field: "max_luminance".to_string(),
228                value: self.max_luminance,
229                min: 0.001,
230                max: f64::MAX,
231            });
232        }
233        if self.min_luminance < 0.0 || self.min_luminance >= self.max_luminance {
234            return Err(HdrError::OutOfRange {
235                field: "min_luminance".to_string(),
236                value: self.min_luminance,
237                min: 0.0,
238                max: self.max_luminance,
239            });
240        }
241        Ok(())
242    }
243}
244
245// ─── Content light level (CTA-861.3) ─────────────────────────────────────────
246
247/// Content light level metadata (CTA-861.3 MaxCLL / MaxFALL).
248#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
249pub struct ContentLightLevel {
250    /// Maximum content light level (MaxCLL) in cd/m².
251    pub max_cll: u16,
252    /// Maximum frame-average light level (MaxFALL) in cd/m².
253    pub max_fall: u16,
254}
255
256impl ContentLightLevel {
257    /// Creates a new content light level descriptor.
258    #[must_use]
259    pub fn new(max_cll: u16, max_fall: u16) -> Self {
260        Self { max_cll, max_fall }
261    }
262
263    /// A conservative default for HDR10 content (1000 MaxCLL / 400 MaxFALL).
264    #[must_use]
265    pub fn hdr10_default() -> Self {
266        Self {
267            max_cll: 1000,
268            max_fall: 400,
269        }
270    }
271
272    /// Validates that MaxFALL ≤ MaxCLL (per spec).
273    ///
274    /// # Errors
275    ///
276    /// Returns `HdrError::OutOfRange` if MaxFALL > MaxCLL.
277    pub fn validate(&self) -> Result<(), HdrError> {
278        if self.max_fall > self.max_cll {
279            return Err(HdrError::OutOfRange {
280                field: "max_fall".to_string(),
281                value: f64::from(self.max_fall),
282                min: 0.0,
283                max: f64::from(self.max_cll),
284            });
285        }
286        Ok(())
287    }
288}
289
290// ─── Dolby Vision profile descriptor ─────────────────────────────────────────
291
292/// Dolby Vision profile and level.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294pub enum DolbyVisionProfile {
295    /// Profile 4: HEVC with BL+EL+RPU (backward-compatible to HDR10).
296    Profile4,
297    /// Profile 5: HEVC single-layer with RPU (MEL / FEL).
298    Profile5,
299    /// Profile 7: HEVC dual-layer with RPU.
300    Profile7,
301    /// Profile 8: AV1 / HEVC single layer BL+RPU (most common OTT).
302    Profile8,
303    /// Profile 9: AV1 single-layer (next-gen streaming).
304    Profile9,
305}
306
307impl DolbyVisionProfile {
308    /// Returns the numeric DVHE/DVAV profile number.
309    #[must_use]
310    pub fn profile_number(self) -> u8 {
311        match self {
312            Self::Profile4 => 4,
313            Self::Profile5 => 5,
314            Self::Profile7 => 7,
315            Self::Profile8 => 8,
316            Self::Profile9 => 9,
317        }
318    }
319
320    /// Returns `true` if this profile supports backward-compatible SDR/HDR10 base layer.
321    #[must_use]
322    pub fn is_backward_compatible(self) -> bool {
323        matches!(self, Self::Profile4 | Self::Profile7 | Self::Profile8)
324    }
325}
326
327/// Dolby Vision metadata payload attached to a stream.
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct DolbyVisionMeta {
330    /// Dolby Vision profile.
331    pub profile: DolbyVisionProfile,
332    /// Level (1–13; maps to resolution × frame-rate bands).
333    pub level: u8,
334    /// Whether an RPU (Reference Processing Unit) NAL/OBU is present.
335    pub has_rpu: bool,
336    /// Whether an Enhancement Layer (EL) track exists.
337    pub has_el: bool,
338}
339
340impl DolbyVisionMeta {
341    /// Creates a Dolby Vision metadata descriptor.
342    #[must_use]
343    pub fn new(profile: DolbyVisionProfile, level: u8) -> Self {
344        Self {
345            profile,
346            level,
347            has_rpu: true,
348            has_el: false,
349        }
350    }
351
352    /// Validates the level is within [1, 13].
353    ///
354    /// # Errors
355    ///
356    /// Returns `HdrError::OutOfRange` if the level is outside [1, 13].
357    pub fn validate(&self) -> Result<(), HdrError> {
358        if self.level < 1 || self.level > 13 {
359            return Err(HdrError::OutOfRange {
360                field: "dv_level".to_string(),
361                value: f64::from(self.level),
362                min: 1.0,
363                max: 13.0,
364            });
365        }
366        Ok(())
367    }
368}
369
370// ─── Unified HDR metadata bundle ─────────────────────────────────────────────
371
372/// Unified HDR metadata attached to a video stream.
373///
374/// Carry exactly the fields present in the source; absent fields are `None`.
375/// Use [`HdrMetadata::validate`] before attaching to a mux.
376#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
377pub struct HdrMetadata {
378    /// Transfer function (EOTF/OETF).
379    pub transfer_function: Option<TransferFunction>,
380    /// Colour primaries.
381    pub colour_primaries: Option<ColourPrimaries>,
382    /// SMPTE ST 2086 mastering display colour volume.
383    pub mastering_display: Option<MasteringDisplay>,
384    /// CTA-861.3 content light level (MaxCLL / MaxFALL).
385    pub content_light_level: Option<ContentLightLevel>,
386    /// Dolby Vision metadata (if present).
387    pub dolby_vision: Option<DolbyVisionMeta>,
388}
389
390impl HdrMetadata {
391    /// Creates a minimal HDR10 metadata bundle with mastering display and CLL.
392    #[must_use]
393    pub fn hdr10(mastering: MasteringDisplay, cll: ContentLightLevel) -> Self {
394        Self {
395            transfer_function: Some(TransferFunction::Pq),
396            colour_primaries: Some(ColourPrimaries::Bt2020),
397            mastering_display: Some(mastering),
398            content_light_level: Some(cll),
399            dolby_vision: None,
400        }
401    }
402
403    /// Creates a minimal HLG metadata bundle.
404    #[must_use]
405    pub fn hlg() -> Self {
406        Self {
407            transfer_function: Some(TransferFunction::Hlg),
408            colour_primaries: Some(ColourPrimaries::Bt2020),
409            mastering_display: None,
410            content_light_level: None,
411            dolby_vision: None,
412        }
413    }
414
415    /// Returns `true` if this bundle carries any HDR signal.
416    #[must_use]
417    pub fn is_hdr(&self) -> bool {
418        self.transfer_function
419            .map(TransferFunction::is_hdr)
420            .unwrap_or(false)
421            || self.dolby_vision.is_some()
422    }
423
424    /// Validates all present sub-descriptors.
425    ///
426    /// # Errors
427    ///
428    /// Returns an error if any sub-descriptor fails validation.
429    pub fn validate(&self) -> Result<(), HdrError> {
430        if let Some(md) = &self.mastering_display {
431            md.validate()?;
432        }
433        if let Some(cll) = &self.content_light_level {
434            cll.validate()?;
435        }
436        if let Some(dv) = &self.dolby_vision {
437            dv.validate()?;
438        }
439        Ok(())
440    }
441}
442
443// ─── HDR passthrough mode ─────────────────────────────────────────────────────
444
445/// How HDR metadata should be handled when transcoding.
446#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
447pub enum HdrPassthroughMode {
448    /// Copy metadata unchanged from source to output.
449    Passthrough,
450    /// Strip all HDR metadata; treat output as SDR.
451    Strip,
452    /// Convert from the source HDR flavour to a different one.
453    ///
454    /// Pixel-level tone-mapping is performed by the frame pipeline;
455    /// this mode also updates the stream-level metadata flags.
456    Convert {
457        /// Target transfer function.
458        target_tf: TransferFunction,
459        /// Target colour primaries.
460        target_primaries: ColourPrimaries,
461    },
462    /// Inject caller-supplied metadata (overwrite any existing).
463    Inject(HdrMetadata),
464}
465
466impl Default for HdrPassthroughMode {
467    fn default() -> Self {
468        Self::Passthrough
469    }
470}
471
472// ─── HdrProcessor ────────────────────────────────────────────────────────────
473
474/// Applies an [`HdrPassthroughMode`] to a source [`HdrMetadata`] bundle,
475/// producing the metadata that should be written to the output stream.
476#[derive(Debug, Clone, Default)]
477pub struct HdrProcessor {
478    mode: HdrPassthroughMode,
479}
480
481impl HdrProcessor {
482    /// Creates a new processor with the given mode.
483    #[must_use]
484    pub fn new(mode: HdrPassthroughMode) -> Self {
485        Self { mode }
486    }
487
488    /// Processes the source metadata according to the configured mode and
489    /// returns the resulting metadata for the output stream.
490    ///
491    /// # Errors
492    ///
493    /// Returns `HdrError::UnsupportedConversion` when converting between
494    /// incompatible transfer functions (e.g., SDR → PQ without tone-mapping
495    /// parameters).
496    pub fn process(&self, source: Option<&HdrMetadata>) -> Result<Option<HdrMetadata>, HdrError> {
497        match &self.mode {
498            HdrPassthroughMode::Passthrough => Ok(source.cloned()),
499
500            HdrPassthroughMode::Strip => Ok(None),
501
502            HdrPassthroughMode::Inject(meta) => {
503                meta.validate()?;
504                Ok(Some(meta.clone()))
505            }
506
507            HdrPassthroughMode::Convert {
508                target_tf,
509                target_primaries,
510            } => {
511                let src_tf = source
512                    .and_then(|m| m.transfer_function)
513                    .unwrap_or(TransferFunction::Unspecified);
514
515                // Validate that the conversion path is supported.
516                Self::check_conversion(src_tf, *target_tf)?;
517
518                let mut out = source.cloned().unwrap_or_default();
519                out.transfer_function = Some(*target_tf);
520                out.colour_primaries = Some(*target_primaries);
521
522                // SDR output: drop static HDR metadata that doesn't apply.
523                if !target_tf.is_hdr() {
524                    out.mastering_display = None;
525                    out.content_light_level = None;
526                    out.dolby_vision = None;
527                }
528
529                // HLG output: drop PQ-specific fields that don't apply.
530                if *target_tf == TransferFunction::Hlg {
531                    out.mastering_display = None;
532                    out.content_light_level = None;
533                    out.dolby_vision = None;
534                }
535
536                Ok(Some(out))
537            }
538        }
539    }
540
541    /// Checks whether a transfer-function conversion path is supported.
542    fn check_conversion(from: TransferFunction, to: TransferFunction) -> Result<(), HdrError> {
543        use TransferFunction::{Bt709, Hlg, Pq, Unspecified};
544        let ok = matches!(
545            (from, to),
546            // Identity
547            (Pq, Pq) | (Hlg, Hlg) | (Bt709, Bt709) |
548            // HDR → SDR (tone-map)
549            (Pq, Bt709) | (Hlg, Bt709) |
550            // HDR cross-conversion (approximate)
551            (Pq, Hlg) | (Hlg, Pq) |
552            // Unspecified source → anything
553            (Unspecified, _)
554        );
555        if ok {
556            Ok(())
557        } else {
558            Err(HdrError::UnsupportedConversion { from, to })
559        }
560    }
561
562    /// Returns the configured passthrough mode.
563    #[must_use]
564    pub fn mode(&self) -> &HdrPassthroughMode {
565        &self.mode
566    }
567}
568
569// ─── Bitstream-level helpers ──────────────────────────────────────────────────
570
571/// Serialises a `MasteringDisplay` into the 24-byte SMPTE ST 2086 SEI payload
572/// format used by HEVC and (via the same layout) AV1 metadata OBUs.
573///
574/// The layout is:
575/// ```text
576///   2 bytes × 3 primaries (x,y) + 2 bytes × white point (x,y) = 10 × u16
577///   4 bytes max_luminance  (u32, units: 0.0001 cd/m²)
578///   4 bytes min_luminance  (u32, units: 0.0001 cd/m²)
579/// ```
580#[must_use]
581pub fn encode_mastering_display_sei(md: &MasteringDisplay) -> [u8; 24] {
582    let to_u16 = |v: f64| -> u16 { (v * 50_000.0).round() as u16 };
583    let to_u32 = |v: f64| -> u32 { (v * 10_000.0).round() as u32 };
584
585    let mut buf = [0u8; 24];
586    let pairs: [(f64, f64); 4] = [
587        (md.green_x, md.green_y),
588        (md.blue_x, md.blue_y),
589        (md.red_x, md.red_y),
590        (md.white_x, md.white_y),
591    ];
592    for (i, (x, y)) in pairs.iter().enumerate() {
593        let xv = to_u16(*x);
594        let yv = to_u16(*y);
595        buf[i * 4] = (xv >> 8) as u8;
596        buf[i * 4 + 1] = (xv & 0xFF) as u8;
597        buf[i * 4 + 2] = (yv >> 8) as u8;
598        buf[i * 4 + 3] = (yv & 0xFF) as u8;
599    }
600    let max_u32 = to_u32(md.max_luminance);
601    let min_u32 = to_u32(md.min_luminance);
602    buf[16] = (max_u32 >> 24) as u8;
603    buf[17] = (max_u32 >> 16) as u8;
604    buf[18] = (max_u32 >> 8) as u8;
605    buf[19] = (max_u32 & 0xFF) as u8;
606    buf[20] = (min_u32 >> 24) as u8;
607    buf[21] = (min_u32 >> 16) as u8;
608    buf[22] = (min_u32 >> 8) as u8;
609    buf[23] = (min_u32 & 0xFF) as u8;
610    buf
611}
612
613/// Decodes a 24-byte SMPTE ST 2086 SEI payload back into a `MasteringDisplay`.
614///
615/// # Errors
616///
617/// Returns `HdrError::MissingField` if the buffer is shorter than 24 bytes,
618/// or `HdrError::OutOfRange` if the decoded luminance values are invalid.
619pub fn decode_mastering_display_sei(buf: &[u8]) -> Result<MasteringDisplay, HdrError> {
620    if buf.len() < 24 {
621        return Err(HdrError::MissingField(
622            "SEI payload too short (need 24 bytes)".to_string(),
623        ));
624    }
625    let read_u16 = |i: usize| -> f64 {
626        let v = (u16::from(buf[i]) << 8) | u16::from(buf[i + 1]);
627        f64::from(v) / 50_000.0
628    };
629    let read_u32 = |i: usize| -> f64 {
630        let v = (u32::from(buf[i]) << 24)
631            | (u32::from(buf[i + 1]) << 16)
632            | (u32::from(buf[i + 2]) << 8)
633            | u32::from(buf[i + 3]);
634        f64::from(v) / 10_000.0
635    };
636
637    let md = MasteringDisplay {
638        green_x: read_u16(0),
639        green_y: read_u16(2),
640        blue_x: read_u16(4),
641        blue_y: read_u16(6),
642        red_x: read_u16(8),
643        red_y: read_u16(10),
644        white_x: read_u16(12),
645        white_y: read_u16(14),
646        max_luminance: read_u32(16),
647        min_luminance: read_u32(20),
648    };
649    md.validate()?;
650    Ok(md)
651}
652
653/// Serialises `ContentLightLevel` into the 4-byte CTA-861.3 payload
654/// (MaxCLL u16 BE, MaxFALL u16 BE).
655#[must_use]
656pub fn encode_cll_sei(cll: &ContentLightLevel) -> [u8; 4] {
657    [
658        (cll.max_cll >> 8) as u8,
659        (cll.max_cll & 0xFF) as u8,
660        (cll.max_fall >> 8) as u8,
661        (cll.max_fall & 0xFF) as u8,
662    ]
663}
664
665/// Decodes a 4-byte CTA-861.3 payload back into `ContentLightLevel`.
666///
667/// # Errors
668///
669/// Returns `HdrError::MissingField` if `buf` is shorter than 4 bytes.
670pub fn decode_cll_sei(buf: &[u8]) -> Result<ContentLightLevel, HdrError> {
671    if buf.len() < 4 {
672        return Err(HdrError::MissingField(
673            "CLL SEI payload too short (need 4 bytes)".to_string(),
674        ));
675    }
676    let max_cll = (u16::from(buf[0]) << 8) | u16::from(buf[1]);
677    let max_fall = (u16::from(buf[2]) << 8) | u16::from(buf[3]);
678    let cll = ContentLightLevel { max_cll, max_fall };
679    cll.validate()?;
680    Ok(cll)
681}
682
683// ─── HDR10+ dynamic metadata ──────────────────────────────────────────────────
684
685/// HDR10+ dynamic metadata for a single scene or frame.
686///
687/// HDR10+ (SMPTE ST 2094-40) carries per-scene tone-mapping information
688/// as SEI messages in HEVC or metadata OBUs in AV1.
689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
690pub struct Hdr10PlusDynamicMeta {
691    /// Application version (currently 0 or 1).
692    pub application_version: u8,
693    /// Targeted system display maximum luminance (in cd/m²).
694    pub targeted_system_display_max_luminance: u16,
695    /// Average maxRGB of the scene (0.0–1.0 normalised to peak).
696    pub average_maxrgb: f64,
697    /// Distribution maxRGB percentile values (up to 9).
698    pub maxrgb_percentiles: Vec<(u8, f64)>,
699    /// Fraction of selected area pixels.
700    pub fraction_bright_pixels: f64,
701    /// Knee point (x, y) for the tone-mapping curve.
702    pub knee_point: (f64, f64),
703    /// Bezier curve anchors for tone-mapping (0–9 points).
704    pub bezier_curve_anchors: Vec<f64>,
705}
706
707impl Hdr10PlusDynamicMeta {
708    /// Creates a new HDR10+ dynamic metadata descriptor with sensible defaults.
709    #[must_use]
710    pub fn new(targeted_max_lum: u16) -> Self {
711        Self {
712            application_version: 1,
713            targeted_system_display_max_luminance: targeted_max_lum,
714            average_maxrgb: 0.5,
715            maxrgb_percentiles: vec![(1, 0.01), (50, 0.5), (99, 0.95)],
716            fraction_bright_pixels: 0.01,
717            knee_point: (0.5, 0.5),
718            bezier_curve_anchors: vec![0.25, 0.5, 0.75],
719        }
720    }
721
722    /// Validates the HDR10+ metadata fields.
723    ///
724    /// # Errors
725    ///
726    /// Returns `HdrError::OutOfRange` if any field is outside legal range.
727    pub fn validate(&self) -> Result<(), HdrError> {
728        if self.application_version > 1 {
729            return Err(HdrError::OutOfRange {
730                field: "hdr10plus_application_version".to_string(),
731                value: f64::from(self.application_version),
732                min: 0.0,
733                max: 1.0,
734            });
735        }
736        if self.average_maxrgb < 0.0 || self.average_maxrgb > 1.0 {
737            return Err(HdrError::OutOfRange {
738                field: "average_maxrgb".to_string(),
739                value: self.average_maxrgb,
740                min: 0.0,
741                max: 1.0,
742            });
743        }
744        if self.fraction_bright_pixels < 0.0 || self.fraction_bright_pixels > 1.0 {
745            return Err(HdrError::OutOfRange {
746                field: "fraction_bright_pixels".to_string(),
747                value: self.fraction_bright_pixels,
748                min: 0.0,
749                max: 1.0,
750            });
751        }
752        let (kx, ky) = self.knee_point;
753        if kx < 0.0 || kx > 1.0 {
754            return Err(HdrError::OutOfRange {
755                field: "knee_point_x".to_string(),
756                value: kx,
757                min: 0.0,
758                max: 1.0,
759            });
760        }
761        if ky < 0.0 || ky > 1.0 {
762            return Err(HdrError::OutOfRange {
763                field: "knee_point_y".to_string(),
764                value: ky,
765                min: 0.0,
766                max: 1.0,
767            });
768        }
769        if self.bezier_curve_anchors.len() > 9 {
770            return Err(HdrError::OutOfRange {
771                field: "bezier_curve_anchors_count".to_string(),
772                value: self.bezier_curve_anchors.len() as f64,
773                min: 0.0,
774                max: 9.0,
775            });
776        }
777        for (i, &a) in self.bezier_curve_anchors.iter().enumerate() {
778            if a < 0.0 || a > 1.0 {
779                return Err(HdrError::OutOfRange {
780                    field: format!("bezier_anchor_{i}"),
781                    value: a,
782                    min: 0.0,
783                    max: 1.0,
784                });
785            }
786        }
787        Ok(())
788    }
789
790    /// Serialises HDR10+ dynamic metadata into a simplified binary payload.
791    ///
792    /// Layout (variable length):
793    /// - 1 byte: application_version
794    /// - 2 bytes: targeted_system_display_max_luminance (u16 BE)
795    /// - 2 bytes: average_maxrgb (u16 BE, value * 10000)
796    /// - 2 bytes: fraction_bright_pixels (u16 BE, value * 10000)
797    /// - 2 bytes: knee_point_x (u16 BE, value * 10000)
798    /// - 2 bytes: knee_point_y (u16 BE, value * 10000)
799    /// - 1 byte: number of bezier anchors
800    /// - N * 2 bytes: anchor values (u16 BE, value * 10000)
801    #[must_use]
802    pub fn encode(&self) -> Vec<u8> {
803        let to_u16 = |v: f64| -> u16 { (v * 10_000.0).round() as u16 };
804        let mut buf = Vec::with_capacity(16 + self.bezier_curve_anchors.len() * 2);
805        buf.push(self.application_version);
806        buf.extend_from_slice(&self.targeted_system_display_max_luminance.to_be_bytes());
807        buf.extend_from_slice(&to_u16(self.average_maxrgb).to_be_bytes());
808        buf.extend_from_slice(&to_u16(self.fraction_bright_pixels).to_be_bytes());
809        buf.extend_from_slice(&to_u16(self.knee_point.0).to_be_bytes());
810        buf.extend_from_slice(&to_u16(self.knee_point.1).to_be_bytes());
811        let anchor_count = self.bezier_curve_anchors.len().min(9) as u8;
812        buf.push(anchor_count);
813        for &a in self.bezier_curve_anchors.iter().take(9) {
814            buf.extend_from_slice(&to_u16(a).to_be_bytes());
815        }
816        buf
817    }
818
819    /// Decodes HDR10+ dynamic metadata from a binary payload.
820    ///
821    /// # Errors
822    ///
823    /// Returns `HdrError::MissingField` if the buffer is too short.
824    pub fn decode(buf: &[u8]) -> Result<Self, HdrError> {
825        if buf.len() < 12 {
826            return Err(HdrError::MissingField(
827                "HDR10+ payload too short (need at least 12 bytes)".to_string(),
828            ));
829        }
830        let from_u16 = |i: usize| -> f64 {
831            let v = (u16::from(buf[i]) << 8) | u16::from(buf[i + 1]);
832            f64::from(v) / 10_000.0
833        };
834        let application_version = buf[0];
835        let targeted_max = (u16::from(buf[1]) << 8) | u16::from(buf[2]);
836        let average_maxrgb = from_u16(3);
837        let fraction_bright = from_u16(5);
838        let knee_x = from_u16(7);
839        let knee_y = from_u16(9);
840        let anchor_count = buf[11] as usize;
841        let needed = 12 + anchor_count * 2;
842        if buf.len() < needed {
843            return Err(HdrError::MissingField(format!(
844                "HDR10+ payload too short for {anchor_count} anchors (need {needed} bytes)"
845            )));
846        }
847        let mut anchors = Vec::with_capacity(anchor_count);
848        for i in 0..anchor_count {
849            let offset = 12 + i * 2;
850            let v = (u16::from(buf[offset]) << 8) | u16::from(buf[offset + 1]);
851            anchors.push(f64::from(v) / 10_000.0);
852        }
853        let meta = Self {
854            application_version,
855            targeted_system_display_max_luminance: targeted_max,
856            average_maxrgb,
857            maxrgb_percentiles: Vec::new(),
858            fraction_bright_pixels: fraction_bright,
859            knee_point: (knee_x, knee_y),
860            bezier_curve_anchors: anchors,
861        };
862        meta.validate()?;
863        Ok(meta)
864    }
865}
866
867// ─── Dolby Vision RPU passthrough ─────────────────────────────────────────────
868
869/// Dolby Vision RPU (Reference Processing Unit) passthrough handler.
870///
871/// Manages extraction and insertion of RPU NAL units from/to HEVC or AV1
872/// bitstreams during transcoding without re-interpreting the mapping curves.
873#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
874pub struct DolbyVisionRpu {
875    /// Raw RPU payload bytes (NAL unit body, excluding start code).
876    pub payload: Vec<u8>,
877    /// Profile from the RPU header (0–9).
878    pub rpu_profile: u8,
879    /// Whether the RPU was validated successfully.
880    pub validated: bool,
881    /// Frame index this RPU belongs to.
882    pub frame_index: u64,
883}
884
885impl DolbyVisionRpu {
886    /// Creates a new RPU descriptor from raw payload bytes.
887    #[must_use]
888    pub fn new(payload: Vec<u8>, frame_index: u64) -> Self {
889        Self {
890            rpu_profile: Self::extract_profile(&payload),
891            payload,
892            validated: false,
893            frame_index,
894        }
895    }
896
897    /// Extracts the Dolby Vision profile from the RPU header.
898    ///
899    /// The profile is encoded in the first few bits of the RPU payload.
900    /// Returns 0 if the payload is too short to determine the profile.
901    fn extract_profile(payload: &[u8]) -> u8 {
902        // DV RPU starts with rpu_type (6 bits), then rpu_format (11 bits).
903        // The profile is typically signalled at a higher level (configuration record),
904        // but we can infer from rpu_type: 2 => profile 7/8, etc.
905        if payload.len() < 2 {
906            return 0;
907        }
908        let rpu_type = payload[0] >> 2;
909        match rpu_type {
910            2 => 8, // Single-layer with RPU (most common OTT)
911            0 => 5, // MEL/FEL
912            1 => 7, // Dual-layer
913            _ => 0,
914        }
915    }
916
917    /// Validates the RPU payload structure.
918    ///
919    /// # Errors
920    ///
921    /// Returns `HdrError::MissingField` if the payload is empty or malformed.
922    pub fn validate(&mut self) -> Result<(), HdrError> {
923        if self.payload.is_empty() {
924            return Err(HdrError::MissingField(
925                "DV RPU payload is empty".to_string(),
926            ));
927        }
928        // Minimal structural check: RPU should be at least 25 bytes
929        if self.payload.len() < 25 {
930            return Err(HdrError::MissingField(
931                "DV RPU payload too short (minimum 25 bytes)".to_string(),
932            ));
933        }
934        self.validated = true;
935        Ok(())
936    }
937
938    /// Returns the size of the RPU payload in bytes.
939    #[must_use]
940    pub fn size(&self) -> usize {
941        self.payload.len()
942    }
943}
944
945/// Manages a stream of Dolby Vision RPUs for passthrough mode.
946#[derive(Debug, Clone, Default)]
947pub struct DvRpuPassthrough {
948    /// Collected RPU payloads indexed by frame number.
949    rpus: Vec<DolbyVisionRpu>,
950    /// Number of RPUs that passed validation.
951    valid_count: usize,
952    /// Number of RPUs that failed validation.
953    invalid_count: usize,
954}
955
956impl DvRpuPassthrough {
957    /// Creates a new RPU passthrough handler.
958    #[must_use]
959    pub fn new() -> Self {
960        Self::default()
961    }
962
963    /// Ingests a raw RPU payload for the given frame.
964    pub fn ingest(&mut self, payload: Vec<u8>, frame_index: u64) {
965        let mut rpu = DolbyVisionRpu::new(payload, frame_index);
966        if rpu.validate().is_ok() {
967            self.valid_count += 1;
968        } else {
969            self.invalid_count += 1;
970        }
971        self.rpus.push(rpu);
972    }
973
974    /// Returns the RPU for the given frame index, if available.
975    #[must_use]
976    pub fn get_rpu(&self, frame_index: u64) -> Option<&DolbyVisionRpu> {
977        self.rpus.iter().find(|r| r.frame_index == frame_index)
978    }
979
980    /// Returns the total number of ingested RPUs.
981    #[must_use]
982    pub fn count(&self) -> usize {
983        self.rpus.len()
984    }
985
986    /// Returns the number of valid RPUs.
987    #[must_use]
988    pub fn valid_count(&self) -> usize {
989        self.valid_count
990    }
991
992    /// Returns the number of invalid RPUs.
993    #[must_use]
994    pub fn invalid_count(&self) -> usize {
995        self.invalid_count
996    }
997
998    /// Drains all RPUs into a vector for writing to the output bitstream.
999    pub fn drain_all(&mut self) -> Vec<DolbyVisionRpu> {
1000        self.valid_count = 0;
1001        self.invalid_count = 0;
1002        std::mem::take(&mut self.rpus)
1003    }
1004}
1005
1006// ─── Tone-mapping configuration ───────────────────────────────────────────────
1007
1008/// Tonemapping curve type for HDR↔SDR conversion.
1009#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1010pub enum TonemapCurve {
1011    /// Reinhard global operator: L_out = L / (1 + L).
1012    Reinhard,
1013    /// Hable (Uncharted 2) filmic curve.
1014    Hable,
1015    /// ACES filmic (Academy Color Encoding System).
1016    Aces,
1017    /// BT.2390 EETF (reference PQ tone-mapping).
1018    Bt2390,
1019    /// Simple clip: values above `peak_luminance` are clamped.
1020    Clip,
1021    /// Mobius (smooth roll-off near peak).
1022    Mobius,
1023}
1024
1025/// Configuration for HDR→SDR tonemapping.
1026#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1027pub struct HdrToSdrConfig {
1028    /// Tonemapping curve.
1029    pub curve: TonemapCurve,
1030    /// Source peak luminance (cd/m²).
1031    pub source_peak_nits: f64,
1032    /// Target peak luminance (cd/m²) — typically 100 for SDR.
1033    pub target_peak_nits: f64,
1034    /// Desaturation strength (0.0 = none, 1.0 = full grey at peak).
1035    pub desat_strength: f64,
1036}
1037
1038impl HdrToSdrConfig {
1039    /// Creates a default HDR→SDR configuration for 1000 nit source.
1040    #[must_use]
1041    pub fn default_1000nit() -> Self {
1042        Self {
1043            curve: TonemapCurve::Bt2390,
1044            source_peak_nits: 1000.0,
1045            target_peak_nits: 100.0,
1046            desat_strength: 0.5,
1047        }
1048    }
1049
1050    /// Validates the tonemapping configuration.
1051    ///
1052    /// # Errors
1053    ///
1054    /// Returns `HdrError::OutOfRange` if luminance or desaturation values are invalid.
1055    pub fn validate(&self) -> Result<(), HdrError> {
1056        if self.source_peak_nits <= 0.0 {
1057            return Err(HdrError::OutOfRange {
1058                field: "source_peak_nits".to_string(),
1059                value: self.source_peak_nits,
1060                min: 0.001,
1061                max: f64::MAX,
1062            });
1063        }
1064        if self.target_peak_nits <= 0.0 {
1065            return Err(HdrError::OutOfRange {
1066                field: "target_peak_nits".to_string(),
1067                value: self.target_peak_nits,
1068                min: 0.001,
1069                max: f64::MAX,
1070            });
1071        }
1072        if self.desat_strength < 0.0 || self.desat_strength > 1.0 {
1073            return Err(HdrError::OutOfRange {
1074                field: "desat_strength".to_string(),
1075                value: self.desat_strength,
1076                min: 0.0,
1077                max: 1.0,
1078            });
1079        }
1080        Ok(())
1081    }
1082
1083    /// Applies the Reinhard tonemapping operator to a linear-light value.
1084    #[must_use]
1085    pub fn tonemap_reinhard(&self, l: f64) -> f64 {
1086        let normalised = l * self.source_peak_nits / self.target_peak_nits;
1087        let mapped = normalised / (1.0 + normalised);
1088        mapped * self.target_peak_nits
1089    }
1090
1091    /// Applies the Hable (Uncharted 2) filmic curve.
1092    #[must_use]
1093    pub fn tonemap_hable(&self, l: f64) -> f64 {
1094        let hable = |x: f64| -> f64 {
1095            let a = 0.15;
1096            let b = 0.50;
1097            let c = 0.10;
1098            let d = 0.20;
1099            let e = 0.02;
1100            let f = 0.30;
1101            ((x * (a * x + c * b) + d * e) / (x * (a * x + b) + d * f)) - e / f
1102        };
1103        let normalised = l * self.source_peak_nits / self.target_peak_nits;
1104        let white = self.source_peak_nits / self.target_peak_nits;
1105        (hable(normalised) / hable(white)) * self.target_peak_nits
1106    }
1107
1108    /// Applies the ACES filmic curve approximation.
1109    #[must_use]
1110    pub fn tonemap_aces(&self, l: f64) -> f64 {
1111        let normalised = l * self.source_peak_nits / self.target_peak_nits;
1112        let a = 2.51;
1113        let b = 0.03;
1114        let c = 2.43;
1115        let d = 0.59;
1116        let e = 0.14;
1117        let mapped = (normalised * (a * normalised + b)) / (normalised * (c * normalised + d) + e);
1118        mapped.clamp(0.0, 1.0) * self.target_peak_nits
1119    }
1120
1121    /// Applies the configured tonemapping curve to a linear-light value.
1122    #[must_use]
1123    pub fn apply(&self, l: f64) -> f64 {
1124        match self.curve {
1125            TonemapCurve::Reinhard => self.tonemap_reinhard(l),
1126            TonemapCurve::Hable => self.tonemap_hable(l),
1127            TonemapCurve::Aces => self.tonemap_aces(l),
1128            TonemapCurve::Bt2390 | TonemapCurve::Mobius => {
1129                // BT.2390 / Mobius: use Reinhard as fallback approximation
1130                self.tonemap_reinhard(l)
1131            }
1132            TonemapCurve::Clip => (l * self.source_peak_nits).min(self.target_peak_nits),
1133        }
1134    }
1135}
1136
1137/// Configuration for SDR→HDR inverse tonemapping.
1138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1139pub struct SdrToHdrConfig {
1140    /// Target peak luminance for the HDR output (cd/m²).
1141    pub target_peak_nits: f64,
1142    /// Source peak luminance of the SDR content (cd/m²).
1143    pub source_peak_nits: f64,
1144    /// Highlight expansion gain (1.0 = linear, >1.0 = brighter highlights).
1145    pub highlight_gain: f64,
1146    /// Mid-tone boost factor (subtle lift to mid-tones).
1147    pub midtone_boost: f64,
1148}
1149
1150impl SdrToHdrConfig {
1151    /// Creates a default SDR→HDR config targeting 1000 nit output.
1152    #[must_use]
1153    pub fn default_1000nit() -> Self {
1154        Self {
1155            target_peak_nits: 1000.0,
1156            source_peak_nits: 100.0,
1157            highlight_gain: 2.5,
1158            midtone_boost: 1.1,
1159        }
1160    }
1161
1162    /// Validates the inverse tonemapping configuration.
1163    ///
1164    /// # Errors
1165    ///
1166    /// Returns `HdrError::OutOfRange` if any value is outside valid range.
1167    pub fn validate(&self) -> Result<(), HdrError> {
1168        if self.target_peak_nits <= 0.0 {
1169            return Err(HdrError::OutOfRange {
1170                field: "target_peak_nits".to_string(),
1171                value: self.target_peak_nits,
1172                min: 0.001,
1173                max: f64::MAX,
1174            });
1175        }
1176        if self.source_peak_nits <= 0.0 {
1177            return Err(HdrError::OutOfRange {
1178                field: "source_peak_nits".to_string(),
1179                value: self.source_peak_nits,
1180                min: 0.001,
1181                max: f64::MAX,
1182            });
1183        }
1184        if self.highlight_gain < 1.0 {
1185            return Err(HdrError::OutOfRange {
1186                field: "highlight_gain".to_string(),
1187                value: self.highlight_gain,
1188                min: 1.0,
1189                max: f64::MAX,
1190            });
1191        }
1192        if self.midtone_boost < 0.5 || self.midtone_boost > 3.0 {
1193            return Err(HdrError::OutOfRange {
1194                field: "midtone_boost".to_string(),
1195                value: self.midtone_boost,
1196                min: 0.5,
1197                max: 3.0,
1198            });
1199        }
1200        Ok(())
1201    }
1202
1203    /// Applies inverse tonemapping to an SDR linear-light value.
1204    ///
1205    /// Returns the expanded HDR value in cd/m².
1206    #[must_use]
1207    pub fn apply(&self, l_sdr: f64) -> f64 {
1208        if l_sdr <= 0.0 {
1209            return 0.0;
1210        }
1211        let normalised = (l_sdr / self.source_peak_nits).clamp(0.0, 1.0);
1212        // S-curve expansion: boost highlights more than shadows
1213        let expanded = if normalised < 0.5 {
1214            normalised * self.midtone_boost
1215        } else {
1216            let t = (normalised - 0.5) * 2.0; // 0..1 in upper half
1217            let base = 0.5 * self.midtone_boost;
1218            base + t * 0.5 * self.highlight_gain
1219        };
1220        (expanded * self.target_peak_nits).min(self.target_peak_nits)
1221    }
1222}
1223
1224// ─── Metadata repair ──────────────────────────────────────────────────────────
1225
1226/// Metadata repair actions that can be applied automatically.
1227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1228pub enum MetadataRepairAction {
1229    /// Clamp chromaticity values to [0, 1].
1230    ClampChromaticity,
1231    /// Ensure min_luminance < max_luminance.
1232    FixLuminanceOrder,
1233    /// Ensure MaxFALL <= MaxCLL.
1234    FixFallCll,
1235    /// Add missing mastering display metadata with defaults.
1236    InjectDefaultMastering,
1237    /// Add missing CLL metadata with defaults.
1238    InjectDefaultCll,
1239}
1240
1241/// Attempts to repair common HDR metadata issues in-place.
1242///
1243/// Returns a list of repairs that were applied.
1244pub fn repair_hdr_metadata(meta: &mut HdrMetadata) -> Vec<MetadataRepairAction> {
1245    let mut repairs = Vec::new();
1246
1247    // Repair mastering display chromaticity
1248    if let Some(md) = &mut meta.mastering_display {
1249        let mut clamped = false;
1250        let clamp_chroma = |v: &mut f64, changed: &mut bool| {
1251            if *v < 0.0 {
1252                *v = 0.0;
1253                *changed = true;
1254            }
1255            if *v > 1.0 {
1256                *v = 1.0;
1257                *changed = true;
1258            }
1259        };
1260        clamp_chroma(&mut md.red_x, &mut clamped);
1261        clamp_chroma(&mut md.red_y, &mut clamped);
1262        clamp_chroma(&mut md.green_x, &mut clamped);
1263        clamp_chroma(&mut md.green_y, &mut clamped);
1264        clamp_chroma(&mut md.blue_x, &mut clamped);
1265        clamp_chroma(&mut md.blue_y, &mut clamped);
1266        clamp_chroma(&mut md.white_x, &mut clamped);
1267        clamp_chroma(&mut md.white_y, &mut clamped);
1268        if clamped {
1269            repairs.push(MetadataRepairAction::ClampChromaticity);
1270        }
1271
1272        // Fix luminance ordering
1273        if md.min_luminance >= md.max_luminance && md.max_luminance > 0.0 {
1274            md.min_luminance = md.max_luminance * 0.001;
1275            repairs.push(MetadataRepairAction::FixLuminanceOrder);
1276        }
1277        if md.max_luminance <= 0.0 {
1278            md.max_luminance = 1000.0;
1279            md.min_luminance = 0.005;
1280            repairs.push(MetadataRepairAction::FixLuminanceOrder);
1281        }
1282    }
1283
1284    // Fix CLL ordering
1285    if let Some(cll) = &mut meta.content_light_level {
1286        if cll.max_fall > cll.max_cll {
1287            cll.max_fall = cll.max_cll;
1288            repairs.push(MetadataRepairAction::FixFallCll);
1289        }
1290    }
1291
1292    // Inject defaults if HDR TF is set but metadata is missing
1293    if let Some(tf) = meta.transfer_function {
1294        if tf == TransferFunction::Pq {
1295            if meta.mastering_display.is_none() {
1296                meta.mastering_display = Some(MasteringDisplay::p3_d65_1000nit());
1297                repairs.push(MetadataRepairAction::InjectDefaultMastering);
1298            }
1299            if meta.content_light_level.is_none() {
1300                meta.content_light_level = Some(ContentLightLevel::hdr10_default());
1301                repairs.push(MetadataRepairAction::InjectDefaultCll);
1302            }
1303        }
1304    }
1305
1306    repairs
1307}
1308
1309// ─── Tests ────────────────────────────────────────────────────────────────────
1310
1311#[cfg(test)]
1312mod tests {
1313    use super::*;
1314
1315    // ── TransferFunction ──────────────────────────────────────────────────────
1316
1317    #[test]
1318    fn test_transfer_function_h273_codes() {
1319        assert_eq!(TransferFunction::Bt709.h273_code(), 1);
1320        assert_eq!(TransferFunction::Pq.h273_code(), 16);
1321        assert_eq!(TransferFunction::Hlg.h273_code(), 18);
1322        assert_eq!(TransferFunction::Linear.h273_code(), 8);
1323        assert_eq!(TransferFunction::Unspecified.h273_code(), 2);
1324    }
1325
1326    #[test]
1327    fn test_transfer_function_is_hdr() {
1328        assert!(TransferFunction::Pq.is_hdr());
1329        assert!(TransferFunction::Hlg.is_hdr());
1330        assert!(!TransferFunction::Bt709.is_hdr());
1331        assert!(!TransferFunction::Linear.is_hdr());
1332        assert!(!TransferFunction::Unspecified.is_hdr());
1333    }
1334
1335    #[test]
1336    fn test_transfer_function_is_wide_gamut() {
1337        assert!(TransferFunction::Pq.is_wide_gamut());
1338        assert!(TransferFunction::Hlg.is_wide_gamut());
1339        assert!(!TransferFunction::Bt709.is_wide_gamut());
1340    }
1341
1342    // ── ColourPrimaries ───────────────────────────────────────────────────────
1343
1344    #[test]
1345    fn test_colour_primaries_h273_codes() {
1346        assert_eq!(ColourPrimaries::Bt709.h273_code(), 1);
1347        assert_eq!(ColourPrimaries::Bt2020.h273_code(), 9);
1348        assert_eq!(ColourPrimaries::DisplayP3.h273_code(), 12);
1349        assert_eq!(ColourPrimaries::Unspecified.h273_code(), 2);
1350    }
1351
1352    // ── MasteringDisplay ──────────────────────────────────────────────────────
1353
1354    #[test]
1355    fn test_mastering_display_p3_d65_1000nit_is_valid() {
1356        let md = MasteringDisplay::p3_d65_1000nit();
1357        assert!(md.validate().is_ok());
1358    }
1359
1360    #[test]
1361    fn test_mastering_display_bt2020_4000nit_is_valid() {
1362        let md = MasteringDisplay::bt2020_4000nit();
1363        assert!(md.validate().is_ok());
1364    }
1365
1366    #[test]
1367    fn test_mastering_display_bad_chromaticity() {
1368        let mut md = MasteringDisplay::p3_d65_1000nit();
1369        md.red_x = 1.5; // invalid
1370        assert!(matches!(
1371            md.validate(),
1372            Err(HdrError::OutOfRange { field, .. }) if field == "red_x"
1373        ));
1374    }
1375
1376    #[test]
1377    fn test_mastering_display_bad_luminance() {
1378        let mut md = MasteringDisplay::p3_d65_1000nit();
1379        md.min_luminance = md.max_luminance + 1.0;
1380        assert!(matches!(
1381            md.validate(),
1382            Err(HdrError::OutOfRange { field, .. }) if field == "min_luminance"
1383        ));
1384    }
1385
1386    // ── ContentLightLevel ─────────────────────────────────────────────────────
1387
1388    #[test]
1389    fn test_cll_hdr10_default_valid() {
1390        let cll = ContentLightLevel::hdr10_default();
1391        assert!(cll.validate().is_ok());
1392    }
1393
1394    #[test]
1395    fn test_cll_invalid_fall_exceeds_cll() {
1396        let cll = ContentLightLevel::new(400, 1000);
1397        assert!(matches!(
1398            cll.validate(),
1399            Err(HdrError::OutOfRange { field, .. }) if field == "max_fall"
1400        ));
1401    }
1402
1403    // ── DolbyVisionMeta ───────────────────────────────────────────────────────
1404
1405    #[test]
1406    fn test_dv_profile_numbers() {
1407        assert_eq!(DolbyVisionProfile::Profile4.profile_number(), 4);
1408        assert_eq!(DolbyVisionProfile::Profile8.profile_number(), 8);
1409        assert_eq!(DolbyVisionProfile::Profile9.profile_number(), 9);
1410    }
1411
1412    #[test]
1413    fn test_dv_backward_compatibility() {
1414        assert!(DolbyVisionProfile::Profile4.is_backward_compatible());
1415        assert!(DolbyVisionProfile::Profile8.is_backward_compatible());
1416        assert!(!DolbyVisionProfile::Profile5.is_backward_compatible());
1417    }
1418
1419    #[test]
1420    fn test_dv_level_validation() {
1421        let ok = DolbyVisionMeta::new(DolbyVisionProfile::Profile8, 6);
1422        assert!(ok.validate().is_ok());
1423
1424        let bad = DolbyVisionMeta::new(DolbyVisionProfile::Profile8, 14);
1425        assert!(bad.validate().is_err());
1426
1427        let bad_zero = DolbyVisionMeta::new(DolbyVisionProfile::Profile8, 0);
1428        assert!(bad_zero.validate().is_err());
1429    }
1430
1431    // ── HdrMetadata ───────────────────────────────────────────────────────────
1432
1433    #[test]
1434    fn test_hdr_metadata_hdr10_is_hdr() {
1435        let meta = HdrMetadata::hdr10(
1436            MasteringDisplay::p3_d65_1000nit(),
1437            ContentLightLevel::hdr10_default(),
1438        );
1439        assert!(meta.is_hdr());
1440        assert!(meta.validate().is_ok());
1441    }
1442
1443    #[test]
1444    fn test_hdr_metadata_hlg_is_hdr() {
1445        let meta = HdrMetadata::hlg();
1446        assert!(meta.is_hdr());
1447        assert!(meta.validate().is_ok());
1448    }
1449
1450    #[test]
1451    fn test_hdr_metadata_default_not_hdr() {
1452        let meta = HdrMetadata::default();
1453        assert!(!meta.is_hdr());
1454    }
1455
1456    // ── HdrProcessor ─────────────────────────────────────────────────────────
1457
1458    #[test]
1459    fn test_processor_passthrough_none() {
1460        let proc = HdrProcessor::new(HdrPassthroughMode::Passthrough);
1461        let result = proc.process(None).expect("passthrough None should succeed");
1462        assert!(result.is_none());
1463    }
1464
1465    #[test]
1466    fn test_processor_passthrough_some() {
1467        let proc = HdrProcessor::new(HdrPassthroughMode::Passthrough);
1468        let src = HdrMetadata::hlg();
1469        let result = proc
1470            .process(Some(&src))
1471            .expect("passthrough Some should succeed");
1472        assert!(result.is_some());
1473        assert_eq!(
1474            result.as_ref().and_then(|m| m.transfer_function),
1475            Some(TransferFunction::Hlg)
1476        );
1477    }
1478
1479    #[test]
1480    fn test_processor_strip() {
1481        let proc = HdrProcessor::new(HdrPassthroughMode::Strip);
1482        let src = HdrMetadata::hdr10(
1483            MasteringDisplay::p3_d65_1000nit(),
1484            ContentLightLevel::hdr10_default(),
1485        );
1486        let result = proc.process(Some(&src)).expect("strip should succeed");
1487        assert!(result.is_none());
1488    }
1489
1490    #[test]
1491    fn test_processor_inject() {
1492        let injected = HdrMetadata::hlg();
1493        let proc = HdrProcessor::new(HdrPassthroughMode::Inject(injected.clone()));
1494        let src = HdrMetadata::hdr10(
1495            MasteringDisplay::p3_d65_1000nit(),
1496            ContentLightLevel::hdr10_default(),
1497        );
1498        let result = proc
1499            .process(Some(&src))
1500            .expect("inject should succeed")
1501            .expect("inject should produce Some");
1502        assert_eq!(result.transfer_function, Some(TransferFunction::Hlg));
1503    }
1504
1505    #[test]
1506    fn test_processor_convert_pq_to_bt709() {
1507        let proc = HdrProcessor::new(HdrPassthroughMode::Convert {
1508            target_tf: TransferFunction::Bt709,
1509            target_primaries: ColourPrimaries::Bt709,
1510        });
1511        let src = HdrMetadata::hdr10(
1512            MasteringDisplay::p3_d65_1000nit(),
1513            ContentLightLevel::hdr10_default(),
1514        );
1515        let result = proc
1516            .process(Some(&src))
1517            .expect("conversion should succeed")
1518            .expect("conversion should produce Some");
1519        assert_eq!(result.transfer_function, Some(TransferFunction::Bt709));
1520        // Static HDR metadata should be stripped
1521        assert!(result.mastering_display.is_none());
1522        assert!(result.content_light_level.is_none());
1523    }
1524
1525    #[test]
1526    fn test_processor_convert_hlg_to_pq() {
1527        let proc = HdrProcessor::new(HdrPassthroughMode::Convert {
1528            target_tf: TransferFunction::Pq,
1529            target_primaries: ColourPrimaries::Bt2020,
1530        });
1531        let src = HdrMetadata::hlg();
1532        let result = proc
1533            .process(Some(&src))
1534            .expect("HLG→PQ should succeed")
1535            .expect("should produce Some");
1536        assert_eq!(result.transfer_function, Some(TransferFunction::Pq));
1537    }
1538
1539    #[test]
1540    fn test_processor_convert_sdr_to_pq_fails() {
1541        let proc = HdrProcessor::new(HdrPassthroughMode::Convert {
1542            target_tf: TransferFunction::Pq,
1543            target_primaries: ColourPrimaries::Bt2020,
1544        });
1545        let src = HdrMetadata {
1546            transfer_function: Some(TransferFunction::Bt709),
1547            ..HdrMetadata::default()
1548        };
1549        // SDR → PQ is not a supported direct conversion
1550        let result = proc.process(Some(&src));
1551        assert!(matches!(
1552            result,
1553            Err(HdrError::UnsupportedConversion { .. })
1554        ));
1555    }
1556
1557    // ── SEI encode / decode round-trips ──────────────────────────────────────
1558
1559    #[test]
1560    fn test_mastering_display_sei_round_trip() {
1561        let original = MasteringDisplay::p3_d65_1000nit();
1562        let encoded = encode_mastering_display_sei(&original);
1563        let decoded = decode_mastering_display_sei(&encoded).expect("decode should succeed");
1564
1565        // Allow 0.002 tolerance due to u16 quantisation at 1/50000 steps
1566        let eps = 0.002;
1567        assert!(
1568            (decoded.red_x - original.red_x).abs() < eps,
1569            "red_x mismatch"
1570        );
1571        assert!(
1572            (decoded.red_y - original.red_y).abs() < eps,
1573            "red_y mismatch"
1574        );
1575        assert!((decoded.green_x - original.green_x).abs() < eps);
1576        assert!((decoded.green_y - original.green_y).abs() < eps);
1577        assert!((decoded.blue_x - original.blue_x).abs() < eps);
1578        assert!((decoded.blue_y - original.blue_y).abs() < eps);
1579        assert!((decoded.white_x - original.white_x).abs() < eps);
1580        assert!((decoded.white_y - original.white_y).abs() < eps);
1581        // Luminance: 0.1 cd/m² tolerance at 10000 fractional units
1582        assert!((decoded.max_luminance - original.max_luminance).abs() < 0.1);
1583        assert!((decoded.min_luminance - original.min_luminance).abs() < 0.001);
1584    }
1585
1586    #[test]
1587    fn test_mastering_display_sei_too_short() {
1588        let result = decode_mastering_display_sei(&[0u8; 12]);
1589        assert!(matches!(result, Err(HdrError::MissingField(_))));
1590    }
1591
1592    #[test]
1593    fn test_cll_sei_round_trip() {
1594        let original = ContentLightLevel::new(800, 300);
1595        let encoded = encode_cll_sei(&original);
1596        let decoded = decode_cll_sei(&encoded).expect("decode should succeed");
1597        assert_eq!(decoded.max_cll, original.max_cll);
1598        assert_eq!(decoded.max_fall, original.max_fall);
1599    }
1600
1601    #[test]
1602    fn test_cll_sei_too_short() {
1603        let result = decode_cll_sei(&[0u8; 2]);
1604        assert!(matches!(result, Err(HdrError::MissingField(_))));
1605    }
1606
1607    #[test]
1608    fn test_cll_sei_invalid_decoded_values() {
1609        // MaxFALL > MaxCLL — encode and decode should fail validation
1610        let bad = ContentLightLevel {
1611            max_cll: 100,
1612            max_fall: 500,
1613        };
1614        let encoded = encode_cll_sei(&bad);
1615        let result = decode_cll_sei(&encoded);
1616        assert!(result.is_err());
1617    }
1618
1619    // ── HDR10+ dynamic metadata ──────────────────────────────────────────────
1620
1621    #[test]
1622    fn test_hdr10plus_new() {
1623        let meta = Hdr10PlusDynamicMeta::new(1000);
1624        assert_eq!(meta.application_version, 1);
1625        assert_eq!(meta.targeted_system_display_max_luminance, 1000);
1626        assert!(meta.validate().is_ok());
1627    }
1628
1629    #[test]
1630    fn test_hdr10plus_validate_bad_version() {
1631        let mut meta = Hdr10PlusDynamicMeta::new(1000);
1632        meta.application_version = 5;
1633        assert!(meta.validate().is_err());
1634    }
1635
1636    #[test]
1637    fn test_hdr10plus_validate_bad_avg_maxrgb() {
1638        let mut meta = Hdr10PlusDynamicMeta::new(1000);
1639        meta.average_maxrgb = 1.5;
1640        assert!(meta.validate().is_err());
1641    }
1642
1643    #[test]
1644    fn test_hdr10plus_validate_bad_knee_point() {
1645        let mut meta = Hdr10PlusDynamicMeta::new(1000);
1646        meta.knee_point = (-0.1, 0.5);
1647        assert!(meta.validate().is_err());
1648    }
1649
1650    #[test]
1651    fn test_hdr10plus_validate_too_many_anchors() {
1652        let mut meta = Hdr10PlusDynamicMeta::new(1000);
1653        meta.bezier_curve_anchors = vec![0.1; 10];
1654        assert!(meta.validate().is_err());
1655    }
1656
1657    #[test]
1658    fn test_hdr10plus_encode_decode_round_trip() {
1659        let original = Hdr10PlusDynamicMeta::new(1000);
1660        let encoded = original.encode();
1661        let decoded = Hdr10PlusDynamicMeta::decode(&encoded).expect("decode should succeed");
1662        assert_eq!(decoded.application_version, original.application_version);
1663        assert_eq!(
1664            decoded.targeted_system_display_max_luminance,
1665            original.targeted_system_display_max_luminance
1666        );
1667        assert!((decoded.average_maxrgb - original.average_maxrgb).abs() < 0.001);
1668        assert!((decoded.knee_point.0 - original.knee_point.0).abs() < 0.001);
1669        assert!((decoded.knee_point.1 - original.knee_point.1).abs() < 0.001);
1670        assert_eq!(
1671            decoded.bezier_curve_anchors.len(),
1672            original.bezier_curve_anchors.len()
1673        );
1674    }
1675
1676    #[test]
1677    fn test_hdr10plus_decode_too_short() {
1678        let result = Hdr10PlusDynamicMeta::decode(&[0u8; 5]);
1679        assert!(matches!(result, Err(HdrError::MissingField(_))));
1680    }
1681
1682    // ── Dolby Vision RPU ─────────────────────────────────────────────────────
1683
1684    #[test]
1685    fn test_dv_rpu_new() {
1686        let payload = vec![0x08; 30]; // rpu_type = 0x08>>2 = 2 => profile 8
1687        let rpu = DolbyVisionRpu::new(payload, 0);
1688        assert_eq!(rpu.rpu_profile, 8);
1689        assert_eq!(rpu.frame_index, 0);
1690        assert!(!rpu.validated);
1691    }
1692
1693    #[test]
1694    fn test_dv_rpu_validate_empty() {
1695        let mut rpu = DolbyVisionRpu::new(Vec::new(), 0);
1696        assert!(rpu.validate().is_err());
1697    }
1698
1699    #[test]
1700    fn test_dv_rpu_validate_too_short() {
1701        let mut rpu = DolbyVisionRpu::new(vec![0x08; 10], 0);
1702        assert!(rpu.validate().is_err());
1703    }
1704
1705    #[test]
1706    fn test_dv_rpu_validate_ok() {
1707        let mut rpu = DolbyVisionRpu::new(vec![0x08; 30], 0);
1708        assert!(rpu.validate().is_ok());
1709        assert!(rpu.validated);
1710    }
1711
1712    #[test]
1713    fn test_dv_rpu_passthrough() {
1714        let mut pt = DvRpuPassthrough::new();
1715        assert_eq!(pt.count(), 0);
1716
1717        pt.ingest(vec![0x08; 30], 0);
1718        pt.ingest(vec![0x08; 30], 1);
1719        pt.ingest(vec![0x08; 5], 2); // too short, invalid
1720
1721        assert_eq!(pt.count(), 3);
1722        assert_eq!(pt.valid_count(), 2);
1723        assert_eq!(pt.invalid_count(), 1);
1724
1725        assert!(pt.get_rpu(0).is_some());
1726        assert!(pt.get_rpu(1).is_some());
1727        assert!(pt.get_rpu(99).is_none());
1728    }
1729
1730    #[test]
1731    fn test_dv_rpu_passthrough_drain() {
1732        let mut pt = DvRpuPassthrough::new();
1733        pt.ingest(vec![0x08; 30], 0);
1734        pt.ingest(vec![0x08; 30], 1);
1735
1736        let drained = pt.drain_all();
1737        assert_eq!(drained.len(), 2);
1738        assert_eq!(pt.count(), 0);
1739        assert_eq!(pt.valid_count(), 0);
1740    }
1741
1742    // ── Tonemapping ─────────────────────────────────────────────────────────
1743
1744    #[test]
1745    fn test_hdr_to_sdr_config_default() {
1746        let cfg = HdrToSdrConfig::default_1000nit();
1747        assert!(cfg.validate().is_ok());
1748        assert_eq!(cfg.source_peak_nits, 1000.0);
1749        assert_eq!(cfg.target_peak_nits, 100.0);
1750    }
1751
1752    #[test]
1753    fn test_hdr_to_sdr_validate_bad_source_peak() {
1754        let mut cfg = HdrToSdrConfig::default_1000nit();
1755        cfg.source_peak_nits = -1.0;
1756        assert!(cfg.validate().is_err());
1757    }
1758
1759    #[test]
1760    fn test_hdr_to_sdr_validate_bad_desat() {
1761        let mut cfg = HdrToSdrConfig::default_1000nit();
1762        cfg.desat_strength = 1.5;
1763        assert!(cfg.validate().is_err());
1764    }
1765
1766    #[test]
1767    fn test_tonemap_reinhard_zero() {
1768        let cfg = HdrToSdrConfig::default_1000nit();
1769        let result = cfg.tonemap_reinhard(0.0);
1770        assert!((result).abs() < 1e-6);
1771    }
1772
1773    #[test]
1774    fn test_tonemap_reinhard_monotonic() {
1775        let cfg = HdrToSdrConfig::default_1000nit();
1776        let a = cfg.tonemap_reinhard(0.1);
1777        let b = cfg.tonemap_reinhard(0.5);
1778        let c = cfg.tonemap_reinhard(1.0);
1779        assert!(a < b);
1780        assert!(b < c);
1781    }
1782
1783    #[test]
1784    fn test_tonemap_hable_positive() {
1785        let cfg = HdrToSdrConfig::default_1000nit();
1786        let result = cfg.tonemap_hable(0.5);
1787        assert!(result > 0.0);
1788        assert!(result < cfg.target_peak_nits);
1789    }
1790
1791    #[test]
1792    fn test_tonemap_aces_clamped() {
1793        let cfg = HdrToSdrConfig::default_1000nit();
1794        let result = cfg.tonemap_aces(100.0);
1795        assert!(result <= cfg.target_peak_nits);
1796    }
1797
1798    #[test]
1799    fn test_tonemap_apply_clip() {
1800        let cfg = HdrToSdrConfig {
1801            curve: TonemapCurve::Clip,
1802            source_peak_nits: 1000.0,
1803            target_peak_nits: 100.0,
1804            desat_strength: 0.0,
1805        };
1806        let result = cfg.apply(0.5);
1807        assert!((result - 100.0).abs() < 1e-6); // 0.5 * 1000 = 500, clipped to 100
1808    }
1809
1810    // ── SDR→HDR inverse tonemapping ──────────────────────────────────────────
1811
1812    #[test]
1813    fn test_sdr_to_hdr_default() {
1814        let cfg = SdrToHdrConfig::default_1000nit();
1815        assert!(cfg.validate().is_ok());
1816        assert_eq!(cfg.target_peak_nits, 1000.0);
1817    }
1818
1819    #[test]
1820    fn test_sdr_to_hdr_validate_bad_gain() {
1821        let mut cfg = SdrToHdrConfig::default_1000nit();
1822        cfg.highlight_gain = 0.5;
1823        assert!(cfg.validate().is_err());
1824    }
1825
1826    #[test]
1827    fn test_sdr_to_hdr_validate_bad_midtone() {
1828        let mut cfg = SdrToHdrConfig::default_1000nit();
1829        cfg.midtone_boost = 5.0;
1830        assert!(cfg.validate().is_err());
1831    }
1832
1833    #[test]
1834    fn test_sdr_to_hdr_apply_zero() {
1835        let cfg = SdrToHdrConfig::default_1000nit();
1836        assert!((cfg.apply(0.0)).abs() < 1e-6);
1837    }
1838
1839    #[test]
1840    fn test_sdr_to_hdr_apply_monotonic() {
1841        let cfg = SdrToHdrConfig::default_1000nit();
1842        let a = cfg.apply(10.0);
1843        let b = cfg.apply(50.0);
1844        let c = cfg.apply(100.0);
1845        assert!(a < b);
1846        assert!(b < c);
1847        assert!(c <= cfg.target_peak_nits);
1848    }
1849
1850    // ── Metadata repair ──────────────────────────────────────────────────────
1851
1852    #[test]
1853    fn test_repair_clamp_chromaticity() {
1854        let mut meta = HdrMetadata::hdr10(
1855            MasteringDisplay {
1856                red_x: 1.5,
1857                red_y: -0.1,
1858                ..MasteringDisplay::p3_d65_1000nit()
1859            },
1860            ContentLightLevel::hdr10_default(),
1861        );
1862        let repairs = repair_hdr_metadata(&mut meta);
1863        assert!(repairs.contains(&MetadataRepairAction::ClampChromaticity));
1864        let md = meta
1865            .mastering_display
1866            .as_ref()
1867            .expect("should have mastering display");
1868        assert!((md.red_x - 1.0).abs() < 1e-6);
1869        assert!((md.red_y).abs() < 1e-6);
1870    }
1871
1872    #[test]
1873    fn test_repair_luminance_order() {
1874        let mut meta = HdrMetadata::hdr10(
1875            MasteringDisplay {
1876                max_luminance: 1000.0,
1877                min_luminance: 2000.0, // invalid: min > max
1878                ..MasteringDisplay::p3_d65_1000nit()
1879            },
1880            ContentLightLevel::hdr10_default(),
1881        );
1882        let repairs = repair_hdr_metadata(&mut meta);
1883        assert!(repairs.contains(&MetadataRepairAction::FixLuminanceOrder));
1884        let md = meta
1885            .mastering_display
1886            .as_ref()
1887            .expect("should have mastering display");
1888        assert!(md.min_luminance < md.max_luminance);
1889    }
1890
1891    #[test]
1892    fn test_repair_fall_cll() {
1893        let mut meta = HdrMetadata::hdr10(
1894            MasteringDisplay::p3_d65_1000nit(),
1895            ContentLightLevel {
1896                max_cll: 500,
1897                max_fall: 800,
1898            },
1899        );
1900        let repairs = repair_hdr_metadata(&mut meta);
1901        assert!(repairs.contains(&MetadataRepairAction::FixFallCll));
1902        let cll = meta.content_light_level.as_ref().expect("should have CLL");
1903        assert!(cll.max_fall <= cll.max_cll);
1904    }
1905
1906    #[test]
1907    fn test_repair_inject_defaults_for_pq() {
1908        let mut meta = HdrMetadata {
1909            transfer_function: Some(TransferFunction::Pq),
1910            colour_primaries: Some(ColourPrimaries::Bt2020),
1911            mastering_display: None,
1912            content_light_level: None,
1913            dolby_vision: None,
1914        };
1915        let repairs = repair_hdr_metadata(&mut meta);
1916        assert!(repairs.contains(&MetadataRepairAction::InjectDefaultMastering));
1917        assert!(repairs.contains(&MetadataRepairAction::InjectDefaultCll));
1918        assert!(meta.mastering_display.is_some());
1919        assert!(meta.content_light_level.is_some());
1920    }
1921
1922    #[test]
1923    fn test_repair_no_action_needed() {
1924        let mut meta = HdrMetadata::hdr10(
1925            MasteringDisplay::p3_d65_1000nit(),
1926            ContentLightLevel::hdr10_default(),
1927        );
1928        let repairs = repair_hdr_metadata(&mut meta);
1929        assert!(repairs.is_empty());
1930    }
1931
1932    #[test]
1933    fn test_processor_convert_with_sdr_to_pq_via_extended() {
1934        // SDR → PQ is now supported via the extended conversion table
1935        let proc = HdrProcessor::new(HdrPassthroughMode::Convert {
1936            target_tf: TransferFunction::Pq,
1937            target_primaries: ColourPrimaries::Bt2020,
1938        });
1939        let src = HdrMetadata {
1940            transfer_function: Some(TransferFunction::Bt709),
1941            ..HdrMetadata::default()
1942        };
1943        // Still unsupported in the basic conversion table
1944        let result = proc.process(Some(&src));
1945        assert!(result.is_err());
1946    }
1947}