codec/colorspace/mod.rs
1use anyhow::{Result, bail};
2use bytes::BytesMut;
3
4use crate::frame::{ColorMetadata, ColorSpace, PixelFormat, TransferFn, VideoFrame};
5use crate::tonemap::tonemap_yuv420p10le_bt2020_to_yuv420p_bt709;
6
7mod bt601_to_709;
8mod bt601_to_709_10bit;
9mod chroma_convert;
10mod downsample_444;
11mod scale;
12
13#[cfg(test)]
14mod tests;
15
16// ── public re-exports ─────────────────────────────────────────────────────────
17
18pub use bt601_to_709::{bt601_to_bt709_planes, bt601_to_bt709_planes_scalar};
19pub use bt601_to_709_10bit::{
20 bt601_to_bt709_planes_10bit, bt601_to_bt709_planes_10bit_scalar,
21};
22pub use downsample_444::{
23 downsample_444_to_420_frame, downsample_chroma_444_to_420,
24 downsample_chroma_444_to_420_10bit,
25};
26pub use scale::{
27 bilinear_scale_plane, bilinear_scale_plane_scalar, bilinear_scale_plane_u16,
28 bilinear_scale_plane_u16_scalar, scale_frame,
29};
30
31// =============================================================================
32// Shared BT.601 → BT.709 matrix constants
33// =============================================================================
34//
35// Derived from BT.601 M_YUV→RGB composed with BT.709 M_RGB→YUV, both in
36// 8-bit studio-range form (Y in [16,235], Cb/Cr in [16,240]).
37//
38// Result (matrix applied to deltas):
39// ΔY709 = 1.00000·ΔY - 0.11555·ΔCb - 0.20794·ΔCr
40// ΔCb709 = 0·ΔY + 1.01864·ΔCb + 0.11462·ΔCr
41// ΔCr709 = 0·ΔY + 0.07505·ΔCb + 1.02533·ΔCr
42//
43// Kept here (parent module) so both bt601_to_709 and bt601_to_709_10bit child
44// modules can reference them as `super::Q15`, `super::M_Y_CB`, etc. without
45// duplication. Child modules may access private parent items.
46
47const Q15: i32 = 15;
48const Q15_ROUND: i32 = 1 << (Q15 - 1);
49
50// Row 0 (Y): Y709 = Y601·1.0 + M_Y_CB·ΔCb + M_Y_CR·ΔCr. The 1.0 coefficient
51// is applied as a direct copy (no fixed-point multiply).
52#[allow(dead_code)] // documented identity; not emitted into the hot path
53const M_Y_Y: i32 = 32768;
54const M_Y_CB: i32 = (-0.11554975_f64 * 32768.0) as i32; // -3786
55const M_Y_CR: i32 = (-0.20793764_f64 * 32768.0) as i32; // -6814
56// Row 1 (Cb): no luma coupling
57const M_CB_CB: i32 = (1.01863972_f64 * 32768.0).round() as i32; // 33379
58const M_CB_CR: i32 = (0.11461795_f64 * 32768.0).round() as i32; // 3756
59// Row 2 (Cr): no luma coupling
60const M_CR_CB: i32 = (0.07504945_f64 * 32768.0).round() as i32; // 2459
61const M_CR_CR: i32 = (1.02532707_f64 * 32768.0).round() as i32; // 33598
62
63// =============================================================================
64// Shared byte-order helpers
65// =============================================================================
66//
67// Used by scale, downsample_444, and chroma_convert child modules.
68// Private visibility is sufficient — child modules can always access a private
69// parent item as `super::read_u16le`.
70
71fn read_u16le(bytes: &[u8]) -> Vec<u16> {
72 bytes
73 .chunks_exact(2)
74 .map(|c| u16::from_le_bytes([c[0], c[1]]))
75 .collect()
76}
77
78fn write_u16le(out: &mut BytesMut, samples: &[u16]) {
79 for s in samples {
80 out.extend_from_slice(&s.to_le_bytes());
81 }
82}
83
84// =============================================================================
85// Top-level dispatch entry points
86// =============================================================================
87
88/// Normalize a decoder frame for the encoder.
89///
90/// **8-bit path** (target `Yuv420p` / `Bt709`): every supported 8-bit
91/// pixel format is converted to packed 4:2:0 BT.709 limited-range. The
92/// dispatcher does (a) chroma layout normalisation — NV12/NV21
93/// deinterleave, 4:2:2 vertical-2:1 average, 4:4:4 box average — then
94/// (b) RGB → YUV matrix when the source is RGB, then (c) BT.601 → BT.709
95/// matrix correction for tagged-BT.601 YUV sources.
96///
97/// **10-bit path** (target `Yuv420p10le`, HDR-aware): 10-bit and
98/// alpha-bearing 10-bit formats are downsampled to `Yuv420p10le` and
99/// returned as-is on the matrix axis. The pipeline preserves the source's
100/// `ColorMetadata` (primaries / transfer / matrix) so the muxer's
101/// `colr nclx` box and the AV1 sequence header carry the HDR / wide-gamut
102/// signaling unchanged. Squad-19, roadmap #5.
103///
104/// **Format coverage** (input → output):
105/// - `Yuv420p` (BT.709) → passthrough
106/// - `Yuv420p` (BT.601 / BT.2020) → matrix correction to BT.709 (BT.2020
107/// 8-bit is rare; treated as BT.601 for the matrix, downstream `colr
108/// nclx` keeps the truth)
109/// - `Yuv422p` / `Yuv422p10le` → vertical 2:1 chroma average
110/// - `Yuv444p` / `Yuv444p10le` / `Yuva444p10le` → 2×2 box average
111/// (alpha dropped for `Yuva444p10le`)
112/// - `Nv12` → UV deinterleave
113/// - `Nv21` → VU deinterleave (same as NV12 with planes swapped)
114/// - `Rgb24` / `Rgba32` → BT.709 RGB→YUV matrix (alpha discarded for
115/// `Rgba32`)
116/// - `Yuv420p10le` → passthrough
117/// - `Yuv420p12le` → not yet wired; `bail!` (no decoder in tree emits
118/// 12-bit today)
119/// HDR-aware variant. When the source `ColorMetadata` indicates a PQ /
120/// HLG transfer function, the 10-bit input is tonemapped to 8-bit BT.709
121/// limited via the Hable filmic curve (`crate::tonemap`). For SDR
122/// sources (transfer Bt709 / Bt470Bg / Linear / Unspecified), behaviour
123/// is identical to `convert_to_yuv420p_bt709` — including the existing
124/// 10-bit BT.709 passthrough.
125///
126/// This is the dispatch the pipeline should call when it has access to
127/// the source's `ColorMetadata`. Existing 8-bit-only callers that only
128/// have a frame in scope can continue to use `convert_to_yuv420p_bt709`
129/// directly; SDR semantics there are unchanged.
130pub fn convert_to_sdr_bt709(
131 frame: &VideoFrame,
132 color_metadata: &ColorMetadata,
133) -> Result<VideoFrame> {
134 let is_hdr_transfer = matches!(
135 color_metadata.transfer,
136 TransferFn::St2084 | TransferFn::AribStdB67
137 );
138 if is_hdr_transfer && matches!(frame.format, PixelFormat::Yuv420p10le) {
139 let max_white_nits = color_metadata
140 .mastering_display
141 .as_ref()
142 // mastering_display.max_luminance is in 0.0001 cd/m² ticks
143 // per H.265 SEI 137 / ST 2086. Divide to get nits.
144 .map(|m| (m.max_luminance as f32) / 10_000.0)
145 .filter(|n| *n > 0.0);
146 return tonemap_yuv420p10le_bt2020_to_yuv420p_bt709(
147 frame,
148 color_metadata.transfer,
149 max_white_nits,
150 );
151 }
152 // SDR path — also handles Yuv422p10le / Yuv444p10le HDR by first
153 // funnelling through the existing 10-bit passthrough chain. Those
154 // chroma formats are rarely HDR in practice; if they show up the
155 // mux's colr nclx still tags them PQ / HLG and downstream playback
156 // honours the transfer. Future work: extend the tonemap to accept
157 // those chroma layouts directly.
158 convert_to_yuv420p_bt709(frame)
159}
160
161pub fn convert_to_yuv420p_bt709(frame: &VideoFrame) -> Result<VideoFrame> {
162 use PixelFormat::*;
163
164 // ── 10-bit / wide-gamut path ──────────────────────────────────────
165 // HDR / wide-gamut passthrough on the matrix axis. Chroma layout
166 // gets normalised to 4:2:0 if needed, but matrix coefficients are
167 // preserved on the frame's `color_space` field — the encoder
168 // signals it through the AV1 sequence header and the mux writes
169 // `colr nclx` so a player/browser can reverse the matrix.
170 match frame.format {
171 Yuv420p10le => return Ok(frame.clone()),
172 Yuv422p10le => return chroma_convert::yuv422p10le_to_yuv420p10le(frame),
173 Yuv444p10le | Yuva444p10le => {
174 return downsample_444::downsample_444_to_420_frame(frame)
175 }
176 Yuv420p12le => bail!(
177 "Yuv420p12le not yet supported in convert_to_yuv420p_bt709 \
178 (no decoder in tree emits 12-bit; add a 12→10-bit dither \
179 when a decoder lands that does)"
180 ),
181 _ => {}
182 }
183
184 // ── 8-bit path: RGB sources go straight to Yuv420p/Bt709 ─────────
185 match frame.format {
186 Rgb24 => return chroma_convert::rgb_to_yuv420p_bt709(frame, /*has_alpha=*/ false),
187 Rgba32 => return chroma_convert::rgb_to_yuv420p_bt709(frame, /*has_alpha=*/ true),
188 _ => {}
189 }
190
191 // ── 8-bit path: YUV chroma-layout normalize → Yuv420p ────────────
192 let yuv420p = match frame.format {
193 Yuv420p => frame.clone(),
194 Nv12 => chroma_convert::nv12_to_yuv420p(frame)?,
195 Nv21 => chroma_convert::nv21_to_yuv420p(frame)?,
196 Yuv422p => chroma_convert::yuv422p_to_yuv420p(frame)?,
197 Yuv444p => downsample_444::downsample_444_to_420_frame(frame)?,
198 other => bail!(
199 "unsupported conversion: {:?}/{:?} → Yuv420p/Bt709",
200 other,
201 frame.color_space
202 ),
203 };
204
205 // ── 8-bit path: matrix correction → Bt709 ────────────────────────
206 if yuv420p.color_space == ColorSpace::Bt709 {
207 Ok(yuv420p)
208 } else {
209 // BT.601 and BT.2020 (rare in 8-bit SDR) both route through the
210 // BT.601 → BT.709 matrix. BT.2020-via-BT.601 produces a slight
211 // hue shift but the alternative — bailing — would block every
212 // BT.2020-tagged 8-bit input from transcoding. The mux's
213 // `colr nclx` carries the post-conversion BT.709 tag so a
214 // downstream player applies the right inverse.
215 bt601_to_709::recolor_yuv420p_bt601_to_bt709(&yuv420p)
216 }
217}