medias 0.3.8

TV media player on bare DRM/KMS: Vulkan UI plane over libmpv video, for local files, UPnP/DLNA servers and YouTube, controlled by HDMI-CEC remote, evdev or terminal
//! CPU rasterizer for `Frame` quads. Matches the Vulkan text pipeline:
//! bilinear sampling with clamp-to-edge, premultiplied-alpha "over" blending,
//! pixel centers at +0.5 with a top-left fill rule. Output is ARGB8888.

use crate::vk::{Frame, Quad, Text, Thumb, THUMB_ATLAS_H, THUMB_ATLAS_W, THUMB_H, THUMB_W};

pub struct Canvas {
    w: usize,
    h: usize,
    px: Vec<u32>,     // premultiplied 0xAARRGGBB
    thumbs: Vec<u8>, // RGBA8 thumbnail atlas, THUMB_ATLAS_W x THUMB_ATLAS_H
}

impl Canvas {
    pub fn new(w: u32, h: u32) -> Canvas {
        let (w, h) = (w as usize, h as usize);
        // Zeroed allocation: pages of unused slots stay untouched.
        let thumbs = vec![0u8; (THUMB_ATLAS_W * THUMB_ATLAS_H * 4) as usize];
        Canvas { w, h, px: vec![0; w * h], thumbs }
    }

    /// Copy a `w`x`h` RGBA8 image (w <= THUMB_W, h <= THUMB_H) into `slot`.
    pub fn upload_thumb(&mut self, slot: usize, w: u32, h: u32, rgba: &[u8]) -> Result<Thumb, String> {
        let (w, h) = (w.min(THUMB_W), h.min(THUMB_H));
        let row = (w * 4) as usize;
        if w == 0 || h == 0 || rgba.len() < row * h as usize {
            return Err(format!("thumb: {w}x{h} with {} bytes", rgba.len()));
        }
        let (x, y) = crate::vk::slot_origin(slot);
        let pitch = (THUMB_ATLAS_W * 4) as usize;
        for r in 0..h as usize {
            let dst = (y as usize + r) * pitch + x as usize * 4;
            self.thumbs[dst..dst + row].copy_from_slice(&rgba[r * row..(r + 1) * row]);
        }
        Ok(Thumb::new(slot, w, h))
    }

    /// Clear to transparent and draw `frame`.
    pub fn render(&mut self, text: &Text, frame: &Frame) {
        self.px.fill(0);
        for q in frame.quads() {
            self.quad(text, &q);
        }
    }

    /// Copy the canvas into a mapped ARGB8888 buffer with row pitch `stride`.
    pub fn write(&self, dst: &mut [u8], stride: usize) {
        for (y, row) in self.px.chunks_exact(self.w).enumerate() {
            let out = &mut dst[y * stride..y * stride + self.w * 4];
            if cfg!(target_endian = "little") {
                // u32 0xAARRGGBB in little-endian memory is B, G, R, A.
                let bytes = unsafe { std::slice::from_raw_parts(row.as_ptr().cast::<u8>(), row.len() * 4) };
                out.copy_from_slice(bytes);
            } else {
                for (o, p) in out.chunks_exact_mut(4).zip(row) {
                    o.copy_from_slice(&p.to_le_bytes());
                }
            }
        }
    }

    fn quad(&mut self, text: &Text, q: &Quad) {
        let [x0, y0, x1, y1] = q.rect;
        let ix0 = ((x0 - 0.5).ceil().max(0.0) as usize).min(self.w);
        let ix1 = ((x1 - 0.5).ceil().max(0.0) as usize).min(self.w);
        let iy0 = ((y0 - 0.5).ceil().max(0.0) as usize).min(self.h);
        let iy1 = ((y1 - 0.5).ceil().max(0.0) as usize).min(self.h);
        if ix0 >= ix1 || iy0 >= iy1 {
            return;
        }
        let [r, g, b, a] = q.color.map(|c| c.clamp(0.0, 1.0));
        let [u0, v0, u1, v1] = q.uv;

        if !q.thumb && u0 == u1 && v0 == v1 {
            // Solid rect: the atlas solid cell has coverage 1.
            let src = pack([r * a, g * a, b * a, a]);
            for y in iy0..iy1 {
                let row = &mut self.px[y * self.w + ix0..y * self.w + ix1];
                if a >= 1.0 {
                    row.fill(src);
                } else {
                    row.iter_mut().for_each(|d| *d = over(src, *d));
                }
            }
            return;
        }

        let (tex, tw, th, channels): (&[u8], usize, usize, usize) = if q.thumb {
            (&self.thumbs, THUMB_ATLAS_W as usize, THUMB_ATLAS_H as usize, 4)
        } else {
            let (p, w, h) = text.atlas();
            (p, w, h, 1)
        };
        // Texel-space sample positions per column and row.
        let tx = |x: usize| (u0 + (x as f32 + 0.5 - x0) / (x1 - x0) * (u1 - u0)) * tw as f32 - 0.5;
        let ty = |y: usize| (v0 + (y as f32 + 0.5 - y0) / (y1 - y0) * (v1 - v0)) * th as f32 - 0.5;
        let cols: Vec<(usize, usize, f32)> = (ix0..ix1).map(|x| taps(tx(x), tw)).collect();
        for y in iy0..iy1 {
            let (ya, yb, fy) = taps(ty(y), th);
            let row = &mut self.px[y * self.w + ix0..y * self.w + ix1];
            for (d, &(xa, xb, fx)) in row.iter_mut().zip(&cols) {
                let src = if channels == 1 {
                    let s = |x: usize, y: usize| tex[y * tw + x] as f32;
                    let c = lerp(lerp(s(xa, ya), s(xb, ya), fx), lerp(s(xa, yb), s(xb, yb), fx), fy) / 255.0;
                    let ca = c * a;
                    if ca <= 0.0 {
                        continue;
                    }
                    [r * ca, g * ca, b * ca, ca]
                } else {
                    let s = |x: usize, y: usize, k: usize| tex[(y * tw + x) * 4 + k] as f32;
                    let ch = |k: usize| {
                        lerp(lerp(s(xa, ya, k), s(xb, ya, k), fx), lerp(s(xa, yb, k), s(xb, yb, k), fx), fy) / 255.0
                    };
                    [ch(0) * r * a, ch(1) * g * a, ch(2) * b * a, a]
                };
                *d = over(pack(src), *d);
            }
        }
    }
}

