Skip to main content

wallr_core/animated/
mod.rs

1//! Animated wallpaper (GIF) decoding and playback timing.
2//!
3//! The daemon decodes an animated GIF into memory once, uses its first frame
4//! as the transition's incoming texture, and then plays the frames live after
5//! the transition completes. Playback is wall-clock driven: `frame_index_at`
6//! maps elapsed time onto the frame timeline and loops forever.
7
8use std::path::Path;
9use std::time::Duration;
10
11/// A fully decoded animated GIF held in memory for live playback.
12pub struct AnimatedImage {
13    frames: Vec<Vec<u8>>,
14    pub width: u32,
15    pub height: u32,
16    delays: Vec<Duration>,
17    total: Duration,
18}
19
20impl AnimatedImage {
21    /// Decodes `path` as an animated GIF. Returns `Ok(None)` when the file is
22    /// not a GIF so callers can keep their existing static-image path.
23    pub fn decode(path: &Path) -> anyhow::Result<Option<Self>> {
24        use image::AnimationDecoder;
25        use image::ImageDecoder;
26
27        let format = image::ImageReader::open(path)?
28            .with_guessed_format()?
29            .format();
30        if format != Some(image::ImageFormat::Gif) {
31            return Ok(None);
32        }
33
34        let decoder = image::codecs::gif::GifDecoder::new(std::io::BufReader::new(
35            std::fs::File::open(path)?,
36        ))?;
37        let (width, height) = decoder.dimensions();
38        let mut frames = Vec::new();
39        let mut delays = Vec::new();
40        for frame in decoder.into_frames() {
41            let frame = frame?;
42            // GIF delays are centiseconds; browsers treat a 0 delay as 100ms.
43            let (numer, denom) = frame.delay().numer_denom_ms();
44            let millis = numer.checked_div(denom).unwrap_or(100);
45            let delay = Duration::from_millis(millis as u64)
46                .clamp(Duration::from_millis(20), Duration::from_secs(5));
47            frames.push(frame.buffer().as_raw().clone());
48            delays.push(delay);
49        }
50        if frames.is_empty() {
51            return Ok(None);
52        }
53        let total = delays.iter().copied().sum();
54        Ok(Some(Self {
55            frames,
56            width,
57            height,
58            delays,
59            total,
60        }))
61    }
62
63    /// RGBA8 bytes of the first frame, used as the transition's incoming image.
64    pub fn first_frame(&self) -> &[u8] {
65        &self.frames[0]
66    }
67
68    pub fn frame_at(&self, index: usize) -> &[u8] {
69        &self.frames[index.min(self.frames.len() - 1)]
70    }
71
72    /// Index of the frame to display at `elapsed` time, looping forever.
73    pub fn frame_index_at(&self, elapsed: Duration) -> usize {
74        let total_ms = self.total.as_millis().max(1);
75        let mut t = elapsed.as_millis() % total_ms;
76        for (i, delay) in self.delays.iter().enumerate() {
77            let ms = delay.as_millis();
78            if t < ms {
79                return i;
80            }
81            t -= ms;
82        }
83        self.frames.len() - 1
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::AnimatedImage;
90    use std::time::Duration;
91
92    fn sample() -> AnimatedImage {
93        AnimatedImage {
94            frames: vec![vec![0; 4], vec![1; 4], vec![2; 4]],
95            width: 1,
96            height: 1,
97            delays: vec![
98                Duration::from_millis(100),
99                Duration::from_millis(200),
100                Duration::from_millis(300),
101            ],
102            total: Duration::from_millis(600),
103        }
104    }
105
106    #[test]
107    fn frame_index_tracks_delays() {
108        let anim = sample();
109        assert_eq!(anim.frame_index_at(Duration::ZERO), 0);
110        assert_eq!(anim.frame_index_at(Duration::from_millis(99)), 0);
111        assert_eq!(anim.frame_index_at(Duration::from_millis(100)), 1);
112        assert_eq!(anim.frame_index_at(Duration::from_millis(299)), 1);
113        assert_eq!(anim.frame_index_at(Duration::from_millis(300)), 2);
114        assert_eq!(anim.frame_index_at(Duration::from_millis(599)), 2);
115    }
116
117    #[test]
118    fn frame_index_loops() {
119        let anim = sample();
120        // 600ms is one full cycle; the playhead wraps back to frame 0.
121        assert_eq!(anim.frame_index_at(Duration::from_millis(600)), 0);
122        assert_eq!(anim.frame_index_at(Duration::from_millis(610)), 0);
123        assert_eq!(anim.frame_index_at(Duration::from_millis(700)), 1);
124        assert_eq!(anim.frame_index_at(Duration::from_millis(3000)), 0);
125    }
126
127    #[test]
128    fn frame_at_clamps_out_of_range() {
129        let anim = sample();
130        assert_eq!(anim.frame_at(0), &[0, 0, 0, 0]);
131        assert_eq!(anim.frame_at(999), &[2, 2, 2, 2]);
132        assert_eq!(anim.first_frame(), &[0, 0, 0, 0]);
133    }
134}