Skip to main content

rustmotion_components/
gif.rs

1use std::sync::Arc;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect};
6
7use rustmotion_core::css::CssStyle;
8use rustmotion_core::engine::animator::AnimatedProperties;
9use rustmotion_core::engine::layout_pass::BoxLayout;
10use rustmotion_core::engine::renderer::gif_cache;
11use rustmotion_core::schema::{ImageFit, TimelineStep};
12use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
13
14fn default_loop_true() -> bool {
15    true
16}
17
18#[derive(Debug, Serialize, Deserialize, JsonSchema)]
19pub struct Gif {
20    pub src: String,
21    #[serde(default)]
22    pub fit: ImageFit,
23    #[serde(default = "default_loop_true")]
24    pub loop_gif: bool,
25    #[serde(flatten)]
26    pub timing: TimingConfig,
27    #[serde(default)]
28    pub style: CssStyle,
29    #[serde(default)]
30    pub timeline: Vec<TimelineStep>,
31    #[serde(default)]
32    pub stagger: Option<f32>,
33}
34
35rustmotion_core::impl_traits!(Gif {
36    Animatable => animation,
37    Timed => timing,
38    Styled => style,
39});
40
41/// The frame's rectangle, clamped to the logical canvas.
42///
43/// A malformed GIF can declare a sub-rectangle that runs past the canvas;
44/// clamping keeps the blit in bounds instead of panicking on the slice.
45fn frame_rect(canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) -> (u32, u32, u32, u32) {
46    let left = frame.left.min(canvas_w as u16) as u32;
47    let top = frame.top.min(canvas_h as u16) as u32;
48    let w = (frame.width as u32).min(canvas_w.saturating_sub(left));
49    let h = (frame.height as u32).min(canvas_h.saturating_sub(top));
50    (left, top, w, h)
51}
52
53/// Composite one decoded frame onto the persistent canvas.
54///
55/// GIF transparency is an index, not a channel: the decoder emits alpha 0 for
56/// transparent pixels, and those must leave what is underneath alone — that is
57/// the whole point of the sub-rectangle encoding.
58fn blit_frame(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) {
59    let (left, top, w, h) = frame_rect(canvas_w, canvas_h, frame);
60    for y in 0..h {
61        for x in 0..w {
62            let src = ((y * frame.width as u32 + x) * 4) as usize;
63            let Some(px) = frame.buffer.get(src..src + 4) else {
64                return; // truncated buffer: keep what we have rather than panic
65            };
66            if px[3] == 0 {
67                continue;
68            }
69            let dst = (((top + y) * canvas_w + (left + x)) * 4) as usize;
70            composed[dst..dst + 4].copy_from_slice(px);
71        }
72    }
73}
74
75/// `DisposalMethod::Background`: clear this frame's rectangle before the next.
76fn clear_rect(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) {
77    let (left, top, w, h) = frame_rect(canvas_w, canvas_h, frame);
78    for y in 0..h {
79        let row = (((top + y) * canvas_w + left) * 4) as usize;
80        composed[row..row + (w as usize * 4)].fill(0);
81    }
82}
83
84/// One decoded GIF: full-canvas RGBA frames with their dimensions, the
85/// cumulative end time of each, and the total duration. Mirrors what
86/// `gif_cache` stores.
87type DecodedGif = (Vec<(Vec<u8>, u32, u32)>, Vec<f64>, f64);
88
89/// Decode a GIF into full-canvas RGBA frames, their cumulative end times, and
90/// the total duration.
91///
92/// Every frame after the first is usually a *sub-rectangle* holding only the
93/// pixels that changed, so frames must be composed onto a persistent canvas
94/// rather than used as images in their own right. Storing `frame.buffer` with
95/// the canvas dimensions produced a buffer shorter than `width * height * 4`;
96/// `raster_from_data` then returned `None` and the paint was skipped — which is
97/// why only the first frame ever appeared (issue #185).
98///
99/// `None` means nothing can be drawn, and the reason has already been reported.
100fn decode_composed_frames(src: &str) -> Option<DecodedGif> {
101    let file = match std::fs::File::open(src) {
102        Ok(f) => f,
103        Err(e) => {
104            if crate::warn_once_for(&format!("gif-open:{src}")) {
105                eprintln!("rustmotion: gif '{src}' could not be opened: {e}");
106            }
107            return None;
108        }
109    };
110
111    let mut options = gif::DecodeOptions::new();
112    options.set_color_output(gif::ColorOutput::RGBA);
113    let mut decoder = match options.read_info(file) {
114        Ok(d) => d,
115        Err(e) => {
116            if crate::warn_once_for(&format!("gif-decode:{src}")) {
117                eprintln!("rustmotion: gif '{src}' could not be decoded: {e}");
118            }
119            return None;
120        }
121    };
122
123    let canvas_w = decoder.width() as u32;
124    let canvas_h = decoder.height() as u32;
125
126    let mut frames: Vec<(Vec<u8>, u32, u32)> = Vec::new();
127    let mut cumulative_times: Vec<f64> = Vec::new();
128    let mut accumulated = 0.0;
129    let mut composed = vec![0u8; canvas_w as usize * canvas_h as usize * 4];
130
131    while let Ok(Some(frame)) = decoder.read_next_frame() {
132        // `Previous` disposal restores what was there before this frame, so it
133        // has to be captured before compositing.
134        let restore = (frame.dispose == gif::DisposalMethod::Previous).then(|| composed.clone());
135
136        blit_frame(&mut composed, canvas_w, canvas_h, frame);
137
138        let delay = frame.delay as f64 / 100.0;
139        let delay = if delay < 0.01 { 0.1 } else { delay };
140        accumulated += delay;
141        frames.push((composed.clone(), canvas_w, canvas_h));
142        cumulative_times.push(accumulated);
143
144        match frame.dispose {
145            gif::DisposalMethod::Background => clear_rect(&mut composed, canvas_w, canvas_h, frame),
146            gif::DisposalMethod::Previous => {
147                if let Some(prev) = restore {
148                    composed = prev;
149                }
150            }
151            // `Any` and `Keep` both leave the canvas as it stands.
152            _ => {}
153        }
154    }
155
156    if frames.is_empty() {
157        if crate::warn_once_for(&format!("gif-empty:{src}")) {
158            eprintln!("rustmotion: gif '{src}' decoded to zero frames");
159        }
160        return None;
161    }
162
163    Some((frames, cumulative_times, accumulated))
164}
165
166impl Painter for Gif {
167    fn paint_content(
168        &self,
169        canvas: &Canvas,
170        layout: &BoxLayout,
171        _props: &AnimatedProperties,
172        ctx: &PaintCtx,
173    ) {
174        let gcache = gif_cache();
175
176        let cached = if let Some(cached) = gcache.get(&self.src) {
177            cached.clone()
178        } else {
179            let Some(decoded) = decode_composed_frames(&self.src) else {
180                return;
181            };
182            let cached = Arc::new(decoded);
183            gcache.insert(self.src.clone(), cached.clone());
184            cached
185        };
186
187        let (ref frames, ref cumulative_times, total_duration) = *cached;
188
189        if frames.is_empty() {
190            return;
191        }
192
193        let effective_time = if self.loop_gif {
194            ctx.time % total_duration
195        } else {
196            ctx.time.min(total_duration)
197        };
198
199        let frame_idx = cumulative_times
200            .partition_point(|&t| t <= effective_time)
201            .min(frames.len() - 1);
202        let (ref frame_data, gif_width, gif_height) = frames[frame_idx];
203
204        let img_info = ImageInfo::new(
205            (gif_width as i32, gif_height as i32),
206            ColorType::RGBA8888,
207            skia_safe::AlphaType::Unpremul,
208            None,
209        );
210        let row_bytes = gif_width as usize * 4;
211        let data = skia_safe::Data::new_copy(frame_data);
212        if let Some(img) = skia_safe::images::raster_from_data(&img_info, data, row_bytes) {
213            let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
214            let paint = Paint::default();
215            canvas.draw_image_rect(img, None, dst, &paint);
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    /// Write a 4x2 GIF: frame 0 fills the canvas red, frame 1 paints a 2x2 blue
225    /// sub-rectangle at (2,0) and keeps what is under it — the shape every
226    /// optimising encoder produces, and the one that used to render nothing.
227    fn write_two_frame_gif(path: &std::path::Path) {
228        let palette: &[u8] = &[0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF];
229        let mut file = std::fs::File::create(path).expect("create gif fixture");
230        let mut encoder = gif::Encoder::new(&mut file, 4, 2, palette).expect("gif encoder");
231
232        let mut full = gif::Frame::from_indexed_pixels(4, 2, vec![0; 8], None);
233        full.delay = 10;
234        encoder.write_frame(&full).expect("write frame 0");
235
236        let mut patch = gif::Frame::from_indexed_pixels(2, 2, vec![1; 4], None);
237        patch.left = 2;
238        patch.top = 0;
239        patch.delay = 10;
240        patch.dispose = gif::DisposalMethod::Keep;
241        encoder.write_frame(&patch).expect("write frame 1");
242    }
243
244    fn px(frame: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
245        let i = ((y * w + x) * 4) as usize;
246        frame[i..i + 4].try_into().expect("pixel")
247    }
248
249    /// The regression: an optimised GIF must yield as many drawable frames as
250    /// it has, all full-canvas, and they must differ.
251    #[test]
252    fn a_subrectangle_frame_is_composed_onto_the_previous_one() {
253        let path = std::env::temp_dir().join(format!(
254            "rustmotion_gif_{}.gif",
255            std::process::id() as u64 * 31 + 7
256        ));
257        write_two_frame_gif(&path);
258
259        let (frames, times, total) =
260            decode_composed_frames(path.to_str().expect("utf-8 path")).expect("gif must decode");
261        std::fs::remove_file(&path).ok();
262
263        assert_eq!(frames.len(), 2, "both frames must be drawable");
264        for (buf, w, h) in &frames {
265            assert_eq!(
266                buf.len(),
267                (*w as usize) * (*h as usize) * 4,
268                "every stored frame must be full-canvas, or raster_from_data \
269                 silently returns None and nothing is painted"
270            );
271        }
272        assert_ne!(frames[0].0, frames[1].0, "the two frames must differ");
273
274        // Frame 1 patches the right half and keeps the left: the whole point of
275        // composing rather than drawing the sub-rectangle alone.
276        assert_eq!(px(&frames[1].0, 4, 0, 0), [0xFF, 0x00, 0x00, 0xFF], "kept");
277        assert_eq!(
278            px(&frames[1].0, 4, 3, 0),
279            [0x00, 0x00, 0xFF, 0xFF],
280            "patched"
281        );
282
283        assert_eq!(times.len(), 2);
284        assert!((total - 0.2).abs() < 1e-9, "0.1s per frame, got {total}");
285    }
286
287    #[test]
288    fn a_missing_file_reports_instead_of_returning_nothing() {
289        let missing = std::env::temp_dir().join("rustmotion_gif_absent_xyz.gif");
290        assert!(decode_composed_frames(missing.to_str().expect("utf-8")).is_none());
291        // The warn-once slot must have been claimed — silence is the bug.
292        assert!(
293            !crate::warn_once_for(&format!("gif-open:{}", missing.to_str().expect("utf-8"))),
294            "the open failure must have reported once"
295        );
296    }
297
298    #[test]
299    fn a_frame_rect_running_past_the_canvas_is_clamped() {
300        let mut frame = gif::Frame::from_indexed_pixels(4, 4, vec![0; 16], None);
301        frame.left = 3;
302        frame.top = 3;
303        assert_eq!(frame_rect(4, 4, &frame), (3, 3, 1, 1));
304    }
305}