use std::path::Path;
use std::time::Duration;
use gif::DisposalMethod;
const CACHE_BUDGET: usize = 32 * 1024 * 1024;
const MAX_GIF_WORKING_SET: usize = 512 * 1024 * 1024;
pub const MAX_GIF_INPUT_BYTES: usize = 256 * 1024 * 1024;
pub const MAX_GIF_FRAMES: usize = 100_000;
#[derive(Clone)]
enum CachedFrame {
Raw(Vec<u8>),
Zstd(Vec<u8>),
}
struct GifReader {
decoder: gif::Decoder<std::io::BufReader<std::io::Cursor<std::sync::Arc<[u8]>>>>,
}
struct DecodedFrame {
left: u16,
top: u16,
width: u16,
height: u16,
dispose: DisposalMethod,
rgba: Vec<u8>,
}
impl GifReader {
fn open_bytes(bytes: std::sync::Arc<[u8]>) -> anyhow::Result<Self> {
let mut options = gif::DecodeOptions::new();
options.set_color_output(gif::ColorOutput::RGBA);
let decoder = options.read_info(std::io::BufReader::new(std::io::Cursor::new(bytes)))?;
Ok(Self { decoder })
}
fn next_rgba(&mut self) -> Option<DecodedFrame> {
match self.decoder.read_next_frame() {
Ok(Some(frame)) => Some(DecodedFrame {
left: frame.left,
top: frame.top,
width: frame.width,
height: frame.height,
dispose: frame.dispose,
rgba: frame.buffer.as_ref().to_vec(),
}),
Ok(None) | Err(_) => None,
}
}
}
struct GifInfo {
width: u32,
height: u32,
delays: Vec<Duration>,
opaque: bool,
}
pub struct AnimatedImage {
bytes: std::sync::Arc<[u8]>,
pub width: u32,
pub height: u32,
delays: Vec<Duration>,
total: Duration,
cache: Vec<Option<CachedFrame>>,
cache_bytes: usize,
reader: Option<GifReader>,
next_index: usize,
canvas: Vec<u8>,
scratch: Vec<u8>,
decompressor: zstd::bulk::Decompressor<'static>,
prev_save: Vec<u8>,
opaque: bool,
raw_cache: bool,
}
impl AnimatedImage {
pub fn decode(path: &Path) -> anyhow::Result<Option<Self>> {
let is_gif = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("gif"));
if !is_gif {
return Ok(None);
}
let metadata = std::fs::metadata(path)?;
anyhow::ensure!(
metadata.len() <= MAX_GIF_INPUT_BYTES as u64,
"GIF file {} is {} bytes, exceeding the {} MiB input limit",
path.display(),
metadata.len(),
MAX_GIF_INPUT_BYTES / (1024 * 1024)
);
let mut bytes = Vec::with_capacity(metadata.len() as usize);
{
use std::io::Read;
let file = std::fs::File::open(path)?;
file.take((MAX_GIF_INPUT_BYTES + 1) as u64)
.read_to_end(&mut bytes)?;
}
anyhow::ensure!(
bytes.len() <= MAX_GIF_INPUT_BYTES,
"GIF file {} grew beyond the {} MiB input limit while reading",
path.display(),
MAX_GIF_INPUT_BYTES / (1024 * 1024)
);
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(bytes);
let Some(info) = scan_gif(&bytes)? else {
return Ok(None);
};
if info.delays.is_empty() {
return Ok(None);
}
let total = info.delays.iter().copied().sum();
let (pixels, raw_size) = gif_allocation_sizes(info.width, info.height, info.delays.len())?;
let frame_count = info.delays.len();
let raw_cache = raw_size <= CACHE_BUDGET;
if !raw_cache {
tracing::debug!(
"GIF too large for raw cache ({:.1}MB > {}MB), using zstd",
raw_size as f64 / 1e6,
CACHE_BUDGET / 1024 / 1024
);
}
Ok(Some(Self {
bytes: bytes.clone(),
width: info.width,
height: info.height,
delays: info.delays,
total,
cache: vec![None; frame_count],
cache_bytes: 0,
reader: Some(GifReader::open_bytes(bytes)?),
next_index: 0,
canvas: vec![0; pixels * 4],
scratch: vec![0; pixels * 4],
decompressor: zstd::bulk::Decompressor::new()?,
prev_save: Vec::new(),
opaque: info.opaque,
raw_cache,
}))
}
fn restart(&mut self) {
self.reader = GifReader::open_bytes(self.bytes.clone()).ok();
self.next_index = 0;
}
fn decode_next(&mut self) -> bool {
let index = self.next_index;
let Some(reader) = self.reader.as_mut() else {
return false;
};
let Some(f) = reader.next_rgba() else {
return false;
};
let (w, h) = (self.width as usize, self.height as usize);
let fw = f.width as usize;
let fh = f.height as usize;
let left = f.left as usize;
let top = f.top as usize;
let Some(right) = left.checked_add(fw) else {
return false;
};
let Some(bottom) = top.checked_add(fh) else {
return false;
};
if right > w || bottom > h || f.rgba.len() != fw.saturating_mul(fh).saturating_mul(4) {
return false;
}
let full_canvas =
left == 0 && top == 0 && fw == w && fh == h && f.rgba.len() == self.canvas.len();
if self.opaque && full_canvas {
self.canvas.copy_from_slice(&f.rgba);
} else {
if self.opaque {
self.opaque = false;
}
if f.dispose == DisposalMethod::Previous {
let need = fw * fh * 4;
if self.prev_save.len() != need {
self.prev_save = vec![0; need];
}
for y in 0..fh {
let src = (y * w + left) * 4;
self.prev_save[y * fw * 4..(y + 1) * fw * 4]
.copy_from_slice(&self.canvas[src..src + fw * 4]);
}
}
for y in 0..fh {
let src = &f.rgba[y * fw * 4..(y + 1) * fw * 4];
let dst = ((top + y) * w + left) * 4;
for px in 0..fw {
let si = px * 4;
if src[si + 3] != 0 {
let di = dst + si;
self.canvas[di..di + 4].copy_from_slice(&src[si..si + 4]);
}
}
}
match f.dispose {
DisposalMethod::Background => {
for y in 0..fh {
let dst = ((top + y) * w + left) * 4;
self.canvas[dst..dst + fw * 4].fill(0);
}
}
DisposalMethod::Previous => {
for y in 0..fh {
let dst = ((top + y) * w + left) * 4;
self.canvas[dst..dst + fw * 4]
.copy_from_slice(&self.prev_save[y * fw * 4..(y + 1) * fw * 4]);
}
}
_ => {}
}
}
let raw_len = self.canvas.len();
if index < self.cache.len() && self.cache[index].is_none() {
if self.raw_cache {
self.cache_bytes += raw_len;
self.cache[index] = Some(CachedFrame::Raw(self.canvas.clone()));
} else {
let compressed =
zstd::bulk::compress(&self.canvas, 1).unwrap_or_else(|_| self.canvas.clone());
if self.cache_bytes + compressed.len() <= CACHE_BUDGET {
self.cache_bytes += compressed.len();
self.cache[index] = Some(CachedFrame::Zstd(compressed));
}
}
}
self.next_index += 1;
true
}
fn ensure_upto(&mut self, index: usize) {
if index < self.next_index {
self.restart();
}
let mut guard = 0;
while self.next_index <= index {
let before = self.next_index;
if !self.decode_next() {
self.restart();
}
guard += 1;
if self.next_index == before || guard > self.delays.len() + 1 {
break;
}
}
}
pub fn first_frame(&mut self) -> &[u8] {
self.frame_at(0)
}
pub fn frame_at(&mut self, index: usize) -> &[u8] {
let index = index.min(self.delays.len().saturating_sub(1));
self.ensure_upto(index);
match self.cache.get(index) {
Some(Some(CachedFrame::Raw(raw))) => return raw,
Some(Some(CachedFrame::Zstd(compressed))) => {
let size = self.canvas.len();
let used = self
.decompressor
.decompress_to_buffer(compressed, &mut self.scratch[..size])
.unwrap_or(0);
if used == size {
return &self.scratch[..used];
}
return &self.canvas;
}
_ => {}
}
&self.canvas
}
pub fn decompress_into(&mut self, index: usize, out: &mut [u8]) -> bool {
let index = index.min(self.delays.len().saturating_sub(1));
self.ensure_upto(index);
match self.cache.get(index) {
Some(Some(CachedFrame::Raw(raw))) if raw.len() == out.len() => {
out.copy_from_slice(raw);
true
}
Some(Some(CachedFrame::Zstd(compressed))) => self
.decompressor
.decompress_to_buffer(compressed, out)
.map(|used| used == out.len())
.unwrap_or(false),
_ => false,
}
}
pub fn frame_index_at(&self, elapsed: Duration) -> usize {
let total_ms = self.total.as_millis().max(1);
let mut t = elapsed.as_millis() % total_ms;
for (i, delay) in self.delays.iter().enumerate() {
let ms = delay.as_millis();
if t < ms {
return i;
}
t -= ms;
}
self.delays.len() - 1
}
pub fn frame_start(&self, index: usize) -> Duration {
self.delays.iter().take(index).copied().sum()
}
pub fn frame_count(&self) -> usize {
self.delays.len()
}
pub fn cache_bytes(&self) -> usize {
self.cache_bytes
}
pub fn raw_cached(&self) -> usize {
self.cache
.iter()
.filter(|c| matches!(c, Some(CachedFrame::Raw(_))))
.count()
}
pub fn zstd_cached(&self) -> usize {
self.cache
.iter()
.filter(|c| matches!(c, Some(CachedFrame::Zstd(_))))
.count()
}
pub fn total_duration(&self) -> Duration {
self.total
}
}
fn gif_allocation_sizes(
width: u32,
height: u32,
frame_count: usize,
) -> anyhow::Result<(usize, usize)> {
anyhow::ensure!(width > 0 && height > 0, "GIF logical screen is empty");
anyhow::ensure!(
frame_count <= MAX_GIF_FRAMES,
"GIF has {frame_count} frames, exceeding the {MAX_GIF_FRAMES} frame limit"
);
let pixels = usize::try_from(u64::from(width) * u64::from(height))?;
let frame_bytes = pixels
.checked_mul(4)
.ok_or_else(|| anyhow::anyhow!("GIF frame size overflow for {width}x{height}"))?;
let working_set = frame_bytes
.checked_mul(3)
.ok_or_else(|| anyhow::anyhow!("GIF working-set overflow for {width}x{height}"))?;
anyhow::ensure!(
working_set <= MAX_GIF_WORKING_SET,
"GIF {width}x{height} requires approximately {:.1} MiB of decode working memory, exceeding the {} MiB safety limit",
working_set as f64 / (1024.0 * 1024.0),
MAX_GIF_WORKING_SET / 1024 / 1024
);
Ok((pixels, frame_bytes.saturating_mul(frame_count)))
}
fn scan_gif(bytes: &[u8]) -> anyhow::Result<Option<GifInfo>> {
if bytes.len() < 13 || &bytes[0..3] != b"GIF" {
return Ok(None);
}
let width = u16::from_le_bytes([bytes[6], bytes[7]]) as u32;
let height = u16::from_le_bytes([bytes[8], bytes[9]]) as u32;
anyhow::ensure!(
width > 0 && height > 0,
"GIF logical screen dimensions are zero"
);
let packed = bytes[10];
let gct_size = 3 * (1 << ((packed & 0x07) + 1));
let mut pos = 13 + gct_size;
if pos > bytes.len() {
return Ok(None);
}
let mut delays = Vec::new();
let mut opaque = true;
let mut frame_count = 0usize;
let mut pending_delay: Option<Duration> = None;
let mut pending_transparency = false;
while pos < bytes.len() {
match bytes[pos] {
0x3B => break, 0x2C => {
if pos + 10 > bytes.len() {
break;
}
let left = u16::from_le_bytes([bytes[pos + 1], bytes[pos + 2]]) as u32;
let top = u16::from_le_bytes([bytes[pos + 3], bytes[pos + 4]]) as u32;
let iw = u16::from_le_bytes([bytes[pos + 5], bytes[pos + 6]]) as u32;
let ih = u16::from_le_bytes([bytes[pos + 7], bytes[pos + 8]]) as u32;
anyhow::ensure!(iw > 0 && ih > 0, "GIF image descriptor has zero dimensions");
anyhow::ensure!(
left.checked_add(iw).is_some_and(|right| right <= width)
&& top.checked_add(ih).is_some_and(|bottom| bottom <= height),
"GIF image rectangle {left},{top} {iw}x{ih} exceeds logical screen {width}x{height}"
);
let ipacked = bytes[pos + 9];
pos += 10;
if ipacked & 0x80 != 0 {
pos += 3 * (1 << ((ipacked & 0x07) + 1));
}
if pos >= bytes.len() {
break;
}
pos += 1; pos = skip_sub_blocks(bytes, pos);
if left != 0 || top != 0 || iw != width || ih != height {
opaque = false;
}
if pending_transparency {
opaque = false;
}
frame_count += 1;
anyhow::ensure!(
frame_count <= MAX_GIF_FRAMES,
"GIF has more than {MAX_GIF_FRAMES} frames"
);
delays.push(pending_delay.take().unwrap_or(Duration::from_millis(20)));
pending_transparency = false;
}
0x21 => {
if pos + 2 > bytes.len() {
break;
}
if bytes[pos + 1] == 0xF9 {
if pos + 8 > bytes.len() {
break;
}
anyhow::ensure!(
bytes[pos + 2] == 4 && bytes[pos + 7] == 0,
"malformed GIF graphic control extension"
);
let gce_packed = bytes[pos + 3];
let centis = u16::from_le_bytes([bytes[pos + 4], bytes[pos + 5]]);
let millis = if centis == 0 { 20 } else { centis as u64 * 10 };
pending_delay = Some(
Duration::from_millis(millis)
.clamp(Duration::from_millis(20), Duration::from_secs(5)),
);
pending_transparency = gce_packed & 0x01 != 0;
pos += 8;
continue;
}
pos += 2;
pos = skip_sub_blocks(bytes, pos);
}
_ => break, }
}
if frame_count == 0 {
return Ok(None);
}
anyhow::ensure!(
delays.len() == frame_count,
"GIF frame timing metadata is inconsistent"
);
Ok(Some(GifInfo {
width,
height,
delays,
opaque,
}))
}
fn skip_sub_blocks(bytes: &[u8], mut pos: usize) -> usize {
loop {
if pos >= bytes.len() {
return bytes.len();
}
let n = bytes[pos] as usize;
pos += 1;
if n == 0 {
return pos;
}
pos += n;
if pos > bytes.len() {
return bytes.len();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn delays() -> Vec<Duration> {
vec![
Duration::from_millis(100),
Duration::from_millis(200),
Duration::from_millis(300),
]
}
fn anim_with(delays: Vec<Duration>) -> AnimatedImage {
AnimatedImage {
bytes: std::sync::Arc::from(Vec::new()),
width: 1,
height: 1,
total: delays.iter().copied().sum(),
cache: vec![None; delays.len()],
cache_bytes: 0,
reader: None,
next_index: 0,
canvas: vec![0; 4],
scratch: vec![0; 4],
decompressor: zstd::bulk::Decompressor::new().unwrap(),
prev_save: Vec::new(),
opaque: true,
raw_cache: true,
delays,
}
}
#[test]
fn frame_index_tracks_delays() {
let anim = anim_with(delays());
assert_eq!(anim.frame_index_at(Duration::ZERO), 0);
assert_eq!(anim.frame_index_at(Duration::from_millis(99)), 0);
assert_eq!(anim.frame_index_at(Duration::from_millis(100)), 1);
assert_eq!(anim.frame_index_at(Duration::from_millis(299)), 1);
assert_eq!(anim.frame_index_at(Duration::from_millis(300)), 2);
assert_eq!(anim.frame_index_at(Duration::from_millis(599)), 2);
}
#[test]
fn frame_index_loops() {
let anim = anim_with(delays());
assert_eq!(anim.frame_index_at(Duration::from_millis(600)), 0);
assert_eq!(anim.frame_index_at(Duration::from_millis(610)), 0);
assert_eq!(anim.frame_index_at(Duration::from_millis(700)), 1);
assert_eq!(anim.frame_index_at(Duration::from_millis(3000)), 0);
}
#[test]
fn scan_rejects_non_gif() {
assert!(scan_gif(b"not a gif at all").unwrap().is_none());
assert!(scan_gif(&[0u8; 100]).unwrap().is_none());
}
#[test]
fn rejects_gif_working_sets_before_allocating_canvases() {
assert!(gif_allocation_sizes(7_680, 4_320, 2).is_ok());
assert!(gif_allocation_sizes(16_384, 16_384, 1).is_err());
}
fn encoded_gif(frames: usize) -> Vec<u8> {
let colors: [[u8; 4]; 4] = [
[255, 0, 0, 255],
[0, 255, 0, 255],
[0, 0, 255, 255],
[255, 255, 0, 255],
];
let palette = [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255];
let mut out = Vec::new();
{
let mut encoder = gif::Encoder::new(&mut out, 2, 2, &palette).unwrap();
encoder.set_repeat(gif::Repeat::Infinite).unwrap();
for index in 0..frames {
let color = colors[index % colors.len()];
let mut rgba = color.repeat(4);
let mut frame = gif::Frame::from_rgba_speed(2, 2, &mut rgba, 10);
frame.delay = 10;
encoder.write_frame(&frame).unwrap();
}
}
out
}
fn gif_with_extra_gce() -> Vec<u8> {
let mut bytes = b"GIF89a".to_vec();
bytes.extend([2, 0, 2, 0, 0x80, 0, 0]);
bytes.extend([0, 0, 0, 255, 255, 255]);
fn gce(bytes: &mut Vec<u8>, delay: u16) {
bytes.extend([
0x21,
0xF9,
4,
0,
(delay & 0xff) as u8,
(delay >> 8) as u8,
0,
0,
]);
}
gce(&mut bytes, 10);
gce(&mut bytes, 20); bytes.extend([0x2C, 0, 0, 0, 0, 2, 0, 2, 0, 0]);
bytes.extend([2, 1, 0, 0]);
gce(&mut bytes, 30);
bytes.extend([0x2C, 0, 0, 0, 0, 2, 0, 2, 0, 0]);
bytes.extend([2, 1, 0, 0]);
gce(&mut bytes, 40); bytes.push(0x3B);
bytes
}
#[test]
fn scan_aligns_gce_delays_with_image_frames() {
let info = scan_gif(&gif_with_extra_gce()).unwrap().unwrap();
assert_eq!(info.delays.len(), 2);
assert_eq!(info.delays[0], Duration::from_millis(200));
assert_eq!(info.delays[1], Duration::from_millis(300));
}
#[test]
fn scan_rejects_out_of_bounds_and_empty_screens() {
let mut bytes = gif_with_extra_gce();
bytes[8] = 0; assert!(scan_gif(&bytes).is_err());
let mut bytes = gif_with_extra_gce();
let descriptor = bytes
.windows(10)
.position(|window| window[0] == 0x2C)
.unwrap();
bytes[descriptor + 5] = 3;
bytes[descriptor + 6] = 0;
assert!(scan_gif(&bytes).is_err());
}
#[test]
fn input_and_metadata_limits_reject_before_huge_allocations() {
let temporary = tempfile::tempdir().unwrap();
let path = temporary.path().join("huge.gif");
let file = std::fs::File::create(&path).unwrap();
file.set_len((MAX_GIF_INPUT_BYTES + 1) as u64).unwrap();
assert!(AnimatedImage::decode(&path).is_err());
assert!(gif_allocation_sizes(2, 2, MAX_GIF_FRAMES + 1).is_err());
}
#[test]
fn uncached_tail_after_loop_wrap_uses_the_right_frames() {
let temporary = tempfile::tempdir().unwrap();
let path = temporary.path().join("animation.gif");
std::fs::write(&path, encoded_gif(3)).unwrap();
let mut expected = AnimatedImage::decode(&path).unwrap().unwrap();
let expected_frame = expected.frame_at(2).to_vec();
let mut anim = AnimatedImage::decode(&path).unwrap().unwrap();
anim.raw_cache = false;
anim.frame_at(2); for slot in anim.cache.iter_mut().skip(1) {
*slot = None;
}
anim.cache_bytes = 0;
anim.restart();
assert_eq!(anim.frame_at(2), expected_frame.as_slice());
}
}