use std::fs::File;
use std::io::{self, BufWriter};
use std::path::Path;
use image::codecs::gif::{GifEncoder, Repeat};
use image::{imageops, Delay, DynamicImage, Frame, ImageFormat, RgbaImage};
fn to_io(e: image::ImageError) -> io::Error {
match e {
image::ImageError::IoError(e) => e,
other => io::Error::other(other),
}
}
fn buffer(w: u32, h: u32, rgba: &[u8]) -> io::Result<RgbaImage> {
let need = w as usize * h as usize * 4;
if w == 0 || h == 0 || rgba.len() < need {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("{w}x{h} RGBA needs {need} bytes, got {}", rgba.len()),
));
}
Ok(RgbaImage::from_raw(w, h, rgba[..need].to_vec()).expect("length checked above"))
}
pub fn flip_rows(w: u32, h: u32, rgba: &mut [u8]) {
let stride = w as usize * 4;
let h = h as usize;
for y in 0..h / 2 {
let (top, bottom) = rgba.split_at_mut((h - 1 - y) * stride);
top[y * stride..(y + 1) * stride].swap_with_slice(&mut bottom[..stride]);
}
}
pub fn scale_to_width(w: u32, h: u32, rgba: &[u8], width: u32) -> io::Result<(u32, u32, Vec<u8>)> {
let img = buffer(w, h, rgba)?;
if width == 0 || width >= w {
return Ok((w, h, img.into_raw()));
}
let nh = ((h as u64 * width as u64 + w as u64 / 2) / w as u64).max(1) as u32;
let out = imageops::resize(&img, width, nh, imageops::FilterType::CatmullRom);
Ok((width, nh, out.into_raw()))
}
pub fn format_for(path: &Path) -> Option<ImageFormat> {
ImageFormat::from_path(path).ok().filter(|f| f.writing_enabled())
}
pub fn save_rgba(path: impl AsRef<Path>, w: u32, h: u32, rgba: &[u8]) -> io::Result<()> {
let path = path.as_ref();
let img = DynamicImage::ImageRgba8(buffer(w, h, rgba)?);
let format = format_for(path).unwrap_or(ImageFormat::Bmp);
let img = match format {
ImageFormat::Jpeg | ImageFormat::Bmp => DynamicImage::ImageRgb8(img.to_rgb8()),
_ => img,
};
img.save_with_format(path, format).map_err(to_io)
}
pub fn save_opaque(path: impl AsRef<Path>, w: u32, h: u32, rgba: &[u8]) -> io::Result<()> {
let mut img = buffer(w, h, rgba)?;
for p in img.pixels_mut() {
p.0[3] = 255;
}
save_rgba(path, w, h, img.as_raw())
}
pub fn load_rgba(bytes: &[u8]) -> io::Result<(u32, u32, Vec<u8>)> {
let img = image::load_from_memory(bytes).map_err(to_io)?.to_rgba8();
Ok((img.width(), img.height(), img.into_raw()))
}
pub struct GifRecorder {
encoder: Option<GifEncoder<BufWriter<File>>>,
delay_ms: u32,
size: Option<(u32, u32)>,
frames: usize,
}
impl GifRecorder {
pub const DEFAULT_SPEED: i32 = 10;
pub fn create(path: impl AsRef<Path>, frame_secs: f32) -> io::Result<Self> {
Self::create_with_speed(path, frame_secs, Self::DEFAULT_SPEED)
}
pub fn create_with_speed(path: impl AsRef<Path>, frame_secs: f32, speed: i32) -> io::Result<Self> {
let file = BufWriter::new(File::create(path)?);
let mut encoder = GifEncoder::new_with_speed(file, speed.clamp(1, 30));
encoder.set_repeat(Repeat::Infinite).map_err(to_io)?;
let cs = (frame_secs.max(0.0) * 100.0).round().max(2.0) as u32;
Ok(Self { encoder: Some(encoder), delay_ms: cs * 10, size: None, frames: 0 })
}
pub fn delay_ms(&self) -> u32 {
self.delay_ms
}
pub fn frames(&self) -> usize {
self.frames
}
pub fn push(&mut self, w: u32, h: u32, rgba: &[u8]) -> io::Result<()> {
if *self.size.get_or_insert((w, h)) != (w, h) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("GIF frame is {w}x{h}, earlier frames were {:?}", self.size.unwrap()),
));
}
let mut img = buffer(w, h, rgba)?;
for p in img.pixels_mut() {
p.0[3] = 255;
}
let frame = Frame::from_parts(img, 0, 0, Delay::from_numer_denom_ms(self.delay_ms, 1));
let enc = self.encoder.as_mut().ok_or_else(|| io::Error::other("GIF already finished"))?;
enc.encode_frame(frame).map_err(to_io)?;
self.frames += 1;
Ok(())
}
pub fn finish(mut self) -> io::Result<usize> {
if let Some(enc) = self.encoder.take() {
drop(enc);
}
Ok(self.frames)
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::AnimationDecoder;
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join("proof_engine_export_tests");
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
fn two_rows(w: u32) -> Vec<u8> {
let mut v = Vec::new();
for _ in 0..w { v.extend_from_slice(&[255, 0, 0, 255]); }
for _ in 0..w { v.extend_from_slice(&[0, 0, 255, 255]); }
v
}
#[test]
fn flip_rows_reverses_row_order() {
let mut px = two_rows(3);
flip_rows(3, 2, &mut px);
assert_eq!(&px[0..4], &[0, 0, 255, 255]);
assert_eq!(&px[12..16], &[255, 0, 0, 255]);
let mut odd: Vec<u8> = (0..3u8).flat_map(|r| [r, r, r, r]).collect();
flip_rows(1, 3, &mut odd);
assert_eq!(odd, vec![2, 2, 2, 2, 1, 1, 1, 1, 0, 0, 0, 0]);
}
#[test]
fn png_round_trips_exactly() {
let path = tmp("roundtrip.png");
let px = two_rows(5);
save_rgba(&path, 5, 2, &px).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[1..4], b"PNG");
let (w, h, back) = load_rgba(&bytes).unwrap();
assert_eq!((w, h), (5, 2));
assert_eq!(back, px);
}
#[test]
fn extension_picks_the_format() {
let px = two_rows(4);
for (name, magic) in [("f.jpg", &b"\xFF\xD8"[..]), ("f.bmp", b"BM"), ("f.gif", b"GIF8"), ("f.unknownext", b"BM")] {
let path = tmp(name);
save_opaque(&path, 4, 2, &px).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(magic), "{name} starts with {:?}", &bytes[..4]);
let (w, h, back) = load_rgba(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((w, h), (4, 2));
assert!(back[0] > 200 && back[2] < 60, "{name}: {:?}", &back[..4]);
}
}
#[test]
fn opaque_save_drops_alpha() {
let path = tmp("opaque.png");
save_opaque(&path, 1, 1, &[10, 20, 30, 0]).unwrap();
let (_, _, back) = load_rgba(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(back, vec![10, 20, 30, 255]);
}
#[test]
fn short_buffers_are_an_error_not_a_panic() {
assert!(save_rgba(tmp("short.png"), 4, 4, &[0; 10]).is_err());
assert!(save_rgba(tmp("zero.png"), 0, 4, &[]).is_err());
}
#[test]
fn scaling_keeps_the_aspect_ratio() {
let px = vec![128u8; 40 * 20 * 4];
let (w, h, out) = scale_to_width(40, 20, &px, 10).unwrap();
assert_eq!((w, h), (10, 5));
assert_eq!(out.len(), 10 * 5 * 4);
assert!(out.iter().all(|&v| (126..=130).contains(&v)));
assert_eq!(scale_to_width(40, 20, &px, 80).unwrap().0, 40);
}
#[test]
fn gif_recorder_writes_a_looping_animation() {
let path = tmp("anim.gif");
let (w, h) = (16u32, 8u32);
let mut rec = GifRecorder::create(&path, 1.0 / 25.0).unwrap();
assert_eq!(rec.delay_ms(), 40);
for shade in [0u8, 120, 240] {
let frame: Vec<u8> = (0..w * h).flat_map(|_| [shade, 255 - shade, 64, 255]).collect();
rec.push(w, h, &frame).unwrap();
}
assert!(rec.push(8, 8, &[0; 256]).is_err(), "mismatched frame size is refused");
assert_eq!(rec.finish().unwrap(), 3);
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"GIF89a"));
assert_eq!(*bytes.last().unwrap(), 0x3B, "GIF trailer present");
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(&bytes)).unwrap();
let frames = dec.into_frames().collect_frames().unwrap();
assert_eq!(frames.len(), 3);
for (i, f) in frames.iter().enumerate() {
let (num, den) = f.delay().numer_denom_ms();
assert_eq!(num / den, 40);
assert_eq!(f.buffer().dimensions(), (w, h));
let red = f.buffer().get_pixel(3, 3).0[0] as i32;
assert!((red - [0, 120, 240][i]).abs() < 12, "frame {i} red {red}");
}
}
}