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}