rustmotion 0.5.0

A CLI tool that renders motion design videos from JSON scenarios. No browser, no Node.js — just a single Rust binary.
use std::sync::Arc;

use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect};

use crate::engine::renderer::gif_cache;
use crate::error::RustmotionError;
use crate::layout::{Constraints, LayoutNode};
use crate::schema::{ImageFit, LayerStyle, Size};
use crate::traits::{RenderContext, TimingConfig, Widget};

fn default_loop_true() -> bool { true }

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct Gif {
    pub src: String,
    #[serde(default)]
    pub size: Option<Size>,
    #[serde(default)]
    pub fit: ImageFit,
    #[serde(default = "default_loop_true")]
    pub loop_gif: bool,
    #[serde(flatten)]
    pub timing: TimingConfig,
    #[serde(default)]
    pub style: LayerStyle,
}

crate::impl_traits!(Gif {
    Animatable => style,
    Timed => timing,
    Styled => style,
});

impl Widget for Gif {
    fn render(&self, canvas: &Canvas, layout: &LayoutNode, ctx: &RenderContext, _props: &crate::engine::animator::AnimatedProperties) -> Result<()> {
        let gcache = gif_cache();

        let cached = if let Some(cached) = gcache.get(&self.src) {
            cached.clone()
        } else {
            let file = std::fs::File::open(&self.src)
                .map_err(|e| RustmotionError::GifOpen { path: self.src.clone(), reason: e.to_string() })?;

            let mut decoder = gif::DecodeOptions::new();
            decoder.set_color_output(gif::ColorOutput::RGBA);
            let mut decoder = decoder.read_info(file)
                .map_err(|e| RustmotionError::GifDecode { path: self.src.clone(), reason: e.to_string() })?;

            let gif_width = decoder.width() as u32;
            let gif_height = 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;

            while let Some(frame) = decoder.read_next_frame()
                .map_err(|e| RustmotionError::GifFrame { reason: e.to_string() })? {
                let delay = frame.delay as f64 / 100.0;
                let delay = if delay < 0.01 { 0.1 } else { delay };
                accumulated += delay;
                frames.push((frame.buffer.to_vec(), gif_width, gif_height));
                cumulative_times.push(accumulated);
            }

            let total_duration = accumulated;
            let cached = Arc::new((frames, cumulative_times, total_duration));
            gcache.insert(self.src.clone(), cached.clone());
            cached
        };

        let (ref frames, ref cumulative_times, total_duration) = *cached;

        if frames.is_empty() {
            return Ok(());
        }

        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::Premul,
            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);
        }

        Ok(())
    }

    fn measure(&self, _constraints: &Constraints) -> (f32, f32) {
        match &self.size {
            Some(s) => (s.width, s.height),
            None => (100.0, 100.0),
        }
    }
}