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 {
29 decoder: gif::Decoder<std::io::BufReader<std::fs::File>>,
30}
31
32struct DecodedFrame {
33 left: u16,
34 top: u16,
35 width: u16,
36 height: u16,
37 dispose: DisposalMethod,
38 rgba: Vec<u8>,
39}
40
41impl GifReader {
42 fn open(path: &Path) -> anyhow::Result<Self> {
43 let file = std::fs::File::open(path)?;
44 let mut options = gif::DecodeOptions::new();
45 options.set_color_output(gif::ColorOutput::RGBA);
46 let decoder = options.read_info(std::io::BufReader::new(file))?;
47 Ok(Self { decoder })
48 }
49
50 fn next_rgba(&mut self) -> Option<DecodedFrame> {
53 match self.decoder.read_next_frame() {
54 Ok(Some(frame)) => Some(DecodedFrame {
55 left: frame.left,
56 top: frame.top,
57 width: frame.width,
58 height: frame.height,
59 dispose: frame.dispose,
60 rgba: frame.buffer.as_ref().to_vec(),
61 }),
62 Ok(None) | Err(_) => None,
63 }
64 }
65}
66
67struct GifInfo {
68 width: u32,
69 height: u32,
70 delays: Vec<Duration>,
71 opaque: bool,
74}
75
76pub struct AnimatedImage {
77 path: PathBuf,
78 pub width: u32,
79 pub height: u32,
80 delays: Vec<Duration>,
81 total: Duration,
82 cache: Vec<Option<CachedFrame>>,
84 cache_bytes: usize,
85 reader: Option<GifReader>,
86 next_index: usize,
87 canvas: Vec<u8>,
90 scratch: Vec<u8>,
91 decompressor: zstd::bulk::Decompressor<'static>,
94 prev_save: Vec<u8>,
95 opaque: bool,
96 raw_cache: bool,
97}
98
99impl AnimatedImage {
100 pub fn decode(path: &Path) -> anyhow::Result<Option<Self>> {
104 let bytes = std::fs::read(path)?;
105 let Some(info) = scan_gif(&bytes)? else {
106 return Ok(None);
107 };
108 if info.delays.is_empty() {
109 return Ok(None);
110 }
111 let total = info.delays.iter().copied().sum();
112 let pixels = (info.width * info.height) as usize;
113 let frame_count = info.delays.len();
114 let raw_size = pixels * 4 * frame_count;
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 scan_gif(bytes: &[u8]) -> anyhow::Result<Option<GifInfo>> {
355 if bytes.len() < 13 || &bytes[0..3] != b"GIF" {
356 return Ok(None);
357 }
358 let width = u16::from_le_bytes([bytes[6], bytes[7]]) as u32;
359 let height = u16::from_le_bytes([bytes[8], bytes[9]]) as u32;
360 let packed = bytes[10];
361 let gct_size = 3 * (1 << ((packed & 0x07) + 1));
362 let mut pos = 13 + gct_size;
363 if pos > bytes.len() {
364 return Ok(None);
365 }
366
367 let mut delays = Vec::new();
368 let mut opaque = true;
369 let mut frame_count = 0usize;
370
371 while pos < bytes.len() {
372 match bytes[pos] {
373 0x3B => break, 0x2C => {
375 if pos + 10 > bytes.len() {
377 break;
378 }
379 let left = u16::from_le_bytes([bytes[pos + 1], bytes[pos + 2]]) as u32;
380 let top = u16::from_le_bytes([bytes[pos + 3], bytes[pos + 4]]) as u32;
381 let iw = u16::from_le_bytes([bytes[pos + 5], bytes[pos + 6]]) as u32;
382 let ih = u16::from_le_bytes([bytes[pos + 7], bytes[pos + 8]]) as u32;
383 let ipacked = bytes[pos + 9];
384 pos += 10;
385 if ipacked & 0x80 != 0 {
386 pos += 3 * (1 << ((ipacked & 0x07) + 1));
387 }
388 if pos >= bytes.len() {
389 break;
390 }
391 pos += 1; pos = skip_sub_blocks(bytes, pos);
393 if left != 0 || top != 0 || iw != width || ih != height {
394 opaque = false;
395 }
396 frame_count += 1;
397 }
398 0x21 => {
399 if pos + 2 <= bytes.len() && bytes[pos + 1] == 0xF9 {
401 if pos + 8 > bytes.len() {
402 break;
403 }
404 let gce_packed = bytes[pos + 3];
405 if gce_packed & 0x01 != 0 {
406 opaque = false; }
408 let centis = u16::from_le_bytes([bytes[pos + 4], bytes[pos + 5]]);
411 let millis = if centis == 0 { 20 } else { centis as u64 * 10 };
412 delays.push(
413 Duration::from_millis(millis)
414 .clamp(Duration::from_millis(20), Duration::from_secs(5)),
415 );
416 pos += 8;
417 continue;
418 }
419 pos += 2;
420 pos = skip_sub_blocks(bytes, pos);
421 }
422 _ => break, }
424 }
425
426 if frame_count == 0 {
427 return Ok(None);
428 }
429 if delays.is_empty() {
431 delays = vec![Duration::from_millis(20); frame_count];
432 }
433 Ok(Some(GifInfo {
434 width,
435 height,
436 delays,
437 opaque,
438 }))
439}
440
441fn skip_sub_blocks(bytes: &[u8], mut pos: usize) -> usize {
444 loop {
445 if pos >= bytes.len() {
446 return bytes.len();
447 }
448 let n = bytes[pos] as usize;
449 pos += 1;
450 if n == 0 {
451 return pos;
452 }
453 pos += n;
454 if pos > bytes.len() {
455 return bytes.len();
456 }
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use std::time::Duration;
464
465 fn delays() -> Vec<Duration> {
466 vec![
467 Duration::from_millis(100),
468 Duration::from_millis(200),
469 Duration::from_millis(300),
470 ]
471 }
472
473 fn anim_with(delays: Vec<Duration>) -> AnimatedImage {
474 AnimatedImage {
475 path: PathBuf::new(),
476 width: 1,
477 height: 1,
478 total: delays.iter().copied().sum(),
479 cache: vec![None; delays.len()],
480 cache_bytes: 0,
481 reader: None,
482 next_index: 0,
483 canvas: vec![0; 4],
484 scratch: vec![0; 4],
485 decompressor: zstd::bulk::Decompressor::new().unwrap(),
486 prev_save: Vec::new(),
487 opaque: true,
488 raw_cache: true,
489 delays,
490 }
491 }
492
493 #[test]
494 fn frame_index_tracks_delays() {
495 let anim = anim_with(delays());
496 assert_eq!(anim.frame_index_at(Duration::ZERO), 0);
497 assert_eq!(anim.frame_index_at(Duration::from_millis(99)), 0);
498 assert_eq!(anim.frame_index_at(Duration::from_millis(100)), 1);
499 assert_eq!(anim.frame_index_at(Duration::from_millis(299)), 1);
500 assert_eq!(anim.frame_index_at(Duration::from_millis(300)), 2);
501 assert_eq!(anim.frame_index_at(Duration::from_millis(599)), 2);
502 }
503
504 #[test]
505 fn frame_index_loops() {
506 let anim = anim_with(delays());
507 assert_eq!(anim.frame_index_at(Duration::from_millis(600)), 0);
509 assert_eq!(anim.frame_index_at(Duration::from_millis(610)), 0);
510 assert_eq!(anim.frame_index_at(Duration::from_millis(700)), 1);
511 assert_eq!(anim.frame_index_at(Duration::from_millis(3000)), 0);
512 }
513
514 #[test]
515 fn scan_rejects_non_gif() {
516 assert!(scan_gif(b"not a gif at all").unwrap().is_none());
517 assert!(scan_gif(&[0u8; 100]).unwrap().is_none());
518 }
519}