Skip to main content

wallr_core/animated/
mod.rs

1//! Animated wallpaper (GIF) streaming and playback timing.
2//!
3//! The daemon parses the GIF header blocks once to learn frame delays and
4//! total duration (no pixel decode), then decodes frames on demand with the
5//! fast `gif` crate. Decoded frames are stored in memory — raw when they fit
6//! the budget, zstd-compressed otherwise — so looping playback skips the
7//! re-decode entirely: each loop is a memcpy (raw) or a decompress (zstd).
8
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use gif::DisposalMethod;
13
14/// Maximum bytes of decoded frame data kept in RAM. Frames beyond this are
15/// still decoded on demand, but not cached across loop wraps.
16const CACHE_BUDGET: usize = 256 * 1024 * 1024;
17const MAX_GIF_WORKING_SET: usize = 512 * 1024 * 1024;
18
19/// A cached frame: raw RGBA8 or zstd-compressed RGBA8. The whole animation
20/// uses one representation, chosen at decode time: raw when the full decoded
21/// size fits [`CACHE_BUDGET`] (small wallpapers → memcpy-speed loops),
22/// zstd otherwise (big wallpapers compress ~30:1 and still fit).
23#[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    /// Decodes the next frame into RGBA8. Returns `None` on end of stream or
52    /// a truncated/corrupt tail (the caller restarts the stream).
53    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    /// True when every frame is full-canvas with no transparency, so the
73    /// decoder can skip canvas compositing entirely.
74    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    /// Per-frame cached data, `None` when not (yet) cached.
84    cache: Vec<Option<CachedFrame>>,
85    cache_bytes: usize,
86    reader: Option<GifReader>,
87    next_index: usize,
88    /// Canvas for transparency compositing; also the scratch target for
89    /// freshly decoded opaque frames.
90    canvas: Vec<u8>,
91    scratch: Vec<u8>,
92    /// Persistent zstd context, reused across frames (creating one per call
93    /// is measurably slower).
94    decompressor: zstd::bulk::Decompressor<'static>,
95    prev_save: Vec<u8>,
96    opaque: bool,
97    raw_cache: bool,
98}
99
100impl AnimatedImage {
101    /// Parses `path`'s GIF header for timing metadata without decoding any
102    /// pixels. Returns `Ok(None)` when the file is not a GIF so callers can
103    /// keep their existing static-image path.
104    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    /// Decodes the next frame, compositing it onto the canvas and caching it.
148    /// Returns `false` at end of stream / on error.
149    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            // Transparency compositing: the frame's rect is blended onto the
163            // persistent canvas, then the disposal method is applied.
164            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        // Cache the freshly decoded frame if the budget allows: raw when the
212        // whole animation fits, zstd otherwise.
213        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    /// Ensure `index` is decoded, advancing the streaming decoder as needed.
233    /// Frames already in the cache are skipped without re-decoding.
234    /// Restarts from frame 0 on loop wrap or end of stream.
235    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                // End of stream while seeking forward: restart from 0.
248                self.restart();
249            }
250            guard += 1;
251            // Safety valve against truncated streams that never progress.
252            if self.next_index == before || guard > self.delays.len() + 1 {
253                break;
254            }
255        }
256    }
257
258    /// RGBA8 bytes of the first frame, used as the transition's incoming
259    /// image.
260    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    /// Decompresses the frame at `index` directly into `out` (which must be
286    /// exactly `width * height * 4` bytes), skipping the shared scratch
287    /// buffer. Returns `false` when the frame is not cached yet.
288    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    /// Index of the frame to display at `elapsed` time, looping forever.
306    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    /// Cumulative time at which frame `index` begins. For `index == len` this
320    /// equals the total duration (the next wrap back to frame 0).
321    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
373/// Parses GIF header blocks (screen descriptor, graphic control extensions,
374/// image descriptors) without decoding pixel data, returning the timeline.
375fn 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, // trailer
395            0x2C => {
396                // Image descriptor: left, top, w, h (u16 each), packed byte.
397                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; // LZW minimum code size
413                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                // Extension: 0xF9 = graphic control extension (delay).
421                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; // transparency flag
428                    }
429                    // Delay in centiseconds; browsers treat 0 as 100ms, but
430                    // keep the prior 20ms clamp for zero delays.
431                    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, // corrupt block: stop scanning
444        }
445    }
446
447    if frame_count == 0 {
448        return Ok(None);
449    }
450    // If no GCE appeared at all, every frame gets the default delay.
451    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
462/// Skips a run of length-prefixed data sub-blocks; returns the position after
463/// the terminating zero-length block.
464fn 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        // 600ms is one full cycle; the playhead wraps back to frame 0.
529        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}