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}