tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! `llimphi-lottie` — puente fino entre `velato` (reproductor de Lottie de
2//! Linebender) y Llimphi.
3//!
4//! `llimphi-svg` cubre el vector *estático* arbitrario (íconos `.desktop`,
5//! logos). `llimphi-motion` cubre el movimiento *generado por código* (tweens
6//! sobre el bucle Elm). Este crate cubre el hueco del medio: **animación
7//! vectorial autorada por fuera** — los `.json` de Lottie que exportan los
8//! diseñadores desde After Effects / lottiefiles (íconos animados,
9//! ilustraciones, estados vacíos, onboarding).
10//!
11//! Es el gemelo de `llimphi-svg`, con un eje de tiempo: `velato` parsea el JSON
12//! a una `Composition` y, para un `frame` dado, emite a la **misma**
13//! `vello::Scene` que usa `llimphi-raster`.
14//!
15//! ## Uso
16//!
17//! ```ignore
18//! use llimphi_lottie::LottieAsset;
19//! use std::time::Duration;
20//!
21//! // Parsea UNA vez (al cargar la app):
22//! let anim = LottieAsset::from_str(include_str!("spinner.json")).expect("lottie válido");
23//!
24//! // En el estado de la app guardas el tiempo transcurrido y lo avanzas con el
25//! // bucle Elm — exactamente el patrón de los workers de simulación:
26//! // handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
27//! // y en update: self.t += 1.0 / 60.0;
28//!
29//! // Pintalo a su instante actual (escala + centra al rect, en loop):
30//! View::new(style).children(vec![anim.view_at_time::<Msg>(self.t)])
31//! ```
32//!
33//! El parse cuesta (corre el importador serde de Lottie); el `paint`/`view` no.
34//! `LottieAsset` es `Clone` barato (`Arc` internamente) — el mismo asset en
35//! varios nodos no replica memoria.
36//!
37//! ## Por qué un `Renderer` nuevo por paint
38//!
39//! `velato::Renderer` es `Default` y sólo guarda Vecs de scratch que se limpian
40//! en cada `append`. No tiene recursos de GPU (eso es trabajo de `llimphi-hal`).
41//! Crear uno por frame es despreciable frente a empujar la geometría — que se
42//! hace igual — y nos deja un `LottieAsset` `Send + Sync` sin `Mutex`.
43//!
44//! ## Cobertura
45//!
46//! `velato` 0.9 implementa shapes, transforms animados, gradientes, trim paths,
47//! máscaras y precomposiciones — el grueso de los Lottie reales de íconos e
48//! ilustración. **No** implementa todavía capas de **texto**, **effects**
49//! (blur/drop-shadow) ni **expresiones** (declarado en su propio
50//! `schema/mod.rs`). Un asset que use esas features se pinta omitiéndolas, no
51//! rompe. Cuando un `.json` concreto las necesite, el camino es vendorizar
52//! `velato` como `shared/foreign-lottie` y completar el `todo` puntual.
53
54#![forbid(unsafe_code)]
55
56use std::sync::Arc;
57
58use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
59use llimphi_ui::llimphi_layout::taffy::Position;
60use llimphi_ui::llimphi_raster::kurbo::Affine;
61use llimphi_ui::llimphi_raster::vello::Scene;
62use llimphi_ui::{PaintRect, View};
63
64// El motor es nuestro fork vendorizado de velato; lo aliasamos a `velato` para
65// que el resto del crate lea igual que la documentación upstream.
66use foreign_lottie as velato;
67use velato::Composition;
68
69/// Animación Lottie parseada y lista para stampear a cualquier `frame`.
70/// Internamente guarda la `Composition` de `velato` (el modelo del `.json`) +
71/// su geometría temporal. Cloneable barato (`Arc`) — un mismo asset en varios
72/// nodos no replica memoria. `Send + Sync`.
73#[derive(Clone)]
74pub struct LottieAsset {
75 inner: Arc<Composition>,
76}
77
78/// Error al parsear un Lottie. Hoy es un wrap del texto del error de `velato` —
79/// las apps típicas lo tratan como "fallback" (no animan) y no inspeccionan la
80/// variante.
81#[derive(Debug)]
82pub struct LottieError(pub String);
83
84impl std::fmt::Display for LottieError {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 write!(f, "lottie: {}", self.0)
87 }
88}
89
90impl std::error::Error for LottieError {}
91
92impl LottieAsset {
93 /// Parsea un Lottie desde su JSON como string. Devuelve un asset inmutable +
94 /// cloneable. **Hace el parseo completo** (serde + importación al modelo
95 /// runtime de `velato`); pensado para llamarse UNA vez por asset, no por
96 /// frame.
97 pub fn from_str(json: &str) -> Result<Self, LottieError> {
98 Self::from_bytes(json.as_bytes())
99 }
100
101 /// Parsea un Lottie desde sus bytes UTF-8 crudos (lo que devuelve
102 /// `include_bytes!("…json")` o `std::fs::read`).
103 ///
104 /// **Blindaje contra panics.** Nuestro fork `foreign-lottie` ya eliminó los
105 /// `todo!()`/`unimplemented!()` del importador de velato 0.9 (split
106 /// rotation/position, blends `Add`/`HardMix`, assets desconocidos, transform
107 /// sin rotación) — degradan con gracia en vez de paniquear. El `catch_unwind`
108 /// queda como red de seguridad secundaria: por si un `.json` raro alcanza
109 /// algún `panic!`/`unwrap` residual del deserializador, el asset cae al
110 /// fallback en vez de tumbar el hilo de UI. El render (`append`) es seguro,
111 /// así que no lo envolvemos por-frame.
112 pub fn from_bytes(json: &[u8]) -> Result<Self, LottieError> {
113 let parsed = std::panic::catch_unwind(|| Composition::from_slice(json));
114 let comp = match parsed {
115 Ok(Ok(comp)) => comp,
116 Ok(Err(e)) => return Err(LottieError(e.to_string())),
117 Err(_) => {
118 return Err(LottieError(
119 "velato paniqueó al importar (feature no soportada: split \
120 rotation/position, asset o blend sin implementar)"
121 .to_string(),
122 ))
123 }
124 };
125 Ok(Self {
126 inner: Arc::new(comp),
127 })
128 }
129
130 /// Tamaño nominal de la animación en px (el `w`/`h` del Lottie). Útil para
131 /// dimensionar el rect destino preservando aspect ratio.
132 pub fn size(&self) -> (f64, f64) {
133 (self.inner.width as f64, self.inner.height as f64)
134 }
135
136 /// Cuadros por segundo declarados en el `.json` (`fr`).
137 pub fn frame_rate(&self) -> f64 {
138 self.inner.frame_rate
139 }
140
141 /// Rango de frames activos `[ip, op)` del Lottie. `paint` espera un `frame`
142 /// dentro de este rango; fuera de él, lo clampa a los extremos.
143 pub fn frames(&self) -> std::ops::Range<f64> {
144 self.inner.frames.clone()
145 }
146
147 /// Duración de una pasada completa, en segundos. `0.0` si el `.json` no
148 /// declara `fr` o no tiene frames.
149 pub fn duration_secs(&self) -> f64 {
150 let span = self.inner.frames.end - self.inner.frames.start;
151 if self.inner.frame_rate > 0.0 && span > 0.0 {
152 span / self.inner.frame_rate
153 } else {
154 0.0
155 }
156 }
157
158 /// Convierte un instante en segundos a un `frame` dentro del rango activo,
159 /// **en loop** (módulo la duración). Es el mapeo que usa `view_at_time` /
160 /// `paint_at_time`. Si el asset no tiene duración válida, devuelve el primer
161 /// frame.
162 pub fn frame_at_time(&self, t_secs: f64) -> f64 {
163 let start = self.inner.frames.start;
164 let span = self.inner.frames.end - start;
165 if span <= 0.0 || self.inner.frame_rate <= 0.0 {
166 return start;
167 }
168 let frames_elapsed = (t_secs.max(0.0) * self.inner.frame_rate) % span;
169 start + frames_elapsed
170 }
171
172 /// Pinta la animación al `frame` indicado sobre `scene`, ajustada al `rect`.
173 /// Escala uniforme al mínimo lado y **centra** dentro del rect (preserva
174 /// aspect ratio). `frame` se clampa al rango activo del Lottie. Útil cuando
175 /// el caller compone varios assets en un `paint_with` propio sin pasar por
176 /// `view()`.
177 pub fn paint(&self, scene: &mut Scene, rect: PaintRect, frame: f64) {
178 self.paint_alpha(scene, rect, frame, 1.0);
179 }
180
181 /// Como `paint`, pero con una opacidad global `alpha` (0..1) aplicada a toda
182 /// la composición. Es lo que habilita el **crossfade** entre clips de una
183 /// máquina de estados (pintar el saliente a `1-mix` y el entrante a `mix`).
184 /// `velato` lo soporta nativo: el `alpha` va al `append`.
185 pub fn paint_alpha(&self, scene: &mut Scene, rect: PaintRect, frame: f64, alpha: f64) {
186 let (vb_w, vb_h) = self.size();
187 let side_w = rect.w as f64;
188 let side_h = rect.h as f64;
189 if side_w <= 0.0 || side_h <= 0.0 || vb_w <= 0.0 || vb_h <= 0.0 || alpha <= 0.0 {
190 return;
191 }
192 let s = (side_w / vb_w).min(side_h / vb_h);
193 let used_w = vb_w * s;
194 let used_h = vb_h * s;
195 let tx = rect.x as f64 + (side_w - used_w) * 0.5;
196 let ty = rect.y as f64 + (side_h - used_h) * 0.5;
197 let xform = Affine::translate((tx, ty)) * Affine::scale(s);
198
199 let frame = frame.clamp(self.inner.frames.start, self.inner.frames.end);
200 // Renderer nuevo por paint: es `Default`, sólo scratch Vecs, sin GPU.
201 let mut renderer = velato::Renderer::new();
202 renderer.append(&self.inner, frame, xform, alpha.clamp(0.0, 1.0), scene);
203 }
204
205 /// Como `paint`, pero recibe el instante en **segundos** y lo mapea a frame
206 /// en loop vía `frame_at_time`. Es la forma esperada de animar desde el
207 /// estado de la app (acumulas `t` en segundos en `update`).
208 pub fn paint_at_time(&self, scene: &mut Scene, rect: PaintRect, t_secs: f64) {
209 self.paint(scene, rect, self.frame_at_time(t_secs));
210 }
211
212 /// Construye un `View` posicionado en absoluto que ocupa todo el rect del
213 /// padre y pinta la animación al `frame` indicado, centrada + escalada al
214 /// mínimo lado. Gemelo de `SvgAsset::view`, con frame. Genérico sobre `Msg`
215 /// igual que los widgets — el `View` no tiene handlers; la app los pone en
216 /// el padre.
217 pub fn view<Msg>(&self, frame: f64) -> View<Msg> {
218 let asset = self.clone();
219 View::new(absolute_fill())
220 .paint_with(move |scene, _ts, rect| asset.paint(scene, rect, frame))
221 }
222
223 /// Como `view`, pero recibe el instante en segundos (loop vía
224 /// `frame_at_time`). Es la variante que usas con un `t` acumulado en el
225 /// estado de la app.
226 pub fn view_at_time<Msg>(&self, t_secs: f64) -> View<Msg> {
227 let asset = self.clone();
228 View::new(absolute_fill())
229 .paint_with(move |scene, _ts, rect| asset.paint_at_time(scene, rect, t_secs))
230 }
231}
232
233/// Pinta un [`RenderFrame`] de una máquina de estados [`llimphi_anim`] usando
234/// `clips` indexados por `ClipId` (= índice en el slice). Pinta el clip primario
235/// y, si hay una transición en curso, hace **crossfade**: saliente a `1-mix`,
236/// entrante a `mix`. Los `ClipId` fuera de rango se omiten (no rompe).
237///
238/// `ClipSample::time_secs` se mapea a frame con loop vía `frame_at_time`, así
239/// que cada clip respeta su propio fps y duración.
240///
241/// [`RenderFrame`]: llimphi_anim::RenderFrame
242pub fn paint_render_frame(
243 scene: &mut Scene,
244 rect: PaintRect,
245 frame: &llimphi_anim::RenderFrame,
246 clips: &[LottieAsset],
247) {
248 let sample = |s: llimphi_anim::ClipSample, alpha: f64, scene: &mut Scene| {
249 if let Some(asset) = clips.get(s.clip as usize) {
250 let f = asset.frame_at_time(s.time_secs);
251 asset.paint_alpha(scene, rect, f, alpha);
252 }
253 };
254 match frame.blend {
255 None => sample(frame.primary, 1.0, scene),
256 Some((incoming, mix)) => {
257 let mix = mix as f64;
258 // Dissolve: el saliente se desvanece mientras el entrante aparece.
259 sample(frame.primary, 1.0 - mix, scene);
260 sample(incoming, mix, scene);
261 }
262 }
263}
264
265/// Construye un `View` absoluto que ocupa el rect del padre y pinta el
266/// [`RenderFrame`] actual de una máquina de estados (con crossfade). El caller
267/// llama `instance.render_frame()` cada frame y le pasa el resultado + sus clips
268/// (cloneados; baratos por `Arc`). Es el gemelo de [`LottieAsset::view`] para
269/// animación dirigida por estado en vez de por un solo clip.
270///
271/// [`RenderFrame`]: llimphi_anim::RenderFrame
272pub fn state_machine_view<Msg>(
273 frame: llimphi_anim::RenderFrame,
274 clips: Vec<LottieAsset>,
275) -> View<Msg> {
276 View::new(absolute_fill())
277 .paint_with(move |scene, _ts, rect| paint_render_frame(scene, rect, &frame, &clips))
278}
279
280/// Estilo "ocupa todo el rect del padre, en absoluto" — compartido por las dos
281/// variantes de `view`.
282fn absolute_fill() -> Style {
283 Style {
284 position: Position::Absolute,
285 size: Size {
286 width: percent(1.0_f32),
287 height: percent(1.0_f32),
288 },
289 ..Default::default()
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 /// Lottie mínimo válido: 60 frames a 30 fps, lienzo 100×100, sin capas.
298 /// (Suficiente para ejercitar parse + geometría temporal; el render de un
299 /// shape real lo cubren los assets de ejemplo, no un unit test.)
300 const LOTTIE_OK: &str =
301 r#"{"v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,"layers":[]}"#;
302
303 #[test]
304 fn from_str_parsea_ok() {
305 let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
306 let (w, h) = a.size();
307 assert_eq!((w, h), (100.0, 100.0));
308 assert_eq!(a.frame_rate(), 30.0);
309 }
310
311 #[test]
312 fn duracion_y_rango() {
313 let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
314 assert_eq!(a.frames(), 0.0..60.0);
315 // 60 frames / 30 fps = 2 s.
316 assert!((a.duration_secs() - 2.0).abs() < 1e-9);
317 }
318
319 #[test]
320 fn frame_at_time_hace_loop() {
321 let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
322 // En t=0 → frame 0.
323 assert!((a.frame_at_time(0.0) - 0.0).abs() < 1e-9);
324 // En t=1 s (mitad) → frame 30.
325 assert!((a.frame_at_time(1.0) - 30.0).abs() < 1e-9);
326 // En t=2 s (una pasada exacta) → vuelve a 0 por el módulo.
327 assert!((a.frame_at_time(2.0) - 0.0).abs() < 1e-9);
328 // En t=2.5 s → frame 15 (loop).
329 assert!((a.frame_at_time(2.5) - 15.0).abs() < 1e-9);
330 }
331
332 #[test]
333 fn json_inválido_da_error() {
334 assert!(LottieAsset::from_str("{no es lottie}").is_err());
335 }
336
337 #[test]
338 fn asset_es_cloneable_barato() {
339 let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
340 let b = a.clone();
341 assert_eq!(a.size(), b.size());
342 }
343
344 #[test]
345 fn paint_no_panica_con_rect_cero() {
346 let a = LottieAsset::from_str(LOTTIE_OK).expect("parsea");
347 let mut s = Scene::new();
348 a.paint(&mut s, PaintRect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }, 0.0);
349 a.paint_at_time(&mut s, PaintRect { x: 0.0, y: 0.0, w: 10.0, h: 0.0 }, 0.5);
350 }
351
352 /// `Send + Sync` es parte del contrato (igual que `SvgAsset`): el asset se
353 /// mueve a closures de paint que pueden cruzar threads.
354 #[test]
355 fn asset_es_send_sync() {
356 fn assert_send_sync<T: Send + Sync>() {}
357 assert_send_sync::<LottieAsset>();
358 }
359
360 /// Lottie con un shape real: un rectángulo rojo de 80×80 centrado en un
361 /// lienzo 100×100, estático 60 frames. Sirve para certificar — con
362 /// evidencia textual, no PNG — que la geometría llega a la `vello::Scene`.
363 const LOTTIE_RECT: &str = r#"{
364 "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
365 "layers":[{
366 "ty":4,"ip":0,"op":60,"st":0,"sr":1,
367 "ks":{"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0}},
368 "shapes":[
369 {"ty":"rc","p":{"a":0,"k":[50,50]},"s":{"a":0,"k":[80,80]},"r":{"a":0,"k":0}},
370 {"ty":"fl","c":{"a":0,"k":[0.8,0,0,1]},"o":{"a":0,"k":100}}
371 ]
372 }]
373 }"#;
374
375 #[test]
376 fn shape_real_emite_geometria_a_la_scene() {
377 let a = LottieAsset::from_str(LOTTIE_RECT).expect("parsea");
378 let mut scene = Scene::new();
379 // Antes de pintar: la Scene está vacía.
380 assert!(scene.encoding().is_empty(), "scene recién creada debe estar vacía");
381 a.paint(&mut scene, PaintRect { x: 0.0, y: 0.0, w: 200.0, h: 200.0 }, 0.0);
382 // Después: velato empujó el rect → encoding con contenido.
383 assert!(
384 !scene.encoding().is_empty(),
385 "tras paint de un shape real, la Scene debe tener geometría"
386 );
387 }
388
389 /// Mismo layer pero con el transform SIN campo de rotación (`r`). En velato
390 /// 0.9 upstream esto **paniquea** (`todo!("split rotation")`); nuestro fork
391 /// `foreign-lottie` lo trata como rotación 0 y **parsea bien**. Aquí
392 /// certificamos el resultado del fork: parse Ok, sin panic.
393 const LOTTIE_SIN_ROTACION: &str = r#"{
394 "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
395 "layers":[{
396 "ty":4,"ip":0,"op":60,"st":0,"sr":1,
397 "ks":{"p":{"a":0,"k":[0,0]}},
398 "shapes":[]
399 }]
400 }"#;
401
402 #[test]
403 fn lottie_sin_rotacion_parsea_con_el_fork() {
404 // En velato upstream esto era un panic; el fork lo importa como rot=0.
405 let a = LottieAsset::from_str(LOTTIE_SIN_ROTACION)
406 .expect("el fork parsea un transform sin rotación");
407 assert_eq!(a.size(), (100.0, 100.0));
408 }
409
410 /// Segundo clip distinto al rect: un círculo, para tener dos animaciones que
411 /// mezclar en la máquina de estados.
412 const LOTTIE_CIRC: &str = r#"{
413 "v":"5.5.2","fr":30,"ip":0,"op":60,"w":100,"h":100,
414 "layers":[{
415 "ty":4,"ip":0,"op":60,"st":0,"sr":1,
416 "ks":{"p":{"a":0,"k":[50,50]},"r":{"a":0,"k":0}},
417 "shapes":[
418 {"ty":"el","p":{"a":0,"k":[0,0]},"s":{"a":0,"k":[70,70]}},
419 {"ty":"fl","c":{"a":0,"k":[0,0.4,0.9,1]},"o":{"a":0,"k":100}}
420 ]
421 }]
422 }"#;
423
424 /// E2E del Tier 1: máquina de estados (llimphi-anim) + dos clips Lottie +
425 /// `paint_render_frame`. Verifica que tanto el estado simple como el
426 /// crossfade emiten geometría a la `Scene` (evidencia textual, sin PNG).
427 #[test]
428 fn state_machine_pinta_clips_y_crossfade() {
429 use llimphi_anim::{Condition, StateMachine};
430
431 let rect = LottieAsset::from_str(LOTTIE_RECT).expect("rect");
432 let circ = LottieAsset::from_str(LOTTIE_CIRC).expect("circ");
433 let clips = vec![rect, circ]; // ClipId 0 = rect, 1 = circ
434
435 let mut sm = StateMachine::new();
436 let idle = sm.add_state("idle", 0, 1.0, true);
437 let walk = sm.add_state("walk", 1, 1.0, true);
438 sm.set_entry(idle);
439 sm.transition(idle, walk, vec![Condition::bool("moving", true)], 0.4);
440 let mut inst = sm.instance();
441
442 let big = PaintRect { x: 0.0, y: 0.0, w: 200.0, h: 200.0 };
443
444 // Estado simple: pinta sólo el clip primario (rect).
445 let mut s0 = Scene::new();
446 paint_render_frame(&mut s0, big, &inst.render_frame(), &clips);
447 assert!(!s0.encoding().is_empty(), "estado simple debe pintar geometría");
448
449 // Arranca la transición y cae a mitad del blend.
450 inst.set_bool("moving", true);
451 inst.advance(0.2); // 0.2/0.4 = mix 0.5
452 let rf = inst.render_frame();
453 assert!(rf.blend.is_some(), "debería estar en crossfade");
454 let mut s1 = Scene::new();
455 paint_render_frame(&mut s1, big, &rf, &clips);
456 assert!(!s1.encoding().is_empty(), "el crossfade debe pintar geometría");
457
458 // El view helper compila y produce un View sin panic.
459 let _v = state_machine_view::<()>(rf, clips);
460 }
461}