use std::sync::Arc;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect};
use rustmotion_core::css::CssStyle;
use rustmotion_core::engine::animator::AnimatedProperties;
use rustmotion_core::engine::layout_pass::BoxLayout;
use rustmotion_core::engine::renderer::gif_cache;
use rustmotion_core::schema::{ImageFit, TimelineStep};
use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
fn default_loop_true() -> bool {
true
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct Gif {
pub src: String,
#[serde(default)]
pub fit: ImageFit,
#[serde(default = "default_loop_true")]
pub loop_gif: bool,
#[serde(flatten)]
pub timing: TimingConfig,
#[serde(default)]
pub style: CssStyle,
#[serde(default)]
pub timeline: Vec<TimelineStep>,
#[serde(default)]
pub stagger: Option<f32>,
}
rustmotion_core::impl_traits!(Gif {
Animatable => animation,
Timed => timing,
Styled => style,
});
fn frame_rect(canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) -> (u32, u32, u32, u32) {
let left = frame.left.min(canvas_w as u16) as u32;
let top = frame.top.min(canvas_h as u16) as u32;
let w = (frame.width as u32).min(canvas_w.saturating_sub(left));
let h = (frame.height as u32).min(canvas_h.saturating_sub(top));
(left, top, w, h)
}
fn blit_frame(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) {
let (left, top, w, h) = frame_rect(canvas_w, canvas_h, frame);
for y in 0..h {
for x in 0..w {
let src = ((y * frame.width as u32 + x) * 4) as usize;
let Some(px) = frame.buffer.get(src..src + 4) else {
return; };
if px[3] == 0 {
continue;
}
let dst = (((top + y) * canvas_w + (left + x)) * 4) as usize;
composed[dst..dst + 4].copy_from_slice(px);
}
}
}
fn clear_rect(composed: &mut [u8], canvas_w: u32, canvas_h: u32, frame: &gif::Frame<'_>) {
let (left, top, w, h) = frame_rect(canvas_w, canvas_h, frame);
for y in 0..h {
let row = (((top + y) * canvas_w + left) * 4) as usize;
composed[row..row + (w as usize * 4)].fill(0);
}
}
type DecodedGif = (Vec<(Vec<u8>, u32, u32)>, Vec<f64>, f64);
fn decode_composed_frames(src: &str) -> Option<DecodedGif> {
let file = match std::fs::File::open(src) {
Ok(f) => f,
Err(e) => {
if crate::warn_once_for(&format!("gif-open:{src}")) {
eprintln!("rustmotion: gif '{src}' could not be opened: {e}");
}
return None;
}
};
let mut options = gif::DecodeOptions::new();
options.set_color_output(gif::ColorOutput::RGBA);
let mut decoder = match options.read_info(file) {
Ok(d) => d,
Err(e) => {
if crate::warn_once_for(&format!("gif-decode:{src}")) {
eprintln!("rustmotion: gif '{src}' could not be decoded: {e}");
}
return None;
}
};
let canvas_w = decoder.width() as u32;
let canvas_h = decoder.height() as u32;
let mut frames: Vec<(Vec<u8>, u32, u32)> = Vec::new();
let mut cumulative_times: Vec<f64> = Vec::new();
let mut accumulated = 0.0;
let mut composed = vec![0u8; canvas_w as usize * canvas_h as usize * 4];
while let Ok(Some(frame)) = decoder.read_next_frame() {
let restore = (frame.dispose == gif::DisposalMethod::Previous).then(|| composed.clone());
blit_frame(&mut composed, canvas_w, canvas_h, frame);
let delay = frame.delay as f64 / 100.0;
let delay = if delay < 0.01 { 0.1 } else { delay };
accumulated += delay;
frames.push((composed.clone(), canvas_w, canvas_h));
cumulative_times.push(accumulated);
match frame.dispose {
gif::DisposalMethod::Background => clear_rect(&mut composed, canvas_w, canvas_h, frame),
gif::DisposalMethod::Previous => {
if let Some(prev) = restore {
composed = prev;
}
}
_ => {}
}
}
if frames.is_empty() {
if crate::warn_once_for(&format!("gif-empty:{src}")) {
eprintln!("rustmotion: gif '{src}' decoded to zero frames");
}
return None;
}
Some((frames, cumulative_times, accumulated))
}
impl Painter for Gif {
fn paint_content(
&self,
canvas: &Canvas,
layout: &BoxLayout,
_props: &AnimatedProperties,
ctx: &PaintCtx,
) {
let gcache = gif_cache();
let cached = if let Some(cached) = gcache.get(&self.src) {
cached.clone()
} else {
let Some(decoded) = decode_composed_frames(&self.src) else {
return;
};
let cached = Arc::new(decoded);
gcache.insert(self.src.clone(), cached.clone());
cached
};
let (ref frames, ref cumulative_times, total_duration) = *cached;
if frames.is_empty() {
return;
}
let effective_time = if self.loop_gif {
ctx.time % total_duration
} else {
ctx.time.min(total_duration)
};
let frame_idx = cumulative_times
.partition_point(|&t| t <= effective_time)
.min(frames.len() - 1);
let (ref frame_data, gif_width, gif_height) = frames[frame_idx];
let img_info = ImageInfo::new(
(gif_width as i32, gif_height as i32),
ColorType::RGBA8888,
skia_safe::AlphaType::Unpremul,
None,
);
let row_bytes = gif_width as usize * 4;
let data = skia_safe::Data::new_copy(frame_data);
if let Some(img) = skia_safe::images::raster_from_data(&img_info, data, row_bytes) {
let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
let paint = Paint::default();
canvas.draw_image_rect(img, None, dst, &paint);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_two_frame_gif(path: &std::path::Path) {
let palette: &[u8] = &[0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF];
let mut file = std::fs::File::create(path).expect("create gif fixture");
let mut encoder = gif::Encoder::new(&mut file, 4, 2, palette).expect("gif encoder");
let mut full = gif::Frame::from_indexed_pixels(4, 2, vec![0; 8], None);
full.delay = 10;
encoder.write_frame(&full).expect("write frame 0");
let mut patch = gif::Frame::from_indexed_pixels(2, 2, vec![1; 4], None);
patch.left = 2;
patch.top = 0;
patch.delay = 10;
patch.dispose = gif::DisposalMethod::Keep;
encoder.write_frame(&patch).expect("write frame 1");
}
fn px(frame: &[u8], w: u32, x: u32, y: u32) -> [u8; 4] {
let i = ((y * w + x) * 4) as usize;
frame[i..i + 4].try_into().expect("pixel")
}
#[test]
fn a_subrectangle_frame_is_composed_onto_the_previous_one() {
let path = std::env::temp_dir().join(format!(
"rustmotion_gif_{}.gif",
std::process::id() as u64 * 31 + 7
));
write_two_frame_gif(&path);
let (frames, times, total) =
decode_composed_frames(path.to_str().expect("utf-8 path")).expect("gif must decode");
std::fs::remove_file(&path).ok();
assert_eq!(frames.len(), 2, "both frames must be drawable");
for (buf, w, h) in &frames {
assert_eq!(
buf.len(),
(*w as usize) * (*h as usize) * 4,
"every stored frame must be full-canvas, or raster_from_data \
silently returns None and nothing is painted"
);
}
assert_ne!(frames[0].0, frames[1].0, "the two frames must differ");
assert_eq!(px(&frames[1].0, 4, 0, 0), [0xFF, 0x00, 0x00, 0xFF], "kept");
assert_eq!(
px(&frames[1].0, 4, 3, 0),
[0x00, 0x00, 0xFF, 0xFF],
"patched"
);
assert_eq!(times.len(), 2);
assert!((total - 0.2).abs() < 1e-9, "0.1s per frame, got {total}");
}
#[test]
fn a_missing_file_reports_instead_of_returning_nothing() {
let missing = std::env::temp_dir().join("rustmotion_gif_absent_xyz.gif");
assert!(decode_composed_frames(missing.to_str().expect("utf-8")).is_none());
assert!(
!crate::warn_once_for(&format!("gif-open:{}", missing.to_str().expect("utf-8"))),
"the open failure must have reported once"
);
}
#[test]
fn a_frame_rect_running_past_the_canvas_is_clamped() {
let mut frame = gif::Frame::from_indexed_pixels(4, 4, vec![0; 16], None);
frame.left = 3;
frame.top = 3;
assert_eq!(frame_rect(4, 4, &frame), (3, 3, 1, 1));
}
}