tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-lottie/src/lib.rs Rust · 461 líneas · 18.8 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

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}