Skip to main content

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}