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;
17
18/// A cached frame: raw RGBA8 or zstd-compressed RGBA8. The whole animation
19/// uses one representation, chosen at decode time: raw when the full decoded
20/// size fits [`CACHE_BUDGET`] (small wallpapers → memcpy-speed loops),
21/// zstd otherwise (big wallpapers compress ~30:1 and still fit).
22#[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    /// Decodes the next frame into RGBA8. Returns `None` on end of stream or
51    /// a truncated/corrupt tail (the caller restarts the stream).
52    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    /// True when every frame is full-canvas with no transparency, so the
72    /// decoder can skip canvas compositing entirely.
73    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    /// Per-frame cached data, `None` when not (yet) cached.
83    cache: Vec<Option<CachedFrame>>,
84    cache_bytes: usize,
85    reader: Option<GifReader>,
86    next_index: usize,
87    /// Canvas for transparency compositing; also the scratch target for
88    /// freshly decoded opaque frames.
89    canvas: Vec<u8>,
90    scratch: Vec<u8>,
91    /// Persistent zstd context, reused across frames (creating one per call
92    /// is measurably slower).
93    decompressor: zstd::bulk::Decompressor<'static>,
94    prev_save: Vec<u8>,
95    opaque: bool,
96    raw_cache: bool,
97}
98
99impl AnimatedImage {
100    /// Parses `path`'s GIF header for timing metadata without decoding any
101    /// pixels. Returns `Ok(None)` when the file is not a GIF so callers can
102    /// keep their existing static-image path.
103    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    /// 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
352/// Parses GIF header blocks (screen descriptor, graphic control extensions,
353/// image descriptors) without decoding pixel data, returning the timeline.
354fn 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, // trailer
374            0x2C => {
375                // Image descriptor: left, top, w, h (u16 each), packed byte.
376                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; // LZW minimum code size
392                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                // Extension: 0xF9 = graphic control extension (delay).
400                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; // transparency flag
407                    }
408                    // Delay in centiseconds; browsers treat 0 as 100ms, but
409                    // keep the prior 20ms clamp for zero delays.
410                    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, // corrupt block: stop scanning
423        }
424    }
425
426    if frame_count == 0 {
427        return Ok(None);
428    }
429    // If no GCE appeared at all, every frame gets the default delay.
430    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
441/// Skips a run of length-prefixed data sub-blocks; returns the position after
442/// the terminating zero-length block.
443fn 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        // 600ms is one full cycle; the playhead wraps back to frame 0.
508        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}