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#[derive(Debug, Serialize, Deserialize, JsonSchema)]
21pub struct Lottie {
22 #[serde(default)]
24 pub src: Option<String>,
25 #[serde(default)]
27 pub data: Option<String>,
28 #[serde(default = "default_speed")]
30 pub speed: f32,
31 #[serde(default = "default_true")]
33 #[serde(rename = "loop")]
34 pub repeat: bool,
35 #[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)> {
67 let json_str = if let Some(ref src) = self.src {
68 std::fs::read_to_string(src).map_err(|e| RustmotionError::LottieRead {
69 path: src.clone(),
70 reason: e.to_string(),
71 })?
72 } else if let Some(ref data) = self.data {
73 data.clone()
74 } else {
75 return Err(RustmotionError::LottieMissingSrc);
76 };
77
78 let json: serde_json::Value = serde_json::from_str(&json_str)?;
79 let fr = json["fr"].as_f64().unwrap_or(30.0);
80 let ip = json["ip"].as_f64().unwrap_or(0.0);
81 let op = json["op"].as_f64().unwrap_or(60.0);
82 let w = json["w"].as_f64().unwrap_or(200.0) as f32;
83 let h = json["h"].as_f64().unwrap_or(200.0) as f32;
84 let total_frames = (op - ip) as usize;
85 let duration = total_frames as f64 / fr;
86
87 Ok((fr, total_frames, duration, w, h))
88 }
89
90 fn cache_key(&self, frame: usize) -> String {
92 let src = self.src.as_deref().unwrap_or("inline");
93 format!("lottie:{}:frame:{}", src, frame)
94 }
95
96 fn load_frame_from_dir(&self, frames_dir: &str, frame: usize) -> Result<skia_safe::Image> {
98 let frame_path = format!("{}/{:04}.png", frames_dir, frame);
99 let data = std::fs::read(&frame_path).map_err(|e| RustmotionError::LottieFrameRead {
100 path: frame_path.clone(),
101 reason: e.to_string(),
102 })?;
103
104 let img =
105 image::load_from_memory(&data).map_err(|e| RustmotionError::LottieFrameDecode {
106 path: frame_path.clone(),
107 reason: e.to_string(),
108 })?;
109 let rgba = img.to_rgba8();
110 let (w, h) = rgba.dimensions();
111
112 let img_data = skia_safe::Data::new_copy(rgba.as_raw());
113 let img_info = ImageInfo::new(
114 (w as i32, h as i32),
115 ColorType::RGBA8888,
116 skia_safe::AlphaType::Unpremul,
117 None,
118 );
119
120 skia_safe::images::raster_from_data(&img_info, img_data, w as usize * 4).ok_or(
121 RustmotionError::SkiaImageCreation {
122 target: "lottie frame".to_string(),
123 },
124 )
125 }
126}
127
128#[cfg(feature = "lottie-native")]
131mod native {
132 use std::hash::Hash;
133 use std::sync::{Arc, OnceLock};
134
135 use dashmap::DashMap;
136
137 const CACHE_MAX_ENTRIES: usize = 128;
140
141 #[derive(Clone, PartialEq, Eq, Hash, Debug)]
143 pub(super) struct FrameKey {
144 pub src_hash: u64,
146 pub frame_index: u32,
147 pub width: u32,
148 pub height: u32,
149 }
150
151 impl FrameKey {
152 pub(super) fn new(json_bytes: &[u8], frame_index: u32, width: u32, height: u32) -> Self {
153 let src_hash = fnv1a(json_bytes);
154 Self {
155 src_hash,
156 frame_index,
157 width,
158 height,
159 }
160 }
161 }
162
163 pub(super) fn fnv1a(bytes: &[u8]) -> u64 {
164 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
165 for &b in bytes {
166 h ^= b as u64;
167 h = h.wrapping_mul(0x0000_0100_0000_01b3);
168 }
169 h
170 }
171
172 type NativeCacheMap = Arc<DashMap<FrameKey, Arc<Vec<u8>>>>;
173
174 static NATIVE_LOTTIE_CACHE: OnceLock<NativeCacheMap> = OnceLock::new();
176
177 pub(super) fn native_lottie_cache() -> &'static NativeCacheMap {
178 NATIVE_LOTTIE_CACHE.get_or_init(|| Arc::new(DashMap::new()))
179 }
180
181 thread_local! {
184 static THORVG_ENGINE: std::cell::RefCell<Option<thorvg::Thorvg>> =
185 const { std::cell::RefCell::new(None) };
186 }
187
188 fn with_engine<F, R>(f: F) -> R
189 where
190 F: FnOnce(&thorvg::Thorvg) -> R,
191 {
192 THORVG_ENGINE.with(|cell| {
193 let mut guard = cell.borrow_mut();
194 if guard.is_none() {
195 *guard = thorvg::Thorvg::init(0).ok();
196 }
197 f(guard.as_ref().expect("thorvg init failed"))
198 })
199 }
200
201 static FAILED_SOURCES: OnceLock<DashMap<u64, ()>> = OnceLock::new();
203
204 fn failed_sources() -> &'static DashMap<u64, ()> {
205 FAILED_SOURCES.get_or_init(DashMap::new)
206 }
207
208 pub(super) fn render_frame(
225 json_bytes: &[u8],
226 frame_index: u32,
227 width: u32,
228 height: u32,
229 ) -> Option<Arc<Vec<u8>>> {
230 use thorvg::{ColorSpace, EngineOption};
231
232 let key = FrameKey::new(json_bytes, frame_index, width, height);
234 let cache = native_lottie_cache();
235 if let Some(cached) = cache.get(&key) {
236 return Some(cached.clone());
237 }
238
239 let src_hash = key.src_hash;
240
241 if failed_sources().contains_key(&src_hash) {
243 return None;
244 }
245
246 let rgba = with_engine(|engine| -> Option<Vec<u8>> {
247 use thorvg::Paint as ThorPaint;
248
249 let mut buffer = vec![0u32; (width * height) as usize];
250
251 let mut canvas = engine.sw_canvas(EngineOption::Default).ok()?;
252 unsafe {
255 canvas
256 .set_target(&mut buffer, width, width, height, ColorSpace::ABGR8888)
257 .ok()?
258 };
259
260 let mut anim = engine.lottie_animation().ok()?;
261 anim.load_data(json_bytes).ok()?;
262 anim.set_size(width as f32, height as f32).ok()?;
263
264 let total = anim.total_frame().ok()?;
265 if total <= 0.0 {
266 return None;
267 }
268 let clamped = (frame_index as f32).min(total - 1.0).max(0.0);
269 let _ = anim.set_frame(clamped);
272
273 let dup = anim.picture().duplicate()?;
275 canvas.add(dup).ok()?;
276 canvas.draw(true).ok()?;
277 canvas.sync().ok()?;
278
279 let byte_len = buffer.len() * 4;
281 let mut out = Vec::with_capacity(byte_len);
282 let byte_slice =
285 unsafe { std::slice::from_raw_parts(buffer.as_ptr() as *const u8, byte_len) };
286 out.extend_from_slice(byte_slice);
287 Some(out)
288 });
289
290 match rgba {
291 None => {
292 eprintln!(
293 "[rustmotion] lottie-native: failed to render source (hash {:016x}); \
294 further errors for this source will be suppressed",
295 src_hash
296 );
297 failed_sources().insert(src_hash, ());
298 None
299 }
300 Some(bytes) => {
301 let arc = Arc::new(bytes);
302 if cache.len() >= CACHE_MAX_ENTRIES {
304 cache.clear();
305 }
306 cache.insert(key, arc.clone());
307 Some(arc)
308 }
309 }
310 }
311
312 pub(super) fn frame_at_time(
315 t: f64,
316 speed: f32,
317 repeat: bool,
318 fr: f64,
319 total_frames: usize,
320 duration: f64,
321 ) -> u32 {
322 if total_frames == 0 || duration <= 0.0 {
323 return 0;
324 }
325 let anim_time = t * speed as f64;
326 let effective = if repeat {
327 anim_time % duration
328 } else {
329 anim_time.min(duration)
330 };
331 let f = (effective * fr) as usize;
332 f.min(total_frames.saturating_sub(1)) as u32
333 }
334
335 pub(super) fn resolve_json(lottie: &super::Lottie) -> Option<Vec<u8>> {
338 if let Some(ref data) = lottie.data {
339 return Some(data.as_bytes().to_vec());
340 }
341 if let Some(ref src) = lottie.src {
342 match std::fs::read(src) {
343 Ok(bytes) => return Some(bytes),
344 Err(e) => {
345 let path_hash = fnv1a(src.as_bytes());
346 if !failed_sources().contains_key(&path_hash) {
347 eprintln!(
348 "[rustmotion] lottie-native: cannot read '{}': {}; \
349 further errors for this source will be suppressed",
350 src, e
351 );
352 failed_sources().insert(path_hash, ());
353 }
354 return None;
355 }
356 }
357 }
358 None
359 }
360
361 pub(super) fn paint_native(
362 lottie: &super::Lottie,
363 canvas: &skia_safe::Canvas,
364 layout: &rustmotion_core::engine::layout_pass::BoxLayout,
365 ctx: &rustmotion_core::traits::PaintCtx,
366 ) {
367 let json_bytes = match resolve_json(lottie) {
368 Some(b) => b,
369 None => return,
370 };
371
372 let Ok((fr, total_frames, duration, _w, _h)) = lottie.parse_metadata() else {
373 return;
374 };
375
376 let w = layout.width as u32;
377 let h = layout.height as u32;
378 if w == 0 || h == 0 {
379 return;
380 }
381
382 let frame_index = frame_at_time(
383 ctx.time,
384 lottie.speed,
385 lottie.repeat,
386 fr,
387 total_frames,
388 duration,
389 );
390
391 let rgba = match render_frame(&json_bytes, frame_index, w, h) {
392 Some(r) => r,
393 None => return,
394 };
395
396 let img_data = skia_safe::Data::new_copy(&rgba);
397 let img_info = skia_safe::ImageInfo::new(
398 (w as i32, h as i32),
399 skia_safe::ColorType::RGBA8888,
400 skia_safe::AlphaType::Unpremul,
401 None,
402 );
403 let Some(img) = skia_safe::images::raster_from_data(&img_info, img_data, w as usize * 4)
404 else {
405 return;
406 };
407
408 let dst = skia_safe::Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
409 let paint = skia_safe::Paint::default();
410 canvas.draw_image_rect(img, None, dst, &paint);
411 }
412
413 #[cfg(test)]
414 pub(super) use frame_at_time as test_frame_at_time;
415 #[cfg(test)]
416 pub(super) use native_lottie_cache as test_cache;
417 #[cfg(test)]
418 pub(super) use render_frame as test_render_frame;
419 #[cfg(test)]
420 pub(super) use resolve_json as test_resolve_json;
421}
422
423impl Painter for Lottie {
426 fn paint_content(
427 &self,
428 canvas: &Canvas,
429 layout: &BoxLayout,
430 _props: &AnimatedProperties,
431 ctx: &PaintCtx,
432 ) {
433 let Ok((fr, total_frames, duration, _intrinsic_w, _intrinsic_h)) = self.parse_metadata()
434 else {
435 return;
436 };
437
438 if total_frames == 0 {
439 return;
440 }
441
442 let anim_time = ctx.time * self.speed as f64;
443 let effective_time = if self.repeat && duration > 0.0 {
444 anim_time % duration
445 } else {
446 anim_time.min(duration)
447 };
448 let frame = ((effective_time * fr) as usize).min(total_frames.saturating_sub(1));
449
450 if let Some(ref frames_dir) = self.frames_dir {
452 let cache_key = self.cache_key(frame);
453 let cache = asset_cache();
454
455 let img = if let Some(cached) = cache.get(&cache_key) {
456 cached.clone()
457 } else {
458 let Ok(img) = self.load_frame_from_dir(frames_dir, frame) else {
459 return;
460 };
461 cache.insert(cache_key, img.clone());
462 img
463 };
464
465 let dst = Rect::from_xywh(0.0, 0.0, layout.width, layout.height);
466 let paint = Paint::default();
467 canvas.draw_image_rect(img, None, dst, &paint);
468 return;
469 }
470
471 #[cfg(feature = "lottie-native")]
473 {
474 native::paint_native(self, canvas, layout, ctx);
475 }
476 }
477}
478
479#[cfg(all(test, feature = "lottie-native"))]
482mod tests {
483 use super::native::{test_cache, test_frame_at_time, test_render_frame};
484
485 const RED_LOTTIE: &str = r#"{
488 "v": "5.7.4", "fr": 30, "ip": 0, "op": 30, "w": 100, "h": 100,
489 "layers": [{
490 "ddd": 0, "ind": 1, "ty": 4, "nm": "rect", "sr": 1,
491 "ks": {"o": {"a": 0, "k": 100}, "r": {"a": 0, "k": 0},
492 "p": {"a": 0, "k": [50, 50, 0]}, "a": {"a": 0, "k": [0, 0, 0]},
493 "s": {"a": 0, "k": [100, 100, 100]}},
494 "shapes": [{"ty": "gr", "it": [
495 {"ty": "rc", "p": {"a": 0, "k": [0, 0]}, "s": {"a": 0, "k": [80, 80]}, "r": {"a": 0, "k": 0}},
496 {"ty": "fl", "c": {"a": 0, "k": [1, 0, 0, 1]}, "o": {"a": 0, "k": 100}},
497 {"ty": "tr", "p": {"a": 0, "k": [0, 0]}, "a": {"a": 0, "k": [0, 0]},
498 "s": {"a": 0, "k": [100, 100]}, "r": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100}}
499 ]}],
500 "ip": 0, "op": 30, "st": 0
501 }]
502 }"#;
503
504 const W: u32 = 100;
505 const H: u32 = 100;
506
507 fn render_mid() -> Vec<u8> {
509 test_render_frame(RED_LOTTIE.as_bytes(), 15, W, H)
510 .expect("render_mid must succeed")
511 .as_ref()
512 .clone()
513 }
514
515 #[test]
522 fn pixel_byte_order_red_lottie() {
523 let buf = render_mid();
524 assert_eq!(buf.len(), (W * H * 4) as usize);
525
526 let mut red_sum: u64 = 0;
527 let mut blue_sum: u64 = 0;
528 for px in buf.chunks_exact(4) {
529 let r = px[0] as u64;
530 let _g = px[1] as u64;
531 let b = px[2] as u64;
532 let a = px[3] as u64;
533 if a > 0 {
534 red_sum += r;
535 blue_sum += b;
536 }
537 }
538 assert!(
541 red_sum > 200_000,
542 "expected dominant red, got red_sum={red_sum} blue_sum={blue_sum}"
543 );
544 assert!(
545 blue_sum < 1000,
546 "expected ~0 blue, got blue_sum={blue_sum} red_sum={red_sum}"
547 );
548 }
549
550 #[test]
556 fn repeat_true_wraps_to_same_frame() {
557 let fr = 30.0f64;
559 let total = 30usize;
560 let dur = 1.0f64;
561
562 let fi_half = test_frame_at_time(0.5, 1.0, true, fr, total, dur);
563 let fi_wrap = test_frame_at_time(1.5, 1.0, true, fr, total, dur);
564 assert_eq!(fi_half, fi_wrap, "repeat=true: t=1.5 should wrap to t=0.5");
565
566 let buf_half =
568 test_render_frame(RED_LOTTIE.as_bytes(), fi_half, W, H).expect("render half");
569 let buf_wrap =
570 test_render_frame(RED_LOTTIE.as_bytes(), fi_wrap, W, H).expect("render wrap");
571 assert_eq!(*buf_half, *buf_wrap);
573 }
574
575 #[test]
576 fn repeat_false_clamps_to_last_frame() {
577 let fr = 30.0f64;
578 let total = 30usize;
579 let dur = 1.0f64;
580
581 let fi_clamped = test_frame_at_time(1.5, 1.0, false, fr, total, dur);
582 let fi_last = test_frame_at_time(1.0, 1.0, false, fr, total, dur);
583 assert_eq!(fi_clamped, 29);
585 assert_eq!(fi_last, 29);
586 }
587
588 #[test]
592 fn speed_multiplier_equivalent_frame() {
593 let fr = 30.0f64;
594 let total = 30usize;
595 let dur = 1.0f64;
596
597 let fi_fast = test_frame_at_time(0.25, 2.0, false, fr, total, dur);
598 let fi_normal = test_frame_at_time(0.5, 1.0, false, fr, total, dur);
599 assert_eq!(
600 fi_fast, fi_normal,
601 "speed=2.0 at t=0.25 should equal speed=1.0 at t=0.5"
602 );
603
604 let buf_fast =
605 test_render_frame(RED_LOTTIE.as_bytes(), fi_fast, W, H).expect("render fast");
606 let buf_normal =
607 test_render_frame(RED_LOTTIE.as_bytes(), fi_normal, W, H).expect("render normal");
608 assert_eq!(*buf_fast, *buf_normal);
609 }
610
611 #[test]
613 fn invalid_json_no_panic_zero_pixels() {
614 let result = test_render_frame(b"not valid json at all!!!", 0, W, H);
615 if let Some(buf) = result {
617 let nonzero = buf.iter().any(|&b| b != 0);
618 assert!(
619 !nonzero,
620 "invalid JSON should produce zero-pixel output, got non-zero pixels"
621 );
622 }
623 }
625
626 #[test]
636 fn frames_dir_priority_no_native_cache_entry() {
637 use super::{native, Lottie};
638
639 test_cache().clear();
641
642 let lottie = Lottie {
644 src: None,
645 data: Some(RED_LOTTIE.to_string()),
646 speed: 1.0,
647 repeat: false,
648 frames_dir: Some("/non/existent/frames_dir".to_string()),
649 timing: Default::default(),
650 style: Default::default(),
651 timeline: vec![],
652 stagger: None,
653 };
654
655 use super::native::FrameKey;
657 let expected_key = FrameKey::new(RED_LOTTIE.as_bytes(), 0, 100, 100);
658
659 let _ = native::test_resolve_json(&lottie);
667
668 assert!(
671 !test_cache().contains_key(&expected_key),
672 "native cache must not be populated when frames_dir takes priority"
673 );
674 }
675}