/// Bilinear taps for texel coordinate `t`: (lower, upper, weight of upper), clamped to edge.
fn taps(t: f32, n: usize) -> (usize, usize, f32) {
    let f = t.floor();
    let last = n as isize - 1;
    let i = f as isize;
    ((i.clamp(0, last)) as usize, ((i + 1).clamp(0, last)) as usize, t - f)
}

fn lerp(a: f32, b: f32, t: f32) -> f32 {
    a + (b - a) * t
}

fn pack([r, g, b, a]: [f32; 4]) -> u32 {
    let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0 + 0.5) as u32;
    q(a) << 24 | q(r) << 16 | q(g) << 8 | q(b)
}

/// Premultiplied "over": src + dst * (1 - src.a), per 8-bit channel.
fn over(src: u32, dst: u32) -> u32 {
    let inv = 255 - (src >> 24);
    if inv == 0 {
        return src;
    }
    let mut out = 0;
    for shift in [0, 8, 16, 24] {
        let s = (src >> shift) & 0xff;
        let d = (dst >> shift) & 0xff;
        let v = s + (d * inv + 127) / 255;
        out |= v.min(255) << shift;
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn over_is_premultiplied_source_over() {
        assert_eq!(over(0xff10_2030, 0x8080_8080), 0xff10_2030);
        assert_eq!(over(0, 0x8040_2010), 0x8040_2010);
        // 50% black over opaque white: 0x80 + 0xff * 127/255.
        assert_eq!(over(0x8000_0000, 0xffff_ffff), 0xff7f_7f7f);
    }

    #[test]
    fn rects_follow_the_pixel_center_rule() {
        let text = Text::new().unwrap();
        let mut canvas = Canvas::new(8, 4);
        let mut frame = Frame::default();
        frame.rect(&text, 1.0, 1.0, 2.0, 2.0, [1.0, 0.0, 0.0, 1.0]); // pixels 1..3
        frame.rect(&text, 4.6, 0.0, 2.0, 1.0, [0.0, 0.0, 1.0, 0.5]); // centers 5.5, 6.5
        canvas.render(&text, &frame);
        let at = |x: usize, y: usize| canvas.px[y * 8 + x];
        assert_eq!((at(0, 1), at(1, 1), at(2, 2), at(3, 1)), (0, 0xffff_0000, 0xffff_0000, 0));
        assert_eq!((at(4, 0), at(5, 0), at(6, 0), at(7, 0)), (0, 0x8000_0080, 0x8000_0080, 0));
        let mut out = vec![0u8; 4 * 40];
        canvas.write(&mut out, 40);
        assert_eq!(&out[40 + 4..40 + 8], &[0x00, 0x00, 0xff, 0xff]); // B, G, R, A
    }

    #[test]
    fn text_and_thumbs_cover_their_quads() {
        let text = Text::new().unwrap();
        let mut canvas = Canvas::new(64, 32);
        let rgba: Vec<u8> = [10u8, 200, 30, 255].repeat(16 * 9);
        let thumb = canvas.upload_thumb(3, 16, 9, &rgba).unwrap();
        let mut frame = Frame::default();
        frame.image(thumb.uv, 0.0, 0.0, 32.0, 18.0, [1.0, 1.0, 1.0, 1.0]);
        frame.text(&text, 34.0, 0.0, 24.0, "H", [1.0, 1.0, 1.0, 1.0]);
        canvas.render(&text, &frame);
        assert_eq!(canvas.px[9 * 64 + 16], 0xff0a_c81e);
        let lit = (0..32).flat_map(|y| (34..64).map(move |x| (x, y))).filter(|&(x, y)| canvas.px[y * 64 + x] >> 24 == 0xff);
        assert!(lit.count() > 20, "glyph H has opaque stems");
    }
}