1use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use gif::DisposalMethod;
13
14const CACHE_BUDGET: usize = 256 * 1024 * 1024;
17const MAX_GIF_WORKING_SET: usize = 512 * 1024 * 1024;
18
19#[derive(Clone)]
24enum CachedFrame {
25 Raw(Vec<u8>),
26 Zstd(Vec<u8>),
27}
28
29struct GifReader {
30 decoder: gif::Decoder<std::io::BufReader<std::fs::File>>,
31}
32
33struct DecodedFrame {
34 left: u16,
35 top: u16,
36 width: u16,
37 height: u16,
38 dispose: DisposalMethod,
39 rgba: Vec<u8>,
40}
41
42impl GifReader {
43 fn open(path: &Path) -> anyhow::Result<Self> {
44 let file = std::fs::File::open(path)?;
45 let mut options = gif::DecodeOptions::new();
46 options.set_color_output(gif::ColorOutput::RGBA);
47 let decoder = options.read_info(std::io::BufReader::new(file))?;
48 Ok(Self { decoder })
49 }
50
51 fn next_rgba(&mut self) -> Option<DecodedFrame> {
54 match self.decoder.read_next_frame() {
55 Ok(Some(frame)) => Some(DecodedFrame {
56 left: frame.left,
57 top: frame.top,
58 width: frame.width,
59 height: frame.height,
60 dispose: frame.dispose,
61 rgba: frame.buffer.as_ref().to_vec(),
62 }),
63 Ok(None) | Err(_) => None,
64 }
65 }
66}
67
68struct GifInfo {
69 width: u32,
70 height: u32,
71 delays: Vec<Duration>,
72 opaque: bool,
75}
76
77pub struct AnimatedImage {
78 path: PathBuf,
79 pub width: u32,
80 pub height: u32,
81 delays: Vec<Duration>,
82 total: Duration,
83 cache: Vec<Option<CachedFrame>>,
85 cache_bytes: usize,
86 reader: Option<GifReader>,
87 next_index: usize,
88 canvas: Vec<u8>,
91 scratch: Vec<u8>,
92 decompressor: zstd::bulk::Decompressor<'static>,
95 prev_save: Vec<u8>,
96 opaque: bool,
97 raw_cache: bool,
98}
99
100impl AnimatedImage {
101 pub fn decode(path: &Path) -> anyhow::Result<Option<Self>> {
105 let bytes = std::fs::read(path)?;
106 let Some(info) = scan_gif(&bytes)? else {
107 return Ok(None);
108 };
109 if info.delays.is_empty() {
110 return Ok(None);
111 }
112 let total = info.delays.iter().copied().sum();
113 let (pixels, raw_size) = gif_allocation_sizes(info.width, info.height, info.delays.len())?;
114 let frame_count = info.delays.len();
115 let raw_cache = raw_size <= CACHE_BUDGET;
116 if !raw_cache {
117 tracing::debug!(
118 "GIF too large for raw cache ({:.1}MB > {}MB), using zstd",
119 raw_size as f64 / 1e6,
120 CACHE_BUDGET / 1024 / 1024
121 );
122 }
123 Ok(Some(Self {
124 path: path.to_path_buf(),
125 width: info.width,
126 height: info.height,
127 delays: info.delays,
128 total,
129 cache: vec![None; frame_count],
130 cache_bytes: 0,
131 reader: Some(GifReader::open(path)?),
132 next_index: 0,
133 canvas: vec![0; pixels * 4],
134 scratch: vec![0; pixels * 4],
135 decompressor: zstd::bulk::Decompressor::new()?,
136 prev_save: Vec::new(),
137 opaque: info.opaque,
138 raw_cache,
139 }))
140 }
141
142 fn restart(&mut self) {
143 self.reader = GifReader::open(&self.path).ok();
144 self.next_index = 0;
145 }
146
147 fn decode_next(&mut self) -> bool {
150 let index = self.next_index;
151 let Some(reader) = self.reader.as_mut() else {
152 return false;
153 };
154 let Some(f) = reader.next_rgba() else {
155 return false;
156 };
157 let (w, _h) = (self.width as usize, self.height as usize);
158
159 if self.opaque {
160 self.canvas.copy_from_slice(&f.rgba);
161 } else {
162 let fw = f.width as usize;
165 let fh = f.height as usize;
166 let left = f.left as usize;
167 let top = f.top as usize;
168
169 if f.dispose == DisposalMethod::Previous {
170 let need = fw * fh * 4;
171 if self.prev_save.len() != need {
172 self.prev_save = vec![0; need];
173 }
174 for y in 0..fh {
175 let src = (y * w + left) * 4;
176 self.prev_save[y * fw * 4..(y + 1) * fw * 4]
177 .copy_from_slice(&self.canvas[src..src + fw * 4]);
178 }
179 }
180
181 for y in 0..fh {
182 let src = &f.rgba[y * fw * 4..(y + 1) * fw * 4];
183 let dst = ((top + y) * w + left) * 4;
184 for px in 0..fw {
185 let si = px * 4;
186 if src[si + 3] != 0 {
187 let di = dst + si;
188 self.canvas[di..di + 4].copy_from_slice(&src[si..si + 4]);
189 }
190 }
191 }
192
193 match f.dispose {
194 DisposalMethod::Background => {
195 for y in 0..fh {
196 let dst = ((top + y) * w + left) * 4;
197 self.canvas[dst..dst + fw * 4].fill(0);
198 }
199 }
200 DisposalMethod::Previous => {
201 for y in 0..fh {
202 let dst = ((top + y) * w + left) * 4;
203 self.canvas[dst..dst + fw * 4]
204 .copy_from_slice(&self.prev_save[y * fw * 4..(y + 1) * fw * 4]);
205 }
206 }
207 _ => {}
208 }
209 }
210
211 let raw_len = self.canvas.len();
214 if index < self.cache.len() && self.cache[index].is_none() {
215 if self.raw_cache {
216 self.cache_bytes += raw_len;
217 self.cache[index] = Some(CachedFrame::Raw(self.canvas.clone()));
218 } else {
219 let compressed =
220 zstd::bulk::compress(&self.canvas, 1).unwrap_or_else(|_| self.canvas.clone());
221 if self.cache_bytes + compressed.len() <= CACHE_BUDGET {
222 self.cache_bytes += compressed.len();
223 self.cache[index] = Some(CachedFrame::Zstd(compressed));
224 }
225 }
226 }
227
228 self.next_index += 1;
229 true
230 }
231
232 fn ensure_upto(&mut self, index: usize) {
236 if index < self.next_index {
237 self.restart();
238 }
239 let mut guard = 0;
240 while self.next_index <= index {
241 if self.next_index < self.cache.len() && self.cache[self.next_index].is_some() {
242 self.next_index += 1;
243 continue;
244 }
245 let before = self.next_index;
246 if !self.decode_next() {
247 self.restart();
249 }
250 guard += 1;
251 if self.next_index == before || guard > self.delays.len() + 1 {
253 break;
254 }
255 }
256 }
257
258 pub fn first_frame(&mut self) -> &[u8] {
261 self.frame_at(0)
262 }
263
264 pub fn frame_at(&mut self, index: usize) -> &[u8] {
265 let index = index.min(self.delays.len().saturating_sub(1));
266 self.ensure_upto(index);
267 match self.cache.get(index) {
268 Some(Some(CachedFrame::Raw(raw))) => return raw,
269 Some(Some(CachedFrame::Zstd(compressed))) => {
270 let size = self.canvas.len();
271 let used = self
272 .decompressor
273 .decompress_to_buffer(compressed, &mut self.scratch[..size])
274 .unwrap_or(0);
275 if used == size {
276 return &self.scratch[..used];
277 }
278 return &self.canvas;
279 }
280 _ => {}
281 }
282 &self.canvas
283 }
284
285 pub fn decompress_into(&mut self, index: usize, out: &mut [u8]) -> bool {
289 let index = index.min(self.delays.len().saturating_sub(1));
290 self.ensure_upto(index);
291 match self.cache.get(index) {
292 Some(Some(CachedFrame::Raw(raw))) => {
293 out.copy_from_slice(raw);
294 true
295 }
296 Some(Some(CachedFrame::Zstd(compressed))) => self
297 .decompressor
298 .decompress_to_buffer(compressed, out)
299 .map(|used| used == out.len())
300 .unwrap_or(false),
301 _ => false,
302 }
303 }
304
305 pub fn frame_index_at(&self, elapsed: Duration) -> usize {
307 let total_ms = self.total.as_millis().max(1);
308 let mut t = elapsed.as_millis() % total_ms;
309 for (i, delay) in self.delays.iter().enumerate() {
310 let ms = delay.as_millis();
311 if t < ms {
312 return i;
313 }
314 t -= ms;
315 }
316 self.delays.len() - 1
317 }
318
319 pub fn frame_start(&self, index: usize) -> Duration {
322 self.delays.iter().take(index).copied().sum()
323 }
324
325 pub fn frame_count(&self) -> usize {
326 self.delays.len()
327 }
328
329 pub fn cache_bytes(&self) -> usize {
330 self.cache_bytes
331 }
332
333 pub fn raw_cached(&self) -> usize {
334 self.cache
335 .iter()
336 .filter(|c| matches!(c, Some(CachedFrame::Raw(_))))
337 .count()
338 }
339
340 pub fn zstd_cached(&self) -> usize {
341 self.cache
342 .iter()
343 .filter(|c| matches!(c, Some(CachedFrame::Zstd(_))))
344 .count()
345 }
346
347 pub fn total_duration(&self) -> Duration {
348 self.total
349 }
350}
351
352fn gif_allocation_sizes(
353 width: u32,
354 height: u32,
355 frame_count: usize,
356) -> anyhow::Result<(usize, usize)> {
357 let pixels = usize::try_from(u64::from(width) * u64::from(height))?;
358 let frame_bytes = pixels
359 .checked_mul(4)
360 .ok_or_else(|| anyhow::anyhow!("GIF frame size overflow for {width}x{height}"))?;
361 let working_set = frame_bytes
362 .checked_mul(3)
363 .ok_or_else(|| anyhow::anyhow!("GIF working-set overflow for {width}x{height}"))?;
364 anyhow::ensure!(
365 working_set <= MAX_GIF_WORKING_SET,
366 "GIF {width}x{height} requires approximately {:.1} MiB of decode working memory, exceeding the {} MiB safety limit",
367 working_set as f64 / (1024.0 * 1024.0),
368 MAX_GIF_WORKING_SET / 1024 / 1024
369 );
370 Ok((pixels, frame_bytes.saturating_mul(frame_count)))
371}
372
373fn scan_gif(bytes: &[u8]) -> anyhow::Result<Option<GifInfo>> {
376 if bytes.len() < 13 || &bytes[0..3] != b"GIF" {
377 return Ok(None);
378 }
379 let width = u16::from_le_bytes([bytes[6], bytes[7]]) as u32;
380 let height = u16::from_le_bytes([bytes[8], bytes[9]]) as u32;
381 let packed = bytes[10];
382 let gct_size = 3 * (1 << ((packed & 0x07) + 1));
383 let mut pos = 13 + gct_size;
384 if pos > bytes.len() {
385 return Ok(None);
386 }
387
388 let mut delays = Vec::new();
389 let mut opaque = true;
390 let mut frame_count = 0usize;
391
392 while pos < bytes.len() {
393 match bytes[pos] {
394 0x3B => break, 0x2C => {
396 if pos + 10 > bytes.len() {
398 break;
399 }
400 let left = u16::from_le_bytes([bytes[pos + 1], bytes[pos + 2]]) as u32;
401 let top = u16::from_le_bytes([bytes[pos + 3], bytes[pos + 4]]) as u32;
402 let iw = u16::from_le_bytes([bytes[pos + 5], bytes[pos + 6]]) as u32;
403 let ih = u16::from_le_bytes([bytes[pos + 7], bytes[pos + 8]]) as u32;
404 let ipacked = bytes[pos + 9];
405 pos += 10;
406 if ipacked & 0x80 != 0 {
407 pos += 3 * (1 << ((ipacked & 0x07) + 1));
408 }
409 if pos >= bytes.len() {
410 break;
411 }
412 pos += 1; pos = skip_sub_blocks(bytes, pos);
414 if left != 0 || top != 0 || iw != width || ih != height {
415 opaque = false;
416 }
417 frame_count += 1;
418 }
419 0x21 => {
420 if pos + 2 <= bytes.len() && bytes[pos + 1] == 0xF9 {
422 if pos + 8 > bytes.len() {
423 break;
424 }
425 let gce_packed = bytes[pos + 3];
426 if gce_packed & 0x01 != 0 {
427 opaque = false; }
429 let centis = u16::from_le_bytes([bytes[pos + 4], bytes[pos + 5]]);
432 let millis = if centis == 0 { 20 } else { centis as u64 * 10 };
433 delays.push(
434 Duration::from_millis(millis)
435 .clamp(Duration::from_millis(20), Duration::from_secs(5)),
436 );
437 pos += 8;
438 continue;
439 }
440 pos += 2;
441 pos = skip_sub_blocks(bytes, pos);
442 }
443 _ => break, }
445 }
446
447 if frame_count == 0 {
448 return Ok(None);
449 }
450 if delays.is_empty() {
452 delays = vec![Duration::from_millis(20); frame_count];
453 }
454 Ok(Some(GifInfo {
455 width,
456 height,
457 delays,
458 opaque,
459 }))
460}
461
462fn skip_sub_blocks(bytes: &[u8], mut pos: usize) -> usize {
465 loop {
466 if pos >= bytes.len() {
467 return bytes.len();
468 }
469 let n = bytes[pos] as usize;
470 pos += 1;
471 if n == 0 {
472 return pos;
473 }
474 pos += n;
475 if pos > bytes.len() {
476 return bytes.len();
477 }
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use std::time::Duration;
485
486 fn delays() -> Vec<Duration> {
487 vec![
488 Duration::from_millis(100),
489 Duration::from_millis(200),
490 Duration::from_millis(300),
491 ]
492 }
493
494 fn anim_with(delays: Vec<Duration>) -> AnimatedImage {
495 AnimatedImage {
496 path: PathBuf::new(),
497 width: 1,
498 height: 1,
499 total: delays.iter().copied().sum(),
500 cache: vec![None; delays.len()],
501 cache_bytes: 0,
502 reader: None,
503 next_index: 0,
504 canvas: vec![0; 4],
505 scratch: vec![0; 4],
506 decompressor: zstd::bulk::Decompressor::new().unwrap(),
507 prev_save: Vec::new(),
508 opaque: true,
509 raw_cache: true,
510 delays,
511 }
512 }
513
514 #[test]
515 fn frame_index_tracks_delays() {
516 let anim = anim_with(delays());
517 assert_eq!(anim.frame_index_at(Duration::ZERO), 0);
518 assert_eq!(anim.frame_index_at(Duration::from_millis(99)), 0);
519 assert_eq!(anim.frame_index_at(Duration::from_millis(100)), 1);
520 assert_eq!(anim.frame_index_at(Duration::from_millis(299)), 1);
521 assert_eq!(anim.frame_index_at(Duration::from_millis(300)), 2);
522 assert_eq!(anim.frame_index_at(Duration::from_millis(599)), 2);
523 }
524
525 #[test]
526 fn frame_index_loops() {
527 let anim = anim_with(delays());
528 assert_eq!(anim.frame_index_at(Duration::from_millis(600)), 0);
530 assert_eq!(anim.frame_index_at(Duration::from_millis(610)), 0);
531 assert_eq!(anim.frame_index_at(Duration::from_millis(700)), 1);
532 assert_eq!(anim.frame_index_at(Duration::from_millis(3000)), 0);
533 }
534
535 #[test]
536 fn scan_rejects_non_gif() {
537 assert!(scan_gif(b"not a gif at all").unwrap().is_none());
538 assert!(scan_gif(&[0u8; 100]).unwrap().is_none());
539 }
540
541 #[test]
542 fn rejects_gif_working_sets_before_allocating_canvases() {
543 assert!(gif_allocation_sizes(7_680, 4_320, 2).is_ok());
544 assert!(gif_allocation_sizes(16_384, 16_384, 1).is_err());
545 }
546}