Skip to main content

proof_engine/
export.rs

1//! Writing pictures to disk: still frames in any common format, and
2//! animated GIFs.
3//!
4//! The encoding is done by the [`image`] crate, so a frame can be saved as
5//! PNG, JPEG, BMP, TGA or GIF just by choosing the file extension, and a run
6//! of frames can be written as one looping GIF with [`GifRecorder`].
7//!
8//! Everything here works on plain RGBA8 buffers with the first row at the
9//! top, so it needs no window and no GPU. Frames read back from OpenGL come
10//! bottom row first; [`flip_rows`] turns them the right way up.
11//!
12//! ```rust
13//! use proof_engine::export::{save_rgba, GifRecorder};
14//! # let dir = std::env::temp_dir();
15//! // A 64x32 horizontal ramp, saved as a PNG...
16//! let (w, h) = (64u32, 32u32);
17//! let ramp: Vec<u8> = (0..w * h)
18//!     .flat_map(|i| { let v = ((i % w) * 4) as u8; [v, v, 255 - v, 255] })
19//!     .collect();
20//! save_rgba(dir.join("ramp.png"), w, h, &ramp).unwrap();
21//!
22//! // ...and as a three-frame looping GIF at 20 frames per second.
23//! let mut gif = GifRecorder::create(dir.join("ramp.gif"), 1.0 / 20.0).unwrap();
24//! for _ in 0..3 {
25//!     gif.push(w, h, &ramp).unwrap();
26//! }
27//! assert_eq!(gif.finish().unwrap(), 3);
28//! ```
29
30use std::fs::File;
31use std::io::{self, BufWriter};
32use std::path::Path;
33
34use image::codecs::gif::{GifEncoder, Repeat};
35use image::{imageops, Delay, DynamicImage, Frame, ImageFormat, RgbaImage};
36
37fn to_io(e: image::ImageError) -> io::Error {
38    match e {
39        image::ImageError::IoError(e) => e,
40        other => io::Error::other(other),
41    }
42}
43
44fn buffer(w: u32, h: u32, rgba: &[u8]) -> io::Result<RgbaImage> {
45    let need = w as usize * h as usize * 4;
46    if w == 0 || h == 0 || rgba.len() < need {
47        return Err(io::Error::new(
48            io::ErrorKind::InvalidInput,
49            format!("{w}x{h} RGBA needs {need} bytes, got {}", rgba.len()),
50        ));
51    }
52    Ok(RgbaImage::from_raw(w, h, rgba[..need].to_vec()).expect("length checked above"))
53}
54
55/// Reverse the row order of an RGBA8 buffer in place.
56///
57/// OpenGL's `glReadPixels` returns the bottom row first; image files store
58/// the top row first.
59pub fn flip_rows(w: u32, h: u32, rgba: &mut [u8]) {
60    let stride = w as usize * 4;
61    let h = h as usize;
62    for y in 0..h / 2 {
63        let (top, bottom) = rgba.split_at_mut((h - 1 - y) * stride);
64        top[y * stride..(y + 1) * stride].swap_with_slice(&mut bottom[..stride]);
65    }
66}
67
68/// Scale an RGBA8 buffer to `width` pixels wide, keeping the aspect ratio.
69///
70/// Uses a Catmull-Rom filter, which keeps thin bright lines (most of what
71/// this engine draws) crisp. Returns the new size and pixels; a buffer that
72/// is already that width or narrower is returned unchanged.
73pub fn scale_to_width(w: u32, h: u32, rgba: &[u8], width: u32) -> io::Result<(u32, u32, Vec<u8>)> {
74    let img = buffer(w, h, rgba)?;
75    if width == 0 || width >= w {
76        return Ok((w, h, img.into_raw()));
77    }
78    let nh = ((h as u64 * width as u64 + w as u64 / 2) / w as u64).max(1) as u32;
79    let out = imageops::resize(&img, width, nh, imageops::FilterType::CatmullRom);
80    Ok((width, nh, out.into_raw()))
81}
82
83/// The format a path's extension asks for, if the `image` crate can write it.
84pub fn format_for(path: &Path) -> Option<ImageFormat> {
85    ImageFormat::from_path(path).ok().filter(|f| f.writing_enabled())
86}
87
88/// Save an RGBA8 buffer (top row first) to `path`.
89///
90/// The format comes from the extension: `.png`, `.jpg`/`.jpeg`, `.bmp`,
91/// `.tga` or `.gif`. An unknown or missing extension writes a BMP, which is
92/// what the engine always wrote before it could do anything else. Formats
93/// without an alpha channel (JPEG, BMP) get the colour only.
94pub fn save_rgba(path: impl AsRef<Path>, w: u32, h: u32, rgba: &[u8]) -> io::Result<()> {
95    let path = path.as_ref();
96    let img = DynamicImage::ImageRgba8(buffer(w, h, rgba)?);
97    let format = format_for(path).unwrap_or(ImageFormat::Bmp);
98    let img = match format {
99        ImageFormat::Jpeg | ImageFormat::Bmp => DynamicImage::ImageRgb8(img.to_rgb8()),
100        _ => img,
101    };
102    img.save_with_format(path, format).map_err(to_io)
103}
104
105/// Save an RGBA8 buffer as an opaque image: the alpha channel is ignored.
106///
107/// This is what frame capture uses. The alpha left in a window's back
108/// buffer is whatever the last blend wrote, and is not meant to be seen.
109pub fn save_opaque(path: impl AsRef<Path>, w: u32, h: u32, rgba: &[u8]) -> io::Result<()> {
110    let mut img = buffer(w, h, rgba)?;
111    for p in img.pixels_mut() {
112        p.0[3] = 255;
113    }
114    save_rgba(path, w, h, img.as_raw())
115}
116
117/// Decode any supported image file into an RGBA8 buffer, top row first.
118pub fn load_rgba(bytes: &[u8]) -> io::Result<(u32, u32, Vec<u8>)> {
119    let img = image::load_from_memory(bytes).map_err(to_io)?.to_rgba8();
120    Ok((img.width(), img.height(), img.into_raw()))
121}
122
123/// Writes frames into one looping animated GIF.
124///
125/// Every frame gets its own 256 colour palette, built with NeuQuant, so
126/// smooth gradients survive better than they would with one palette for the
127/// whole file. Frames are written as they arrive, so memory use stays at
128/// one frame however long the recording is.
129///
130/// The file is complete once [`finish`](Self::finish) returns. Dropping the
131/// recorder also finishes the file, but cannot report a write error.
132pub struct GifRecorder {
133    encoder: Option<GifEncoder<BufWriter<File>>>,
134    delay_ms: u32,
135    size: Option<(u32, u32)>,
136    frames: usize,
137}
138
139impl GifRecorder {
140    /// Default quantiser speed: 1 is best and slowest, 30 fastest.
141    pub const DEFAULT_SPEED: i32 = 10;
142
143    /// Start a GIF at `path` whose frames are shown for `frame_secs` each.
144    ///
145    /// GIF timing is in hundredths of a second, so the delay is rounded to
146    /// the nearest 10 ms (and never below 20 ms, the fastest rate browsers
147    /// honour).
148    pub fn create(path: impl AsRef<Path>, frame_secs: f32) -> io::Result<Self> {
149        Self::create_with_speed(path, frame_secs, Self::DEFAULT_SPEED)
150    }
151
152    /// As [`create`](Self::create), with the quantiser speed (1 to 30).
153    pub fn create_with_speed(path: impl AsRef<Path>, frame_secs: f32, speed: i32) -> io::Result<Self> {
154        let file = BufWriter::new(File::create(path)?);
155        let mut encoder = GifEncoder::new_with_speed(file, speed.clamp(1, 30));
156        encoder.set_repeat(Repeat::Infinite).map_err(to_io)?;
157        let cs = (frame_secs.max(0.0) * 100.0).round().max(2.0) as u32;
158        Ok(Self { encoder: Some(encoder), delay_ms: cs * 10, size: None, frames: 0 })
159    }
160
161    /// The delay given to each frame, in milliseconds.
162    pub fn delay_ms(&self) -> u32 {
163        self.delay_ms
164    }
165
166    /// Frames written so far.
167    pub fn frames(&self) -> usize {
168        self.frames
169    }
170
171    /// Append one RGBA8 frame, top row first. Alpha is ignored.
172    ///
173    /// Every frame must be the size of the first.
174    pub fn push(&mut self, w: u32, h: u32, rgba: &[u8]) -> io::Result<()> {
175        if *self.size.get_or_insert((w, h)) != (w, h) {
176            return Err(io::Error::new(
177                io::ErrorKind::InvalidInput,
178                format!("GIF frame is {w}x{h}, earlier frames were {:?}", self.size.unwrap()),
179            ));
180        }
181        let mut img = buffer(w, h, rgba)?;
182        for p in img.pixels_mut() {
183            p.0[3] = 255;
184        }
185        let frame = Frame::from_parts(img, 0, 0, Delay::from_numer_denom_ms(self.delay_ms, 1));
186        let enc = self.encoder.as_mut().ok_or_else(|| io::Error::other("GIF already finished"))?;
187        enc.encode_frame(frame).map_err(to_io)?;
188        self.frames += 1;
189        Ok(())
190    }
191
192    /// Write the GIF trailer and close the file. Returns the frame count.
193    pub fn finish(mut self) -> io::Result<usize> {
194        if let Some(enc) = self.encoder.take() {
195            // The trailer is written when the inner gif encoder drops; the
196            // buffered file is flushed when its writer drops after that.
197            drop(enc);
198        }
199        Ok(self.frames)
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use image::AnimationDecoder;
207
208    fn tmp(name: &str) -> std::path::PathBuf {
209        let dir = std::env::temp_dir().join("proof_engine_export_tests");
210        std::fs::create_dir_all(&dir).unwrap();
211        dir.join(name)
212    }
213
214    /// Red on the top row, blue on the bottom row.
215    fn two_rows(w: u32) -> Vec<u8> {
216        let mut v = Vec::new();
217        for _ in 0..w { v.extend_from_slice(&[255, 0, 0, 255]); }
218        for _ in 0..w { v.extend_from_slice(&[0, 0, 255, 255]); }
219        v
220    }
221
222    #[test]
223    fn flip_rows_reverses_row_order() {
224        let mut px = two_rows(3);
225        flip_rows(3, 2, &mut px);
226        assert_eq!(&px[0..4], &[0, 0, 255, 255]);
227        assert_eq!(&px[12..16], &[255, 0, 0, 255]);
228        // Odd heights leave the middle row alone.
229        let mut odd: Vec<u8> = (0..3u8).flat_map(|r| [r, r, r, r]).collect();
230        flip_rows(1, 3, &mut odd);
231        assert_eq!(odd, vec![2, 2, 2, 2, 1, 1, 1, 1, 0, 0, 0, 0]);
232    }
233
234    #[test]
235    fn png_round_trips_exactly() {
236        let path = tmp("roundtrip.png");
237        let px = two_rows(5);
238        save_rgba(&path, 5, 2, &px).unwrap();
239        let bytes = std::fs::read(&path).unwrap();
240        assert_eq!(&bytes[1..4], b"PNG");
241        let (w, h, back) = load_rgba(&bytes).unwrap();
242        assert_eq!((w, h), (5, 2));
243        assert_eq!(back, px);
244    }
245
246    #[test]
247    fn extension_picks_the_format() {
248        let px = two_rows(4);
249        for (name, magic) in [("f.jpg", &b"\xFF\xD8"[..]), ("f.bmp", b"BM"), ("f.gif", b"GIF8"), ("f.unknownext", b"BM")] {
250            let path = tmp(name);
251            save_opaque(&path, 4, 2, &px).unwrap();
252            let bytes = std::fs::read(&path).unwrap();
253            assert!(bytes.starts_with(magic), "{name} starts with {:?}", &bytes[..4]);
254            let (w, h, back) = load_rgba(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
255            assert_eq!((w, h), (4, 2));
256            // Lossy formats get close; the top row stays red.
257            assert!(back[0] > 200 && back[2] < 60, "{name}: {:?}", &back[..4]);
258        }
259    }
260
261    #[test]
262    fn opaque_save_drops_alpha() {
263        let path = tmp("opaque.png");
264        save_opaque(&path, 1, 1, &[10, 20, 30, 0]).unwrap();
265        let (_, _, back) = load_rgba(&std::fs::read(&path).unwrap()).unwrap();
266        assert_eq!(back, vec![10, 20, 30, 255]);
267    }
268
269    #[test]
270    fn short_buffers_are_an_error_not_a_panic() {
271        assert!(save_rgba(tmp("short.png"), 4, 4, &[0; 10]).is_err());
272        assert!(save_rgba(tmp("zero.png"), 0, 4, &[]).is_err());
273    }
274
275    #[test]
276    fn scaling_keeps_the_aspect_ratio() {
277        let px = vec![128u8; 40 * 20 * 4];
278        let (w, h, out) = scale_to_width(40, 20, &px, 10).unwrap();
279        assert_eq!((w, h), (10, 5));
280        assert_eq!(out.len(), 10 * 5 * 4);
281        assert!(out.iter().all(|&v| (126..=130).contains(&v)));
282        // Never upscales.
283        assert_eq!(scale_to_width(40, 20, &px, 80).unwrap().0, 40);
284    }
285
286    #[test]
287    fn gif_recorder_writes_a_looping_animation() {
288        let path = tmp("anim.gif");
289        let (w, h) = (16u32, 8u32);
290        let mut rec = GifRecorder::create(&path, 1.0 / 25.0).unwrap();
291        assert_eq!(rec.delay_ms(), 40);
292        for shade in [0u8, 120, 240] {
293            let frame: Vec<u8> = (0..w * h).flat_map(|_| [shade, 255 - shade, 64, 255]).collect();
294            rec.push(w, h, &frame).unwrap();
295        }
296        assert!(rec.push(8, 8, &[0; 256]).is_err(), "mismatched frame size is refused");
297        assert_eq!(rec.finish().unwrap(), 3);
298
299        let bytes = std::fs::read(&path).unwrap();
300        assert!(bytes.starts_with(b"GIF89a"));
301        assert_eq!(*bytes.last().unwrap(), 0x3B, "GIF trailer present");
302        let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(&bytes)).unwrap();
303        let frames = dec.into_frames().collect_frames().unwrap();
304        assert_eq!(frames.len(), 3);
305        for (i, f) in frames.iter().enumerate() {
306            let (num, den) = f.delay().numer_denom_ms();
307            assert_eq!(num / den, 40);
308            assert_eq!(f.buffer().dimensions(), (w, h));
309            let red = f.buffer().get_pixel(3, 3).0[0] as i32;
310            assert!((red - [0, 120, 240][i]).abs() < 12, "frame {i} red {red}");
311        }
312    }
313}