ailloli_ui_render_wgpu/capture.rs
1//! Swapchain readback, row padding, and PNG encoding for visual tests.
2
3use std::io::Cursor;
4
5/// Pixel format for captured frames.
6///
7/// # Examples
8///
9/// ```
10/// use ailloli_ui_render_wgpu::CapturedFrameFormat;
11/// let format = CapturedFrameFormat::Rgba8;
12/// assert_eq!(format, CapturedFrameFormat::Rgba8);
13/// ```
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CapturedFrameFormat {
16 /// Four eight-bit channels in red, green, blue, alpha byte order.
17 Rgba8,
18}
19
20/// Parameters for one capture readback.
21///
22/// The default requests PNG encoding in addition to the tight RGBA buffer.
23///
24/// # Examples
25///
26/// ```
27/// use ailloli_ui_render_wgpu::CaptureParams;
28/// let params = CaptureParams::default();
29/// assert!(params.encode_png);
30/// ```
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct CaptureParams {
33 /// Whether to populate [`CapturedFrame::png_data`] with an RGBA8 PNG.
34 pub encode_png: bool,
35}
36
37impl Default for CaptureParams {
38 fn default() -> Self {
39 Self { encode_png: true }
40 }
41}
42
43/// A captured frame read back from a render target.
44///
45/// `rgba` is always available. `png_data` is `None` when PNG encoding was not
46/// requested and `Some` only after a successful encode.
47///
48/// # Examples
49///
50/// ```
51/// use ailloli_ui_render_wgpu::{CapturedFrame, CapturedFrameFormat};
52/// let frame = CapturedFrame {
53/// width: 1,
54/// height: 1,
55/// format: CapturedFrameFormat::Rgba8,
56/// rgba: vec![255, 0, 0, 255],
57/// png_data: None,
58/// };
59/// assert_eq!(frame.rgba.len(), frame.width as usize * frame.height as usize * 4);
60/// ```
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CapturedFrame {
63 /// Frame width in physical pixels.
64 pub width: u32,
65 /// Frame height in physical pixels.
66 pub height: u32,
67 /// Byte layout used by [`Self::rgba`].
68 pub format: CapturedFrameFormat,
69 /// Tight RGBA8 buffer (`width * height * 4`).
70 pub rgba: Vec<u8>,
71 /// Optional PNG-encoded bytes for easy artifact writing.
72 pub png_data: Option<Vec<u8>>,
73}
74
75/// Returns a row byte length rounded up to wgpu's 256-byte copy alignment.
76///
77/// Zero remains zero. Callers must keep the input below the largest multiple
78/// that fits in `u32`; multiplication can otherwise overflow.
79///
80/// # Panics
81///
82/// In debug builds, panics when the rounded result exceeds `u32::MAX`.
83///
84/// # Examples
85///
86/// ```
87/// use ailloli_ui_render_wgpu::capture::bytes_per_row_padded_256;
88/// assert_eq!(bytes_per_row_padded_256(0), 0);
89/// assert_eq!(bytes_per_row_padded_256(68), 256);
90/// assert_eq!(bytes_per_row_padded_256(256), 256);
91/// ```
92pub fn bytes_per_row_padded_256(unpadded_bpr: u32) -> u32 {
93 unpadded_bpr.div_ceil(256) * 256
94}
95
96/// Strips row padding from a mapped texture buffer into a tight row layout.
97///
98/// Exactly `rows * dst_bpr` bytes are returned. Each row copies at most
99/// `dst_bpr` bytes from the corresponding `src_bpr`-spaced source row.
100///
101/// # Panics
102///
103/// Panics if a requested source row starts beyond `src`, if `src_bpr` is
104/// smaller than `dst_bpr` for a fully present row, or if output-size arithmetic
105/// overflows `usize`.
106///
107/// # Examples
108///
109/// ```
110/// use ailloli_ui_render_wgpu::capture::unpad_rows_rgba;
111/// let src = [1, 2, 0, 0, 3, 4, 0, 0];
112/// assert_eq!(unpad_rows_rgba(&src, 4, 2, 2), vec![1, 2, 3, 4]);
113/// ```
114pub fn unpad_rows_rgba(src: &[u8], src_bpr: usize, dst_bpr: usize, rows: usize) -> Vec<u8> {
115 let mut out = vec![0u8; dst_bpr * rows];
116 for row in 0..rows {
117 let src_off = row * src_bpr;
118 let dst_off = row * dst_bpr;
119 let src_end = (src_off + dst_bpr).min(src.len());
120 out[dst_off..dst_off + (src_end - src_off)].copy_from_slice(&src[src_off..src_end]);
121 }
122 out
123}
124
125/// Swaps R and B channels in-place (BGRA to RGBA).
126///
127/// A trailing slice shorter than four bytes is left unchanged.
128///
129/// # Examples
130///
131/// ```
132/// use ailloli_ui_render_wgpu::capture::bgra_to_rgba_in_place;
133/// let mut bytes = [10, 20, 30, 255, 9];
134/// bgra_to_rgba_in_place(&mut bytes);
135/// assert_eq!(bytes, [30, 20, 10, 255, 9]);
136/// ```
137pub fn bgra_to_rgba_in_place(buf: &mut [u8]) {
138 for px in buf.chunks_exact_mut(4) {
139 px.swap(0, 2);
140 }
141}
142
143/// Encodes a tight RGBA8 buffer as PNG.
144///
145/// # Errors
146///
147/// Returns an error when `rgba.len()` is not exactly `width * height * 4` or
148/// when the image encoder rejects the output.
149///
150/// # Examples
151///
152/// ```
153/// use ailloli_ui_render_wgpu::capture::encode_png_rgba;
154/// let png = encode_png_rgba(1, 1, &[255, 0, 0, 255])?;
155/// assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
156/// # Ok::<(), String>(())
157/// ```
158pub fn encode_png_rgba(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, String> {
159 let mut data = Vec::new();
160 let mut writer = Cursor::new(&mut data);
161 let img = image::RgbaImage::from_raw(width, height, rgba.to_vec())
162 .ok_or_else(|| "failed to construct RgbaImage".to_string())?;
163 image::DynamicImage::ImageRgba8(img)
164 .write_to(&mut writer, image::ImageFormat::Png)
165 .map_err(|e| format!("png encode failed: {e}"))?;
166 Ok(data)
167}
168
169#[cfg(test)]
170/// Verifies row alignment, row unpadding, and BGRA channel conversion.
171mod tests {
172 use super::*;
173
174 #[test]
175 fn padded_bpr_is_multiple_of_256_and_at_least_unpadded() {
176 let unpadded = 4 * 17;
177 let padded = bytes_per_row_padded_256(unpadded);
178 assert!(padded >= unpadded);
179 assert_eq!(padded % 256, 0);
180 }
181
182 #[test]
183 fn unpad_rows_copies_tight_prefix_per_row() {
184 let w = 3u32;
185 let h = 2usize;
186 let unpadded_bpr = (w * 4) as usize;
187 let padded_bpr = bytes_per_row_padded_256(w * 4) as usize;
188 assert!(padded_bpr >= unpadded_bpr);
189
190 // Build padded rows with known content.
191 let mut padded = vec![0u8; padded_bpr * h];
192 for row in 0..h {
193 for i in 0..unpadded_bpr {
194 padded[row * padded_bpr + i] = (row as u8) * 10 + (i as u8);
195 }
196 }
197
198 let tight = unpad_rows_rgba(&padded, padded_bpr, unpadded_bpr, h);
199 assert_eq!(tight.len(), unpadded_bpr * h);
200 for row in 0..h {
201 assert_eq!(
202 &tight[row * unpadded_bpr..(row + 1) * unpadded_bpr],
203 &padded[row * padded_bpr..row * padded_bpr + unpadded_bpr]
204 );
205 }
206 }
207
208 #[test]
209 fn bgra_to_rgba_swaps_r_and_b() {
210 let mut px = vec![1u8, 2u8, 3u8, 4u8];
211 bgra_to_rgba_in_place(&mut px);
212 assert_eq!(px, vec![3u8, 2u8, 1u8, 4u8]);
213 }
214}