use std::fs::File;
use std::io::{BufRead, BufReader, Cursor, Seek};
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Context, Result};
use image::{AnimationDecoder, DynamicImage};
#[derive(Debug, Clone)]
pub struct AnimatedFrame {
pub image: DynamicImage,
pub delay: Duration,
}
pub struct AnimationState {
pub frames: Vec<AnimatedFrame>,
pub current: usize,
pub last_advance: Instant,
}
impl AnimationState {
pub fn new(frames: Vec<AnimatedFrame>) -> Option<Self> {
if frames.len() < 2 {
return None;
}
Some(Self {
frames,
current: 0,
last_advance: Instant::now(),
})
}
pub fn tick(&mut self) -> bool {
if self.frames.len() < 2 {
return false;
}
let delay = self.frames[self.current].delay;
if self.last_advance.elapsed() < delay {
return false;
}
self.current = (self.current + 1) % self.frames.len();
self.last_advance = Instant::now();
true
}
pub fn current_image(&self) -> &DynamicImage {
&self.frames[self.current].image
}
}
pub fn decode_animation_path(path: &Path) -> Result<Vec<AnimatedFrame>> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase());
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
let reader = BufReader::new(file);
decode_with_hint(reader, ext.as_deref(), Some(path))
}
pub fn decode_animation_bytes(data: &[u8], hint_ext: Option<&str>) -> Result<Vec<AnimatedFrame>> {
let ext = hint_ext
.map(|s| s.to_lowercase())
.or_else(|| sniff_ext(data));
decode_with_hint(Cursor::new(data), ext.as_deref(), None)
}
pub fn is_animated_bytes(data: &[u8]) -> bool {
match sniff_ext(data).as_deref() {
Some("gif") => true,
Some("png") => is_apng(data),
Some("webp") => is_animated_webp(data),
_ => false,
}
}
fn sniff_ext(data: &[u8]) -> Option<String> {
if data.len() >= 6 && (data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a")) {
return Some("gif".into());
}
if data.len() >= 8 && data[..8] == [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
return Some("png".into());
}
if data.len() >= 12 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
return Some("webp".into());
}
None
}
fn is_apng(data: &[u8]) -> bool {
if data.len() < 8 || &data[..8] != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A].as_slice() {
return false;
}
let mut i = 8usize;
while i + 8 <= data.len() {
let len = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
let kind = &data[i + 4..i + 8];
if kind == b"acTL" {
return true;
}
if kind == b"IDAT" {
return false;
}
let next = i.saturating_add(8).saturating_add(len).saturating_add(4);
if next <= i {
return false;
}
i = next;
}
false
}
fn is_animated_webp(data: &[u8]) -> bool {
if data.len() < 21 || &data[12..16] != b"VP8X" {
return false;
}
let flags = data[20];
flags & 0x02 != 0
}
fn decode_with_hint<R: BufRead + Seek>(
reader: R,
ext: Option<&str>,
path: Option<&Path>,
) -> Result<Vec<AnimatedFrame>> {
match ext {
Some("gif") => collect(image::codecs::gif::GifDecoder::new(reader)?.into_frames()),
Some("png") | Some("apng") => {
let decoder = image::codecs::png::PngDecoder::new(reader)?;
let apng = decoder
.apng()
.map_err(|e| anyhow!("not an animated PNG: {e}"))?;
collect(apng.into_frames())
}
Some("webp") => collect(image::codecs::webp::WebPDecoder::new(reader)?.into_frames()),
other => Err(anyhow!(
"unsupported animation format: {} ({})",
other.unwrap_or("<unknown>"),
path.map(|p| p.display().to_string())
.unwrap_or_else(|| "<bytes>".into())
)),
}
}
fn collect<I: Iterator<Item = image::ImageResult<image::Frame>>>(
frames: I,
) -> Result<Vec<AnimatedFrame>> {
let mut out = Vec::new();
for frame in frames {
let frame = frame.context("decoding animation frame")?;
let (num, den) = frame.delay().numer_denom_ms();
let micros = if den == 0 {
100_000
} else {
(u64::from(num) * 1000) / u64::from(den.max(1))
};
let delay = Duration::from_micros(micros).max(Duration::from_millis(20));
let image = DynamicImage::ImageRgba8(frame.into_buffer());
out.push(AnimatedFrame { image, delay });
}
if out.is_empty() {
return Err(anyhow!("animation contained no frames"));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use image::codecs::gif::GifEncoder;
use image::Frame;
fn synth_gif(frames: u32, w: u32, h: u32) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut enc = GifEncoder::new(&mut buf);
enc.set_repeat(image::codecs::gif::Repeat::Infinite)
.unwrap();
for i in 0..frames {
let img = image::RgbaImage::from_pixel(
w,
h,
image::Rgba([(i * 30) as u8 % 255, 0, 0, 255]),
);
let delay = image::Delay::from_numer_denom_ms(80, 1);
enc.encode_frame(Frame::from_parts(img, 0, 0, delay))
.unwrap();
}
}
buf
}
#[test]
fn sniff_gif_signature() {
let bytes = synth_gif(2, 4, 4);
assert_eq!(sniff_ext(&bytes).as_deref(), Some("gif"));
assert!(is_animated_bytes(&bytes));
}
#[test]
fn decode_multiframe_gif() {
let bytes = synth_gif(5, 8, 8);
let frames = decode_animation_bytes(&bytes, Some("gif")).unwrap();
assert_eq!(frames.len(), 5);
for f in &frames {
assert_eq!(f.image.width(), 8);
assert_eq!(f.image.height(), 8);
assert!(f.delay >= Duration::from_millis(20));
}
}
#[test]
fn animation_state_advances_after_delay() {
let bytes = synth_gif(3, 4, 4);
let frames = decode_animation_bytes(&bytes, Some("gif")).unwrap();
let mut state = AnimationState::new(frames).expect("multi-frame");
assert_eq!(state.current, 0);
state.last_advance = Instant::now() - Duration::from_secs(1);
assert!(state.tick());
assert_eq!(state.current, 1);
assert!(!state.tick());
}
#[test]
fn animation_state_wraps_around() {
let bytes = synth_gif(2, 4, 4);
let frames = decode_animation_bytes(&bytes, Some("gif")).unwrap();
let mut state = AnimationState::new(frames).unwrap();
state.last_advance = Instant::now() - Duration::from_secs(1);
assert!(state.tick());
assert_eq!(state.current, 1);
state.last_advance = Instant::now() - Duration::from_secs(1);
assert!(state.tick());
assert_eq!(state.current, 0);
}
#[test]
fn animation_state_rejects_single_frame() {
let bytes = synth_gif(1, 4, 4);
let frames = decode_animation_bytes(&bytes, Some("gif")).unwrap();
assert!(AnimationState::new(frames).is_none());
}
#[test]
fn sniff_ignores_non_animated_inputs() {
assert!(sniff_ext(b"not an image").is_none());
assert!(!is_animated_bytes(b"not an image"));
}
#[test]
fn unsupported_format_errors() {
let mut buf = Vec::new();
let img = image::RgbImage::from_pixel(4, 4, image::Rgb([10, 20, 30]));
image::DynamicImage::ImageRgb8(img)
.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Jpeg)
.unwrap();
assert!(decode_animation_bytes(&buf, Some("jpeg")).is_err());
}
}