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
28/// A cursor over a GIF file that yields frames in order.
29struct GifReader {
30    decoder: gif::Decoder<std::io::BufReader<std::fs::File>>,
31}
32
33/// A decoded frame with its placement metadata.
34struct 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    /// Decodes the next frame into RGBA8. Returns `None` on end of stream or
53    /// a truncated/corrupt tail (the caller restarts the stream).
54    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
69/// Timeline metadata collected by the header scan.
70struct GifInfo {
71    width: u32,
72    height: u32,
73    delays: Vec<Duration>,
74    /// True when every frame is full-canvas with no transparency, so the
75    /// decoder can skip canvas compositing entirely.
76    opaque: bool,
77}
78
79/// An animated GIF streamed on demand during live playback.
80pub struct AnimatedImage {
81    path: PathBuf,
82    pub width: u32,
83    pub height: u32,
84    delays: Vec<Duration>,
85    total: Duration,
86    /// Per-frame cached data, `None` when not (yet) cached.
87    cache: Vec<Option<CachedFrame>>,
88    cache_bytes: usize,
89    /// Streaming decoder positioned after `next_index` frames.
90    reader: Option<GifReader>,
91    next_index: usize,
92    /// Canvas for transparency compositing; also the scratch target for
93    /// freshly decoded opaque frames.
94    canvas: Vec<u8>,
95    /// Scratch buffer for zstd decompression.
96    scratch: Vec<u8>,
97    /// Persistent zstd context, reused across frames (creating one per call
98    /// is measurably slower).
99    decompressor: zstd::bulk::Decompressor<'static>,
100    /// Saved canvas region for `DisposalMethod::Previous` frames.
101    prev_save: Vec<u8>,
102    opaque: bool,
103    /// Cache mode: raw when the whole animation fits the budget.
104    raw_cache: bool,
105}
106
107impl AnimatedImage {
108    /// Parses `path`'s GIF header for timing metadata without decoding any
109    /// pixels. Returns `Ok(None)` when the file is not a GIF so callers can
110    /// keep their existing static-image path.
111    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    /// Decodes the next frame, compositing it onto the canvas and caching it.
156    /// Returns `false` at end of stream / on error.
157    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            // Transparency compositing: the frame's rect is blended onto the
171            // persistent canvas, then the disposal method is applied.
172            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        // Cache the freshly decoded frame if the budget allows: raw when the
220        // whole animation fits, zstd otherwise.
221        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    /// Ensure `index` is decoded, advancing the streaming decoder as needed.
241    /// Frames already in the cache are skipped without re-decoding.
242    /// Restarts from frame 0 on loop wrap or end of stream.
243    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                // End of stream while seeking forward: restart from 0.
256                self.restart();
257            }
258            guard += 1;
259            // Safety valve against truncated streams that never progress.
260            if self.next_index == before || guard > self.delays.len() + 1 {
261                break;
262            }
263        }
264    }
265
266    /// RGBA8 bytes of the first frame, used as the transition's incoming
267    /// image.
268    pub fn first_frame(&mut self) -> &[u8] {
269        self.frame_at(0)
270    }
271
272    /// RGBA8 bytes of the frame at `index`.
273    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    /// Decompresses the frame at `index` directly into `out` (which must be
295    /// exactly `width * height * 4` bytes), skipping the shared scratch
296    /// buffer. Returns `false` when the frame is not cached yet.
297    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    /// Index of the frame to display at `elapsed` time, looping forever.
315    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    /// Cumulative time at which frame `index` begins. For `index == len` this
329    /// equals the total duration (the next wrap back to frame 0).
330    pub fn frame_start(&self, index: usize) -> Duration {
331        self.delays.iter().take(index).copied().sum()
332    }
333
334    /// Total number of frames in the animation.
335    pub fn frame_count(&self) -> usize {
336        self.delays.len()
337    }
338
339    /// Bytes currently held by the frame cache.
340    pub fn cache_bytes(&self) -> usize {
341        self.cache_bytes
342    }
343
344    /// Number of frames stored uncompressed.
345    pub fn raw_cached(&self) -> usize {
346        self.cache
347            .iter()
348            .filter(|c| matches!(c, Some(CachedFrame::Raw(_))))
349            .count()
350    }
351
352    /// Number of frames stored zstd-compressed.
353    pub fn zstd_cached(&self) -> usize {
354        self.cache
355            .iter()
356            .filter(|c| matches!(c, Some(CachedFrame::Zstd(_))))
357            .count()
358    }
359
360    /// Total duration of one full loop of the animation.
361    pub fn total_duration(&self) -> Duration {
362        self.total
363    }
364}
365
366/// Parses GIF header blocks (screen descriptor, graphic control extensions,
367/// image descriptors) without decoding pixel data, returning the timeline.
368fn 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, // trailer
388            0x2C => {
389                // Image descriptor: left, top, w, h (u16 each), packed byte.
390                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; // LZW minimum code size
406                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                // Extension: 0xF9 = graphic control extension (delay).
414                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; // transparency flag
421                    }
422                    // Delay in centiseconds; browsers treat 0 as 100ms, but
423                    // keep the prior 20ms clamp for zero delays.
424                    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, // corrupt block: stop scanning
437        }
438    }
439
440    if frame_count == 0 {
441        return Ok(None);
442    }
443    // If no GCE appeared at all, every frame gets the default delay.
444    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
455/// Skips a run of length-prefixed data sub-blocks; returns the position after
456/// the terminating zero-length block.
457fn 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        // 600ms is one full cycle; the playhead wraps back to frame 0.
522        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}