1use 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
55pub 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
68pub 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
83pub fn format_for(path: &Path) -> Option<ImageFormat> {
85 ImageFormat::from_path(path).ok().filter(|f| f.writing_enabled())
86}
87
88pub 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
105pub 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
117pub 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
123pub 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 pub const DEFAULT_SPEED: i32 = 10;
142
143 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 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 pub fn delay_ms(&self) -> u32 {
163 self.delay_ms
164 }
165
166 pub fn frames(&self) -> usize {
168 self.frames
169 }
170
171 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 pub fn finish(mut self) -> io::Result<usize> {
194 if let Some(enc) = self.encoder.take() {
195 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 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 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 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 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}