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