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
41fn frame_rect(canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) -> (u32, u32, u32, u32) {
42    let left = frame.left.min(canvas_w as u16) as u32;
43    let top = frame.top.min(canvas_h as u16) as u32;
44    let w = (frame.width as u32).min(canvas_w.saturating_sub(left));
45    let h = (frame.height as u32).min(canvas_h.saturating_sub(top));
46    (left, top, w, h)
47}
48
49fn blit_frame(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) {
50    let (left, top, w, h) = frame_rect(canvas_w, canvas_h, frame);
51    for y in 0..h {
52        for x in 0..w {
53            let src = ((y * frame.width as u32 + x) * 4) as usize;
54            let Some(px) = frame.buffer.get(src..src + 4) else {
55                return;
56            };
57            if px[3] == 0 {
58                continue;
59            }
60            let dst = (((top + y) * canvas_w + (left + x)) * 4) as usize;
61            composed[dst..dst + 4].copy_from_slice(px);
62        }
63    }
64}
65
66fn clear_rect(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) {
67    let (left, top, w, h) = frame_rect(canvas_w, canvas_h, frame);
68    for y in 0..h {
69        let row = (((top + y) * canvas_w + left) * 4) as usize;
70        composed[row..row + (w as usize * 4)].fill(0);
71    }
72}
73
74type DecodedGif = (Vec<(Vec<u8>, u32, u32)>, Vec<f64>, f64);
75
76#[cfg(test)]
77static DECODE_STALL_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
78
79#[cfg(test)]
80fn stall_decode_for_tests() {
81    let stall_ms = DECODE_STALL_MS.load(std::sync::atomic::Ordering::SeqCst);
82    if stall_ms > 0 {
83        std::thread::sleep(std::time::Duration::from_millis(stall_ms));
84    }
85}
86
87const MAX_GIF_CANVAS_BYTES: u64 = 128 * 1024 * 1024;
88
89const MAX_GIF_FRAMES: usize = 600;
90
91fn decode_composed_frames(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option<DecodedGif> {
92    let file = match std::fs::File::open(src) {
93        Ok(f) => f,
94        Err(e) => {
95            if crate::warn_once_for(&format!("gif-open:{src}")) {
96                eprintln!("rustmotion: gif '{src}' could not be opened: {e}");
97            }
98            return None;
99        }
100    };
101
102    let mut options = gif::DecodeOptions::new();
103    options.set_color_output(gif::ColorOutput::RGBA);
104    let mut decoder = match options.read_info(file) {
105        Ok(d) => d,
106        Err(e) => {
107            if crate::warn_once_for(&format!("gif-decode:{src}")) {
108                eprintln!("rustmotion: gif '{src}' could not be decoded: {e}");
109            }
110            return None;
111        }
112    };
113
114    let canvas_w = decoder.width() as u32;
115    let canvas_h = decoder.height() as u32;
116
117    let canvas_bytes = canvas_w as u64 * canvas_h as u64 * 4;
118    if canvas_bytes > MAX_GIF_CANVAS_BYTES || canvas_w > max_canvas_w || canvas_h > max_canvas_h {
119        if crate::warn_once_for(&format!("gif-oversized:{src}")) {
120            eprintln!(
121                "rustmotion: gif '{src}' declares a {canvas_w}x{canvas_h} canvas ({canvas_bytes} \
122                 bytes/frame), over the {MAX_GIF_CANVAS_BYTES}-byte budget or larger than this \
123                 render's own {max_canvas_w}x{max_canvas_h} video — refusing to decode it."
124            );
125        }
126        return None;
127    }
128
129    #[cfg(test)]
130    stall_decode_for_tests();
131
132    let mut frames: Vec<(Vec<u8>, u32, u32)> = Vec::new();
133    let mut cumulative_times: Vec<f64> = Vec::new();
134    let mut accumulated = 0.0;
135    let mut composed = vec![0u8; canvas_w as usize * canvas_h as usize * 4];
136
137    while let Ok(Some(frame)) = decoder.read_next_frame() {
138        if frames.len() >= MAX_GIF_FRAMES {
139            if crate::warn_once_for(&format!("gif-frame-cap:{src}")) {
140                eprintln!(
141                    "rustmotion: gif '{src}' has more than {MAX_GIF_FRAMES} frames — truncating \
142                     the decoded animation at the cap."
143                );
144            }
145            break;
146        }
147
148        let restore = (frame.dispose == gif::DisposalMethod::Previous).then(|| composed.clone());
149
150        blit_frame(&mut composed, canvas_w, canvas_h, frame);
151
152        let delay = frame.delay as f64 / 100.0;
153        let delay = if delay < 0.01 { 0.1 } else { delay };
154        accumulated += delay;
155        frames.push((composed.clone(), canvas_w, canvas_h));
156        cumulative_times.push(accumulated);
157
158        match frame.dispose {
159            gif::DisposalMethod::Background => clear_rect(&mut composed, canvas_w, canvas_h, frame),
160            gif::DisposalMethod::Previous => {
161                if let Some(prev) = restore {
162                    composed = prev;
163                }
164            }
165            _ => {}
166        }
167    }
168
169    if frames.is_empty() {
170        if crate::warn_once_for(&format!("gif-empty:{src}")) {
171            eprintln!("rustmotion: gif '{src}' decoded to zero frames");
172        }
173        return None;
174    }
175
176    Some((frames, cumulative_times, accumulated))
177}
178
179fn cached_decode(src: &str, max_canvas_w: u32, max_canvas_h: u32) -> Option<Arc<DecodedGif>> {
180    gif_cache()
181        .entry(src.to_string())
182        .or_try_insert_with(|| {
183            decode_composed_frames(src, max_canvas_w, max_canvas_h)
184                .map(Arc::new)
185                .ok_or(())
186        })
187        .ok()
188        .map(|entry| entry.clone())
189}
190
191impl Painter for Gif {
192    fn paint_content(
193        &self,
194        canvas: &Canvas,
195        layout: &BoxLayout,
196        _props: &AnimatedProperties,
197        ctx: &PaintCtx,
198    ) {
199        let Some(cached) = cached_decode(&self.src, ctx.video_width, ctx.video_height) else {
200            return;
201        };
202
203        let (ref frames, ref cumulative_times, total_duration) = *cached;
204
205        if frames.is_empty() {
206            return;
207        }
208
209        let effective_time = if self.loop_gif {
210            ctx.time % total_duration
211        } else {
212            ctx.time.min(total_duration)
213        };
214
215        let frame_idx = cumulative_times
216            .partition_point(|&t| t <= effective_time)
217            .min(frames.len() - 1);
218        let (ref frame_data, gif_width, gif_height) = frames[frame_idx];
219
220        let img_info = ImageInfo::new(
221            (gif_width as i32, gif_height as i32),
222            ColorType::RGBA8888,
223            skia_safe::AlphaType::Unpremul,
224            None,
225        );
226        let row_bytes = gif_width as usize * 4;
227        let data = skia_safe::Data::new_copy(frame_data);
228        if let Some(img) = skia_safe::images::raster_from_data(&img_info, data, row_bytes) {
229            let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
230            let paint = Paint::default();
231            canvas.draw_image_rect(img, None, dst, &paint);
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn write_two_frame_gif(path: &std::path::Path) {
241        let palette: &[u8] = &[0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF];
242        let mut file = std::fs::File::create(path).expect("create gif fixture");
243        let mut encoder = gif::Encoder::new(&mut file, 4, 2, palette).expect("gif encoder");
244
245        let mut full = gif::Frame::from_indexed_pixels(4, 2, vec![0; 8], None);
246        full.delay = 10;
247        encoder.write_frame(&full).expect("write frame 0");
248
249        let mut patch = gif::Frame::from_indexed_pixels(2, 2, vec![1; 4], None);
250        patch.left = 2;
251        patch.top = 0;
252        patch.delay = 10;
253        patch.dispose = gif::DisposalMethod::Keep;
254        encoder.write_frame(&patch).expect("write frame 1");
255    }
256
257    fn px(frame: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
258        let i = ((y * w + x) * 4) as usize;
259        frame[i..i + 4].try_into().expect("pixel")
260    }
261
262    #[test]
263    fn a_subrectangle_frame_is_composed_onto_the_previous_one() {
264        let path = std::env::temp_dir().join(format!(
265            "rustmotion_gif_{}.gif",
266            std::process::id() as u64 * 31 + 7
267        ));
268        write_two_frame_gif(&path);
269
270        let (frames, times, total) =
271            decode_composed_frames(path.to_str().expect("utf-8 path"), 1920, 1080)
272                .expect("gif must decode");
273        std::fs::remove_file(&path).ok();
274
275        assert_eq!(frames.len(), 2, "both frames must be drawable");
276        for (buf, w, h) in &frames {
277            assert_eq!(
278                buf.len(),
279                (*w as usize) * (*h as usize) * 4,
280                "every stored frame must be full-canvas, or raster_from_data \
281                 silently returns None and nothing is painted"
282            );
283        }
284        assert_ne!(frames[0].0, frames[1].0, "the two frames must differ");
285
286        assert_eq!(px(&frames[1].0, 4, 0, 0), [0xFF, 0x00, 0x00, 0xFF], "kept");
287        assert_eq!(
288            px(&frames[1].0, 4, 3, 0),
289            [0x00, 0x00, 0xFF, 0xFF],
290            "patched"
291        );
292
293        assert_eq!(times.len(), 2);
294        assert!((total - 0.2).abs() < 1e-9, "0.1s per frame, got {total}");
295    }
296
297    #[test]
298    fn a_missing_file_reports_instead_of_returning_nothing() {
299        let missing = std::env::temp_dir().join("rustmotion_gif_absent_xyz.gif");
300        assert!(decode_composed_frames(missing.to_str().expect("utf-8"), 1920, 1080).is_none());
301        assert!(
302            !crate::warn_once_for(&format!("gif-open:{}", missing.to_str().expect("utf-8"))),
303            "the open failure must have reported once"
304        );
305    }
306
307    #[test]
308    fn a_frame_rect_running_past_the_canvas_is_clamped() {
309        let mut frame = gif::Frame::from_indexed_pixels(4, 4, vec![0; 16], None);
310        frame.left = 3;
311        frame.top = 3;
312        assert_eq!(frame_rect(4, 4, &frame), (3, 3, 1, 1));
313    }
314
315    struct StallGuard;
316
317    impl Drop for StallGuard {
318        fn drop(&mut self) {
319            DECODE_STALL_MS.store(0, std::sync::atomic::Ordering::SeqCst);
320        }
321    }
322
323    #[test]
324    fn concurrent_paints_of_the_same_uncached_gif_decode_exactly_once() {
325        let _guard = StallGuard;
326        DECODE_STALL_MS.store(120, std::sync::atomic::Ordering::SeqCst);
327
328        let path = std::env::temp_dir().join(format!(
329            "rustmotion_gif_stampede_{}_{}.gif",
330            std::process::id(),
331            std::time::SystemTime::now()
332                .duration_since(std::time::UNIX_EPOCH)
333                .unwrap()
334                .as_nanos(),
335        ));
336        write_two_frame_gif(&path);
337        let src = path.to_str().expect("utf-8 path").to_string();
338
339        const THREADS: usize = 8;
340        let barrier = std::sync::Arc::new(std::sync::Barrier::new(THREADS));
341        let handles: Vec<_> = (0..THREADS)
342            .map(|_| {
343                let barrier = barrier.clone();
344                let src = src.clone();
345                std::thread::spawn(move || {
346                    barrier.wait();
347                    cached_decode(&src, 4, 2)
348                })
349            })
350            .collect();
351
352        let results: Vec<Option<Arc<DecodedGif>>> =
353            handles.into_iter().map(|h| h.join().unwrap()).collect();
354        std::fs::remove_file(&path).ok();
355
356        let first = results[0].as_ref().expect("gif must decode");
357        for (i, result) in results.iter().enumerate() {
358            let result = result.as_ref().unwrap_or_else(|| {
359                panic!("thread {i} did not get a decoded result");
360            });
361            assert!(
362                Arc::ptr_eq(first, result),
363                "thread {i} observed a different Arc than thread 0 — the GIF was decoded \
364                 more than once for the same cache-cold source"
365            );
366        }
367    }
368
369    fn write_oversized_header_gif(path: &std::path::Path, w: u16, h: u16) {
370        let palette: &[u8] = &[0, 0, 0, 255, 255, 255];
371        let mut file = std::fs::File::create(path).expect("create gif fixture");
372        let mut encoder = gif::Encoder::new(&mut file, w, h, palette).expect("gif encoder");
373        let frame = gif::Frame::from_indexed_pixels(1, 1, vec![0], None);
374        encoder.write_frame(&frame).expect("write frame");
375    }
376
377    #[test]
378    fn a_canvas_over_the_byte_budget_is_rejected_without_allocating_it() {
379        let path = std::env::temp_dir().join(format!(
380            "rustmotion_gif_bomb_budget_{}_{}.gif",
381            std::process::id(),
382            std::time::SystemTime::now()
383                .duration_since(std::time::UNIX_EPOCH)
384                .unwrap()
385                .as_nanos(),
386        ));
387        write_oversized_header_gif(&path, 6000, 6000);
388
389        let result = decode_composed_frames(path.to_str().expect("utf-8"), 8192, 8192);
390        std::fs::remove_file(&path).ok();
391
392        assert!(
393            result.is_none(),
394            "a 144 MiB canvas must be refused, not decoded"
395        );
396        assert!(
397            !crate::warn_once_for(&format!("gif-oversized:{}", path.to_str().unwrap())),
398            "the rejection must have reported once"
399        );
400    }
401
402    #[test]
403    fn a_canvas_larger_than_the_video_is_rejected_even_under_the_byte_budget() {
404        let path = std::env::temp_dir().join(format!(
405            "rustmotion_gif_bomb_dims_{}_{}.gif",
406            std::process::id(),
407            std::time::SystemTime::now()
408                .duration_since(std::time::UNIX_EPOCH)
409                .unwrap()
410                .as_nanos(),
411        ));
412        write_oversized_header_gif(&path, 3000, 2000);
413
414        let result = decode_composed_frames(path.to_str().expect("utf-8"), 1920, 1080);
415        std::fs::remove_file(&path).ok();
416
417        assert!(
418            result.is_none(),
419            "a canvas bigger than the video's own dimensions must be refused"
420        );
421    }
422
423    fn write_many_frame_gif(path: &std::path::Path, count: u32) {
424        let palette: &[u8] = &[0, 0, 0, 255, 255, 255];
425        let mut file = std::fs::File::create(path).expect("create gif fixture");
426        let mut encoder = gif::Encoder::new(&mut file, 2, 2, palette).expect("gif encoder");
427        for _ in 0..count {
428            let mut frame = gif::Frame::from_indexed_pixels(2, 2, vec![0, 1, 1, 0], None);
429            frame.delay = 1;
430            encoder.write_frame(&frame).expect("write frame");
431        }
432    }
433
434    #[test]
435    fn frame_count_beyond_the_cap_is_truncated_not_unbounded() {
436        let path = std::env::temp_dir().join(format!(
437            "rustmotion_gif_many_frames_{}_{}.gif",
438            std::process::id(),
439            std::time::SystemTime::now()
440                .duration_since(std::time::UNIX_EPOCH)
441                .unwrap()
442                .as_nanos(),
443        ));
444        write_many_frame_gif(&path, MAX_GIF_FRAMES as u32 + 50);
445
446        let (frames, times, _total) = decode_composed_frames(path.to_str().expect("utf-8"), 10, 10)
447            .expect("a small, merely-long gif must still decode");
448        std::fs::remove_file(&path).ok();
449
450        assert_eq!(
451            frames.len(),
452            MAX_GIF_FRAMES,
453            "frame count must be truncated to the cap, not grow past it"
454        );
455        assert_eq!(times.len(), MAX_GIF_FRAMES);
456    }
457}