tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-compositor/examples/showreel.rs Rust · 741 líneas · 27.9 KB Historia Quién tocó qué crudo

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · Sergio ·

1//! **Showreel** del motor Llimphi — para r/rust. NO es eye-candy abstracto:
2//! es una vitrina de **widgets reales** del toolkit, *en acción*. Cada frame
3//! reconstruye un árbol `View` con widgets de verdad (`llimphi-widget-switch`,
4//! `-slider`, `-progress`, `-button`, `-segmented`) cuyo **estado** se deriva
5//! del tiempo normalizado `t∈[0,1]` — el toggle se enciende, el slider sube,
6//! la barra avanza, el segmented cambia de pestaña. Se montan con el `mount` /
7//! `paint` / `compute_with_measure` reales (taffy + parley + vello), idéntico
8//! al eventloop. No se dibujan a mano: si existe el widget, se usa el widget.
9//!
10//! El render es **headless y determinista** (sin reloj, sin runtime, sin
11//! winit): frame `i` de `N` → `t = i/(N-1)` → View → layout → vello::Scene →
12//! wgpu → PNG. El cold-open (trazo bezier draw-on) y el wordmark de cierre
13//! son `paint_with` sobre un nodo full-screen, superpuestos sobre los widgets.
14//!
15//! ```text
16//! cargo run -p llimphi-compositor --example showreel --release -- \
17//!     [out_dir] [n_frames] [W] [H]
18//! ```
19//! Defaults: `out_dir=showreel_frames`, `n_frames=360`, `W=1600`, `H=900`.
20
21use std::fs::{create_dir_all, File};
22use std::io::BufWriter;
23
24use llimphi_compositor::{
25    measure_text_node, mount, paint, DragPhase, PaintRect, Shadow, View,
26};
27use llimphi_hal::{wgpu, Hal};
28use llimphi_layout::taffy;
29use llimphi_layout::taffy::prelude::{
30    auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style,
31};
32use llimphi_layout::taffy::Rect;
33use llimphi_layout::LayoutTree;
34use llimphi_raster::peniko::{self, Color, Gradient};
35use llimphi_raster::{vello, Renderer};
36use llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter};
37use llimphi_theme::motion;
38use vello::kurbo::{Affine, BezPath, Circle, Point, Stroke};
39
40use llimphi_widget_button::{button_view, ButtonPalette};
41use llimphi_widget_progress::{linear_progress_view, radial_progress_view};
42use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
43use llimphi_widget_slider::{slider_view, SliderPalette};
44use llimphi_widget_switch::{switch_view, SwitchPalette};
45
46const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
47
48// ───────────────────────── utilidades ─────────────────────────
49
50/// Color con alpha escalado a `a∈[0,1]` (para fade del overlay vector).
51fn with_alpha(c: Color, a: f32) -> Color {
52    let [r, g, b, _] = c.components;
53    Color::new([r, g, b, a.clamp(0.0, 1.0)])
54}
55
56fn lerp(a: f64, b: f64, t: f64) -> f64 {
57    a + (b - a) * t
58}
59
60/// Reescala `t` desde el subintervalo `[lo,hi]` de la timeline a `[0,1]`,
61/// clampado. Fuera del intervalo devuelve 0 (antes) o 1 (después).
62fn seg(t: f32, lo: f32, hi: f32) -> f32 {
63    ((t - lo) / (hi - lo)).clamp(0.0, 1.0)
64}
65
66// ───────────────────────── tema / paleta ─────────────────────────
67
68#[derive(Clone)]
69struct Skin {
70    theme: llimphi_theme::Theme,
71    accent: Color,
72    panel: Color,
73    panel_hi: Color,
74    border: Color,
75    border_accent: Color,
76    fg: Color,
77    fg_muted: Color,
78    bg: Color,
79}
80
81// ───────────────────────── geometría de las tarjetas ─────────────────────────
82
83#[derive(Clone, Copy)]
84struct CardRect {
85    x: f64,
86    y: f64,
87    w: f64,
88    h: f64,
89}
90
91impl CardRect {
92    fn lerp(self, b: CardRect, t: f64) -> CardRect {
93        CardRect {
94            x: lerp(self.x, b.x, t),
95            y: lerp(self.y, b.y, t),
96            w: lerp(self.w, b.w, t),
97            h: lerp(self.h, b.h, t),
98        }
99    }
100}
101
102const N_CARDS: usize = 6;
103
104/// Disposición A — grilla 3×2 centrada (beat de ensamblado).
105fn layout_grid(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
106    let card_w = 360.0;
107    let card_h = 196.0;
108    let gap = 40.0;
109    let cols = 3.0;
110    let rows = 2.0;
111    let total_w = cols * card_w + (cols - 1.0) * gap;
112    let total_h = rows * card_h + (rows - 1.0) * gap;
113    let x0 = (cw - total_w) / 2.0;
114    let y0 = (ch - total_h) / 2.0;
115    let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: card_h }; N_CARDS];
116    for (i, c) in out.iter_mut().enumerate() {
117        let col = (i % 3) as f64;
118        let row = (i / 3) as f64;
119        c.x = x0 + col * (card_w + gap);
120        c.y = y0 + row * (card_h + gap);
121    }
122    out
123}
124
125/// Disposición B — fila única ancha, alturas escalonadas (beat de morph).
126/// Los MISMOS widgets adentro, otra geometría: "cualquier layout con taffy".
127fn layout_row(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
128    let gap = 22.0;
129    let n = N_CARDS as f64;
130    let card_w = (cw - 2.0 * 90.0 - (n - 1.0) * gap) / n;
131    let x0 = 90.0;
132    let cy = ch / 2.0;
133    // alturas tipo "ecualizador" — silueta dinámica al reacomodar.
134    let hs = [240.0, 300.0, 210.0, 320.0, 260.0, 230.0];
135    let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: 220.0 }; N_CARDS];
136    for (i, c) in out.iter_mut().enumerate() {
137        c.x = x0 + i as f64 * (card_w + gap);
138        c.h = hs[i];
139        c.y = cy - c.h / 2.0;
140        c.w = card_w;
141    }
142    out
143}
144
145// ───────────────────────── contenido de cada card ─────────────────────────
146
147/// Header de card: chip de acento + título.
148fn card_header(title: &str, s: &Skin, accented: bool) -> View<()> {
149    let chip = View::new(Style {
150        size: Size { width: length(28.0), height: length(8.0) },
151        flex_shrink: 0.0,
152        ..Default::default()
153    })
154    .radius(4.0)
155    .fill(if accented { s.accent } else { s.fg_muted });
156    View::new(Style {
157        size: Size { width: percent(1.0_f32), height: length(20.0) },
158        flex_direction: FlexDirection::Row,
159        align_items: Some(AlignItems::Center),
160        gap: Size { width: length(10.0), height: length(0.0) },
161        flex_shrink: 0.0,
162        ..Default::default()
163    })
164    .children(vec![
165        chip,
166        View::new(Style { flex_grow: 1.0, ..Default::default() })
167            .text_aligned(title.to_string(), 12.5, s.fg_muted, Alignment::Start)
168            .bold(),
169    ])
170}
171
172/// Línea de "valor" grande (estado legible) bajo el control.
173fn value_line(text: &str, color: Color, size: f32) -> View<()> {
174    View::new(Style {
175        size: Size { width: percent(1.0_f32), height: length(size + 6.0) },
176        flex_shrink: 0.0,
177        ..Default::default()
178    })
179    .text_aligned(text.to_string(), size, color, Alignment::Start)
180    .bold()
181}
182
183/// Cuerpo de una card según índice — cada una hospeda widgets REALES cuyo
184/// estado deriva de `p∈[0,1]` (progreso del beat de widgets).
185fn card_body(i: usize, p: f32, s: &Skin) -> Vec<View<()>> {
186    match i {
187        // ── 0: Switch (off → on) ──────────────────────────────────────
188        0 => {
189            // El thumb se desliza en una rampa centrada del beat.
190            let prog = motion::ease_in_out_cubic(seg(p, 0.15, 0.6));
191            let on = prog > 0.5;
192            let pal = SwitchPalette::from_theme(&s.theme);
193            let sw_row = View::new(Style {
194                size: Size { width: percent(1.0_f32), height: length(26.0) },
195                flex_direction: FlexDirection::Row,
196                align_items: Some(AlignItems::Center),
197                gap: Size { width: length(14.0), height: length(0.0) },
198                ..Default::default()
199            })
200            .children(vec![
201                switch_view(prog, (), &pal),
202                View::new(Style { flex_grow: 1.0, ..Default::default() })
203                    .text_aligned(
204                        "Sincronizar".to_string(),
205                        13.0,
206                        s.fg,
207                        Alignment::Start,
208                    ),
209            ]);
210            vec![
211                card_header("switch", s, true),
212                spacer(8.0),
213                sw_row,
214                spacer(10.0),
215                value_line(if on { "ENCENDIDO" } else { "apagado" }, if on { s.accent } else { s.fg_muted }, 22.0),
216            ]
217        }
218        // ── 1: Slider (20% → 75%) ─────────────────────────────────────
219        1 => {
220            let v = lerp(0.2, 0.75, motion::ease_in_out_cubic(seg(p, 0.1, 0.7)) as f64) as f32;
221            let mut pal = SliderPalette::from_theme(&s.theme);
222            pal.track_width = 168.0;
223            pal.label_width = 0.0;
224            pal.value_width = 50.0;
225            pal.track_thickness = 8.0;
226            pal.row_height = 26.0;
227            let sld = slider_view::<(), _>(
228                "",
229                v,
230                0.0,
231                1.0,
232                &pal,
233                |_phase: DragPhase, _dv: f32| None,
234            );
235            vec![
236                card_header("slider", s, false),
237                spacer(10.0),
238                sld,
239                spacer(12.0),
240                value_line(&format!("{:>3.0}%", v * 100.0), s.fg, 26.0),
241            ]
242        }
243        // ── 2: Linear progress (avanza) ───────────────────────────────
244        2 => {
245            let v = motion::ease_out_cubic(seg(p, 0.05, 0.85));
246            let bar = View::new(Style {
247                size: Size { width: percent(1.0_f32), height: length(12.0) },
248                position: Position::Relative,
249                ..Default::default()
250            })
251            .fill(s.theme.bg_button)
252            .radius(6.0)
253            .children(vec![linear_progress_view(
254                v,
255                s.theme.bg_button,
256                s.accent,
257                12.0,
258            )]);
259            vec![
260                card_header("progress", s, true),
261                spacer(14.0),
262                bar,
263                spacer(14.0),
264                value_line(&format!("{:>3.0}%  ·  compilando", v * 100.0), s.fg_muted, 13.0),
265            ]
266        }
267        // ── 3: Segmented control (cambia de pestaña activa) ───────────
268        3 => {
269            // 3 segmentos; el activo recorre 0 → 1 → 2 a lo largo del beat.
270            let phase = seg(p, 0.1, 0.95);
271            let active = ((phase * 3.0).floor() as usize).min(2);
272            let labels = ["Día", "Semana", "Mes"];
273            let pal = SegmentedPalette::from_theme(&s.theme);
274            let seg_ctrl = segmented_view::<(), _>(&labels, active, |_| (), &pal);
275            vec![
276                card_header("segmented", s, false),
277                spacer(14.0),
278                seg_ctrl,
279                spacer(14.0),
280                value_line(labels[active], s.accent, 22.0),
281            ]
282        }
283        // ── 4: Botones (primario teal + ghost) ────────────────────────
284        4 => {
285            // Paleta primaria: fondo teal, texto sobre fondo.
286            let mut prim = ButtonPalette::from_theme(&s.theme);
287            prim.bg = s.accent;
288            prim.bg_hover = s.accent;
289            prim.fg = s.bg; // texto oscuro sobre teal
290            prim.radius = 8.0;
291            let mut ghost = ButtonPalette::from_theme(&s.theme);
292            ghost.bg = s.theme.bg_button;
293            ghost.fg = s.fg;
294            ghost.radius = 8.0;
295            let row = View::new(Style {
296                size: Size { width: percent(1.0_f32), height: length(38.0) },
297                flex_direction: FlexDirection::Row,
298                gap: Size { width: length(12.0), height: length(0.0) },
299                ..Default::default()
300            })
301            .children(vec![
302                View::new(Style {
303                    size: Size { width: length(132.0), height: length(38.0) },
304                    flex_shrink: 0.0,
305                    ..Default::default()
306                })
307                .children(vec![button_view("Regenerar", &prim, ())]),
308                View::new(Style {
309                    size: Size { width: length(110.0), height: length(38.0) },
310                    flex_shrink: 0.0,
311                    ..Default::default()
312                })
313                .children(vec![button_view("Difundir", &ghost, ())]),
314            ]);
315            vec![
316                card_header("button", s, true),
317                spacer(14.0),
318                row,
319                spacer(14.0),
320                value_line("primario · ghost", s.fg_muted, 13.0),
321            ]
322        }
323        // ── 5: Radial progress (anillo que se llena) ──────────────────
324        _ => {
325            let v = motion::ease_out_cubic(seg(p, 0.1, 0.9));
326            let ring = View::new(Style {
327                size: Size { width: length(96.0), height: length(96.0) },
328                position: Position::Relative,
329                ..Default::default()
330            })
331            .children(vec![radial_progress_view(
332                v,
333                s.theme.bg_button,
334                s.accent,
335                0.14,
336            )]);
337            let ring_row = View::new(Style {
338                size: Size { width: percent(1.0_f32), height: length(96.0) },
339                flex_direction: FlexDirection::Row,
340                align_items: Some(AlignItems::Center),
341                justify_content: Some(JustifyContent::Center),
342                ..Default::default()
343            })
344            .children(vec![ring]);
345            vec![
346                card_header("radial", s, false),
347                spacer(6.0),
348                ring_row,
349            ]
350        }
351    }
352}
353
354/// Espaciador vertical de alto fijo.
355fn spacer(h: f32) -> View<()> {
356    View::new(Style {
357        size: Size { width: percent(1.0_f32), height: length(h) },
358        flex_shrink: 0.0,
359        ..Default::default()
360    })
361}
362
363/// Una card como contenedor absoluto, hospedando widgets reales.
364fn card_view(i: usize, rect: CardRect, alpha: f32, scale: f64, p: f32, s: &Skin) -> View<()> {
365    let accented = i == 0 || i == 2 || i == 4;
366    let border_col = if accented { s.border_accent } else { s.border };
367
368    // Pop de entrada: escala desde el centro de la card.
369    let cx = rect.x + rect.w / 2.0;
370    let cy = rect.y + rect.h / 2.0;
371    let xf = Affine::translate((cx, cy)) * Affine::scale(scale) * Affine::translate((-cx, -cy));
372
373    View::new(Style {
374        position: Position::Absolute,
375        inset: Rect {
376            left: length(rect.x as f32),
377            top: length(rect.y as f32),
378            right: auto(),
379            bottom: auto(),
380        },
381        size: Size { width: length(rect.w as f32), height: length(rect.h as f32) },
382        flex_direction: FlexDirection::Column,
383        gap: Size { width: length(0.0), height: length(0.0) },
384        padding: Rect {
385            left: length(22.0),
386            right: length(22.0),
387            top: length(20.0),
388            bottom: length(18.0),
389        },
390        ..Default::default()
391    })
392    .fill_gradient(
393        Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
394            .with_stops([s.panel_hi, s.panel].as_slice()),
395    )
396    .radius(18.0)
397    .border(if accented { 1.4 } else { 1.0 }, border_col)
398    .shadow(Shadow::soft(120, 26.0).offset(0.0, 12.0))
399    .transform(xf)
400    .alpha(alpha)
401    .children(card_body(i, p, s))
402}
403
404// ───────────────────────── overlays vector (cold-open + wordmark) ─────────────────────────
405
406/// Curva bezier "firma" del cold-open.
407fn signature_path(cw: f64, ch: f64) -> BezPath {
408    let cx = cw / 2.0;
409    let cy = ch / 2.0;
410    let mut p = BezPath::new();
411    p.move_to((cx - 360.0, cy + 40.0));
412    p.curve_to(
413        (cx - 150.0, cy - 220.0),
414        (cx + 150.0, cy + 220.0),
415        (cx + 360.0, cy - 40.0),
416    );
417    p
418}
419
420/// Recorta un `BezPath` cúbico a su fracción inicial `prog`. Devuelve la
421/// cabeza del trazo para anclar el punto teal.
422fn trim_path(full: &BezPath, prog: f64) -> (BezPath, Point) {
423    use vello::kurbo::ParamCurve;
424    let prog = prog.clamp(0.0, 1.0);
425    let mut cubic = None;
426    let mut start = Point::ZERO;
427    for el in full.elements() {
428        match el {
429            vello::kurbo::PathEl::MoveTo(p) => start = *p,
430            vello::kurbo::PathEl::CurveTo(c1, c2, p) => {
431                cubic = Some(vello::kurbo::CubicBez::new(start, *c1, *c2, *p));
432            }
433            _ => {}
434        }
435    }
436    let mut out = BezPath::new();
437    let mut head = start;
438    if let Some(cb) = cubic {
439        out.move_to(cb.p0);
440        let steps = 96;
441        for i in 1..=steps {
442            let u = (i as f64 / steps as f64) * prog;
443            let pt = cb.eval(u);
444            out.line_to(pt);
445            head = pt;
446        }
447    }
448    (out, head)
449}
450
451/// Dibuja los overlays vector (cold-open + wordmark + punto firma) sobre un
452/// nodo full-screen, en función de `t`. Los widgets ya están pintados debajo.
453fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64, s: &Skin) {
454    // ── COLD OPEN (0–12%) ──────────────────────────────────────────
455    let b1 = seg(t, 0.0, 0.12);
456    let line_vis = 1.0 - seg(t, 0.12, 0.20);
457    if line_vis > 0.001 {
458        let path = signature_path(cw, ch);
459        let draw_on = motion::ease_out_cubic(seg(t, 0.02, 0.13)) as f64;
460        let (trimmed, head) = trim_path(&path, draw_on);
461        let line_col = with_alpha(s.accent, 0.9 * line_vis);
462        scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, line_col, None, &trimmed);
463        let pop = motion::ease_out_back(b1);
464        let r = (4.0 + 7.0 * pop as f64).max(0.0);
465        let dot_a = (b1 * line_vis).clamp(0.0, 1.0);
466        scene.fill(
467            peniko::Fill::NonZero,
468            Affine::IDENTITY,
469            with_alpha(s.accent, 0.18 * dot_a),
470            None,
471            &Circle::new(head, r * 3.2),
472        );
473        scene.fill(
474            peniko::Fill::NonZero,
475            Affine::IDENTITY,
476            with_alpha(s.accent, dot_a),
477            None,
478            &Circle::new(head, r),
479        );
480    }
481
482    // ── WORDMARK (82–100%) ─────────────────────────────────────────
483    let word_in = seg(t, 0.84, 0.95);
484    let word_a = motion::ease_out_cubic(word_in);
485    if word_a > 0.001 {
486        let size = 132.0_f32;
487        let layout = ts.layout(
488            "Llimphi", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0,
489        );
490        let m = measurement(&layout);
491        let rise = lerp(24.0, 0.0, word_a as f64);
492        let ox = (cw - m.width as f64) / 2.0;
493        let oy = (ch - m.height as f64) / 2.0 - 18.0 + rise;
494        let brush = peniko::Brush::Solid(with_alpha(s.fg, word_a));
495        draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
496
497        let sub_a = motion::ease_out_cubic(seg(t, 0.88, 0.99));
498        if sub_a > 0.001 {
499            let ssz = 26.0_f32;
500            let sub = ts.layout(
501                "a Rust GUI framework", ssz, None, Alignment::Start, 1.0, false, None, 400.0,
502                false, false, 0.0, 0.0,
503            );
504            let sm = measurement(&sub);
505            let dot_r = 6.0;
506            let block_w = sm.width as f64 + dot_r * 2.0 + 14.0;
507            let sx = (cw - block_w) / 2.0;
508            let sy = oy + m.height as f64 + 18.0;
509            scene.fill(
510                peniko::Fill::NonZero,
511                Affine::IDENTITY,
512                with_alpha(s.accent, sub_a),
513                None,
514                &Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r as f64),
515            );
516            let sbrush = peniko::Brush::Solid(with_alpha(s.fg_muted, sub_a));
517            draw_layout_brush_xf(
518                scene,
519                &sub,
520                &sbrush,
521                Affine::translate((sx + dot_r * 2.0 + 14.0, sy)),
522            );
523        }
524    }
525
526    // ── punto teal de firma (esquina inf-der), ancla de marca ───────
527    let corner_a = seg(t, 0.04, 0.12) * (1.0 - seg(t, 0.80, 0.86));
528    if corner_a > 0.001 {
529        let cx = cw - 54.0;
530        let cy = ch - 54.0;
531        scene.fill(
532            peniko::Fill::NonZero,
533            Affine::IDENTITY,
534            with_alpha(s.accent, 0.16 * corner_a),
535            None,
536            &Circle::new(Point::new(cx, cy), 18.0),
537        );
538        scene.fill(
539            peniko::Fill::NonZero,
540            Affine::IDENTITY,
541            with_alpha(s.accent, 0.9 * corner_a),
542            None,
543            &Circle::new(Point::new(cx, cy), 6.0),
544        );
545    }
546}
547
548// ───────────────────────── la escena por frame ─────────────────────────
549
550/// Construye el árbol `View` completo del frame `t`: las cards con widgets
551/// reales (con su estado derivado de t) + un nodo overlay full-screen que
552/// pinta cold-open / wordmark encima.
553fn build_view(t: f32, cw: f64, ch: f64, s: &Skin) -> View<()> {
554    let grid = layout_grid(cw, ch);
555    let row = layout_row(cw, ch);
556
557    // Progreso del "estado" de los widgets (toggle/slider/progress/…).
558    let widget_p = seg(t, 0.16, 0.58);
559    // Morph grid → fila (58–80%).
560    let morph = motion::ease_in_out_cubic(seg(t, 0.60, 0.80)) as f64;
561    // Fade-out de las cards antes del wordmark.
562    let cards_fade = 1.0 - seg(t, 0.80, 0.86);
563
564    let mut children: Vec<View<()>> = Vec::new();
565
566    if cards_fade > 0.001 {
567        for i in 0..N_CARDS {
568            // Stagger de entrada: cada card arranca con retraso incremental.
569            let delay = i as f32 * 0.035;
570            let enter = motion::ease_out_back(seg(t, 0.12 + delay, 0.12 + delay + 0.16));
571            if enter <= 0.001 {
572                continue;
573            }
574            let rect = grid[i].lerp(row[i], morph);
575            let scale = lerp(0.88, 1.0, enter.min(1.0) as f64);
576            let alpha = (enter.min(1.0) * cards_fade).clamp(0.0, 1.0);
577            children.push(card_view(i, rect, alpha, scale, widget_p, s));
578        }
579    }
580
581    // Nodo overlay full-screen para el vector (cold-open + wordmark).
582    let overlay = View::new(Style {
583        position: Position::Absolute,
584        inset: Rect {
585            left: length(0.0),
586            top: length(0.0),
587            right: length(0.0),
588            bottom: length(0.0),
589        },
590        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
591        ..Default::default()
592    })
593    .paint_with({
594        let s = s.clone();
595        move |scene, ts, _rect: PaintRect| {
596            draw_overlays(scene, ts, t, cw, ch, &s);
597        }
598    });
599    children.push(overlay);
600
601    View::new(Style {
602        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
603        position: Position::Relative,
604        ..Default::default()
605    })
606    .fill(s.bg)
607    .children(children)
608}
609
610fn main() {
611    let mut args = std::env::args().skip(1);
612    let out_dir = args.next().unwrap_or_else(|| "showreel_frames".to_string());
613    let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(360);
614    let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
615    let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
616    create_dir_all(&out_dir).expect("mkdir out_dir");
617
618    let theme = llimphi_theme::Theme::by_name("Tawa").expect("tema Tawa");
619    let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF); // teal #2BD9A6 (acento firma)
620    let skin = Skin {
621        accent,
622        panel: theme.bg_panel,
623        panel_hi: theme.bg_button,
624        border: theme.border,
625        border_accent: with_alpha(accent, 0.55),
626        fg: theme.fg_text,
627        fg_muted: theme.fg_muted,
628        bg: theme.bg_app,
629        theme,
630    };
631    let [br, bg, bb, _] = skin.bg.components;
632    let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255);
633
634    // GPU una sola vez; reusar device/renderer/target/buffer para los N frames.
635    let hal = pollster::block_on(Hal::new(None)).expect("hal");
636    let mut renderer = Renderer::new(&hal).expect("renderer");
637    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
638        label: Some("showreel"),
639        size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
640        mip_level_count: 1,
641        sample_count: 1,
642        dimension: wgpu::TextureDimension::D2,
643        format: FMT,
644        usage: wgpu::TextureUsages::STORAGE_BINDING
645            | wgpu::TextureUsages::RENDER_ATTACHMENT
646            | wgpu::TextureUsages::COPY_SRC,
647        view_formats: &[],
648    });
649    let view = target.create_view(&wgpu::TextureViewDescriptor::default());
650
651    let mut ts = Typesetter::new();
652    let cw = w as f64;
653    let ch = h as f64;
654
655    for i in 0..n {
656        let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
657
658        let root = build_view(t, cw, ch, &skin);
659
660        // view → layout (con medición de texto real) → scene — idéntico al eventloop.
661        let mut layout = LayoutTree::new();
662        let mounted = mount(&mut layout, root);
663        let computed = {
664            let tmap = &mounted.text_measures;
665            layout
666                .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
667                    match tmap.get(&nid) {
668                        Some(tm) => measure_text_node(&mut ts, tm, known, avail),
669                        None => taffy::Size::ZERO,
670                    }
671                })
672                .expect("layout")
673        };
674        let mut scene = vello::Scene::new();
675        paint(&mut scene, &mounted, &computed, &mut ts, None, None);
676
677        renderer
678            .render_to_view(&hal, &scene, &view, w, h, base)
679            .expect("render_to_view");
680        let path = format!("{out_dir}/frame_{i:04}.png");
681        write_png(&hal, &target, &path, w, h);
682        if i % 30 == 0 || i == n - 1 {
683            eprintln!("showreel: frame {}/{} (t={:.3})", i + 1, n, t);
684        }
685    }
686    eprintln!("showreel: {n} frames en {out_dir}/ ({w}x{h})");
687}
688
689fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
690    let unpadded = (w * 4) as usize;
691    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
692    let padded = unpadded.div_ceil(align) * align;
693    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
694        label: Some("readback"),
695        size: (padded * h as usize) as u64,
696        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
697        mapped_at_creation: false,
698    });
699    let mut enc = hal
700        .device
701        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
702    enc.copy_texture_to_buffer(
703        wgpu::TexelCopyTextureInfo {
704            texture: target,
705            mip_level: 0,
706            origin: wgpu::Origin3d::ZERO,
707            aspect: wgpu::TextureAspect::All,
708        },
709        wgpu::TexelCopyBufferInfo {
710            buffer: &buf,
711            layout: wgpu::TexelCopyBufferLayout {
712                offset: 0,
713                bytes_per_row: Some(padded as u32),
714                rows_per_image: Some(h),
715            },
716        },
717        wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
718    );
719    hal.queue.submit(std::iter::once(enc.finish()));
720    let slice = buf.slice(..);
721    let (tx, rx) = std::sync::mpsc::channel();
722    slice.map_async(wgpu::MapMode::Read, move |r| {
723        let _ = tx.send(r);
724    });
725    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
726    rx.recv().unwrap().unwrap();
727    let data = slice.get_mapped_range();
728    let mut pixels = Vec::with_capacity((w * h * 4) as usize);
729    for r in 0..h as usize {
730        let sidx = r * padded;
731        pixels.extend_from_slice(&data[sidx..sidx + unpadded]);
732    }
733    drop(data);
734    buf.unmap();
735    let file = File::create(path).expect("png");
736    let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
737    enc.set_color(png::ColorType::Rgba);
738    enc.set_depth(png::BitDepth::Eight);
739    let mut wr = enc.write_header().unwrap();
740    wr.write_image_data(&pixels).unwrap();
741}