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