use std::time::Duration;
use crate::base::{Error, Result};
use crate::gfx::bitmap::Bitmap;
pub const MAX_ANIMATION_PIXELS: u64 = 1 << 26;
pub const MAX_FRAMES: usize = 4096;
#[derive(Clone, Debug)]
pub struct Frame {
pub image: Bitmap,
pub delay: Duration,
}
#[derive(Clone, Debug)]
pub struct Animation {
pub frames: Vec<Frame>,
pub loop_count: Option<u32>,
pub width: u32,
pub height: u32,
}
impl Animation {
pub fn still(image: Bitmap) -> Animation {
let (width, height) = (image.width(), image.height());
Animation {
frames: vec![Frame {
image,
delay: Duration::ZERO,
}],
loop_count: Some(1),
width,
height,
}
}
pub fn len(&self) -> usize {
self.frames.len()
}
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
pub fn is_still(&self) -> bool {
self.frames.len() <= 1
}
pub fn duration(&self) -> Duration {
self.frames.iter().map(|f| f.delay).sum()
}
pub fn frame_at(&self, elapsed: Duration) -> Option<&Frame> {
if self.frames.is_empty() {
return None;
}
let total = self.duration();
if total.is_zero() {
return self.frames.first();
}
let passes = elapsed.as_nanos() / total.as_nanos();
if let Some(limit) = self.loop_count {
if passes >= limit as u128 {
return self.frames.last();
}
}
let mut into = Duration::from_nanos((elapsed.as_nanos() % total.as_nanos()) as u64);
for f in &self.frames {
if into < f.delay {
return Some(f);
}
into -= f.delay;
}
self.frames.last()
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum AnimationFormat {
Gif,
Apng,
}
pub fn sniff_animation(bytes: &[u8]) -> Option<AnimationFormat> {
if bytes.starts_with(&crate::gfx::gif::SIGNATURE) {
return Some(AnimationFormat::Gif);
}
if bytes.starts_with(&crate::gfx::png::SIGNATURE) {
return crate::gfx::apng::is_animated(bytes).then_some(AnimationFormat::Apng);
}
None
}
pub fn decode_animation(bytes: &[u8]) -> Result<Animation> {
match sniff_animation(bytes) {
Some(AnimationFormat::Gif) => crate::gfx::gif::decode(bytes),
Some(AnimationFormat::Apng) => crate::gfx::apng::decode(bytes),
None => crate::gfx::decode_image(bytes).map(Animation::still),
}
}
pub(crate) fn check_budget(frames: usize, w: u32, h: u32) -> Result<()> {
if frames > MAX_FRAMES {
return Err(Error::Parse(format!(
"animation: more than {MAX_FRAMES} frames"
)));
}
if (frames as u64) * (w as u64) * (h as u64) > MAX_ANIMATION_PIXELS {
return Err(Error::Parse(
"animation: exceeds the total pixel budget".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gfx::anim_fixtures as fx;
#[test]
fn every_container_decodes_through_one_entry() {
for (label, bytes) in [("gif", fx::GIF), ("apng", fx::APNG)] {
let a = decode_animation(bytes).unwrap_or_else(|e| panic!("{label}: {e}"));
assert_eq!(a.len(), fx::FRAMES, "{label} frames");
assert_eq!((a.width, a.height), fx::SIZE, "{label} size");
assert!(!a.is_still(), "{label} moves");
assert_eq!(
a.duration(),
std::time::Duration::from_millis(300),
"{label}"
);
}
}
#[test]
fn stills_decode_as_one_frame_animations() {
let png = crate::gfx::png_encode::encode(&crate::gfx::Bitmap::new(
3,
2,
crate::base::Rgba::WHITE,
));
let a = decode_animation(&png).unwrap();
assert_eq!(a.len(), 1);
assert!(a.is_still());
assert_eq!((a.width, a.height), (3, 2));
assert_eq!(a.duration(), Duration::ZERO, "a still has no clock");
}
#[test]
fn sniffing_separates_animated_from_still() {
assert_eq!(sniff_animation(fx::GIF), Some(AnimationFormat::Gif));
assert_eq!(sniff_animation(fx::APNG), Some(AnimationFormat::Apng));
assert_eq!(
sniff_animation(fx::H264_MP4),
None,
"a video container is not an animation this engine decodes"
);
let still = crate::gfx::png_encode::encode(&crate::gfx::Bitmap::new(
2,
2,
crate::base::Rgba::WHITE,
));
assert_eq!(sniff_animation(&still), None, "a plain PNG is a still");
assert_eq!(sniff_animation(b"nothing at all"), None);
}
#[test]
fn video_refuses_by_name_with_the_conversion_line() {
for msg in [
decode_animation(fx::H264_MP4).unwrap_err().to_string(),
crate::gfx::decode_image(fx::H264_MP4)
.unwrap_err()
.to_string(),
] {
assert!(msg.contains("mp4/mov"), "names the container: {msg}");
assert!(msg.contains("video is not decoded"), "{msg}");
assert!(msg.contains("ffmpeg -i"), "carries the fix: {msg}");
}
let mut avi = b"RIFF\x00\x00\x00\x00AVI LIST".to_vec();
avi.resize(64, 0);
assert!(decode_animation(&avi)
.unwrap_err()
.to_string()
.contains("avi:"));
let mut mkv = vec![0x1A, 0x45, 0xDF, 0xA3];
mkv.resize(64, 0);
assert!(decode_animation(&mkv)
.unwrap_err()
.to_string()
.contains("webm/mkv:"));
}
#[test]
fn frame_at_walks_the_timeline_and_honors_the_loop_count() {
let bmp = |v: u8| crate::gfx::Bitmap::new(1, 1, crate::base::Rgba::rgb(v, v, v));
let frames = (0..3)
.map(|i| Frame {
image: bmp(i * 10),
delay: Duration::from_millis(100),
})
.collect::<Vec<_>>();
let looping = Animation {
frames: frames.clone(),
loop_count: None,
width: 1,
height: 1,
};
let at = |a: &Animation, ms: u64| {
a.frame_at(Duration::from_millis(ms))
.unwrap()
.image
.get(0, 0)
.unwrap()
.r
};
assert_eq!(at(&looping, 0), 0);
assert_eq!(at(&looping, 99), 0, "still inside frame 0");
assert_eq!(
at(&looping, 100),
10,
"exactly the boundary is the next frame"
);
assert_eq!(at(&looping, 250), 20);
assert_eq!(at(&looping, 300), 0, "wraps forever");
assert_eq!(at(&looping, 1_000_000), 10, "no drift after many loops");
let once = Animation {
frames,
loop_count: Some(1),
width: 1,
height: 1,
};
assert_eq!(at(&once, 250), 20);
assert_eq!(at(&once, 300), 20, "a finished clip holds its last frame");
assert_eq!(at(&once, 9_999), 20);
}
#[test]
fn budget_rejects_absurd_sequences() {
assert!(check_budget(1, 16, 16).is_ok());
assert!(check_budget(MAX_FRAMES + 1, 1, 1).is_err(), "frame count");
assert!(check_budget(4096, 4096, 4096).is_err(), "total pixels");
}
}