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 SIMD every pixel goes through the scalar loop.
412#[cfg(not(target_feature = "simd128"))]
413mod simd {
414    use super::Coeffs;
415
416    pub fn planar(_: &[u8], _: &[u8], _: &[u8], _: &Coeffs, _: &mut [u8]) -> usize {
417        0
418    }
419
420    pub fn nv12(_: &[u8], _: &[u8], _: &Coeffs, _: &mut [u8]) -> usize {
421        0
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    // Failure cases for any faster row conversion: a different pixel anywhere against `yuv_pixel` applied
430    // pixel by pixel; the last column of an odd width (its own chroma sample); I444 (no subsampling); NV12's
431    // interleaved U/V; I422's full-height chroma; rows with a stride wider than the row; and values that clamp
432    // at 0 and 255 under every matrix.
433    fn reference(
434        src: &[u8],
435        format: Format,
436        planes: &[Plane],
437        w: usize,
438        h: usize,
439        c: &Coeffs,
440    ) -> Vec<u8> {
441        let mut out = vec![0u8; w * h * 4];
442        let (cw, shift) = (w.div_ceil(2), usize::from(format != Format::I444));
443        for y in 0..h {
444            for x in 0..w {
445                let cy = match format {
446                    Format::I420 | Format::Nv12 => y / 2,
447                    _ => y,
448                };
449                let at = |i: usize, r: usize, col: usize| {
450                    src[planes[i].offset + r * planes[i].stride + col]
451                };
452                let (u, v) = match format {
453                    Format::Nv12 => (at(1, cy, (x >> 1) * 2), at(1, cy, (x >> 1) * 2 + 1)),
454                    _ => (at(1, cy, x >> shift), at(2, cy, x >> shift)),
455                };
456                let [r, g, b] = yuv_pixel(at(0, y, x), u, v, c);
457                out[(y * w + x) * 4..][..4].copy_from_slice(&[r, g, b, 255]);
458            }
459        }
460        let _ = cw;
461        out
462    }
463
464    #[test]
465    fn colour_codes_keep_the_sdr_matrices_and_name_every_hdr_signal() {
466        for code in 0..4 {
467            assert!(
468                matches!(Signal::from_code(code), Some(Signal::Sdr(c)) if std::ptr::eq(c, &MATRICES[code as usize]))
469            );
470        }
471        // SDR BT.2020, a fourth matrix, a fourth transfer: no code.
472        for code in [4, 5, 6, 7, 14, 15, 22, 23, 24, 31] {
473            assert!(Signal::from_code(code).is_none(), "{code}");
474        }
475        let hdr = |code| match Signal::from_code(code) {
476            Some(Signal::Hdr {
477                matrix,
478                full,
479                transfer,
480            }) => (matrix, full, transfer),
481            _ => panic!("{code}"),
482        };
483        use hdr::{Matrix, Transfer};
484        assert_eq!(hdr(8), (Matrix::Bt709, false, Transfer::Pq));
485        assert_eq!(hdr(13), (Matrix::Bt2020, true, Transfer::Pq));
486        assert_eq!(hdr(19), (Matrix::Bt601, true, Transfer::Hlg));
487        assert_eq!(hdr(20), (Matrix::Bt2020, false, Transfer::Hlg));
488    }
489
490    #[test]
491    fn rows_match_the_per_pixel_reference_for_every_layout_and_matrix() {
492        let mut seed = 0x9e37_79b9u32;
493        let mut next = move || {
494            seed ^= seed << 13;
495            seed ^= seed >> 17;
496            seed ^= seed << 5;
497            seed
498        };
499        for format in [Format::I420, Format::I422, Format::I444, Format::Nv12] {
500            for (w, h) in [(1, 1), (2, 2), (7, 5), (33, 9), (64, 3)] {
501                // Planes with 3 bytes of padding per row, so a stride bug shows.
502                let mut planes = Vec::new();
503                let mut len = 0;
504                for (row_bytes, rows) in format.planes(w, h) {
505                    planes.push(Plane {
506                        offset: len,
507                        stride: row_bytes + 3,
508                    });
509                    len += (row_bytes + 3) * rows;
510                }
511                for fill in 0..3 {
512                    let src: Vec<u8> = (0..len)
513                        .map(|_| match fill {
514                            0 => next() as u8,
515                            1 => [0u8, 255][(next() & 1) as usize],
516                            _ => [16u8, 235, 240, 0, 255][(next() % 5) as usize],
517                        })
518                        .collect();
519                    for c in &MATRICES {
520                        let mut out = vec![0u8; w * h * 4];
521                        to_rgba(&src, format, &planes, w, h, c, &mut out);
522                        assert!(
523                            out == reference(&src, format, &planes, w, h, c),
524                            "{w}x{h} fill {fill}"
525                        );
526                    }
527                }
528            }
529        }
530    }
531}