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, draw_photo};
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    use rustmotion_core::engine::renderer::draw_photo;
132
133    const CACHE_MAX_ENTRIES: usize = 128;
134
135    #[derive(Clone, PartialEq, Eq, Hash, Debug)]
136    pub(super) struct FrameKey {
137        pub src_hash: u64,
138        pub frame_index: u32,
139        pub width: u32,
140        pub height: u32,
141    }
142
143    impl FrameKey {
144        pub(super) fn new(json_bytes: &[u8], frame_index: u32, width: u32, height: u32) -> Self {
145            let src_hash = fnv1a(json_bytes);
146            Self {
147                src_hash,
148                frame_index,
149                width,
150                height,
151            }
152        }
153    }
154
155    pub(super) fn fnv1a(bytes: &[u8]) -> u64 {
156        let mut h: u64 = 0xcbf2_9ce4_8422_2325;
157        for &b in bytes {
158            h ^= b as u64;
159            h = h.wrapping_mul(0x0000_0100_0000_01b3);
160        }
161        h
162    }
163
164    type NativeCacheMap = Arc<DashMap<FrameKey, Arc<Vec<u8>>>>;
165
166    static NATIVE_LOTTIE_CACHE: OnceLock<NativeCacheMap> = OnceLock::new();
167
168    pub(super) fn native_lottie_cache() -> &'static NativeCacheMap {
169        NATIVE_LOTTIE_CACHE.get_or_init(|| Arc::new(DashMap::new()))
170    }
171
172    thread_local! {
173        static THORVG_ENGINE: std::cell::RefCell<Option<thorvg::Thorvg>> =
174            const { std::cell::RefCell::new(None) };
175    }
176
177    fn with_engine<F, R>(f: F) -> R
178    where
179        F: FnOnce(&thorvg::Thorvg) -> R,
180    {
181        THORVG_ENGINE.with(|cell| {
182            let mut guard = cell.borrow_mut();
183            if guard.is_none() {
184                *guard = thorvg::Thorvg::init(0).ok();
185            }
186            f(guard.as_ref().expect("thorvg init failed"))
187        })
188    }
189
190    static FAILED_SOURCES: OnceLock<DashMap<u64, ()>> = OnceLock::new();
191
192    fn failed_sources() -> &'static DashMap<u64, ()> {
193        FAILED_SOURCES.get_or_init(DashMap::new)
194    }
195
196    pub(super) fn render_frame(
197        json_bytes: &[u8],
198        frame_index: u32,
199        width: u32,
200        height: u32,
201    ) -> Option<Arc<Vec<u8>>> {
202        use thorvg::{ColorSpace, EngineOption};
203
204        let key = FrameKey::new(json_bytes, frame_index, width, height);
205        let cache = native_lottie_cache();
206        if let Some(cached) = cache.get(&key) {
207            return Some(cached.clone());
208        }
209
210        let src_hash = key.src_hash;
211
212        if failed_sources().contains_key(&src_hash) {
213            return None;
214        }
215
216        let rgba = with_engine(|engine| -> Option<Vec<u8>> {
217            use thorvg::Paint as ThorPaint;
218
219            let mut buffer = vec![0u32; (width * height) as usize];
220
221            let mut canvas = engine.sw_canvas(EngineOption::Default).ok()?;
222            unsafe {
223                canvas
224                    .set_target(&mut buffer, width, width, height, ColorSpace::ABGR8888)
225                    .ok()?
226            };
227
228            let mut anim = engine.lottie_animation().ok()?;
229            anim.load_data(json_bytes).ok()?;
230            anim.set_size(width as f32, height as f32).ok()?;
231
232            let total = anim.total_frame().ok()?;
233            if total <= 0.0 {
234                return None;
235            }
236            let clamped = (frame_index as f32).min(total - 1.0).max(0.0);
237            let _ = anim.set_frame(clamped);
238
239            let dup = anim.picture().duplicate()?;
240            canvas.add(dup).ok()?;
241            canvas.draw(true).ok()?;
242            canvas.sync().ok()?;
243
244            let byte_len = buffer.len() * 4;
245            let mut out = Vec::with_capacity(byte_len);
246            let byte_slice =
247                unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const u8, byte_len) };
248            out.extend_from_slice(byte_slice);
249            Some(out)
250        });
251
252        match rgba {
253            None => {
254                eprintln!(
255                    "[rustmotion] lottie-native: failed to render source (hash {:016x}); \
256                     further errors for this source will be suppressed",
257                    src_hash
258                );
259                failed_sources().insert(src_hash, ());
260                None
261            }
262            Some(bytes) => {
263                let arc = Arc::new(bytes);
264                if cache.len() >= CACHE_MAX_ENTRIES {
265                    cache.clear();
266                }
267                cache.insert(key, arc.clone());
268                Some(arc)
269            }
270        }
271    }
272
273    pub(super) fn frame_at_time(
274        t: f64,
275        speed: f32,
276        repeat: bool,
277        fr: f64,
278        total_frames: usize,
279        duration: f64,
280    ) -> u32 {
281        if total_frames == 0 || duration <= 0.0 {
282            return 0;
283        }
284        let anim_time = t * speed as f64;
285        let effective = if repeat {
286            anim_time % duration
287        } else {
288            anim_time.min(duration)
289        };
290        let f = (effective * fr) as usize;
291        f.min(total_frames.saturating_sub(1)) as u32
292    }
293
294    pub(super) fn resolve_json(lottie: &super::Lottie) -> Option<Vec<u8>> {
295        if let Some(ref data) = lottie.data {
296            return Some(data.as_bytes().to_vec());
297        }
298        if let Some(ref src) = lottie.src {
299            match std::fs::read(src) {
300                Ok(bytes) => return Some(bytes),
301                Err(e) => {
302                    let path_hash = fnv1a(src.as_bytes());
303                    if !failed_sources().contains_key(&path_hash) {
304                        eprintln!(
305                            "[rustmotion] lottie-native: cannot read '{}': {}; \
306                             further errors for this source will be suppressed",
307                            src, e
308                        );
309                        failed_sources().insert(path_hash, ());
310                    }
311                    return None;
312                }
313            }
314        }
315        None
316    }
317
318    pub(super) fn paint_native(
319        lottie: &super::Lottie,
320        canvas: &skia_safe::Canvas,
321        layout: &rustmotion_core::engine::layout_pass::BoxLayout,
322        ctx: &rustmotion_core::traits::PaintCtx,
323    ) {
324        let json_bytes = match resolve_json(lottie) {
325            Some(b) => b,
326            None => return,
327        };
328
329        let Ok((fr, total_frames, duration, _w, _h)) = lottie.parse_metadata() else {
330            return;
331        };
332
333        let w = layout.width as u32;
334        let h = layout.height as u32;
335        if w == 0 || h == 0 {
336            return;
337        }
338
339        let frame_index = frame_at_time(
340            ctx.time,
341            lottie.speed,
342            lottie.repeat,
343            fr,
344            total_frames,
345            duration,
346        );
347
348        let rgba = match render_frame(&json_bytes, frame_index, w, h) {
349            Some(r) => r,
350            None => return,
351        };
352
353        let img_data = skia_safe::Data::new_copy(&rgba);
354        let img_info = skia_safe::ImageInfo::new(
355            (w as i32, h as i32),
356            skia_safe::ColorType::RGBA8888,
357            skia_safe::AlphaType::Unpremul,
358            None,
359        );
360        let Some(img) = skia_safe::images::raster_from_data(&img_info, img_data, w as usize * 4)
361        else {
362            return;
363        };
364
365        let dst = skia_safe::Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
366        let paint = skia_safe::Paint::default();
367        draw_photo(canvas, img, dst, &paint);
368    }
369
370    #[cfg(test)]
371    pub(super) use frame_at_time as test_frame_at_time;
372    #[cfg(test)]
373    pub(super) use native_lottie_cache as test_cache;
374    #[cfg(test)]
375    pub(super) use render_frame as test_render_frame;
376    #[cfg(test)]
377    pub(super) use resolve_json as test_resolve_json;
378}
379
380impl Painter for Lottie {
381    fn paint_content(
382        &self,
383        canvas: &Canvas,
384        layout: &BoxLayout,
385        _props: &AnimatedProperties,
386        ctx: &PaintCtx,
387    ) {
388        let Ok((fr, total_frames, duration, _intrinsic_w, _intrinsic_h)) = self.parse_metadata()
389        else {
390            return;
391        };
392
393        if total_frames == 0 {
394            return;
395        }
396
397        let anim_time = ctx.time * self.speed as f64;
398        let effective_time = if self.repeat && duration > 0.0 {
399            anim_time % duration
400        } else {
401            anim_time.min(duration)
402        };
403        let frame = ((effective_time * fr) as usize).min(total_frames.saturating_sub(1));
404
405        if let Some(ref frames_dir) = self.frames_dir {
406            let cache_key = self.cache_key(frame);
407            let cache = asset_cache();
408
409            let img = if let Some(cached) = cache.get(&cache_key) {
410                cached.clone()
411            } else {
412                let Ok(img) = self.load_frame_from_dir(frames_dir, frame) else {
413                    return;
414                };
415                cache.insert(cache_key, img.clone());
416                img
417            };
418
419            let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
420            let paint = Paint::default();
421            draw_photo(canvas, img, dst, &paint);
422            return;
423        }
424
425        #[cfg(feature = "lottie-native")]
426        {
427            native::paint_native(self, canvas, layout, ctx);
428        }
429    }
430}
431
432#[cfg(all(test, feature = "lottie-native"))]
433mod tests {
434    use super::native::{test_cache, test_frame_at_time, test_render_frame};
435
436    const RED_LOTTIE: &str = r#"{
437        "v": "5.7.4", "fr": 30, "ip": 0, "op": 30, "w": 100, "h": 100,
438        "layers": [{
439            "ddd": 0, "ind": 1, "ty": 4, "nm": "rect", "sr": 1,
440            "ks": {"o": {"a": 0, "k": 100}, "r": {"a": 0, "k": 0},
441                    "p": {"a": 0, "k": [50, 50, 0]}, "a": {"a": 0, "k": [0, 0, 0]},
442                    "s": {"a": 0, "k": [100, 100, 100]}},
443            "shapes": [{"ty": "gr", "it": [
444                {"ty": "rc", "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [80, 80]}, "r": {"a": 0, "k": 0}},
445                {"ty": "fl", "c": {"a": 0, "k": [1, 0, 0, 1]}, "o": {"a": 0, "k": 100}},
446                {"ty": "tr", "p": {"a": 0, "k": [0, 0]}, "a": {"a": 0, "k": [0, 0]},
447                 "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}}
448            ]}],
449            "ip": 0, "op": 30, "st": 0
450        }]
451    }"#;
452
453    const W: u32 = 100;
454    const H: u32 = 100;
455
456    fn render_mid() -> Vec<u8> {
457        test_render_frame(RED_LOTTIE.as_bytes(), 15, W, H)
458            .expect("render_mid must succeed")
459            .as_ref()
460            .clone()
461    }
462
463    #[test]
464    fn pixel_byte_order_red_lottie() {
465        let buf = render_mid();
466        assert_eq!(buf.len(), (W * H * 4) as usize);
467
468        let mut red_sum: u64 = 0;
469        let mut blue_sum: u64 = 0;
470        for px in buf.as_chunks::<4>().0.iter() {
471            let r = px[0] as u64;
472            let _g = px[1] as u64;
473            let b = px[2] as u64;
474            let a = px[3] as u64;
475            if a > 0 {
476                red_sum += r;
477                blue_sum += b;
478            }
479        }
480        assert!(
481            red_sum > 200_000,
482            "expected dominant red, got red_sum={red_sum} blue_sum={blue_sum}"
483        );
484        assert!(
485            blue_sum < 1000,
486            "expected ~0 blue, got blue_sum={blue_sum} red_sum={red_sum}"
487        );
488    }
489
490    #[test]
491    fn repeat_true_wraps_to_same_frame() {
492        let fr = 30.0f64;
493        let total = 30usize;
494        let dur = 1.0f64;
495
496        let fi_half = test_frame_at_time(0.5, 1.0, true, fr, total, dur);
497        let fi_wrap = test_frame_at_time(1.5, 1.0, true, fr, total, dur);
498        assert_eq!(fi_half, fi_wrap, "repeat=true: t=1.5 should wrap to t=0.5");
499
500        let buf_half =
501            test_render_frame(RED_LOTTIE.as_bytes(), fi_half, W, H).expect("render half");
502        let buf_wrap =
503            test_render_frame(RED_LOTTIE.as_bytes(), fi_wrap, W, H).expect("render wrap");
504        assert_eq!(*buf_half, *buf_wrap);
505    }
506
507    #[test]
508    fn repeat_false_clamps_to_last_frame() {
509        let fr = 30.0f64;
510        let total = 30usize;
511        let dur = 1.0f64;
512
513        let fi_clamped = test_frame_at_time(1.5, 1.0, false, fr, total, dur);
514        let fi_last = test_frame_at_time(1.0, 1.0, false, fr, total, dur);
515        assert_eq!(fi_clamped, 29);
516        assert_eq!(fi_last, 29);
517    }
518
519    #[test]
520    fn speed_multiplier_equivalent_frame() {
521        let fr = 30.0f64;
522        let total = 30usize;
523        let dur = 1.0f64;
524
525        let fi_fast = test_frame_at_time(0.25, 2.0, false, fr, total, dur);
526        let fi_normal = test_frame_at_time(0.5, 1.0, false, fr, total, dur);
527        assert_eq!(
528            fi_fast, fi_normal,
529            "speed=2.0 at t=0.25 should equal speed=1.0 at t=0.5"
530        );
531
532        let buf_fast =
533            test_render_frame(RED_LOTTIE.as_bytes(), fi_fast, W, H).expect("render fast");
534        let buf_normal =
535            test_render_frame(RED_LOTTIE.as_bytes(), fi_normal, W, H).expect("render normal");
536        assert_eq!(*buf_fast, *buf_normal);
537    }
538
539    #[test]
540    fn invalid_json_no_panic_zero_pixels() {
541        let result = test_render_frame(b"not valid json at all!!!", 0, W, H);
542        if let Some(buf) = result {
543            let nonzero = buf.iter().any(|&b| b != 0);
544            assert!(
545                !nonzero,
546                "invalid JSON should produce zero-pixel output, got non-zero pixels"
547            );
548        }
549    }
550
551    #[test]
552    fn frames_dir_priority_no_native_cache_entry() {
553        use super::{native, Lottie};
554
555        test_cache().clear();
556
557        let lottie = Lottie {
558            src: None,
559            data: Some(RED_LOTTIE.to_string()),
560            speed: 1.0,
561            repeat: false,
562            frames_dir: Some("/non/existent/frames_dir".to_string()),
563            timing: Default::default(),
564            style: Default::default(),
565            timeline: vec![],
566            stagger: None,
567        };
568
569        use super::native::FrameKey;
570        let expected_key = FrameKey::new(RED_LOTTIE.as_bytes(), 0, 100, 100);
571
572        let _ = native::test_resolve_json(&lottie);
573
574        assert!(
575            !test_cache().contains_key(&expected_key),
576            "native cache must not be populated when frames_dir takes priority"
577        );
578    }
579}