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