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}