Skip to main content

long_screen/kernels/yuv/
hdr.rs

1//! HDR frames (PQ or HLG transfer, BT.2020 primaries) to SDR sRGB RGBA, for screen recordings that phones and Macs
2//! with HDR displays write even when every pixel on screen was SDR. Browsers hand these frames back with the coded
3//! signal untouched (WebKit's planes and canvas, WebGL with colour conversion off) or tone-mapped for display
4//! (Chrome's canvas and `copyTo`), neither of which is the screen's pixels; this undoes the transfer from the coded
5//! values. The mapping takes SDR white as the signal's reference white and clips what lies above it: 100 nits for PQ
6//! (where Apple's screen recordings put it, measured on an iPhone and a Mac recording), 75 % signal for HLG
7//! (ITU-R BT.2408). The coded R'G'B' goes through the transfer's EOTF to linear light, BT.2020 → BT.709 primaries,
8//! and the sRGB curve.
9//!
10//! Two entry points: 8-bit planes as `VideoFrame.copyTo` writes them (`planes_to_rgba`, chroma point-sampled like
11//! the SDR path), and R'G'B' already converted from YUV by the browser (`rgb_to_rgba`, 8-bit RGBA or 10-bit
12//! RGB10A2). Both count the pixels brighter than `ABOVE_WHITE`, which a caller uses to tell a recording whose SDR
13//! white sits higher than assumed (and so clips) from one with the odd highlight.
14
15use super::{Format, Plane};
16use crate::kernels::pool::SyncPtr;
17use std::sync::atomic::{AtomicUsize, Ordering};
18use std::sync::OnceLock;
19
20/// The transfer of the coded signal.
21#[derive(Clone, Copy, PartialEq, Eq, Debug)]
22pub enum Transfer {
23    Pq,
24    Hlg,
25}
26
27/// The YUV matrix of a frame's planes.
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum Matrix {
30    Bt709,
31    Bt601,
32    Bt2020,
33}
34
35/// How `rgb_to_rgba` receives R'G'B': four bytes per pixel, either RGBA8 or little-endian RGB10A2 (red in the low
36/// ten bits, as WebGPU's `rgb10a2unorm` reads back).
37#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum Packing {
39    Rgba8,
40    Rgb10a2,
41}
42
43/// Linear light, relative to SDR white, above which a pixel counts as brighter than white: 150 nits under PQ.
44pub const ABOVE_WHITE: f32 = 1.5;
45
46/// Converts 8-bit planes coding an HDR signal to `width × height` sRGB RGBA in `out`; returns how many pixels lie
47/// above `ABOVE_WHITE`. Same plane contract as `super::to_rgba`.
48#[allow(clippy::too_many_arguments)]
49pub fn planes_to_rgba(
50    src: &[u8],
51    format: Format,
52    planes: &[Plane],
53    width: usize,
54    height: usize,
55    matrix: Matrix,
56    full: bool,
57    transfer: Transfer,
58    out: &mut [u8],
59) -> usize {
60    let (kr, kb) = match matrix {
61        Matrix::Bt709 => (0.2126f32, 0.0722f32),
62        Matrix::Bt601 => (0.299, 0.114),
63        Matrix::Bt2020 => (0.2627, 0.0593),
64    };
65    let kg = 1.0 - kr - kb;
66    // Y'CbCr bytes to coded R'G'B': (scale, offset) of luma and chroma for the range, then the matrix's terms.
67    let (ys, yo, cs) = if full {
68        (1.0 / 255.0, 0.0, 1.0 / 255.0)
69    } else {
70        (1.0 / 219.0, 16.0, 1.0 / 224.0)
71    };
72    let (rv, bu) = (2.0 * (1.0 - kr) * cs, 2.0 * (1.0 - kb) * cs);
73    let (gu, gv) = (
74        2.0 * kb * (1.0 - kb) / kg * cs,
75        2.0 * kr * (1.0 - kr) / kg * cs,
76    );
77    let luma: [f32; 256] = std::array::from_fn(|y| (y as f32 - yo) * ys);
78    let (eotf, oetf) = (eotf_table(transfer), srgb_table());
79    // Neutral pixels (chroma exactly 128: R' = G' = B' = luma) are most of a screen recording, and depend on luma
80    // alone, so they are converted once per value here by the same `pixel` and looked up below.
81    let grey: [([u8; 4], bool); 256] = std::array::from_fn(|y| pixel([luma[y]; 3], eotf, oetf));
82    let (shift, chroma_rows) = match format {
83        Format::I444 => (0, 1),
84        Format::I422 => (1, 1),
85        Format::I420 | Format::Nv12 => (1, 2),
86    };
87    rows(width, height, out, |y, row, memo| {
88        let line =
89            |i: usize, r: usize, len: usize| &src[planes[i].offset + r * planes[i].stride..][..len];
90        let cw = if shift == 0 { width } else { width.div_ceil(2) };
91        let cy = y / chroma_rows;
92        let ls = line(0, y, width);
93        let (us, vs, interleaved) = if format == Format::Nv12 {
94            (line(1, cy, cw * 2), &[][..], true)
95        } else {
96            (line(1, cy, cw), line(2, cy, cw), false)
97        };
98        let mut above = 0;
99        for (x, px) in row.chunks_exact_mut(4).enumerate() {
100            let c = x >> shift;
101            let (u, v) = if interleaved {
102                (us[c * 2], us[c * 2 + 1])
103            } else {
104                (us[c], vs[c])
105            };
106            let (p, bright) = if u == 128 && v == 128 {
107                grey[ls[x] as usize]
108            } else {
109                let key = (ls[x] as u32) << 16 | (u as u32) << 8 | v as u32;
110                memo.get(key, || {
111                    let (yy, u, v) = (luma[ls[x] as usize], u as f32 - 128.0, v as f32 - 128.0);
112                    pixel([yy + rv * v, yy - gu * u - gv * v, yy + bu * u], eotf, oetf)
113                })
114            };
115            px.copy_from_slice(&p);
116            above += bright as usize;
117        }
118        above
119    })
120}
121
122/// Converts R'G'B' (the HDR signal after the YUV matrix, BT.2020 primaries) packed as `packing` to sRGB RGBA in
123/// place; returns how many pixels lie above `ABOVE_WHITE`. `pixels` holds `width * height * 4` bytes.
124pub fn rgb_to_rgba(
125    pixels: &mut [u8],
126    packing: Packing,
127    width: usize,
128    height: usize,
129    transfer: Transfer,
130) -> usize {
131    let (eotf, oetf) = (eotf_table(transfer), srgb_table());
132    // Neutral pixels (R' = G' = B') by code, converted once by the same `pixel` (see `planes_to_rgba`).
133    let top = match packing {
134        Packing::Rgba8 => 255,
135        Packing::Rgb10a2 => 1023,
136    };
137    // Packed RGB has only 256 or 1024 possible channel values. Reuse the exact
138    // interpolated EOTF value per code rather than dividing and interpolating per pixel.
139    let linear: Vec<f32> = (0..=top)
140        .map(|code| linear_value(code as f32 / top as f32, eotf))
141        .collect();
142    let grey: Vec<([u8; 4], bool)> = (0..=top)
143        .map(|c| pixel([c as f32 / top as f32; 3], eotf, oetf))
144        .collect();
145    rows(width, height, pixels, |_, row, _| {
146        let mut above = 0;
147        for px in row.chunks_exact_mut(4) {
148            let s = [px[0], px[1], px[2], px[3]];
149            let codes = match packing {
150                Packing::Rgba8 => [s[0], s[1], s[2]].map(u32::from),
151                Packing::Rgb10a2 => {
152                    let w = u32::from_le_bytes([s[0], s[1], s[2], s[3]]);
153                    [w, w >> 10, w >> 20].map(|v| v & 1023)
154                }
155            };
156            let (p, bright) = if codes[0] == codes[1] && codes[1] == codes[2] {
157                grey[codes[0] as usize]
158            } else {
159                pixel_linear(codes.map(|v| linear[v as usize]), oetf)
160            };
161            px.copy_from_slice(&p);
162            above += bright as usize;
163        }
164        above
165    })
166}
167
168/// Entries of the EOTF table over the coded value 0..=1 (read with linear interpolation: the nearest entry alone is a
169/// few output levels off near black once the primaries matrix subtracts two bright channels), and of the sRGB table
170/// over linear light 0..=1 (nearest entry, within a fifth of an output level of the exact curve).
171const EOTF_SIZE: usize = 4096;
172const SRGB_SIZE: usize = 16384;
173
174/// BT.2020 → BT.709 primaries, linear light (ITU-R BT.2087's inverse).
175const BT2020_TO_709: [[f32; 3]; 3] = [
176    [1.660_491, -0.587_641, -0.072_850],
177    [-0.124_550, 1.132_9, -0.008_349],
178    [-0.018_151, -0.100_579, 1.118_73],
179];
180
181/// The transfer's EOTF sampled at `EOTF_SIZE` coded values, as linear light relative to SDR white.
182fn eotf_table(transfer: Transfer) -> &'static [f32] {
183    static PQ: OnceLock<Vec<f32>> = OnceLock::new();
184    static HLG: OnceLock<Vec<f32>> = OnceLock::new();
185    let (cell, f): (_, fn(f64) -> f64) = match transfer {
186        Transfer::Pq => (&PQ, |e| pq_eotf(e) / PQ_WHITE),
187        Transfer::Hlg => (&HLG, |e| hlg_inverse_oetf(e) / hlg_inverse_oetf(0.75)),
188    };
189    cell.get_or_init(|| {
190        (0..EOTF_SIZE)
191            .map(|i| f(i as f64 / (EOTF_SIZE - 1) as f64) as f32)
192            .collect()
193    })
194}
195
196/// The sRGB curve sampled at `SRGB_SIZE` linear values, as output bytes.
197fn srgb_table() -> &'static [u8] {
198    static SRGB: OnceLock<Vec<u8>> = OnceLock::new();
199    SRGB.get_or_init(|| {
200        (0..SRGB_SIZE)
201            .map(|i| {
202                let l = i as f64 / (SRGB_SIZE - 1) as f64;
203                let v = if l <= 0.003_130_8 {
204                    12.92 * l
205                } else {
206                    1.055 * libm::pow(l, 1.0 / 2.4) - 0.055
207                };
208                (v * 255.0).round() as u8
209            })
210            .collect()
211    })
212}
213
214/// SDR white under PQ, in nits.
215const PQ_WHITE: f64 = 100.0;
216
217/// SMPTE ST 2084: coded value → nits.
218fn pq_eotf(e: f64) -> f64 {
219    let (m1, m2, c1, c2, c3) = (
220        0.159_301_757_812_5,
221        78.843_75,
222        0.835_937_5,
223        18.851_562_5,
224        18.687_5,
225    );
226    let p = libm::pow(e.clamp(0.0, 1.0), 1.0 / m2);
227    10000.0 * libm::pow((p - c1).max(0.0) / (c2 - c3 * p), 1.0 / m1)
228}
229
230/// ARIB STD-B67 / BT.2100 HLG inverse OETF: coded value → scene light 0..=1.
231fn hlg_inverse_oetf(e: f64) -> f64 {
232    let (a, b) = (0.178_832_77f64, 1.0 - 4.0 * 0.178_832_77);
233    let c = 0.5 - a * libm::log(4.0 * a);
234    let e = e.clamp(0.0, 1.0);
235    if e <= 0.5 {
236        e * e / 3.0
237    } else {
238        (libm::exp((e - c) / a) + b) / 12.0
239    }
240}
241
242/// One pixel: coded R'G'B' → sRGB RGBA, and whether it lies above `ABOVE_WHITE`.
243#[inline]
244fn pixel(rgb: [f32; 3], eotf: &[f32], srgb: &[u8]) -> ([u8; 4], bool) {
245    pixel_linear(rgb.map(|v| linear_value(v, eotf)), srgb)
246}
247
248/// Interpolated transfer value; YUV-derived values remain continuous.
249fn linear_value(v: f32, eotf: &[f32]) -> f32 {
250    let at = v.clamp(0.0, 1.0) * (EOTF_SIZE - 1) as f32;
251    let i = (at as usize).min(EOTF_SIZE - 2);
252    eotf[i] + (at - i as f32) * (eotf[i + 1] - eotf[i])
253}
254
255fn pixel_linear(lin: [f32; 3], srgb: &[u8]) -> ([u8; 4], bool) {
256    let out = BT2020_TO_709.map(|m| {
257        let l = m[0] * lin[0] + m[1] * lin[1] + m[2] * lin[2];
258        srgb[(l.clamp(0.0, 1.0) * (SRGB_SIZE - 1) as f32 + 0.5) as usize]
259    });
260    (
261        [out[0], out[1], out[2], 255],
262        lin[0].max(lin[1]).max(lin[2]) > ABOVE_WHITE,
263    )
264}
265
266/// Entries of the per-chunk memo of converted colours.
267const MEMO_SIZE: usize = 4096;
268
269/// A direct-mapped memo of `pixel`'s result by Y'CbCr bytes, kept per pool chunk. A screen recording holds few
270/// distinct non-neutral colours (about 7,000 to 12,000 per 3456 × 2234 frame of an iPhone recording), repeated
271/// over many pixels, so most of them are looked up here instead of interpolated and matrixed again. An entry is the
272/// key plus one in the high word (0 is empty) and the RGB bytes and the above-white flag in the low word; a hit
273/// returns exactly what the computation returned for that key, so the output does not depend on what was cached.
274pub(crate) struct Memo([u64; MEMO_SIZE]);
275
276impl Memo {
277    #[inline]
278    fn get(&mut self, key: u32, compute: impl FnOnce() -> ([u8; 4], bool)) -> ([u8; 4], bool) {
279        let slot = (key.wrapping_mul(0x9e37_79b1) >> (32 - MEMO_SIZE.trailing_zeros())) as usize;
280        let e = self.0[slot];
281        if (e >> 32) as u32 == key + 1 {
282            let [r, g, b, bright] = (e as u32).to_le_bytes();
283            return ([r, g, b, 255], bright != 0);
284        }
285        let (p, bright) = compute();
286        self.0[slot] =
287            ((key + 1) as u64) << 32 | u32::from_le_bytes([p[0], p[1], p[2], bright as u8]) as u64;
288        (p, bright)
289    }
290}
291
292/// Runs `row(y, out_row, memo)` for every output row across the pool, with one empty memo per chunk; returns the
293/// sum of what the rows return.
294fn rows<F: Fn(usize, &mut [u8], &mut Memo) -> usize + Sync>(
295    width: usize,
296    height: usize,
297    out: &mut [u8],
298    row: F,
299) -> usize {
300    let chunks = crate::kernels::pool::chunks_for(width * height, 64 * 1024);
301    let dst = SyncPtr(out.as_mut_ptr());
302    let above = AtomicUsize::new(0);
303    crate::kernels::pool::par_for(chunks, |c| {
304        let mut count = 0;
305        let mut memo = Memo([0; MEMO_SIZE]);
306        for y in crate::kernels::pool::split(height, chunks, c)
307            ..crate::kernels::pool::split(height, chunks, c + 1)
308        {
309            // SAFETY: output row `y` belongs to this chunk alone.
310            let line =
311                unsafe { std::slice::from_raw_parts_mut(dst.get().add(y * width * 4), width * 4) };
312            count += row(y, line, &mut memo);
313        }
314        above.fetch_add(count, Ordering::Relaxed);
315    });
316    above.into_inner()
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    // Failure cases: SDR white not landing on 255 or black not on 0; a grey that does not come back as itself
324    // (wrong curve, wrong white); a BT.709 colour carried in the BT.2020 container coming back as another colour
325    // (missing or transposed primaries matrix); highlights above white wrapping instead of clipping, or not being
326    // counted, or white itself being counted; limited and full range disagreeing; a plane layout read wrong (I420,
327    // I422, I444, NV12, strides wider than the row, odd sizes, frames split across pool threads); HLG's reference
328    // white not at 75 %; the planes and RGB entry points disagreeing on the same pixel; RGB10A2's channel order.
329    // The expected bytes come from the formulas evaluated in f64 here, never from the module's tables.
330
331    // Failure cases for the colour memo: a hit returning another key's colour after a slot collision, losing the
332    // above-white flag or a channel in the packing, or treating the empty slot as key 0.
333    #[test]
334    fn memo_returns_what_the_computation_returned_for_every_key() {
335        let colour = |key: u32| {
336            let h = key.wrapping_mul(0x85eb_ca6b) ^ key >> 7;
337            (
338                [h as u8, (h >> 8) as u8, (h >> 16) as u8, 255],
339                h >> 24 & 1 == 1,
340            )
341        };
342        let mut memo = Memo([0; MEMO_SIZE]);
343        let mut seed = 0x2545_f491u32;
344        let mut keys = vec![0, 0, 0xff_ffff, 0xff_ffff, 0x80_8080];
345        for _ in 0..200_000 {
346            seed ^= seed << 13;
347            seed ^= seed >> 17;
348            seed ^= seed << 5;
349            // A few thousand keys, revisited in a random order, so slots both hit and collide.
350            keys.push((seed % 6000 * 2797) & 0xff_ffff);
351        }
352        for key in keys {
353            assert_eq!(memo.get(key, || colour(key)), colour(key), "{key:06x}");
354        }
355    }
356
357    const KR_KB: [(f64, f64); 3] = [(0.2126, 0.0722), (0.299, 0.114), (0.2627, 0.0593)];
358    const M709_TO_2020: [[f64; 3]; 3] = [
359        [0.627_404, 0.329_282, 0.043_314],
360        [0.069_097, 0.919_540, 0.011_361],
361        [0.016_392, 0.088_013, 0.895_595],
362    ];
363
364    fn pq_oetf(nits: f64) -> f64 {
365        let (m1, m2, c1, c2, c3) = (
366            0.159_301_757_812_5,
367            78.843_75,
368            0.835_937_5,
369            18.851_562_5,
370            18.687_5,
371        );
372        let p = libm::pow(nits.max(0.0) / 10000.0, m1);
373        libm::pow((c1 + c2 * p) / (1.0 + c3 * p), m2)
374    }
375    fn pq_eotf(e: f64) -> f64 {
376        let (m1, m2, c1, c2, c3) = (
377            0.159_301_757_812_5,
378            78.843_75,
379            0.835_937_5,
380            18.851_562_5,
381            18.687_5,
382        );
383        let p = libm::pow(e.clamp(0.0, 1.0), 1.0 / m2);
384        10000.0 * libm::pow((p - c1).max(0.0) / (c2 - c3 * p), 1.0 / m1)
385    }
386    const HLG_A: f64 = 0.178_832_77;
387    const HLG_B: f64 = 1.0 - 4.0 * HLG_A;
388    fn hlg_c() -> f64 {
389        0.5 - HLG_A * libm::log(4.0 * HLG_A)
390    }
391    fn hlg_oetf(e: f64) -> f64 {
392        if e <= 1.0 / 12.0 {
393            (3.0 * e).sqrt()
394        } else {
395            HLG_A * libm::log(12.0 * e - HLG_B) + hlg_c()
396        }
397    }
398    fn hlg_inverse(v: f64) -> f64 {
399        let v = v.clamp(0.0, 1.0);
400        if v <= 0.5 {
401            v * v / 3.0
402        } else {
403            (libm::exp((v - hlg_c()) / HLG_A) + HLG_B) / 12.0
404        }
405    }
406    /// Scene light of HLG's reference white (75 % signal).
407    fn hlg_white() -> f64 {
408        hlg_inverse(0.75)
409    }
410    fn srgb_eotf(v: f64) -> f64 {
411        if v <= 0.040_45 {
412            v / 12.92
413        } else {
414            libm::pow((v + 0.055) / 1.055, 2.4)
415        }
416    }
417    fn srgb_oetf(l: f64) -> f64 {
418        let l = l.clamp(0.0, 1.0);
419        if l <= 0.003_130_8 {
420            12.92 * l
421        } else {
422            1.055 * libm::pow(l, 1.0 / 2.4) - 0.055
423        }
424    }
425    /// The coded value of linear light `l` (1.0 = SDR white).
426    fn encode(l: f64, t: Transfer) -> f64 {
427        match t {
428            Transfer::Pq => pq_oetf(l * 100.0),
429            Transfer::Hlg => hlg_oetf(l * hlg_white()),
430        }
431    }
432    fn decode(e: f64, t: Transfer) -> f64 {
433        match t {
434            Transfer::Pq => pq_eotf(e) / 100.0,
435            Transfer::Hlg => hlg_inverse(e) / hlg_white(),
436        }
437    }
438    fn invert(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
439        let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
440            - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
441            + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
442        let c = |r: usize, k: usize| {
443            let (r0, r1) = ((r + 1) % 3, (r + 2) % 3);
444            let (k0, k1) = ((k + 1) % 3, (k + 2) % 3);
445            m[r0][k0] * m[r1][k1] - m[r0][k1] * m[r1][k0]
446        };
447        std::array::from_fn(|i| std::array::from_fn(|j| c(j, i) / det))
448    }
449    fn mul(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
450        std::array::from_fn(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2])
451    }
452    /// The sRGB bytes the coded R'G'B' `rgb` should become.
453    fn expected(rgb: [f64; 3], t: Transfer) -> [u8; 3] {
454        let lin = rgb.map(|e| decode(e, t));
455        mul(invert(M709_TO_2020), lin).map(|l| (srgb_oetf(l) * 255.0).round() as u8)
456    }
457    /// The coded R'G'B' of an sRGB colour.
458    fn coded(srgb: [u8; 3], t: Transfer) -> [f64; 3] {
459        let lin = srgb.map(|v| srgb_eotf(v as f64 / 255.0));
460        mul(M709_TO_2020, lin).map(|l| encode(l, t))
461    }
462    /// Y'CbCr bytes of coded R'G'B'.
463    fn ycbcr(rgb: [f64; 3], m: Matrix, full: bool) -> [u8; 3] {
464        let (kr, kb) = KR_KB[m as usize];
465        let y = kr * rgb[0] + (1.0 - kr - kb) * rgb[1] + kb * rgb[2];
466        let (cb, cr) = (
467            (rgb[2] - y) / (2.0 * (1.0 - kb)),
468            (rgb[0] - y) / (2.0 * (1.0 - kr)),
469        );
470        let q = |v: f64| v.round().clamp(0.0, 255.0) as u8;
471        if full {
472            [q(y * 255.0), q(128.0 + cb * 255.0), q(128.0 + cr * 255.0)]
473        } else {
474            [
475                q(16.0 + y * 219.0),
476                q(128.0 + cb * 224.0),
477                q(128.0 + cr * 224.0),
478            ]
479        }
480    }
481    /// The coded R'G'B' that Y'CbCr bytes stand for.
482    fn rgb_of(yuv: [u8; 3], m: Matrix, full: bool) -> [f64; 3] {
483        let (kr, kb) = KR_KB[m as usize];
484        let (y, cb, cr) = if full {
485            (
486                yuv[0] as f64 / 255.0,
487                (yuv[1] as f64 - 128.0) / 255.0,
488                (yuv[2] as f64 - 128.0) / 255.0,
489            )
490        } else {
491            (
492                (yuv[0] as f64 - 16.0) / 219.0,
493                (yuv[1] as f64 - 128.0) / 224.0,
494                (yuv[2] as f64 - 128.0) / 224.0,
495            )
496        };
497        let r = y + 2.0 * (1.0 - kr) * cr;
498        let b = y + 2.0 * (1.0 - kb) * cb;
499        let g = (y - kr * r - kb * b) / (1.0 - kr - kb);
500        [r, g, b].map(|v| v.clamp(0.0, 1.0))
501    }
502
503    fn rgb8(pixels: &[[f64; 3]]) -> Vec<u8> {
504        pixels
505            .iter()
506            .flat_map(|p| {
507                [p[0], p[1], p[2], 1.0].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
508            })
509            .collect()
510    }
511    fn rgb10(pixels: &[[f64; 3]]) -> Vec<u8> {
512        pixels
513            .iter()
514            .flat_map(|p| {
515                let q = p.map(|v| (v.clamp(0.0, 1.0) * 1023.0).round() as u32);
516                (q[0] | q[1] << 10 | q[2] << 20 | 3 << 30).to_le_bytes()
517            })
518            .collect()
519    }
520    fn convert_rgb(src: &[u8], packing: Packing, t: Transfer) -> (Vec<u8>, usize) {
521        let mut out = src.to_vec();
522        let above = rgb_to_rgba(&mut out, packing, src.len() / 4, 1, t);
523        (out, above)
524    }
525    fn near(actual: &[u8], expected: [u8; 3], tolerance: i32, what: &str) {
526        for c in 0..3 {
527            assert!(
528                (actual[c] as i32 - expected[c] as i32).abs() <= tolerance,
529                "{what}: got {:?}, expected {expected:?}",
530                &actual[..4]
531            );
532        }
533        assert_eq!(actual[3], 255, "{what}: alpha");
534    }
535
536    #[test]
537    fn sdr_white_is_255_and_black_is_0() {
538        for t in [Transfer::Pq, Transfer::Hlg] {
539            let white = [encode(1.0, t); 3];
540            for packing in [Packing::Rgba8, Packing::Rgb10a2] {
541                let src = if packing == Packing::Rgba8 {
542                    rgb8(&[white, [0.0; 3]])
543                } else {
544                    rgb10(&[white, [0.0; 3]])
545                };
546                let (out, above) = convert_rgb(&src, packing, t);
547                // 8-bit PQ puts white between two codes (129.6); either neighbour is within two levels of 255.
548                near(
549                    &out[..4],
550                    [255; 3],
551                    if packing == Packing::Rgba8 { 2 } else { 0 },
552                    &format!("{t:?} {packing:?} white"),
553                );
554                near(&out[4..], [0; 3], 0, &format!("{t:?} {packing:?} black"));
555                assert_eq!(
556                    above, 0,
557                    "{t:?} {packing:?}: white and black are not above white"
558                );
559            }
560        }
561    }
562
563    #[test]
564    fn grey_levels_come_back_as_themselves() {
565        for t in [Transfer::Pq, Transfer::Hlg] {
566            let greys: Vec<[f64; 3]> = (0..=255u8).map(|v| coded([v; 3], t)).collect();
567            let (out, _) = convert_rgb(&rgb10(&greys), Packing::Rgb10a2, t);
568            for v in 0..=255u8 {
569                near(
570                    &out[v as usize * 4..],
571                    [v; 3],
572                    1,
573                    &format!("{t:?} grey {v} (10-bit)"),
574                );
575            }
576            // From 8-bit codes, each code must become what that exact code stands for.
577            let codes: Vec<[f64; 3]> = (0..=255u8).map(|c| [c as f64 / 255.0; 3]).collect();
578            let (out, _) = convert_rgb(&rgb8(&codes), Packing::Rgba8, t);
579            for (c, rgb) in codes.iter().enumerate() {
580                near(
581                    &out[c * 4..],
582                    expected(*rgb, t),
583                    1,
584                    &format!("{t:?} code {c} (8-bit)"),
585                );
586            }
587        }
588    }
589
590    #[test]
591    fn bt709_colours_in_the_bt2020_container_come_back() {
592        let colours = [
593            [255, 0, 0],
594            [0, 255, 0],
595            [0, 0, 255],
596            [0, 255, 255],
597            [255, 0, 255],
598            [255, 255, 0],
599            [229, 115, 115],
600            [30, 144, 255],
601            [12, 160, 50],
602        ];
603        for t in [Transfer::Pq, Transfer::Hlg] {
604            let coded: Vec<[f64; 3]> = colours.iter().map(|&c| coded(c, t)).collect();
605            let (out, _) = convert_rgb(&rgb10(&coded), Packing::Rgb10a2, t);
606            for (i, &c) in colours.iter().enumerate() {
607                // Exactly what the 10-bit input stands for; and the colour itself, up to what quantising its coded
608                // values to 10 bits moves a channel near black (steep sRGB toe, amplified by the primaries matrix).
609                let quantised = coded[i].map(|v| (v * 1023.0).round() / 1023.0);
610                near(
611                    &out[i * 4..],
612                    expected(quantised, t),
613                    1,
614                    &format!("{t:?} {c:?} exact"),
615                );
616                near(&out[i * 4..], c, 16, &format!("{t:?} {c:?} round trip"));
617            }
618        }
619    }
620
621    #[test]
622    fn highlights_clip_to_white_and_are_counted() {
623        for t in [Transfer::Pq, Transfer::Hlg] {
624            let levels = [1.0, 1.4, 1.6, 3.0, 10.0];
625            let pixels: Vec<[f64; 3]> = levels.iter().map(|&l| [encode(l, t); 3]).collect();
626            let (out, above) = convert_rgb(&rgb10(&pixels), Packing::Rgb10a2, t);
627            for i in 0..levels.len() {
628                near(&out[i * 4..], [255; 3], 0, &format!("{t:?} {}", levels[i]));
629            }
630            // HLG's signal tops out at about 3.8 × its reference white, so 10 is coded as the 3.8 ceiling: still above.
631            assert_eq!(above, 3, "{t:?}: 1.6, 3 and 10 are above {ABOVE_WHITE}");
632        }
633    }
634
635    #[test]
636    fn rgb10a2_reads_red_from_the_low_bits() {
637        let t = Transfer::Pq;
638        let red = coded([255, 0, 0], t);
639        let (out, _) = convert_rgb(&rgb10(&[red]), Packing::Rgb10a2, t);
640        near(&out, [255, 0, 0], 2, "red");
641    }
642
643    /// Planes of `format` for a `w × h` frame of `pixels` (row-major Y'CbCr per pixel; chroma taken from the
644    /// top-left pixel of each chroma sample's block, which is what point sampling reads back), with `pad` spare
645    /// bytes per row.
646    fn planes_of(
647        format: Format,
648        w: usize,
649        h: usize,
650        yuv: &[[u8; 3]],
651        pad: usize,
652    ) -> (Vec<u8>, Vec<Plane>) {
653        let (sx, sy) = match format {
654            Format::I444 => (1, 1),
655            Format::I422 => (2, 1),
656            Format::I420 | Format::Nv12 => (2, 2),
657        };
658        let mut layout = Vec::new();
659        let mut len = 0;
660        for (row_bytes, rows) in format.planes(w, h) {
661            layout.push(Plane {
662                offset: len,
663                stride: row_bytes + pad,
664            });
665            len += (row_bytes + pad) * rows;
666        }
667        let mut src = vec![7u8; len];
668        for y in 0..h {
669            for x in 0..w {
670                src[layout[0].offset + y * layout[0].stride + x] = yuv[y * w + x][0];
671            }
672        }
673        for cy in 0..h.div_ceil(sy) {
674            for cx in 0..w.div_ceil(sx) {
675                let p = yuv[cy * sy * w + cx * sx];
676                if format == Format::Nv12 {
677                    let at = layout[1].offset + cy * layout[1].stride + cx * 2;
678                    src[at] = p[1];
679                    src[at + 1] = p[2];
680                } else {
681                    src[layout[1].offset + cy * layout[1].stride + cx] = p[1];
682                    src[layout[2].offset + cy * layout[2].stride + cx] = p[2];
683                }
684            }
685        }
686        (src, layout)
687    }
688
689    #[test]
690    fn every_layout_matrix_and_range_matches_the_formulas() {
691        let mut seed = 0x2545_f491u32;
692        let mut next = move || {
693            seed ^= seed << 13;
694            seed ^= seed >> 17;
695            seed ^= seed << 5;
696            seed
697        };
698        for format in [Format::I420, Format::I422, Format::I444, Format::Nv12] {
699            for (w, h) in [(1, 1), (3, 2), (7, 5), (33, 9), (640, 120)] {
700                for t in [Transfer::Pq, Transfer::Hlg] {
701                    for (mi, m) in [Matrix::Bt709, Matrix::Bt601, Matrix::Bt2020]
702                        .into_iter()
703                        .enumerate()
704                    {
705                        for full in [false, true] {
706                            // Chroma is constant over each sample's block, so point sampling has one right answer.
707                            let colours: Vec<[u8; 3]> = (0..w * h)
708                                .map(|_| [next() as u8, next() as u8, next() as u8])
709                                .collect();
710                            let mut yuv: Vec<[u8; 3]> = colours
711                                .iter()
712                                .map(|&c| ycbcr(coded(c, t), m, full))
713                                .collect();
714                            let (sx, sy) = match format {
715                                Format::I444 => (1, 1),
716                                Format::I422 => (2, 1),
717                                _ => (2, 2),
718                            };
719                            for y in 0..h {
720                                for x in 0..w {
721                                    let anchor = yuv[(y / sy * sy) * w + x / sx * sx];
722                                    yuv[y * w + x][1] = anchor[1];
723                                    yuv[y * w + x][2] = anchor[2];
724                                }
725                            }
726                            let (src, layout) = planes_of(format, w, h, &yuv, 3);
727                            let mut out = vec![0u8; w * h * 4];
728                            planes_to_rgba(&src, format, &layout, w, h, m, full, t, &mut out);
729                            for (i, p) in yuv.iter().enumerate() {
730                                near(
731                                    &out[i * 4..],
732                                    expected(rgb_of(*p, m, full), t),
733                                    1,
734                                    &format!("{w}x{h} matrix {mi} full {full} {t:?} pixel {i}"),
735                                );
736                            }
737                        }
738                    }
739                }
740            }
741        }
742    }
743
744    // Neutral pixels (chroma exactly 128, or R' = G' = B') take a table lookup instead of the arithmetic, which most
745    // of a screen recording is: every luma value or code in both ranges and transfers must still come out as the
746    // formulas say, and a neighbour one chroma step away (the arithmetic) must not be mistaken for neutral.
747    #[test]
748    fn neutral_pixels_match_the_formulas_whichever_path_converts_them() {
749        for t in [Transfer::Pq, Transfer::Hlg] {
750            for full in [false, true] {
751                let yuv: Vec<[u8; 3]> = (0..=255u8)
752                    .flat_map(|y| [[y, 128, 128], [y, 129, 128], [y, 128, 127]])
753                    .collect();
754                let (src, layout) = planes_of(Format::I444, yuv.len(), 1, &yuv, 0);
755                let mut out = vec![0u8; yuv.len() * 4];
756                planes_to_rgba(
757                    &src,
758                    Format::I444,
759                    &layout,
760                    yuv.len(),
761                    1,
762                    Matrix::Bt2020,
763                    full,
764                    t,
765                    &mut out,
766                );
767                for (i, p) in yuv.iter().enumerate() {
768                    near(
769                        &out[i * 4..],
770                        expected(rgb_of(*p, Matrix::Bt2020, full), t),
771                        1,
772                        &format!("{t:?} full {full} {p:?}"),
773                    );
774                }
775            }
776            for packing in [Packing::Rgba8, Packing::Rgb10a2] {
777                let top = if packing == Packing::Rgba8 {
778                    255.0
779                } else {
780                    1023.0
781                };
782                let codes: Vec<[f64; 3]> = (0..=top as u32)
783                    .flat_map(|c| {
784                        let v = c as f64 / top;
785                        [[v; 3], [v, v, (v + 1.0 / top).min(1.0)]]
786                    })
787                    .collect();
788                let src = if packing == Packing::Rgba8 {
789                    rgb8(&codes)
790                } else {
791                    rgb10(&codes)
792                };
793                let (out, _) = convert_rgb(&src, packing, t);
794                for (i, c) in codes.iter().enumerate() {
795                    near(
796                        &out[i * 4..],
797                        expected(*c, t),
798                        1,
799                        &format!("{t:?} {packing:?} {c:?}"),
800                    );
801                }
802            }
803        }
804    }
805
806    #[test]
807    fn limited_and_full_range_planes_give_the_same_picture() {
808        let t = Transfer::Pq;
809        // Greys: chroma sits exactly on 128 in both ranges, so the two pictures differ only by luma quantisation, which
810        // for 8-bit PQ is coarse: one limited-range step is about 4 % luminance, three output levels at mid grey.
811        let colours: Vec<[u8; 3]> = (0..=255u8).step_by(5).map(|v| [v, v, v]).collect();
812        let picture = |full: bool| {
813            let yuv: Vec<[u8; 3]> = colours
814                .iter()
815                .map(|&c| ycbcr(coded(c, t), Matrix::Bt2020, full))
816                .collect();
817            let (src, layout) = planes_of(Format::I444, yuv.len(), 1, &yuv, 0);
818            let mut out = vec![0u8; yuv.len() * 4];
819            planes_to_rgba(
820                &src,
821                Format::I444,
822                &layout,
823                yuv.len(),
824                1,
825                Matrix::Bt2020,
826                full,
827                t,
828                &mut out,
829            );
830            out
831        };
832        let (limited, full) = (picture(false), picture(true));
833        for i in 0..colours.len() {
834            near(
835                &limited[i * 4..],
836                [full[i * 4], full[i * 4 + 1], full[i * 4 + 2]],
837                4,
838                &format!("grey {:?}", colours[i]),
839            );
840            near(
841                &full[i * 4..],
842                colours[i],
843                2,
844                &format!("full-range round trip {:?}", colours[i]),
845            );
846        }
847    }
848
849    #[test]
850    fn planes_and_rgb_entry_points_both_match_the_formulas_on_one_picture() {
851        let t = Transfer::Pq;
852        let yuv: Vec<[u8; 3]> = (0..=255u8)
853            .step_by(3)
854            .map(|v| [v, 255 - v, v / 2 + 64])
855            .collect();
856        let (src, layout) = planes_of(Format::I444, yuv.len(), 1, &yuv, 0);
857        let mut from_planes = vec![0u8; yuv.len() * 4];
858        planes_to_rgba(
859            &src,
860            Format::I444,
861            &layout,
862            yuv.len(),
863            1,
864            Matrix::Bt2020,
865            true,
866            t,
867            &mut from_planes,
868        );
869        let rgb: Vec<[f64; 3]> = yuv
870            .iter()
871            .map(|&p| rgb_of(p, Matrix::Bt2020, true))
872            .collect();
873        let (from_rgb, _) = convert_rgb(&rgb10(&rgb), Packing::Rgb10a2, t);
874        for (i, coded) in rgb.iter().enumerate() {
875            let quantised = coded.map(|v| (v * 1023.0).round() / 1023.0);
876            near(
877                &from_planes[i * 4..],
878                expected(*coded, t),
879                1,
880                &format!("planes {:?}", yuv[i]),
881            );
882            near(
883                &from_rgb[i * 4..],
884                expected(quantised, t),
885                1,
886                &format!("rgb {:?}", yuv[i]),
887            );
888        }
889    }
890
891    #[test]
892    fn a_frame_split_across_threads_counts_every_pixel_above_white_once() {
893        let (w, h) = (1024, 300);
894        let bright = [encode(4.0, Transfer::Pq); 3];
895        let pixels: Vec<[f64; 3]> = (0..w * h)
896            .map(|i| if i % 7 == 0 { bright } else { [0.4; 3] })
897            .collect();
898        let mut out = rgb10(&pixels);
899        let above = rgb_to_rgba(&mut out, Packing::Rgb10a2, w, h, Transfer::Pq);
900        assert_eq!(above, (0..w * h).filter(|i| i % 7 == 0).count());
901        assert!(out
902            .chunks_exact(4)
903            .enumerate()
904            .all(|(i, p)| p[0] == if i % 7 == 0 { 255 } else { out[4] }));
905    }
906}