Skip to main content

alpha2_media/
color.rs

1use serde::{Deserialize, Serialize};
2
3/// Host-independent video color description.
4#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
6#[serde(deny_unknown_fields)]
7pub struct ColorMetadata {
8    /// RGB chromaticity standard.
9    pub primaries: ColorPrimaries,
10    /// Nonlinear transfer curve.
11    pub transfer: TransferFunction,
12    /// YUV-to-RGB coefficient family, or RGB for already converted sources.
13    pub matrix: ColorMatrix,
14    /// Encoded component range.
15    pub range: ColorRange,
16    /// SDR or HDR; HDR exactly when `transfer` is PQ or HLG ([`Self::is_consistent`]).
17    pub dynamic_range: DynamicRange,
18}
19
20impl ColorMetadata {
21    /// The color every SDR export is tagged with: BT.709 primaries, transfer and matrix, limited
22    /// range.
23    ///
24    /// The working space is display-referred, so export encodes with the sRGB curve (the exact
25    /// inverse of SDR input linearization) and tags the result BT.709 like other SDR video.
26    pub const EXPORT: Self = Self {
27        primaries: ColorPrimaries::Bt709,
28        transfer: TransferFunction::Bt709,
29        matrix: ColorMatrix::Bt709,
30        range: ColorRange::Limited,
31        dynamic_range: DynamicRange::Sdr,
32    };
33
34    /// How a source without declared color is decoded: BT.709 limited SDR. The renderer reports
35    /// it as `ColorDiagnostic::MissingMetadataFallback`.
36    pub const UNDECLARED: Self = Self::EXPORT;
37
38    /// Whether the signal is HDR by transfer (PQ or HLG) or classification. Every consumer uses
39    /// this one rule.
40    #[must_use]
41    pub const fn is_hdr(self) -> bool {
42        matches!(self.dynamic_range, DynamicRange::Hdr)
43            || matches!(self.transfer, TransferFunction::Pq | TransferFunction::Hlg)
44    }
45
46    /// Whether `dynamic_range` matches `transfer` (HDR exactly for PQ or HLG). Projects with an
47    /// inconsistent color are rejected.
48    #[must_use]
49    pub const fn is_consistent(self) -> bool {
50        matches!(self.dynamic_range, DynamicRange::Hdr)
51            == matches!(self.transfer, TransferFunction::Pq | TransferFunction::Hlg)
52    }
53}
54
55impl Default for ColorMetadata {
56    /// [`Self::UNDECLARED`].
57    fn default() -> Self {
58        Self::UNDECLARED
59    }
60}
61
62/// How rendered RGB is packed into 8-bit 4:2:0 YUV: color coding and chroma siting.
63#[derive(Copy, Clone, Debug, Eq, PartialEq)]
64pub struct YuvEncoding {
65    /// Matrix, range, and the tags the output carries.
66    pub color: ColorMetadata,
67    /// Chroma sample siting, which the pack filters for.
68    pub chroma: crate::ChromaLocation,
69}
70
71impl YuvEncoding {
72    /// `color` with chroma sited at `chroma`.
73    #[must_use]
74    pub const fn new(color: ColorMetadata, chroma: crate::ChromaLocation) -> Self {
75        Self { color, chroma }
76    }
77
78    /// [`ColorMetadata::EXPORT`] with the codec's chroma siting
79    /// ([`crate::VideoCodec::chroma_location`]).
80    #[must_use]
81    pub const fn export(codec: crate::VideoCodec) -> Self {
82        Self::new(ColorMetadata::EXPORT, codec.chroma_location())
83    }
84}
85
86/// RGB primary chromaticities a source or export declares.
87#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89#[serde(rename_all = "snake_case")]
90pub enum ColorPrimaries {
91    /// SD video primaries.
92    Bt601,
93    /// HD video and sRGB primaries.
94    Bt709,
95    /// Wide-gamut BT.2020 primaries, normally indicating HDR input.
96    Bt2020,
97}
98
99/// Encoded transfer curve.
100#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
101#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
102#[serde(rename_all = "snake_case")]
103pub enum TransferFunction {
104    /// BT.709 opto-electronic transfer function.
105    Bt709,
106    /// IEC sRGB transfer function.
107    Srgb,
108    /// Perceptual quantizer HDR transfer function.
109    Pq,
110    /// Hybrid log-gamma HDR transfer function.
111    Hlg,
112}
113
114/// Coefficients used to decode source components.
115#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117#[serde(rename_all = "snake_case")]
118pub enum ColorMatrix {
119    /// SD BT.601 YUV coefficients.
120    Bt601,
121    /// HD BT.709 YUV coefficients.
122    Bt709,
123    /// BT.2020 non-constant-luminance YUV coefficients.
124    Bt2020Ncl,
125    /// Source components are already RGB.
126    Rgb,
127}
128
129impl ColorMatrix {
130    /// The red and blue luma weights `(Kr, Kb)` of a YCbCr matrix, or `None` for RGB. Green's
131    /// weight is `1 - Kr - Kb`.
132    ///
133    /// This is the single definition that import, export and the CPU reference derive from.
134    #[must_use]
135    pub const fn luma_weights(self) -> Option<(f32, f32)> {
136        match self {
137            Self::Bt601 => Some((0.299, 0.114)),
138            Self::Bt709 => Some((0.2126, 0.0722)),
139            Self::Bt2020Ncl => Some((0.2627, 0.0593)),
140            Self::Rgb => None,
141        }
142    }
143}
144
145/// Where a YCbCr range puts a bit depth's codes (ITU-T H.273).
146///
147/// Luma `Y'` is `luma_black + luma_span * Y` for `Y` in `0..=1`; each chroma
148/// component is `chroma_center + chroma_span * C` for `C` in `-0.5..=0.5`.
149/// A sampled unorm texture reads a code as `code / maximum`.
150#[derive(Copy, Clone, Debug, PartialEq)]
151pub struct YcbcrLevels {
152    /// The largest code, `2^depth - 1`.
153    pub maximum: f32,
154    /// Black's luma code.
155    pub luma_black: f32,
156    /// Codes from black to white.
157    pub luma_span: f32,
158    /// Neutral chroma's code.
159    pub chroma_center: f32,
160    /// Codes across chroma's full swing, `-0.5` to `0.5`.
161    pub chroma_span: f32,
162}
163
164impl ColorRange {
165    /// The code levels for `bit_depth`. Limited range scales 16, 219, 128 and 224 with the depth;
166    /// full range spans every code, with neutral chroma at `2^(depth - 1)`.
167    ///
168    /// This is the single definition that import, export and the CPU reference use.
169    #[must_use]
170    #[allow(clippy::cast_precision_loss)] // Every code of 16 bits is exact in f32.
171    pub fn levels(self, bit_depth: u8) -> YcbcrLevels {
172        let depth = u32::from(bit_depth.clamp(8, 16));
173        let maximum = ((1_u32 << depth) - 1) as f32;
174        let scale = (1_u32 << (depth - 8)) as f32;
175        match self {
176            Self::Limited => YcbcrLevels {
177                maximum,
178                luma_black: 16.0 * scale,
179                luma_span: 219.0 * scale,
180                chroma_center: 128.0 * scale,
181                chroma_span: 224.0 * scale,
182            },
183            Self::Full => YcbcrLevels {
184                maximum,
185                luma_black: 0.0,
186                luma_span: maximum,
187                chroma_center: (1_u32 << (depth - 1)) as f32,
188                chroma_span: maximum,
189            },
190        }
191    }
192}
193
194/// Nominal encoded component range.
195#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197#[serde(rename_all = "snake_case")]
198pub enum ColorRange {
199    /// Studio/video range.
200    Limited,
201    /// Full component range.
202    Full,
203}
204
205/// Standard or high dynamic range; must agree with the transfer.
206#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
207#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
208#[serde(rename_all = "snake_case")]
209pub enum DynamicRange {
210    /// Standard dynamic range: a BT.709 or sRGB transfer.
211    Sdr,
212    /// High dynamic range: a PQ or HLG transfer, tone-mapped or rejected per the color pipeline.
213    Hdr,
214}
215
216#[cfg(test)]
217// The levels are exact codes.
218#[allow(clippy::float_cmp)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn levels_scale_the_limited_range_with_the_bit_depth() {
224        let eight = ColorRange::Limited.levels(8);
225        assert_eq!(
226            [
227                eight.maximum,
228                eight.luma_black,
229                eight.luma_span,
230                eight.chroma_span
231            ],
232            [255.0, 16.0, 219.0, 224.0]
233        );
234        let ten = ColorRange::Limited.levels(10);
235        assert_eq!(
236            [
237                ten.maximum,
238                ten.luma_black,
239                ten.luma_span,
240                ten.chroma_center
241            ],
242            [1023.0, 64.0, 876.0, 512.0]
243        );
244    }
245
246    #[test]
247    fn full_range_centres_chroma_on_the_middle_code_not_on_one_half() {
248        let eight = ColorRange::Full.levels(8);
249        assert_eq!(eight.chroma_center, 128.0);
250        assert!(eight.chroma_center / eight.maximum > 0.5);
251        assert_eq!(ColorRange::Full.levels(10).chroma_center, 512.0);
252    }
253
254    #[test]
255    fn the_matrices_weigh_luma_as_their_standards_do() {
256        for matrix in [
257            ColorMatrix::Bt601,
258            ColorMatrix::Bt709,
259            ColorMatrix::Bt2020Ncl,
260        ] {
261            let (kr, kb) = matrix.luma_weights().expect("YCbCr");
262            assert!(kr > 0.0 && kb > 0.0 && kr + kb < 1.0);
263        }
264        assert_eq!(ColorMatrix::Rgb.luma_weights(), None);
265        assert_eq!(ColorMetadata::default(), ColorMetadata::EXPORT);
266    }
267}