Skip to main content

pixelflow_core/
format.rs

1//! Pixel format descriptors and alias resolution.
2
3use crate::{ErrorCategory, ErrorCode, PixelFlowError, Result};
4
5/// Official color range metadata values.
6#[repr(u8)]
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub enum ColorRange {
9    /// Full-range code values.
10    Full,
11    /// Limited-range code values.
12    Limited,
13}
14
15impl ColorRange {
16    /// Parses canonical range metadata string.
17    pub fn parse(value: &str) -> Result<Self> {
18        match value {
19            "full" => Ok(Self::Full),
20            "limited" => Ok(Self::Limited),
21            _ => Err(PixelFlowError::new(
22                ErrorCategory::Format,
23                ErrorCode::new("format.unsupported_range"),
24                format!("unsupported color range '{value}'"),
25            )),
26        }
27    }
28
29    /// Returns canonical metadata string.
30    #[must_use]
31    pub const fn as_str(self) -> &'static str {
32        match self {
33            Self::Full => "full",
34            Self::Limited => "limited",
35        }
36    }
37}
38
39/// Official matrix coefficient metadata values.
40#[repr(u8)]
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum ColorMatrix {
43    /// Identity matrix coefficients.
44    Identity,
45    /// ITU-R BT.709 matrix coefficients.
46    Bt709,
47    /// ITU-R BT.470 System M matrix coefficients.
48    Bt470M,
49    /// ITU-R BT.470 System B/G matrix coefficients.
50    Bt470Bg,
51    /// SMPTE 170M matrix coefficients.
52    Smpte170M,
53    /// SMPTE 240M matrix coefficients.
54    Smpte240M,
55    /// YCgCo matrix coefficients.
56    Ycgco,
57    /// ITU-R BT.2020 non-constant-luminance matrix coefficients.
58    Bt2020Ncl,
59    /// ITU-R BT.2020 constant-luminance matrix coefficients.
60    Bt2020Cl,
61    /// SMPTE ST 2085 matrix coefficients.
62    Smpte2085,
63    /// Chromaticity-derived non-constant-luminance matrix coefficients.
64    ChromaticityDerivedNcl,
65    /// Chromaticity-derived constant-luminance matrix coefficients.
66    ChromaticityDerivedCl,
67    /// ICtCp matrix coefficients.
68    Ictcp,
69}
70
71impl ColorMatrix {
72    /// Parses canonical matrix metadata string.
73    pub fn parse(value: &str) -> Result<Self> {
74        match value {
75            "identity" => Ok(Self::Identity),
76            "bt709" => Ok(Self::Bt709),
77            "bt470m" => Ok(Self::Bt470M),
78            "bt470bg" => Ok(Self::Bt470Bg),
79            "smpte170m" => Ok(Self::Smpte170M),
80            "smpte240m" => Ok(Self::Smpte240M),
81            "ycgco" => Ok(Self::Ycgco),
82            "bt2020_ncl" => Ok(Self::Bt2020Ncl),
83            "bt2020_cl" => Ok(Self::Bt2020Cl),
84            "smpte2085" => Ok(Self::Smpte2085),
85            "chromaticity_derived_ncl" => Ok(Self::ChromaticityDerivedNcl),
86            "chromaticity_derived_cl" => Ok(Self::ChromaticityDerivedCl),
87            "ictcp" => Ok(Self::Ictcp),
88            _ => Err(color_metadata_error(
89                "format.unsupported_matrix",
90                format!("unsupported color matrix '{value}'"),
91            )),
92        }
93    }
94
95    /// Returns canonical metadata string.
96    #[must_use]
97    pub const fn as_str(self) -> &'static str {
98        match self {
99            Self::Identity => "identity",
100            Self::Bt709 => "bt709",
101            Self::Bt470M => "bt470m",
102            Self::Bt470Bg => "bt470bg",
103            Self::Smpte170M => "smpte170m",
104            Self::Smpte240M => "smpte240m",
105            Self::Ycgco => "ycgco",
106            Self::Bt2020Ncl => "bt2020_ncl",
107            Self::Bt2020Cl => "bt2020_cl",
108            Self::Smpte2085 => "smpte2085",
109            Self::ChromaticityDerivedNcl => "chromaticity_derived_ncl",
110            Self::ChromaticityDerivedCl => "chromaticity_derived_cl",
111            Self::Ictcp => "ictcp",
112        }
113    }
114
115    /// Returns `(Kr, Kg, Kb)` luma coefficients for matrix families.
116    #[must_use]
117    pub const fn luma_coefficients(self) -> (f32, f32, f32) {
118        match self {
119            Self::Identity | Self::Ycgco | Self::Ictcp => (1.0, 1.0, 1.0),
120            Self::Bt709 => (0.2126, 0.7152, 0.0722),
121            Self::Bt470M => (0.30, 0.59, 0.11),
122            Self::Bt470Bg | Self::Smpte170M => (0.299, 0.587, 0.114),
123            Self::Smpte240M => (0.212, 0.701, 0.087),
124            Self::Bt2020Ncl | Self::Bt2020Cl | Self::Smpte2085 => (0.2627, 0.6780, 0.0593),
125            Self::ChromaticityDerivedNcl | Self::ChromaticityDerivedCl => (0.0, 0.0, 0.0),
126        }
127    }
128}
129
130/// Official transfer-function metadata values.
131#[repr(u8)]
132#[derive(Clone, Copy, Debug, Eq, PartialEq)]
133pub enum ColorTransfer {
134    /// ITU-R BT.1886 transfer function.
135    Bt1886,
136    /// ITU-R BT.470 System M transfer function.
137    Bt470M,
138    /// ITU-R BT.470 System B/G transfer function.
139    Bt470Bg,
140    /// SMPTE 170M transfer function.
141    Smpte170M,
142    /// SMPTE 240M transfer function.
143    Smpte240M,
144    /// Linear-light transfer function.
145    Linear,
146    /// Logarithmic curve with 100:1 range.
147    Log100,
148    /// Logarithmic curve with 316:1 range.
149    Log316,
150    /// xvYCC transfer function.
151    Xvycc,
152    /// ITU-R BT.1361 extended-color-gamut transfer function.
153    Bt1361E,
154    /// IEC sRGB transfer function.
155    Srgb,
156    /// ITU-R BT.2020 10-bit transfer function.
157    Bt2020_10,
158    /// ITU-R BT.2020 12-bit transfer function.
159    Bt2020_12,
160    /// SMPTE ST 2084 perceptual quantizer.
161    Pq,
162    /// SMPTE ST 428 transfer function.
163    Smpte428,
164    /// ARIB STD-B67 hybrid log-gamma.
165    Hlg,
166}
167
168impl ColorTransfer {
169    /// Parses canonical transfer metadata string.
170    pub fn parse(value: &str) -> Result<Self> {
171        match value {
172            "bt1886" => Ok(Self::Bt1886),
173            "bt470m" => Ok(Self::Bt470M),
174            "bt470bg" => Ok(Self::Bt470Bg),
175            "smpte170m" => Ok(Self::Smpte170M),
176            "smpte240m" => Ok(Self::Smpte240M),
177            "linear" => Ok(Self::Linear),
178            "log100" => Ok(Self::Log100),
179            "log316" => Ok(Self::Log316),
180            "xvycc" => Ok(Self::Xvycc),
181            "bt1361e" => Ok(Self::Bt1361E),
182            "srgb" => Ok(Self::Srgb),
183            "bt2020_10" => Ok(Self::Bt2020_10),
184            "bt2020_12" => Ok(Self::Bt2020_12),
185            "pq" => Ok(Self::Pq),
186            "smpte428" => Ok(Self::Smpte428),
187            "hlg" => Ok(Self::Hlg),
188            _ => Err(color_metadata_error(
189                "format.unsupported_transfer",
190                format!("unsupported color transfer '{value}'"),
191            )),
192        }
193    }
194
195    /// Returns canonical metadata string.
196    #[must_use]
197    pub const fn as_str(self) -> &'static str {
198        match self {
199            Self::Bt1886 => "bt1886",
200            Self::Bt470M => "bt470m",
201            Self::Bt470Bg => "bt470bg",
202            Self::Smpte170M => "smpte170m",
203            Self::Smpte240M => "smpte240m",
204            Self::Linear => "linear",
205            Self::Log100 => "log100",
206            Self::Log316 => "log316",
207            Self::Xvycc => "xvycc",
208            Self::Bt1361E => "bt1361e",
209            Self::Srgb => "srgb",
210            Self::Bt2020_10 => "bt2020_10",
211            Self::Bt2020_12 => "bt2020_12",
212            Self::Pq => "pq",
213            Self::Smpte428 => "smpte428",
214            Self::Hlg => "hlg",
215        }
216    }
217}
218
219/// Official color-primaries metadata values.
220#[repr(u8)]
221#[derive(Clone, Copy, Debug, Eq, PartialEq)]
222pub enum ColorPrimaries {
223    /// ITU-R BT.709 primaries.
224    Bt709,
225    /// ITU-R BT.470 System M primaries.
226    Bt470M,
227    /// ITU-R BT.470 System B/G primaries.
228    Bt470Bg,
229    /// SMPTE 170M primaries.
230    Smpte170M,
231    /// SMPTE 240M primaries.
232    Smpte240M,
233    /// Film primaries.
234    Film,
235    /// ITU-R BT.2020 primaries.
236    Bt2020,
237    /// SMPTE ST 428 primaries.
238    Smpte428,
239    /// DCI-P3 primaries.
240    DciP3,
241    /// Display P3 primaries.
242    DisplayP3,
243}
244
245impl ColorPrimaries {
246    /// Parses canonical primaries metadata string.
247    pub fn parse(value: &str) -> Result<Self> {
248        match value {
249            "bt709" => Ok(Self::Bt709),
250            "bt470m" => Ok(Self::Bt470M),
251            "bt470bg" => Ok(Self::Bt470Bg),
252            "smpte170m" => Ok(Self::Smpte170M),
253            "smpte240m" => Ok(Self::Smpte240M),
254            "film" => Ok(Self::Film),
255            "bt2020" => Ok(Self::Bt2020),
256            "smpte428" => Ok(Self::Smpte428),
257            "dci_p3" => Ok(Self::DciP3),
258            "display_p3" => Ok(Self::DisplayP3),
259            _ => Err(color_metadata_error(
260                "format.unsupported_primaries",
261                format!("unsupported color primaries '{value}'"),
262            )),
263        }
264    }
265
266    /// Returns canonical metadata string.
267    #[must_use]
268    pub const fn as_str(self) -> &'static str {
269        match self {
270            Self::Bt709 => "bt709",
271            Self::Bt470M => "bt470m",
272            Self::Bt470Bg => "bt470bg",
273            Self::Smpte170M => "smpte170m",
274            Self::Smpte240M => "smpte240m",
275            Self::Film => "film",
276            Self::Bt2020 => "bt2020",
277            Self::Smpte428 => "smpte428",
278            Self::DciP3 => "dci_p3",
279            Self::DisplayP3 => "display_p3",
280        }
281    }
282}
283
284/// Official chroma-siting metadata values.
285#[repr(u8)]
286#[derive(Clone, Copy, Debug, Eq, PartialEq)]
287pub enum ChromaSiting {
288    /// Chroma samples sit on left edge and centered vertically.
289    Left,
290    /// Chroma samples are centered horizontally and vertically.
291    Center,
292    /// Chroma samples sit on top-left corner.
293    TopLeft,
294    /// Chroma samples are centered horizontally on top edge.
295    Top,
296    /// Chroma samples sit on bottom-left corner.
297    BottomLeft,
298    /// Chroma samples are centered horizontally on bottom edge.
299    Bottom,
300}
301
302impl ChromaSiting {
303    /// Parses canonical chroma-siting metadata string.
304    pub fn parse(value: &str) -> Result<Self> {
305        match value {
306            "left" => Ok(Self::Left),
307            "center" => Ok(Self::Center),
308            "top_left" => Ok(Self::TopLeft),
309            "top" => Ok(Self::Top),
310            "bottom_left" => Ok(Self::BottomLeft),
311            "bottom" => Ok(Self::Bottom),
312            _ => Err(color_metadata_error(
313                "format.unsupported_chroma_siting",
314                format!("unsupported chroma siting '{value}'"),
315            )),
316        }
317    }
318
319    /// Returns canonical metadata string.
320    #[must_use]
321    pub const fn as_str(self) -> &'static str {
322        match self {
323            Self::Left => "left",
324            Self::Center => "center",
325            Self::TopLeft => "top_left",
326            Self::Top => "top",
327            Self::BottomLeft => "bottom_left",
328            Self::Bottom => "bottom",
329        }
330    }
331
332    /// Returns `(x, y)` chroma offsets in luma-space coordinates.
333    #[must_use]
334    pub const fn offsets(self) -> (f32, f32) {
335        match self {
336            Self::Left => (0.0, 0.5),
337            Self::Center => (0.5, 0.5),
338            Self::TopLeft => (0.0, 0.0),
339            Self::Top => (0.5, 0.0),
340            Self::BottomLeft => (0.0, 1.0),
341            Self::Bottom => (0.5, 1.0),
342        }
343    }
344}
345
346fn color_metadata_error(code: &'static str, message: impl Into<String>) -> PixelFlowError {
347    PixelFlowError::new(ErrorCategory::Format, ErrorCode::new(code), message)
348}
349
350/// Stored scalar type for plane samples.
351#[repr(u8)]
352#[derive(Clone, Copy, Debug, Eq, PartialEq)]
353pub enum SampleType {
354    /// Unsigned 8-bit integer sample.
355    U8,
356    /// Unsigned 16-bit integer sample. Logical bit depths 9 through 16 are stored here.
357    U16,
358    /// 32-bit float sample.
359    F32,
360}
361
362impl SampleType {
363    /// Returns byte size of one stored sample.
364    #[must_use]
365    pub const fn bytes_per_sample(self) -> usize {
366        match self {
367            Self::U8 => 1,
368            Self::U16 => 2,
369            Self::F32 => 4,
370        }
371    }
372}
373
374/// High-level pixel family.
375#[repr(u8)]
376#[derive(Clone, Copy, Debug, Eq, PartialEq)]
377pub enum FormatFamily {
378    /// Single-plane luma format.
379    Gray,
380    /// Three-plane YUV format.
381    Yuv,
382    /// Three-plane planar RGB format.
383    PlanarRgb,
384}
385
386/// Chroma subsampling for YUV formats.
387#[repr(u8)]
388#[derive(Clone, Copy, Debug, Eq, PartialEq)]
389pub enum ChromaSubsampling {
390    /// 4:4:4 with full chroma resolution.
391    Cs444,
392    /// 4:2:2 with half horizontal chroma.
393    Cs422,
394    /// 4:2:0 with half horizontal and vertical chroma.
395    Cs420,
396}
397
398/// Semantic role of one plane.
399#[repr(u8)]
400#[derive(Clone, Copy, Debug, Eq, PartialEq)]
401pub enum PlaneRole {
402    /// Gray plane.
403    Gray,
404    /// Y luma plane.
405    Y,
406    /// U chroma plane.
407    U,
408    /// V chroma plane.
409    V,
410    /// R plane.
411    R,
412    /// G plane.
413    G,
414    /// B plane.
415    B,
416}
417
418/// Descriptor for one plane in a format.
419#[derive(Clone, Debug, Eq, PartialEq)]
420pub struct PlaneDescriptor {
421    /// Plane role.
422    pub role: PlaneRole,
423    /// Divisor from full frame width for this plane.
424    pub width_divisor: usize,
425    /// Divisor from full frame height for this plane.
426    pub height_divisor: usize,
427    /// Stored sample type.
428    pub sample_type: SampleType,
429}
430
431/// Stable descriptor for a concrete planar format.
432#[derive(Clone, Debug, Eq, PartialEq)]
433pub struct FormatDescriptor {
434    name: String,
435    family: FormatFamily,
436    subsampling: Option<ChromaSubsampling>,
437    bits_per_sample: u8,
438    sample_type: SampleType,
439    planes: Vec<PlaneDescriptor>,
440}
441
442impl FormatDescriptor {
443    /// Returns canonical format name.
444    #[must_use]
445    pub fn name(&self) -> &str {
446        &self.name
447    }
448
449    /// Returns format family.
450    #[must_use]
451    pub const fn family(&self) -> FormatFamily {
452        self.family
453    }
454
455    /// Returns chroma subsampling for YUV formats.
456    #[must_use]
457    pub const fn subsampling(&self) -> Option<ChromaSubsampling> {
458        self.subsampling
459    }
460
461    /// Returns logical bit depth.
462    #[must_use]
463    pub const fn bits_per_sample(&self) -> u8 {
464        self.bits_per_sample
465    }
466
467    /// Returns stored sample type.
468    #[must_use]
469    pub const fn sample_type(&self) -> SampleType {
470        self.sample_type
471    }
472
473    /// Returns plane descriptors in storage order.
474    #[must_use]
475    pub fn planes(&self) -> &[PlaneDescriptor] {
476        &self.planes
477    }
478}
479
480/// Derives same-family format at different logical bit depth.
481pub fn format_with_bit_depth(format: &FormatDescriptor, bits: u8) -> Result<FormatDescriptor> {
482    let suffix = match bits {
483        8 => "8",
484        10 => "10",
485        12 => "12",
486        16 => "16",
487        32 => "f32",
488        _ => {
489            return Err(PixelFlowError::new(
490                ErrorCategory::Format,
491                ErrorCode::new("format.unsupported_bit_depth"),
492                format!("unsupported bit depth '{bits}'"),
493            ));
494        }
495    };
496
497    let alias = match (format.family(), format.subsampling()) {
498        (FormatFamily::Gray, None) => format!("gray{suffix}"),
499        (FormatFamily::Yuv, Some(ChromaSubsampling::Cs420)) => format!("yuv420p{suffix}"),
500        (FormatFamily::Yuv, Some(ChromaSubsampling::Cs422)) => format!("yuv422p{suffix}"),
501        (FormatFamily::Yuv, Some(ChromaSubsampling::Cs444)) => format!("yuv444p{suffix}"),
502        (FormatFamily::PlanarRgb, None) => format!("rgbp{suffix}"),
503        _ => {
504            return Err(PixelFlowError::new(
505                ErrorCategory::Format,
506                ErrorCode::new("format.unsupported_bit_depth"),
507                format!("cannot derive bit depth format from '{}'", format.name()),
508            ));
509        }
510    };
511
512    resolve_format_alias(&alias)
513}
514
515/// Resolves a user-facing alias to stable format descriptor.
516pub fn resolve_format_alias(alias: &str) -> Result<FormatDescriptor> {
517    let normalized = alias.to_ascii_lowercase();
518
519    if let Some(descriptor) = resolve_gray(&normalized) {
520        return Ok(descriptor);
521    }
522    if let Some(descriptor) = resolve_yuv(&normalized) {
523        return Ok(descriptor);
524    }
525    if let Some(descriptor) = resolve_planar_rgb(&normalized) {
526        return Ok(descriptor);
527    }
528
529    Err(PixelFlowError::new(
530        ErrorCategory::Format,
531        ErrorCode::new("format.unsupported_alias"),
532        format!("unsupported format alias '{alias}'"),
533    ))
534}
535
536fn resolve_gray(alias: &str) -> Option<FormatDescriptor> {
537    let suffix = alias.strip_prefix("gray")?;
538    let (bits, sample, canonical_suffix) = parse_sample_suffix(suffix)?;
539    Some(FormatDescriptor {
540        name: format!("gray{canonical_suffix}"),
541        family: FormatFamily::Gray,
542        subsampling: None,
543        bits_per_sample: bits,
544        sample_type: sample,
545        planes: vec![PlaneDescriptor {
546            role: PlaneRole::Gray,
547            width_divisor: 1,
548            height_divisor: 1,
549            sample_type: sample,
550        }],
551    })
552}
553
554fn resolve_yuv(alias: &str) -> Option<FormatDescriptor> {
555    let (prefix, subsampling) = [
556        ("yuv420p", ChromaSubsampling::Cs420),
557        ("yuv422p", ChromaSubsampling::Cs422),
558        ("yuv444p", ChromaSubsampling::Cs444),
559    ]
560    .into_iter()
561    .find(|(prefix, _)| alias.starts_with(prefix))?;
562
563    let suffix = alias.strip_prefix(prefix)?;
564    let (bits, sample, canonical_suffix) = parse_sample_suffix(suffix)?;
565    let (chroma_w_div, chroma_h_div) = match subsampling {
566        ChromaSubsampling::Cs444 => (1, 1),
567        ChromaSubsampling::Cs422 => (2, 1),
568        ChromaSubsampling::Cs420 => (2, 2),
569    };
570
571    Some(FormatDescriptor {
572        name: format!("{prefix}{canonical_suffix}"),
573        family: FormatFamily::Yuv,
574        subsampling: Some(subsampling),
575        bits_per_sample: bits,
576        sample_type: sample,
577        planes: vec![
578            PlaneDescriptor {
579                role: PlaneRole::Y,
580                width_divisor: 1,
581                height_divisor: 1,
582                sample_type: sample,
583            },
584            PlaneDescriptor {
585                role: PlaneRole::U,
586                width_divisor: chroma_w_div,
587                height_divisor: chroma_h_div,
588                sample_type: sample,
589            },
590            PlaneDescriptor {
591                role: PlaneRole::V,
592                width_divisor: chroma_w_div,
593                height_divisor: chroma_h_div,
594                sample_type: sample,
595            },
596        ],
597    })
598}
599
600fn resolve_planar_rgb(alias: &str) -> Option<FormatDescriptor> {
601    let suffix = alias
602        .strip_prefix("rgbp")
603        .or_else(|| alias.strip_prefix("gbrp"))?;
604    let (bits, sample, canonical_suffix) = parse_sample_suffix(suffix)?;
605
606    Some(FormatDescriptor {
607        name: format!("rgbp{canonical_suffix}"),
608        family: FormatFamily::PlanarRgb,
609        subsampling: None,
610        bits_per_sample: bits,
611        sample_type: sample,
612        planes: vec![
613            PlaneDescriptor {
614                role: PlaneRole::R,
615                width_divisor: 1,
616                height_divisor: 1,
617                sample_type: sample,
618            },
619            PlaneDescriptor {
620                role: PlaneRole::G,
621                width_divisor: 1,
622                height_divisor: 1,
623                sample_type: sample,
624            },
625            PlaneDescriptor {
626                role: PlaneRole::B,
627                width_divisor: 1,
628                height_divisor: 1,
629                sample_type: sample,
630            },
631        ],
632    })
633}
634
635fn parse_sample_suffix(suffix: &str) -> Option<(u8, SampleType, &'static str)> {
636    match suffix {
637        "" | "8" => Some((8, SampleType::U8, "8")),
638        "10" => Some((10, SampleType::U16, "10")),
639        "12" => Some((12, SampleType::U16, "12")),
640        "16" => Some((16, SampleType::U16, "16")),
641        "f32" => Some((32, SampleType::F32, "f32")),
642        _ => None,
643    }
644}
645
646#[cfg(test)]
647mod tests {
648    #![expect(clippy::indexing_slicing, reason = "allow in tests")]
649
650    use crate::{ErrorCategory, ErrorCode};
651
652    use super::{
653        ChromaSiting, ChromaSubsampling, ColorMatrix, ColorPrimaries, ColorRange, ColorTransfer,
654        FormatFamily, PlaneRole, SampleType, format_with_bit_depth, resolve_format_alias,
655    };
656
657    #[test]
658    fn color_metadata_parse_accepts_canonical_names() {
659        assert_eq!(
660            ColorRange::parse("full").expect("full range"),
661            ColorRange::Full
662        );
663        assert_eq!(
664            ColorMatrix::parse("identity").expect("identity matrix"),
665            ColorMatrix::Identity
666        );
667        assert_eq!(
668            ColorMatrix::parse("bt709").expect("bt709 matrix"),
669            ColorMatrix::Bt709
670        );
671        assert_eq!(
672            ColorMatrix::parse("bt470m").expect("bt470m matrix"),
673            ColorMatrix::Bt470M
674        );
675        assert_eq!(
676            ColorMatrix::parse("bt470bg").expect("bt470bg matrix"),
677            ColorMatrix::Bt470Bg
678        );
679        assert_eq!(
680            ColorMatrix::parse("smpte170m").expect("smpte170m matrix"),
681            ColorMatrix::Smpte170M
682        );
683        assert_eq!(
684            ColorMatrix::parse("smpte240m").expect("smpte240m matrix"),
685            ColorMatrix::Smpte240M
686        );
687        assert_eq!(
688            ColorMatrix::parse("ycgco").expect("ycgco matrix"),
689            ColorMatrix::Ycgco
690        );
691        assert_eq!(
692            ColorMatrix::parse("bt2020_ncl").expect("bt2020 matrix"),
693            ColorMatrix::Bt2020Ncl
694        );
695        assert_eq!(
696            ColorMatrix::parse("bt2020_cl").expect("bt2020 CL matrix"),
697            ColorMatrix::Bt2020Cl
698        );
699        assert_eq!(
700            ColorMatrix::parse("smpte2085").expect("smpte2085 matrix"),
701            ColorMatrix::Smpte2085
702        );
703        assert_eq!(
704            ColorMatrix::parse("chromaticity_derived_ncl").expect("chromaticity NCL matrix"),
705            ColorMatrix::ChromaticityDerivedNcl
706        );
707        assert_eq!(
708            ColorMatrix::parse("chromaticity_derived_cl").expect("chromaticity CL matrix"),
709            ColorMatrix::ChromaticityDerivedCl
710        );
711        assert_eq!(
712            ColorMatrix::parse("ictcp").expect("ICtCp matrix"),
713            ColorMatrix::Ictcp
714        );
715
716        assert_eq!(
717            ColorTransfer::parse("bt1886").expect("BT.1886 transfer"),
718            ColorTransfer::Bt1886
719        );
720        assert_eq!(
721            ColorTransfer::parse("bt470m").expect("BT.470M transfer"),
722            ColorTransfer::Bt470M
723        );
724        assert_eq!(
725            ColorTransfer::parse("bt470bg").expect("BT.470BG transfer"),
726            ColorTransfer::Bt470Bg
727        );
728        assert_eq!(
729            ColorTransfer::parse("smpte170m").expect("SMPTE 170M transfer"),
730            ColorTransfer::Smpte170M
731        );
732        assert_eq!(
733            ColorTransfer::parse("smpte240m").expect("SMPTE 240M transfer"),
734            ColorTransfer::Smpte240M
735        );
736        assert_eq!(
737            ColorTransfer::parse("linear").expect("linear transfer"),
738            ColorTransfer::Linear
739        );
740        assert_eq!(
741            ColorTransfer::parse("log100").expect("Log100 transfer"),
742            ColorTransfer::Log100
743        );
744        assert_eq!(
745            ColorTransfer::parse("log316").expect("Log316 transfer"),
746            ColorTransfer::Log316
747        );
748        assert_eq!(
749            ColorTransfer::parse("xvycc").expect("xvYCC transfer"),
750            ColorTransfer::Xvycc
751        );
752        assert_eq!(
753            ColorTransfer::parse("bt1361e").expect("BT.1361E transfer"),
754            ColorTransfer::Bt1361E
755        );
756        assert_eq!(
757            ColorTransfer::parse("srgb").expect("sRGB transfer"),
758            ColorTransfer::Srgb
759        );
760        assert_eq!(
761            ColorTransfer::parse("bt2020_10").expect("bt2020 transfer"),
762            ColorTransfer::Bt2020_10
763        );
764        assert_eq!(
765            ColorTransfer::parse("bt2020_12").expect("bt2020 transfer"),
766            ColorTransfer::Bt2020_12
767        );
768        assert_eq!(
769            ColorTransfer::parse("pq").expect("PQ transfer"),
770            ColorTransfer::Pq
771        );
772        assert_eq!(
773            ColorTransfer::parse("smpte428").expect("SMPTE 428 transfer"),
774            ColorTransfer::Smpte428
775        );
776        assert_eq!(
777            ColorTransfer::parse("hlg").expect("HLG transfer"),
778            ColorTransfer::Hlg
779        );
780
781        assert_eq!(
782            ColorPrimaries::parse("bt709").expect("BT.709 primaries"),
783            ColorPrimaries::Bt709
784        );
785        assert_eq!(
786            ColorPrimaries::parse("bt470m").expect("BT.470M primaries"),
787            ColorPrimaries::Bt470M
788        );
789        assert_eq!(
790            ColorPrimaries::parse("bt470bg").expect("BT.470BG primaries"),
791            ColorPrimaries::Bt470Bg
792        );
793        assert_eq!(
794            ColorPrimaries::parse("smpte170m").expect("SMPTE 170M primaries"),
795            ColorPrimaries::Smpte170M
796        );
797        assert_eq!(
798            ColorPrimaries::parse("smpte240m").expect("SMPTE 240M primaries"),
799            ColorPrimaries::Smpte240M
800        );
801        assert_eq!(
802            ColorPrimaries::parse("film").expect("film primaries"),
803            ColorPrimaries::Film
804        );
805        assert_eq!(
806            ColorPrimaries::parse("bt2020").expect("BT.2020 primaries"),
807            ColorPrimaries::Bt2020
808        );
809        assert_eq!(
810            ColorPrimaries::parse("smpte428").expect("SMPTE 428 primaries"),
811            ColorPrimaries::Smpte428
812        );
813        assert_eq!(
814            ColorPrimaries::parse("dci_p3").expect("DCI-P3 primaries"),
815            ColorPrimaries::DciP3
816        );
817        assert_eq!(
818            ColorPrimaries::parse("display_p3").expect("Display P3 primaries"),
819            ColorPrimaries::DisplayP3
820        );
821        assert_eq!(
822            ChromaSiting::parse("top_left").expect("top-left siting"),
823            ChromaSiting::TopLeft
824        );
825
826        assert_eq!(ColorMatrix::Ictcp.as_str(), "ictcp");
827        assert_eq!(ColorTransfer::Pq.as_str(), "pq");
828        assert_eq!(ColorPrimaries::DisplayP3.as_str(), "display_p3");
829        assert_eq!(ChromaSiting::Bottom.offsets(), (0.5, 1.0));
830    }
831
832    #[test]
833    fn color_metadata_parse_rejects_noncanonical_names() {
834        for (label, error) in [
835            (
836                "matrix",
837                ColorMatrix::parse("rec709").expect_err("alias should fail"),
838            ),
839            (
840                "transfer",
841                ColorTransfer::parse("bt.1886").expect_err("punctuated name should fail"),
842            ),
843            (
844                "primaries",
845                ColorPrimaries::parse("p3").expect_err("alias should fail"),
846            ),
847            (
848                "siting",
849                ChromaSiting::parse("mpeg2").expect_err("alias should fail"),
850            ),
851        ] {
852            assert_eq!(error.category(), ErrorCategory::Format, "{label}");
853        }
854    }
855
856    #[test]
857    fn color_metadata_matrix_luma_coefficients_are_stable() {
858        assert_eq!(
859            ColorMatrix::Bt709.luma_coefficients(),
860            (0.2126, 0.7152, 0.0722)
861        );
862        assert_eq!(ColorMatrix::Bt470M.luma_coefficients(), (0.30, 0.59, 0.11));
863        assert_eq!(
864            ColorMatrix::Bt470Bg.luma_coefficients(),
865            (0.299, 0.587, 0.114)
866        );
867        assert_eq!(
868            ColorMatrix::Smpte170M.luma_coefficients(),
869            (0.299, 0.587, 0.114)
870        );
871        assert_eq!(
872            ColorMatrix::Smpte240M.luma_coefficients(),
873            (0.212, 0.701, 0.087)
874        );
875        assert_eq!(
876            ColorMatrix::Bt2020Ncl.luma_coefficients(),
877            (0.2627, 0.6780, 0.0593)
878        );
879    }
880
881    #[test]
882    fn color_range_parse_accepts_official_names() {
883        assert_eq!(
884            ColorRange::parse("full").expect("full should parse"),
885            ColorRange::Full
886        );
887        assert_eq!(
888            ColorRange::parse("limited").expect("limited should parse"),
889            ColorRange::Limited
890        );
891        assert_eq!(ColorRange::Full.as_str(), "full");
892        assert_eq!(ColorRange::Limited.as_str(), "limited");
893    }
894
895    #[test]
896    fn color_range_parse_rejects_unknown_names() {
897        let error = ColorRange::parse("tv").expect_err("unknown range should fail");
898
899        assert_eq!(error.category(), ErrorCategory::Format);
900        assert_eq!(error.code(), ErrorCode::new("format.unsupported_range"));
901    }
902
903    #[test]
904    fn format_with_bit_depth_preserves_family_and_subsampling() {
905        let yuv = resolve_format_alias("yuv420p8").expect("alias should resolve");
906        let rgb = resolve_format_alias("rgbp16").expect("alias should resolve");
907        let gray = resolve_format_alias("grayf32").expect("alias should resolve");
908
909        assert_eq!(
910            format_with_bit_depth(&yuv, 10)
911                .expect("10-bit YUV should derive")
912                .name(),
913            "yuv420p10"
914        );
915        assert_eq!(
916            format_with_bit_depth(&rgb, 32)
917                .expect("f32 RGB should derive")
918                .name(),
919            "rgbpf32"
920        );
921        assert_eq!(
922            format_with_bit_depth(&gray, 8)
923                .expect("8-bit gray should derive")
924                .name(),
925            "gray8"
926        );
927    }
928
929    #[test]
930    fn format_with_bit_depth_rejects_unsupported_depth() {
931        let format = resolve_format_alias("gray8").expect("alias should resolve");
932        let error = format_with_bit_depth(&format, 14).expect_err("14-bit output should fail");
933
934        assert_eq!(error.category(), ErrorCategory::Format);
935        assert_eq!(error.code(), ErrorCode::new("format.unsupported_bit_depth"));
936    }
937
938    #[test]
939    fn resolves_yuv420p10_to_stable_descriptor() {
940        let descriptor = resolve_format_alias("yuv420p10").expect("alias should resolve");
941
942        assert_eq!(descriptor.name(), "yuv420p10");
943        assert_eq!(descriptor.family(), FormatFamily::Yuv);
944        assert_eq!(descriptor.subsampling(), Some(ChromaSubsampling::Cs420));
945        assert_eq!(descriptor.bits_per_sample(), 10);
946        assert_eq!(descriptor.sample_type(), SampleType::U16);
947        assert_eq!(descriptor.planes().len(), 3);
948        assert_eq!(descriptor.planes()[0].role, PlaneRole::Y);
949        assert_eq!(descriptor.planes()[1].role, PlaneRole::U);
950        assert_eq!(descriptor.planes()[1].width_divisor, 2);
951        assert_eq!(descriptor.planes()[1].height_divisor, 2);
952    }
953
954    #[test]
955    fn resolves_planar_rgb_aliases_to_same_descriptor() {
956        let rgb = resolve_format_alias("rgbp10").expect("alias should resolve");
957        let gbr = resolve_format_alias("gbrp10").expect("alias should resolve");
958
959        assert_eq!(rgb, gbr);
960        assert_eq!(rgb.family(), FormatFamily::PlanarRgb);
961        assert_eq!(rgb.planes()[0].role, PlaneRole::R);
962        assert_eq!(rgb.planes()[1].role, PlaneRole::G);
963        assert_eq!(rgb.planes()[2].role, PlaneRole::B);
964    }
965
966    #[test]
967    fn resolves_float_gray_descriptor() {
968        let descriptor = resolve_format_alias("grayf32").expect("alias should resolve");
969
970        assert_eq!(descriptor.name(), "grayf32");
971        assert_eq!(descriptor.family(), FormatFamily::Gray);
972        assert_eq!(descriptor.bits_per_sample(), 32);
973        assert_eq!(descriptor.sample_type(), SampleType::F32);
974        assert_eq!(descriptor.planes()[0].role, PlaneRole::Gray);
975    }
976
977    #[test]
978    fn unsupported_alias_returns_structured_format_error() {
979        let error = resolve_format_alias("yuv420p14").expect_err("unsupported alias should fail");
980
981        assert_eq!(error.category(), ErrorCategory::Format);
982        assert_eq!(error.code(), ErrorCode::new("format.unsupported_alias"));
983        assert!(error.message().contains("yuv420p14"));
984    }
985}