Skip to main content

long_screen/kernels/
yuv.rs

1//! Decoded-frame layouts (planar and semi-planar YUV) to RGBA, for browsers whose
2//! `VideoFrame.copyTo` cannot convert to RGB itself (Safari, through at least 27). The arithmetic is libyuv's C
3//! reference (`row_common.cc`: `YuvPixel`, x86 form) with its `YuvConstants`, which is what Chrome's own
4//! `copyTo({ format: 'RGBA' })` runs on a software-decoded frame; chroma is point-sampled like libyuv's
5//! `I4xxToARGBRow_C`. Rows are split across the pool; every output byte depends only on its own inputs. Planes
6//! coding an HDR signal (PQ or HLG) go through `hdr` instead, which also undoes the transfer.
7
8use crate::kernels::pool::SyncPtr;
9
10pub mod hdr;
11
12/// libyuv `YuvConstants`: Y gain/bias and the chroma contributions (6-bit fixed point).
13#[derive(Clone, Copy)]
14pub struct Coeffs {
15    yg: u32,
16    yb: i32,
17    ub: i32,
18    ug: i32,
19    vg: i32,
20    vr: i32,
21}
22
23/// Indexed by the ABI's matrix code. libyuv caps UB at 128 in its limited-range tables (the int8 SIMD limit)
24/// unless built with LIBYUV_UNLIMITED_*; the capped values are the ones browsers ship. No BT.2020 entry: browsers
25/// also convert its wider gamut to sRGB, which a matrix alone cannot, so such frames are left to the canvas.
26pub static MATRICES: [Coeffs; 4] = [
27    // H709: BT.709 limited range
28    Coeffs {
29        yg: 18997,
30        yb: -1160,
31        ub: 128,
32        ug: 14,
33        vg: 34,
34        vr: 115,
35    },
36    // F709: BT.709 full range
37    Coeffs {
38        yg: 16320,
39        yb: 32,
40        ub: 119,
41        ug: 12,
42        vg: 30,
43        vr: 101,
44    },
45    // I601: BT.601 limited range
46    Coeffs {
47        yg: 18997,
48        yb: -1160,
49        ub: 128,
50        ug: 25,
51        vg: 52,
52        vr: 102,
53    },
54    // JPEG: BT.601 full range
55    Coeffs {
56        yg: 16320,
57        yb: 32,
58        ub: 113,
59        ug: 22,
60        vg: 46,
61        vr: 90,
62    },
63];
64
65/// What a frame's planes code, from the ABI's colour code `transfer × 8 + matrix × 2 + full` (transfer 0 SDR, 1 PQ,
66/// 2 HLG; matrix 0 BT.709, 1 BT.601, 2 BT.2020). The SDR codes 0–3 index `MATRICES`; SDR BT.2020 has no code.
67#[derive(Clone, Copy)]
68pub enum Signal {
69    Sdr(&'static Coeffs),
70    Hdr {
71        matrix: hdr::Matrix,
72        full: bool,
73        transfer: hdr::Transfer,
74    },
75}
76
77impl Signal {
78    pub fn from_code(code: u32) -> Option<Signal> {
79        let (full, matrix) = (code & 1 == 1, (code >> 1) & 3);
80        let transfer = match code >> 3 {
81            0 => return MATRICES.get(code as usize).map(Signal::Sdr),
82            1 => hdr::Transfer::Pq,
83            2 => hdr::Transfer::Hlg,
84            _ => return None,
85        };
86        let matrix = match matrix {
87            0 => hdr::Matrix::Bt709,
88            1 => hdr::Matrix::Bt601,
89            2 => hdr::Matrix::Bt2020,
90            _ => return None,
91        };
92        Some(Signal::Hdr {
93            matrix,
94            full,
95            transfer,
96        })
97    }
98}
99
100/// `to_rgba` or `hdr::planes_to_rgba`, as `signal` says; returns the HDR conversion's count of pixels above white
101/// (0 for SDR).
102#[allow(clippy::too_many_arguments)]
103pub fn convert(
104    src: &[u8],
105    format: Format,
106    planes: &[Plane],
107    width: usize,
108    height: usize,
109    signal: Signal,
110    out: &mut [u8],
111) -> usize {
112    match signal {
113        Signal::Sdr(coeffs) => {
114            to_rgba(src, format, planes, width, height, coeffs, out);
115            0
116        }
117        Signal::Hdr {
118            matrix,
119            full,
120            transfer,
121        } => hdr::planes_to_rgba(
122            src, format, planes, width, height, matrix, full, transfer, out,
123        ),
124    }
125}
126
127#[derive(Clone, Copy, PartialEq, Eq)]
128pub enum Format {
129    I420,
130    I422,
131    I444,
132    Nv12,
133}
134
135impl Format {
136    pub fn from_code(code: u32) -> Option<Format> {
137        Some(match code {
138            0 => Format::I420,
139            1 => Format::I422,
140            2 => Format::I444,
141            3 => Format::Nv12,
142            _ => return None,
143        })
144    }
145
146    /// Per plane: (bytes per row, rows) for a `width × height` frame, as `VideoFrame.copyTo` lays them out.
147    pub fn planes(self, width: usize, height: usize) -> Vec<(usize, usize)> {
148        let (cw, ch) = (width.div_ceil(2), height.div_ceil(2));
149        match self {
150            Format::I420 => vec![(width, height), (cw, ch), (cw, ch)],
151            Format::I422 => vec![(width, height), (cw, height), (cw, height)],
152            Format::I444 => vec![(width, height); 3],
153            Format::Nv12 => vec![(width, height), (cw * 2, ch)],
154        }
155    }
156}
157
158#[inline]
159fn clamp(v: i32) -> u8 {
160    v.clamp(0, 255) as u8
161}
162
163/// libyuv `YuvPixel`: `[r, g, b]`.
164#[inline]
165pub fn yuv_pixel(y: u8, u: u8, v: u8, c: &Coeffs) -> [u8; 3] {
166    let y1 = ((y as u32 * 0x0101).wrapping_mul(c.yg) >> 16) as i32 + c.yb;
167    let (ui, vi) = (u as i32 - 128, v as i32 - 128);
168    [
169        clamp((y1 + vi * c.vr) >> 6),
170        clamp((y1 - (ui * c.ug + vi * c.vg)) >> 6),
171        clamp((y1 + ui * c.ub) >> 6),
172    ]
173}
174
175/// One plane of the source: byte offset of row 0 and bytes between rows.
176#[derive(Clone, Copy)]
177pub struct Plane {
178    pub offset: usize,
179    pub stride: usize,
180}
181
182/// Converts `src` (planes as described by `format` and `planes`) to tightly packed `width × height` RGBA in
183/// `out`. The caller has checked that every plane lies inside `src` and `out` holds `width * height * 4` bytes.
184pub fn to_rgba(
185    src: &[u8],
186    format: Format,
187    planes: &[Plane],
188    width: usize,
189    height: usize,
190    coeffs: &Coeffs,
191    out: &mut [u8],
192) {
193    let chunks = crate::kernels::pool::chunks_for(width * height, 128 * 1024);
194    let dst = SyncPtr(out.as_mut_ptr());
195    let luma = luma_table(coeffs);
196    crate::kernels::pool::par_for(chunks, |c| {
197        for y in crate::kernels::pool::split(height, chunks, c)
198            ..crate::kernels::pool::split(height, chunks, c + 1)
199        {
200            // SAFETY: output row `y` belongs to this chunk alone.
201            let row =
202                unsafe { std::slice::from_raw_parts_mut(dst.get().add(y * width * 4), width * 4) };
203            convert_row(src, format, planes, width, y, coeffs, &luma, row);
204        }
205    });
206}
207
208/// `yuv_pixel`'s luma term for every Y value, so a pixel looks it up instead of multiplying.
209fn luma_table(c: &Coeffs) -> [i32; 256] {
210    std::array::from_fn(|y| ((y as u32 * 0x0101).wrapping_mul(c.yg) >> 16) as i32 + c.yb)
211}
212
213/// `yuv_pixel`'s chroma terms for one (U, V) sample, shared by the pixels it covers: (R, G, B).
214#[inline]
215fn chroma(u: u8, v: u8, c: &Coeffs) -> (i32, i32, i32) {
216    let (ui, vi) = (u as i32 - 128, v as i32 - 128);
217    (vi * c.vr, ui * c.ug + vi * c.vg, ui * c.ub)
218}
219
220/// `yuv_pixel` from its luma and chroma terms, as RGBA.
221#[inline]
222fn rgba(y1: i32, (r, g, b): (i32, i32, i32)) -> [u8; 4] {
223    [
224        clamp((y1 + r) >> 6),
225        clamp((y1 - g) >> 6),
226        clamp((y1 + b) >> 6),
227        255,
228    ]
229}
230
231/// One output row. Pixels sharing a chroma sample are converted together (one chroma computation per sample);
232/// the arithmetic is `yuv_pixel`'s, so the bytes are the same.
233#[allow(clippy::too_many_arguments)]
234fn convert_row(
235    src: &[u8],
236    format: Format,
237    p: &[Plane],
238    width: usize,
239    y: usize,
240    c: &Coeffs,
241    luma: &[i32; 256],
242    row: &mut [u8],
243) {
244    let line = |i: usize, r: usize, len: usize| &src[p[i].offset + r * p[i].stride..][..len];
245    let (cw, cy) = (width.div_ceil(2), y / 2);
246    let ys = line(0, y, width);
247    // Every pixel of `pair` (up to two RGBA pixels) from the luma values `yy` and one chroma sample.
248    let put = |pair: &mut [u8], yy: &[u8], u: u8, v: u8| {
249        let terms = chroma(u, v, c);
250        for (px, &yv) in pair.chunks_exact_mut(4).zip(yy) {
251            px.copy_from_slice(&rgba(luma[yv as usize], terms));
252        }
253    };
254    match format {
255        Format::I444 => {
256            let (us, vs) = (line(1, y, width), line(2, y, width));
257            for (((px, &yv), &u), &v) in row.chunks_exact_mut(4).zip(ys).zip(us).zip(vs) {
258                px.copy_from_slice(&rgba(luma[yv as usize], chroma(u, v, c)));
259            }
260        }
261        Format::I420 | Format::I422 => {
262            let chroma_row = if format == Format::I422 { y } else { cy };
263            let (us, vs) = (line(1, chroma_row, cw), line(2, chroma_row, cw));
264            let done = simd::planar(ys, us, vs, c, row);
265            let (row, ys, us, vs) = (
266                &mut row[done * 4..],
267                &ys[done..],
268                &us[done / 2..],
269                &vs[done / 2..],
270            );
271            for (((pair, yy), &u), &v) in row.chunks_mut(8).zip(ys.chunks(2)).zip(us).zip(vs) {
272                put(pair, yy, u, v);
273            }
274        }
275        Format::Nv12 => {
276            let uv = line(1, cy, cw * 2);
277            let done = simd::nv12(ys, uv, c, row);
278            let (row, ys, uv) = (&mut row[done * 4..], &ys[done..], &uv[done..]);
279            for ((pair, yy), uv) in row.chunks_mut(8).zip(ys.chunks(2)).zip(uv.chunks_exact(2)) {
280                put(pair, yy, uv[0], uv[1]);
281            }
282        }
283    }
284}
285
286/// Eight pixels at a time with WebAssembly SIMD for the 2:1 horizontally subsampled layouts: the same integer
287/// arithmetic as `yuv_pixel` in 32-bit lanes (the luma product stays below 2^32, shifts are arithmetic like `>>`
288/// on `i32`, and min/max clamp like `clamp`), so the bytes are the same. Each function converts the longest
289/// prefix of whole groups of eight and returns its length in pixels; the caller converts the rest.
290#[cfg(target_feature = "simd128")]
291mod simd {
292    use super::Coeffs;
293    use std::arch::wasm32::*;
294
295    struct Lanes {
296        k: v128,
297        yb: v128,
298        vr: v128,
299        ug: v128,
300        vg: v128,
301        ub: v128,
302    }
303
304    impl Lanes {
305        fn new(c: &Coeffs) -> Self {
306            Lanes {
307                // (y * 0x0101) * yg == y * (0x0101 * yg), at most 255 * 257 * 18997 < 2^32.
308                k: u32x4_splat(0x0101 * c.yg),
309                yb: i32x4_splat(c.yb),
310                vr: i32x4_splat(c.vr),
311                ug: i32x4_splat(c.ug),
312                vg: i32x4_splat(c.vg),
313                ub: i32x4_splat(c.ub),
314            }
315        }
316    }
317
318    /// Four RGBA pixels from four Y values (u32 lanes) and their chroma terms (i32 lanes).
319    #[inline]
320    fn four(y: v128, r: v128, g: v128, b: v128, l: &Lanes) -> v128 {
321        let y1 = i32x4_add(u32x4_shr(i32x4_mul(y, l.k), 16), l.yb);
322        let byte =
323            |t: v128| i32x4_min(i32x4_max(i32x4_shr(t, 6), i32x4_splat(0)), i32x4_splat(255));
324        let (r, g, b) = (
325            byte(i32x4_add(y1, r)),
326            byte(i32x4_sub(y1, g)),
327            byte(i32x4_add(y1, b)),
328        );
329        v128_or(
330            v128_or(r, i32x4_shl(g, 8)),
331            v128_or(i32x4_shl(b, 16), u32x4_splat(0xff00_0000)),
332        )
333    }
334
335    /// Eight pixels from eight Y values at `ys` and four chroma samples `(u - 128, v - 128)`, written to `out`.
336    ///
337    /// # Safety
338    /// `ys` must have 8 readable bytes and `out` 32 writable ones.
339    #[inline]
340    unsafe fn eight(ys: *const u8, ui: v128, vi: v128, l: &Lanes, out: *mut u8) {
341        let y16 = u16x8_extend_low_u8x16(v128_load64_zero(ys as *const u64));
342        let (lo, hi) = (u32x4_extend_low_u16x8(y16), u32x4_extend_high_u16x8(y16));
343        let r = i32x4_mul(vi, l.vr);
344        let g = i32x4_add(i32x4_mul(ui, l.ug), i32x4_mul(vi, l.vg));
345        let b = i32x4_mul(ui, l.ub);
346        let first = |t: v128| i32x4_shuffle::<0, 0, 1, 1>(t, t);
347        let second = |t: v128| i32x4_shuffle::<2, 2, 3, 3>(t, t);
348        v128_store(out as *mut v128, four(lo, first(r), first(g), first(b), l));
349        v128_store(
350            out.add(16) as *mut v128,
351            four(hi, second(r), second(g), second(b), l),
352        );
353    }
354
355    /// Four bytes at `p` as i32 lanes minus 128.
356    #[inline]
357    unsafe fn centred4(p: *const u8) -> v128 {
358        let w = u32x4_extend_low_u16x8(u16x8_extend_low_u8x16(v128_load32_zero(p as *const u32)));
359        i32x4_sub(w, i32x4_splat(128))
360    }
361
362    pub fn planar(ys: &[u8], us: &[u8], vs: &[u8], c: &Coeffs, row: &mut [u8]) -> usize {
363        let (l, groups) = (
364            Lanes::new(c),
365            (ys.len() / 8).min(us.len() / 4).min(vs.len() / 4),
366        );
367        for k in 0..groups {
368            // SAFETY: group k reads ys[8k..8k + 8], us/vs[4k..4k + 4] and writes row[32k..32k + 32], all in bounds by
369            // `groups` (row holds 4 bytes per Y value).
370            unsafe {
371                let (ui, vi) = (
372                    centred4(us.as_ptr().add(4 * k)),
373                    centred4(vs.as_ptr().add(4 * k)),
374                );
375                eight(
376                    ys.as_ptr().add(8 * k),
377                    ui,
378                    vi,
379                    &l,
380                    row.as_mut_ptr().add(32 * k),
381                );
382            }
383        }
384        groups * 8
385    }
386
387    pub fn nv12(ys: &[u8], uv: &[u8], c: &Coeffs, row: &mut [u8]) -> usize {
388        let (l, groups) = (Lanes::new(c), (ys.len() / 8).min(uv.len() / 8));
389        for k in 0..groups {
390            // SAFETY: group k reads ys[8k..8k + 8], uv[8k..8k + 8] and writes row[32k..32k + 32], all in bounds by
391            // `groups`.
392            unsafe {
393                let w =
394                    u16x8_extend_low_u8x16(v128_load64_zero(uv.as_ptr().add(8 * k) as *const u64));
395                let (lo, hi) = (u32x4_extend_low_u16x8(w), u32x4_extend_high_u16x8(w));
396                let ui = i32x4_sub(i32x4_shuffle::<0, 2, 4, 6>(lo, hi), i32x4_splat(128));
397                let vi = i32x4_sub(i32x4_shuffle::<1, 3, 5, 7>(lo, hi), i32x4_splat(128));
398                eight(
399                    ys.as_ptr().add(8 * k),
400                    ui,
401                    vi,
402                    &l,
403                    row.as_mut_ptr().add(32 * k),
404                );
405            }
406        }
407        groups * 8
408    }
409}
410
411/// Without WebAssembly SIMD the same whole groups go through a plain indexed loop over chroma samples, which the
412/// compiler can unroll and vectorize itself: each pair of pixels sharing a sample is packed into two little-endian
413/// words with `yuv_pixel`'s integer arithmetic (the luma product stays below 2^32 as in the SIMD lanes), so the
414/// bytes are the same. The callers' per-pixel loop, with its closure and four-byte copies per pixel, was a third of
415/// a native run's samples.
416#[cfg(not(target_feature = "simd128"))]
417mod simd {
418    use super::Coeffs;
419
420    /// Two RGBA pixels from two Y values and one chroma sample, as little-endian words.
421    #[inline(always)]
422    fn pair(y0: u8, y1: u8, u: u8, v: u8, c: &Coeffs) -> (u32, u32) {
423        let (ui, vi) = (u as i32 - 128, v as i32 - 128);
424        let (r, g, b) = (vi * c.vr, ui * c.ug + vi * c.vg, ui * c.ub);
425        let k = 0x0101 * c.yg;
426        let byte = |t: i32| (t >> 6).clamp(0, 255) as u32;
427        let px = |y: u8| {
428            let l = ((y as u32 * k) >> 16) as i32 + c.yb;
429            byte(l + r) | byte(l - g) << 8 | byte(l + b) << 16 | 0xff00_0000
430        };
431        (px(y0), px(y1))
432    }
433
434    #[inline(always)]
435    fn store(row: &mut [u8], k: usize, (a, b): (u32, u32)) {
436        let o: &mut [u8; 8] = (&mut row[8 * k..8 * k + 8]).try_into().unwrap();
437        o[..4].copy_from_slice(&a.to_le_bytes());
438        o[4..].copy_from_slice(&b.to_le_bytes());
439    }
440
441    pub fn planar(ys: &[u8], us: &[u8], vs: &[u8], c: &Coeffs, row: &mut [u8]) -> usize {
442        let n = (ys.len() / 2)
443            .min(us.len())
444            .min(vs.len())
445            .min(row.len() / 8);
446        let (ys, us, vs, row) = (&ys[..2 * n], &us[..n], &vs[..n], &mut row[..8 * n]);
447        for k in 0..n {
448            store(row, k, pair(ys[2 * k], ys[2 * k + 1], us[k], vs[k], c));
449        }
450        2 * n
451    }
452
453    pub fn nv12(ys: &[u8], uv: &[u8], c: &Coeffs, row: &mut [u8]) -> usize {
454        let n = (ys.len() / 2).min(uv.len() / 2).min(row.len() / 8);
455        let (ys, uv, row) = (&ys[..2 * n], &uv[..2 * n], &mut row[..8 * n]);
456        for k in 0..n {
457            store(
458                row,
459                k,
460                pair(ys[2 * k], ys[2 * k + 1], uv[2 * k], uv[2 * k + 1], c),
461            );
462        }
463        2 * n
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    // Failure cases for any faster row conversion: a different pixel anywhere against `yuv_pixel` applied
472    // pixel by pixel; the last column of an odd width (its own chroma sample); I444 (no subsampling); NV12's
473    // interleaved U/V; I422's full-height chroma; rows with a stride wider than the row; and values that clamp
474    // at 0 and 255 under every matrix.
475    fn reference(
476        src: &[u8],
477        format: Format,
478        planes: &[Plane],
479        w: usize,
480        h: usize,
481        c: &Coeffs,
482    ) -> Vec<u8> {
483        let mut out = vec![0u8; w * h * 4];
484        let (cw, shift) = (w.div_ceil(2), usize::from(format != Format::I444));
485        for y in 0..h {
486            for x in 0..w {
487                let cy = match format {
488                    Format::I420 | Format::Nv12 => y / 2,
489                    _ => y,
490                };
491                let at = |i: usize, r: usize, col: usize| {
492                    src[planes[i].offset + r * planes[i].stride + col]
493                };
494                let (u, v) = match format {
495                    Format::Nv12 => (at(1, cy, (x >> 1) * 2), at(1, cy, (x >> 1) * 2 + 1)),
496                    _ => (at(1, cy, x >> shift), at(2, cy, x >> shift)),
497                };
498                let [r, g, b] = yuv_pixel(at(0, y, x), u, v, c);
499                out[(y * w + x) * 4..][..4].copy_from_slice(&[r, g, b, 255]);
500            }
501        }
502        let _ = cw;
503        out
504    }
505
506    #[test]
507    fn colour_codes_keep_the_sdr_matrices_and_name_every_hdr_signal() {
508        for code in 0..4 {
509            assert!(
510                matches!(Signal::from_code(code), Some(Signal::Sdr(c)) if std::ptr::eq(c, &MATRICES[code as usize]))
511            );
512        }
513        // SDR BT.2020, a fourth matrix, a fourth transfer: no code.
514        for code in [4, 5, 6, 7, 14, 15, 22, 23, 24, 31] {
515            assert!(Signal::from_code(code).is_none(), "{code}");
516        }
517        let hdr = |code| match Signal::from_code(code) {
518            Some(Signal::Hdr {
519                matrix,
520                full,
521                transfer,
522            }) => (matrix, full, transfer),
523            _ => panic!("{code}"),
524        };
525        use hdr::{Matrix, Transfer};
526        assert_eq!(hdr(8), (Matrix::Bt709, false, Transfer::Pq));
527        assert_eq!(hdr(13), (Matrix::Bt2020, true, Transfer::Pq));
528        assert_eq!(hdr(19), (Matrix::Bt601, true, Transfer::Hlg));
529        assert_eq!(hdr(20), (Matrix::Bt2020, false, Transfer::Hlg));
530    }
531
532    #[test]
533    fn rows_match_the_per_pixel_reference_for_every_layout_and_matrix() {
534        let mut seed = 0x9e37_79b9u32;
535        let mut next = move || {
536            seed ^= seed << 13;
537            seed ^= seed >> 17;
538            seed ^= seed << 5;
539            seed
540        };
541        for format in [Format::I420, Format::I422, Format::I444, Format::Nv12] {
542            for (w, h) in [(1, 1), (2, 2), (7, 5), (33, 9), (64, 3)] {
543                // Planes with 3 bytes of padding per row, so a stride bug shows.
544                let mut planes = Vec::new();
545                let mut len = 0;
546                for (row_bytes, rows) in format.planes(w, h) {
547                    planes.push(Plane {
548                        offset: len,
549                        stride: row_bytes + 3,
550                    });
551                    len += (row_bytes + 3) * rows;
552                }
553                for fill in 0..3 {
554                    let src: Vec<u8> = (0..len)
555                        .map(|_| match fill {
556                            0 => next() as u8,
557                            1 => [0u8, 255][(next() & 1) as usize],
558                            _ => [16u8, 235, 240, 0, 255][(next() % 5) as usize],
559                        })
560                        .collect();
561                    for c in &MATRICES {
562                        let mut out = vec![0u8; w * h * 4];
563                        to_rgba(&src, format, &planes, w, h, c, &mut out);
564                        assert!(
565                            out == reference(&src, format, &planes, w, h, c),
566                            "{w}x{h} fill {fill}"
567                        );
568                    }
569                }
570            }
571        }
572    }
573}