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#[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)> {
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}