Skip to main content

rustmotion_components/
lottie.rs

1use rustmotion_core::css::CssStyle;
2use rustmotion_core::engine::animator::AnimatedProperties;
3use rustmotion_core::engine::layout_pass::BoxLayout;
4use rustmotion_core::engine::renderer::asset_cache;
5use rustmotion_core::error::{Result, RustmotionError};
6use rustmotion_core::schema::TimelineStep;
7use rustmotion_core::traits::{PaintCtx, Painter, TimingConfig};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use skia_safe::{Canvas, ColorType, ImageInfo, Paint, Rect};
11
12/// A Lottie animation component that renders frame-by-frame from a .json Lottie file.
13///
14/// With the `lottie-native` feature (default), Lottie JSON files are rendered natively
15/// via the `thorvg` CPU rasterizer — no external tools required. Priority:
16/// 1. `frames_dir` — backward-compatible pre-rendered PNG frames directory.
17/// 2. Native thorvg path — resolves `data` (inline JSON) or `src` (file path).
18///
19/// Without `lottie-native`, only `frames_dir` works; all other inputs produce no output.
20#[derive(Debug, Serialize, Deserialize, JsonSchema)]
21pub struct Lottie {
22    /// Path to the Lottie JSON file.
23    #[serde(default)]
24    pub src: Option<String>,
25    /// Inline Lottie JSON data.
26    #[serde(default)]
27    pub data: Option<String>,
28    /// Playback speed multiplier (1.0 = normal, 2.0 = double speed).
29    #[serde(default = "default_speed")]
30    pub speed: f32,
31    /// Whether to loop the animation.
32    #[serde(default = "default_true")]
33    #[serde(rename = "loop")]
34    pub repeat: bool,
35    /// Directory containing pre-rendered frames (PNG files named 0000.png, 0001.png, ...).
36    /// If provided, the component loads frames directly from this directory.
37    /// Takes priority over the native thorvg path.
38    #[serde(default)]
39    pub frames_dir: Option<String>,
40    #[serde(flatten)]
41    pub timing: TimingConfig,
42    #[serde(default)]
43    pub style: CssStyle,
44    #[serde(default)]
45    pub timeline: Vec<TimelineStep>,
46    #[serde(default)]
47    pub stagger: Option<f32>,
48}
49
50fn default_speed() -> f32 {
51    1.0
52}
53
54fn default_true() -> bool {
55    true
56}
57
58rustmotion_core::impl_traits!(Lottie {
59    Animatable => animation,
60    Timed => timing,
61    Styled => style,
62});
63
64impl Lottie {
65    fn parse_metadata(&self) -> Result<(f64, usize, f64, f32, f32)> {
66        let json_str = if let Some(ref src) = self.src {
67            std::fs::read_to_string(src).map_err(|e| RustmotionError::LottieRead {
68                path: src.clone(),
69                reason: e.to_string(),
70            })?
71        } else if let Some(ref data) = self.data {
72            data.clone()
73        } else {
74            return Err(RustmotionError::LottieMissingSrc);
75        };
76
77        let json: serde_json::Value = serde_json::from_str(&json_str)?;
78        let fr = json["fr"].as_f64().unwrap_or(30.0);
79        let ip = json["ip"].as_f64().unwrap_or(0.0);
80        let op = json["op"].as_f64().unwrap_or(60.0);
81        let w = json["w"].as_f64().unwrap_or(200.0) as f32;
82        let h = json["h"].as_f64().unwrap_or(200.0) as f32;
83        let total_frames = (op - ip) as usize;
84        let duration = total_frames as f64 / fr;
85
86        Ok((fr, total_frames, duration, w, h))
87    }
88
89    fn cache_key(&self, frame: usize) -> String {
90        let src = self.src.as_deref().unwrap_or("inline");
91        format!("lottie:{}:frame:{}", src, frame)
92    }
93
94    fn load_frame_from_dir(&self, frames_dir: &str, frame: usize) -> Result<skia_safe::Image> {
95        let frame_path = format!("{}/{:04}.png", frames_dir, frame);
96        let data = std::fs::read(&frame_path).map_err(|e| RustmotionError::LottieFrameRead {
97            path: frame_path.clone(),
98            reason: e.to_string(),
99        })?;
100
101        let img =
102            image::load_from_memory(&data).map_err(|e| RustmotionError::LottieFrameDecode {
103                path: frame_path.clone(),
104                reason: e.to_string(),
105            })?;
106        let rgba = img.to_rgba8();
107        let (w, h) = rgba.dimensions();
108
109        let img_data = skia_safe::Data::new_copy(rgba.as_raw());
110        let img_info = ImageInfo::new(
111            (w as i32, h as i32),
112            ColorType::RGBA8888,
113            skia_safe::AlphaType::Unpremul,
114            None,
115        );
116
117        skia_safe::images::raster_from_data(&img_info, img_data, w as usize * 4).ok_or(
118            RustmotionError::SkiaImageCreation {
119                target: "lottie frame".to_string(),
120            },
121        )
122    }
123}
124
125#[cfg(feature = "lottie-native")]
126mod native {
127    use std::hash::Hash;
128    use std::sync::{Arc, OnceLock};
129
130    use dashmap::DashMap;
131
132    const CACHE_MAX_ENTRIES: usize = 128;
133
134    #[derive(Clone, PartialEq, Eq, Hash, Debug)]
135    pub(super) struct FrameKey {
136        pub src_hash: u64,
137        pub frame_index: u32,
138        pub width: u32,
139        pub height: u32,
140    }
141
142    impl FrameKey {
143        pub(super) fn new(json_bytes: &[u8], frame_index: u32, width: u32, height: u32) -> Self {
144            let src_hash = fnv1a(json_bytes);
145            Self {
146                src_hash,
147                frame_index,
148                width,
149                height,
150            }
151        }
152    }
153
154    pub(super) fn fnv1a(bytes: &[u8]) -> u64 {
155        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
156        for &b in bytes {
157            h ^= b as u64;
158            h = h.wrapping_mul(0x0000_0100_0000_01b3);
159        }
160        h
161    }
162
163    type NativeCacheMap = Arc<DashMap<FrameKey, Arc<Vec<u8>>>>;
164
165    static NATIVE_LOTTIE_CACHE: OnceLock<NativeCacheMap> = OnceLock::new();
166
167    pub(super) fn native_lottie_cache() -> &'static NativeCacheMap {
168        NATIVE_LOTTIE_CACHE.get_or_init(|| Arc::new(DashMap::new()))
169    }
170
171    thread_local! {
172        static THORVG_ENGINE: std::cell::RefCell<Option<thorvg::Thorvg>> =
173            const { std::cell::RefCell::new(None) };
174    }
175
176    fn with_engine<F, R>(f: F) -> R
177    where
178        F: FnOnce(&thorvg::Thorvg) -> R,
179    {
180        THORVG_ENGINE.with(|cell| {
181            let mut guard = cell.borrow_mut();
182            if guard.is_none() {
183                *guard = thorvg::Thorvg::init(0).ok();
184            }
185            f(guard.as_ref().expect("thorvg init failed"))
186        })
187    }
188
189    static FAILED_SOURCES: OnceLock<DashMap<u64, ()>> = OnceLock::new();
190
191    fn failed_sources() -> &'static DashMap<u64, ()> {
192        FAILED_SOURCES.get_or_init(DashMap::new)
193    }
194
195    pub(super) fn render_frame(
196        json_bytes: &[u8],
197        frame_index: u32,
198        width: u32,
199        height: u32,
200    ) -> Option<Arc<Vec<u8>>> {
201        use thorvg::{ColorSpace, EngineOption};
202
203        let key = FrameKey::new(json_bytes, frame_index, width, height);
204        let cache = native_lottie_cache();
205        if let Some(cached) = cache.get(&key) {
206            return Some(cached.clone());
207        }
208
209        let src_hash = key.src_hash;
210
211        if failed_sources().contains_key(&src_hash) {
212            return None;
213        }
214
215        let rgba = with_engine(|engine| -> Option<Vec<u8>> {
216            use thorvg::Paint as ThorPaint;
217
218            let mut buffer = vec![0u32; (width * height) as usize];
219
220            let mut canvas = engine.sw_canvas(EngineOption::Default).ok()?;
221            unsafe {
222                canvas
223                    .set_target(&mut buffer, width, width, height, ColorSpace::ABGR8888)
224                    .ok()?
225            };
226
227            let mut anim = engine.lottie_animation().ok()?;
228            anim.load_data(json_bytes).ok()?;
229            anim.set_size(width as f32, height as f32).ok()?;
230
231            let total = anim.total_frame().ok()?;
232            if total <= 0.0 {
233                return None;
234            }
235            let clamped = (frame_index as f32).min(total - 1.0).max(0.0);
236            let _ = anim.set_frame(clamped);
237
238            let dup = anim.picture().duplicate()?;
239            canvas.add(dup).ok()?;
240            canvas.draw(true).ok()?;
241            canvas.sync().ok()?;
242
243            let byte_len = buffer.len() * 4;
244            let mut out = Vec::with_capacity(byte_len);
245            let byte_slice =
246                unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const u8, byte_len) };
247            out.extend_from_slice(byte_slice);
248            Some(out)
249        });
250
251        match rgba {
252            None => {
253                eprintln!(
254                    "[rustmotion] lottie-native: failed to render source (hash {:016x}); \
255                     further errors for this source will be suppressed",
256                    src_hash
257                );
258                failed_sources().insert(src_hash, ());
259                None
260            }
261            Some(bytes) => {
262                let arc = Arc::new(bytes);
263                if cache.len() >= CACHE_MAX_ENTRIES {
264                    cache.clear();
265                }
266                cache.insert(key, arc.clone());
267                Some(arc)
268            }
269        }
270    }
271
272    pub(super) fn frame_at_time(
273        t: f64,
274        speed: f32,
275        repeat: bool,
276        fr: f64,
277        total_frames: usize,
278        duration: f64,
279    ) -> u32 {
280        if total_frames == 0 || duration <= 0.0 {
281            return 0;
282        }
283        let anim_time = t * speed as f64;
284        let effective = if repeat {
285            anim_time % duration
286        } else {
287            anim_time.min(duration)
288        };
289        let f = (effective * fr) as usize;
290        f.min(total_frames.saturating_sub(1)) as u32
291    }
292
293    pub(super) fn resolve_json(lottie: &super::Lottie) -> Option<Vec<u8>> {
294        if let Some(ref data) = lottie.data {
295            return Some(data.as_bytes().to_vec());
296        }
297        if let Some(ref src) = lottie.src {
298            match std::fs::read(src) {
299                Ok(bytes) => return Some(bytes),
300                Err(e) => {
301                    let path_hash = fnv1a(src.as_bytes());
302                    if !failed_sources().contains_key(&path_hash) {
303                        eprintln!(
304                            "[rustmotion] lottie-native: cannot read '{}': {}; \
305                             further errors for this source will be suppressed",
306                            src, e
307                        );
308                        failed_sources().insert(path_hash, ());
309                    }
310                    return None;
311                }
312            }
313        }
314        None
315    }
316
317    pub(super) fn paint_native(
318        lottie: &super::Lottie,
319        canvas: &skia_safe::Canvas,
320        layout: &rustmotion_core::engine::layout_pass::BoxLayout,
321        ctx: &rustmotion_core::traits::PaintCtx,
322    ) {
323        let json_bytes = match resolve_json(lottie) {
324            Some(b) => b,
325            None => return,
326        };
327
328        let Ok((fr, total_frames, duration, _w, _h)) = lottie.parse_metadata() else {
329            return;
330        };
331
332        let w = layout.width as u32;
333        let h = layout.height as u32;
334        if w == 0 || h == 0 {
335            return;
336        }
337
338        let frame_index = frame_at_time(
339            ctx.time,
340            lottie.speed,
341            lottie.repeat,
342            fr,
343            total_frames,
344            duration,
345        );
346
347        let rgba = match render_frame(&json_bytes, frame_index, w, h) {
348            Some(r) => r,
349            None => return,
350        };
351
352        let img_data = skia_safe::Data::new_copy(&rgba);
353        let img_info = skia_safe::ImageInfo::new(
354            (w as i32, h as i32),
355            skia_safe::ColorType::RGBA8888,
356            skia_safe::AlphaType::Unpremul,
357            None,
358        );
359        let Some(img) = skia_safe::images::raster_from_data(&img_info, img_data, w as usize * 4)
360        else {
361            return;
362        };
363
364        let dst = skia_safe::Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
365        let paint = skia_safe::Paint::default();
366        canvas.draw_image_rect(img, None, dst, &paint);
367    }
368
369    #[cfg(test)]
370    pub(super) use frame_at_time as test_frame_at_time;
371    #[cfg(test)]
372    pub(super) use native_lottie_cache as test_cache;
373    #[cfg(test)]
374    pub(super) use render_frame as test_render_frame;
375    #[cfg(test)]
376    pub(super) use resolve_json as test_resolve_json;
377}
378
379impl Painter for Lottie {
380    fn paint_content(
381        &self,
382        canvas: &Canvas,
383        layout: &BoxLayout,
384        _props: &AnimatedProperties,
385        ctx: &PaintCtx,
386    ) {
387        let Ok((fr, total_frames, duration, _intrinsic_w, _intrinsic_h)) = self.parse_metadata()
388        else {
389            return;
390        };
391
392        if total_frames == 0 {
393            return;
394        }
395
396        let anim_time = ctx.time * self.speed as f64;
397        let effective_time = if self.repeat && duration > 0.0 {
398            anim_time % duration
399        } else {
400            anim_time.min(duration)
401        };
402        let frame = ((effective_time * fr) as usize).min(total_frames.saturating_sub(1));
403
404        if let Some(ref frames_dir) = self.frames_dir {
405            let cache_key = self.cache_key(frame);
406            let cache = asset_cache();
407
408            let img = if let Some(cached) = cache.get(&cache_key) {
409                cached.clone()
410            } else {
411                let Ok(img) = self.load_frame_from_dir(frames_dir, frame) else {
412                    return;
413                };
414                cache.insert(cache_key, img.clone());
415                img
416            };
417
418            let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
419            let paint = Paint::default();
420            canvas.draw_image_rect(img, None, dst, &paint);
421            return;
422        }
423
424        #[cfg(feature = "lottie-native")]
425        {
426            native::paint_native(self, canvas, layout, ctx);
427        }
428    }
429}
430
431#[cfg(all(test, feature = "lottie-native"))]
432mod tests {
433    use super::native::{test_cache, test_frame_at_time, test_render_frame};
434
435    const RED_LOTTIE: &str = r#"{
436        "v": "5.7.4", "fr": 30, "ip": 0, "op": 30, "w": 100, "h": 100,
437        "layers": [{
438            "ddd": 0, "ind": 1, "ty": 4, "nm": "rect", "sr": 1,
439            "ks": {"o": {"a": 0, "k": 100}, "r": {"a": 0, "k": 0},
440                    "p": {"a": 0, "k": [50, 50, 0]}, "a": {"a": 0, "k": [0, 0, 0]},
441                    "s": {"a": 0, "k": [100, 100, 100]}},
442            "shapes": [{"ty": "gr", "it": [
443                {"ty": "rc", "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [80, 80]}, "r": {"a": 0, "k": 0}},
444                {"ty": "fl", "c": {"a": 0, "k": [1, 0, 0, 1]}, "o": {"a": 0, "k": 100}},
445                {"ty": "tr", "p": {"a": 0, "k": [0, 0]}, "a": {"a": 0, "k": [0, 0]},
446                 "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}}
447            ]}],
448            "ip": 0, "op": 30, "st": 0
449        }]
450    }"#;
451
452    const W: u32 = 100;
453    const H: u32 = 100;
454
455    fn render_mid() -> Vec<u8> {
456        test_render_frame(RED_LOTTIE.as_bytes(), 15, W, H)
457            .expect("render_mid must succeed")
458            .as_ref()
459            .clone()
460    }
461
462    #[test]
463    fn pixel_byte_order_red_lottie() {
464        let buf = render_mid();
465        assert_eq!(buf.len(), (W * H * 4) as usize);
466
467        let mut red_sum: u64 = 0;
468        let mut blue_sum: u64 = 0;
469        for px in buf.as_chunks::<4>().0.iter() {
470            let r = px[0] as u64;
471            let _g = px[1] as u64;
472            let b = px[2] as u64;
473            let a = px[3] as u64;
474            if a > 0 {
475                red_sum += r;
476                blue_sum += b;
477            }
478        }
479        assert!(
480            red_sum > 200_000,
481            "expected dominant red, got red_sum={red_sum} blue_sum={blue_sum}"
482        );
483        assert!(
484            blue_sum < 1000,
485            "expected ~0 blue, got blue_sum={blue_sum} red_sum={red_sum}"
486        );
487    }
488
489    #[test]
490    fn repeat_true_wraps_to_same_frame() {
491        let fr = 30.0f64;
492        let total = 30usize;
493        let dur = 1.0f64;
494
495        let fi_half = test_frame_at_time(0.5, 1.0, true, fr, total, dur);
496        let fi_wrap = test_frame_at_time(1.5, 1.0, true, fr, total, dur);
497        assert_eq!(fi_half, fi_wrap, "repeat=true: t=1.5 should wrap to t=0.5");
498
499        let buf_half =
500            test_render_frame(RED_LOTTIE.as_bytes(), fi_half, W, H).expect("render half");
501        let buf_wrap =
502            test_render_frame(RED_LOTTIE.as_bytes(), fi_wrap, W, H).expect("render wrap");
503        assert_eq!(*buf_half, *buf_wrap);
504    }
505
506    #[test]
507    fn repeat_false_clamps_to_last_frame() {
508        let fr = 30.0f64;
509        let total = 30usize;
510        let dur = 1.0f64;
511
512        let fi_clamped = test_frame_at_time(1.5, 1.0, false, fr, total, dur);
513        let fi_last = test_frame_at_time(1.0, 1.0, false, fr, total, dur);
514        assert_eq!(fi_clamped, 29);
515        assert_eq!(fi_last, 29);
516    }
517
518    #[test]
519    fn speed_multiplier_equivalent_frame() {
520        let fr = 30.0f64;
521        let total = 30usize;
522        let dur = 1.0f64;
523
524        let fi_fast = test_frame_at_time(0.25, 2.0, false, fr, total, dur);
525        let fi_normal = test_frame_at_time(0.5, 1.0, false, fr, total, dur);
526        assert_eq!(
527            fi_fast, fi_normal,
528            "speed=2.0 at t=0.25 should equal speed=1.0 at t=0.5"
529        );
530
531        let buf_fast =
532            test_render_frame(RED_LOTTIE.as_bytes(), fi_fast, W, H).expect("render fast");
533        let buf_normal =
534            test_render_frame(RED_LOTTIE.as_bytes(), fi_normal, W, H).expect("render normal");
535        assert_eq!(*buf_fast, *buf_normal);
536    }
537
538    #[test]
539    fn invalid_json_no_panic_zero_pixels() {
540        let result = test_render_frame(b"not valid json at all!!!", 0, W, H);
541        if let Some(buf) = result {
542            let nonzero = buf.iter().any(|&b| b != 0);
543            assert!(
544                !nonzero,
545                "invalid JSON should produce zero-pixel output, got non-zero pixels"
546            );
547        }
548    }
549
550    #[test]
551    fn frames_dir_priority_no_native_cache_entry() {
552        use super::{native, Lottie};
553
554        test_cache().clear();
555
556        let lottie = Lottie {
557            src: None,
558            data: Some(RED_LOTTIE.to_string()),
559            speed: 1.0,
560            repeat: false,
561            frames_dir: Some("/non/existent/frames_dir".to_string()),
562            timing: Default::default(),
563            style: Default::default(),
564            timeline: vec![],
565            stagger: None,
566        };
567
568        use super::native::FrameKey;
569        let expected_key = FrameKey::new(RED_LOTTIE.as_bytes(), 0, 100, 100);
570
571        let _ = native::test_resolve_json(&lottie);
572
573        assert!(
574            !test_cache().contains_key(&expected_key),
575            "native cache must not be populated when frames_dir takes priority"
576        );
577    }
578}