Skip to main content

guise/video/
frame.rs

1//! One decoded picture.
2//!
3//! gpui's image path wants BGRA, so that is what a frame stores. Decoders hand
4//! over RGBA or planar YUV; the constructors convert once, here, off the render
5//! path. Every constructor returns `None` for a buffer the wrong size for its
6//! dimensions — a decoder bug should surface as a missing frame, not as a
7//! panic inside the paint pass.
8
9use std::sync::Arc;
10
11use gpui::RenderImage;
12use image::{Frame, RgbaImage};
13
14/// A decoded video frame, ready to show.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct VideoFrame {
17  width: u32,
18  height: u32,
19  bgra: Vec<u8>,
20}
21
22impl VideoFrame {
23  /// From tightly packed 8-bit RGBA (`width * height * 4` bytes). Swaps the
24  /// red and blue channels in place.
25  pub fn rgba(width: u32, height: u32, mut data: Vec<u8>) -> Option<Self> {
26    if !fits(width, height, data.len(), 4) {
27      return None;
28    }
29    for px in data.chunks_exact_mut(4) {
30      px.swap(0, 2);
31    }
32    Some(VideoFrame {
33      width,
34      height,
35      bgra: data,
36    })
37  }
38
39  /// From tightly packed 8-bit BGRA — no conversion, the cheapest way in when
40  /// the source can produce it (most capture APIs can).
41  pub fn bgra(width: u32, height: u32, data: Vec<u8>) -> Option<Self> {
42    if !fits(width, height, data.len(), 4) {
43      return None;
44    }
45    Some(VideoFrame {
46      width,
47      height,
48      bgra: data,
49    })
50  }
51
52  /// From planar 4:2:0 YUV (I420, what most software decoders emit), BT.601
53  /// limited range. `y` is `width * height` bytes; `u` and `v` are each
54  /// `ceil(width / 2) * ceil(height / 2)`. Strides must equal the widths.
55  pub fn i420(width: u32, height: u32, y: &[u8], u: &[u8], v: &[u8]) -> Option<Self> {
56    let (w, h) = (width as usize, height as usize);
57    let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
58    if w == 0 || h == 0 || y.len() != w * h || u.len() != cw * ch || v.len() != cw * ch {
59      return None;
60    }
61    let mut bgra = Vec::with_capacity(w * h * 4);
62    for row in 0..h {
63      for col in 0..w {
64        let luma = i32::from(y[row * w + col]) - 16;
65        let ci = (row / 2) * cw + col / 2;
66        let d = i32::from(u[ci]) - 128;
67        let e = i32::from(v[ci]) - 128;
68        let c = 298 * luma;
69        let clamp = |x: i32| (x >> 8).clamp(0, 255) as u8;
70        bgra.extend_from_slice(&[
71          clamp(c + 516 * d + 128),
72          clamp(c - 100 * d - 208 * e + 128),
73          clamp(c + 409 * e + 128),
74          255,
75        ]);
76      }
77    }
78    Some(VideoFrame {
79      width,
80      height,
81      bgra,
82    })
83  }
84
85  pub fn width(&self) -> u32 {
86    self.width
87  }
88
89  pub fn height(&self) -> u32 {
90    self.height
91  }
92
93  /// The pixels, BGRA, tightly packed.
94  pub fn as_bgra(&self) -> &[u8] {
95    &self.bgra
96  }
97
98  /// Hand the pixels to gpui. Each call makes a new image id, which is what
99  /// tells the sprite atlas this is a different texture.
100  pub(crate) fn into_image(self) -> Option<Arc<RenderImage>> {
101    let buffer = RgbaImage::from_raw(self.width, self.height, self.bgra)?;
102    Some(Arc::new(RenderImage::new(vec![Frame::new(buffer)])))
103  }
104}
105
106fn fits(width: u32, height: u32, len: usize, bytes: usize) -> bool {
107  width > 0 && height > 0 && len == width as usize * height as usize * bytes
108}
109
110#[cfg(test)]
111mod tests {
112  use super::*;
113
114  #[test]
115  fn rgba_is_stored_as_bgra() {
116    let f = VideoFrame::rgba(2, 1, vec![1, 2, 3, 255, 10, 20, 30, 255]).unwrap();
117    assert_eq!(f.as_bgra(), &[3, 2, 1, 255, 30, 20, 10, 255]);
118  }
119
120  #[test]
121  fn bgra_is_kept_as_is() {
122    let f = VideoFrame::bgra(1, 1, vec![9, 8, 7, 6]).unwrap();
123    assert_eq!(f.as_bgra(), &[9, 8, 7, 6]);
124  }
125
126  #[test]
127  fn a_wrong_sized_buffer_is_refused() {
128    assert!(VideoFrame::rgba(2, 2, vec![0; 15]).is_none());
129    assert!(VideoFrame::bgra(0, 4, vec![]).is_none());
130    assert!(VideoFrame::i420(2, 2, &[0; 4], &[0; 1], &[0; 2]).is_none());
131  }
132
133  #[test]
134  fn i420_maps_the_limited_range_extremes() {
135    // Y=16 is black and Y=235 is white with neutral chroma.
136    let black = VideoFrame::i420(2, 2, &[16; 4], &[128], &[128]).unwrap();
137    assert_eq!(&black.as_bgra()[..4], &[0, 0, 0, 255]);
138    let white = VideoFrame::i420(2, 2, &[235; 4], &[128], &[128]).unwrap();
139    assert_eq!(&white.as_bgra()[..4], &[255, 255, 255, 255]);
140  }
141
142  #[test]
143  fn i420_puts_red_chroma_in_the_red_channel() {
144    // BT.601 red is roughly Y=81, U=90, V=240.
145    let f = VideoFrame::i420(2, 2, &[81; 4], &[90], &[240]).unwrap();
146    let [b, g, r, a] = f.as_bgra()[..4] else {
147      unreachable!()
148    };
149    assert!(r > 240 && g < 20 && b < 20 && a == 255, "{r} {g} {b}");
150  }
151
152  #[test]
153  fn i420_takes_odd_dimensions() {
154    let f = VideoFrame::i420(3, 3, &[16; 9], &[128; 4], &[128; 4]).unwrap();
155    assert_eq!(f.as_bgra().len(), 3 * 3 * 4);
156  }
157}