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