tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Sutichaykuna

⎇ main llimphi-compositor/examples/showreel.rs Rust · 668 siq'ikuna · 25.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · 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. Las tarjetas entran desde el primer cuadro; el wordmark de
13//! cierre es `paint_with` sobre un nodo full-screen, encima de 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, Circle, Point};
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 (wordmark) ─────────────────────────
405
406/// Dibuja los overlays vector (wordmark + punto firma) sobre un nodo
407/// full-screen, en función de `t`. Los widgets ya están pintados debajo.
408fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64, s: &Skin) {
409    // ── WORDMARK (82–100%) ─────────────────────────────────────────
410    let word_in = seg(t, 0.84, 0.95);
411    let word_a = motion::ease_out_cubic(word_in);
412    if word_a > 0.001 {
413        let size = 132.0_f32;
414        let layout = ts.layout(
415            "Llimphi", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0,
416        );
417        let m = measurement(&layout);
418        let rise = lerp(24.0, 0.0, word_a as f64);
419        let ox = (cw - m.width as f64) / 2.0;
420        let oy = (ch - m.height as f64) / 2.0 - 18.0 + rise;
421        let brush = peniko::Brush::Solid(with_alpha(s.fg, word_a));
422        draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
423
424        let sub_a = motion::ease_out_cubic(seg(t, 0.88, 0.99));
425        if sub_a > 0.001 {
426            let ssz = 26.0_f32;
427            let sub = ts.layout(
428                "a Rust GUI framework", ssz, None, Alignment::Start, 1.0, false, None, 400.0,
429                false, false, 0.0, 0.0,
430            );
431            let sm = measurement(&sub);
432            let dot_r = 6.0;
433            let block_w = sm.width as f64 + dot_r * 2.0 + 14.0;
434            let sx = (cw - block_w) / 2.0;
435            let sy = oy + m.height as f64 + 18.0;
436            scene.fill(
437                peniko::Fill::NonZero,
438                Affine::IDENTITY,
439                with_alpha(s.accent, sub_a),
440                None,
441                &Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r as f64),
442            );
443            let sbrush = peniko::Brush::Solid(with_alpha(s.fg_muted, sub_a));
444            draw_layout_brush_xf(
445                scene,
446                &sub,
447                &sbrush,
448                Affine::translate((sx + dot_r * 2.0 + 14.0, sy)),
449            );
450        }
451    }
452
453    // ── punto teal de firma (esquina inf-der), ancla de marca ───────
454    let corner_a = seg(t, 0.04, 0.12) * (1.0 - seg(t, 0.80, 0.86));
455    if corner_a > 0.001 {
456        let cx = cw - 54.0;
457        let cy = ch - 54.0;
458        scene.fill(
459            peniko::Fill::NonZero,
460            Affine::IDENTITY,
461            with_alpha(s.accent, 0.16 * corner_a),
462            None,
463            &Circle::new(Point::new(cx, cy), 18.0),
464        );
465        scene.fill(
466            peniko::Fill::NonZero,
467            Affine::IDENTITY,
468            with_alpha(s.accent, 0.9 * corner_a),
469            None,
470            &Circle::new(Point::new(cx, cy), 6.0),
471        );
472    }
473}
474
475// ───────────────────────── la escena por frame ─────────────────────────
476
477/// Construye el árbol `View` completo del frame `t`: las cards con widgets
478/// reales (con su estado derivado de t) + un nodo overlay full-screen que
479/// pinta el wordmark encima.
480fn build_view(t: f32, cw: f64, ch: f64, s: &Skin) -> View<()> {
481    let grid = layout_grid(cw, ch);
482    let row = layout_row(cw, ch);
483
484    // Progreso del "estado" de los widgets (toggle/slider/progress/…).
485    let widget_p = seg(t, 0.16, 0.58);
486    // Morph grid → fila (58–80%).
487    let morph = motion::ease_in_out_cubic(seg(t, 0.60, 0.80)) as f64;
488    // Fade-out de las cards antes del wordmark.
489    let cards_fade = 1.0 - seg(t, 0.80, 0.86);
490
491    let mut children: Vec<View<()>> = Vec::new();
492
493    if cards_fade > 0.001 {
494        for i in 0..N_CARDS {
495            // Stagger de entrada: cada card arranca con retraso incremental.
496            let delay = i as f32 * 0.035;
497            let enter = motion::ease_out_back(seg(t, delay, delay + 0.16));
498            if enter <= 0.001 {
499                continue;
500            }
501            let rect = grid[i].lerp(row[i], morph);
502            let scale = lerp(0.88, 1.0, enter.min(1.0) as f64);
503            let alpha = (enter.min(1.0) * cards_fade).clamp(0.0, 1.0);
504            children.push(card_view(i, rect, alpha, scale, widget_p, s));
505        }
506    }
507
508    // Nodo overlay full-screen para el vector (wordmark).
509    let overlay = View::new(Style {
510        position: Position::Absolute,
511        inset: Rect {
512            left: length(0.0),
513            top: length(0.0),
514            right: length(0.0),
515            bottom: length(0.0),
516        },
517        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
518        ..Default::default()
519    })
520    .paint_with({
521        let s = s.clone();
522        move |scene, ts, _rect: PaintRect| {
523            draw_overlays(scene, ts, t, cw, ch, &s);
524        }
525    });
526    children.push(overlay);
527
528    View::new(Style {
529        size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
530        position: Position::Relative,
531        ..Default::default()
532    })
533    .fill(s.bg)
534    .children(children)
535}
536
537fn main() {
538    let mut args = std::env::args().skip(1);
539    let out_dir = args.next().unwrap_or_else(|| "showreel_frames".to_string());
540    let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(360);
541    let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
542    let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
543    create_dir_all(&out_dir).expect("mkdir out_dir");
544
545    let theme = llimphi_theme::Theme::by_name("Tawa").expect("tema Tawa");
546    let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF); // teal #2BD9A6 (acento firma)
547    let skin = Skin {
548        accent,
549        panel: theme.bg_panel,
550        panel_hi: theme.bg_button,
551        border: theme.border,
552        border_accent: with_alpha(accent, 0.55),
553        fg: theme.fg_text,
554        fg_muted: theme.fg_muted,
555        bg: theme.bg_app,
556        theme,
557    };
558    let [br, bg, bb, _] = skin.bg.components;
559    let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255);
560
561    // GPU una sola vez; reusar device/renderer/target/buffer para los N frames.
562    let hal = pollster::block_on(Hal::new(None)).expect("hal");
563    let mut renderer = Renderer::new(&hal).expect("renderer");
564    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
565        label: Some("showreel"),
566        size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
567        mip_level_count: 1,
568        sample_count: 1,
569        dimension: wgpu::TextureDimension::D2,
570        format: FMT,
571        usage: wgpu::TextureUsages::STORAGE_BINDING
572            | wgpu::TextureUsages::RENDER_ATTACHMENT
573            | wgpu::TextureUsages::COPY_SRC,
574        view_formats: &[],
575    });
576    let view = target.create_view(&wgpu::TextureViewDescriptor::default());
577
578    let mut ts = Typesetter::new();
579    let cw = w as f64;
580    let ch = h as f64;
581
582    for i in 0..n {
583        let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
584
585        let root = build_view(t, cw, ch, &skin);
586
587        // view → layout (con medición de texto real) → scene — idéntico al eventloop.
588        let mut layout = LayoutTree::new();
589        let mounted = mount(&mut layout, root);
590        let computed = {
591            let tmap = &mounted.text_measures;
592            layout
593                .compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
594                    match tmap.get(&nid) {
595                        Some(tm) => measure_text_node(&mut ts, tm, known, avail),
596                        None => taffy::Size::ZERO,
597                    }
598                })
599                .expect("layout")
600        };
601        let mut scene = vello::Scene::new();
602        paint(&mut scene, &mounted, &computed, &mut ts, None, None);
603
604        renderer
605            .render_to_view(&hal, &scene, &view, w, h, base)
606            .expect("render_to_view");
607        let path = format!("{out_dir}/frame_{i:04}.png");
608        write_png(&hal, &target, &path, w, h);
609        if i % 30 == 0 || i == n - 1 {
610            eprintln!("showreel: frame {}/{} (t={:.3})", i + 1, n, t);
611        }
612    }
613    eprintln!("showreel: {n} frames en {out_dir}/ ({w}x{h})");
614}
615
616fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
617    let unpadded = (w * 4) as usize;
618    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
619    let padded = unpadded.div_ceil(align) * align;
620    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
621        label: Some("readback"),
622        size: (padded * h as usize) as u64,
623        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
624        mapped_at_creation: false,
625    });
626    let mut enc = hal
627        .device
628        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
629    enc.copy_texture_to_buffer(
630        wgpu::TexelCopyTextureInfo {
631            texture: target,
632            mip_level: 0,
633            origin: wgpu::Origin3d::ZERO,
634            aspect: wgpu::TextureAspect::All,
635        },
636        wgpu::TexelCopyBufferInfo {
637            buffer: &buf,
638            layout: wgpu::TexelCopyBufferLayout {
639                offset: 0,
640                bytes_per_row: Some(padded as u32),
641                rows_per_image: Some(h),
642            },
643        },
644        wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
645    );
646    hal.queue.submit(std::iter::once(enc.finish()));
647    let slice = buf.slice(..);
648    let (tx, rx) = std::sync::mpsc::channel();
649    slice.map_async(wgpu::MapMode::Read, move |r| {
650        let _ = tx.send(r);
651    });
652    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
653    rx.recv().unwrap().unwrap();
654    let data = slice.get_mapped_range();
655    let mut pixels = Vec::with_capacity((w * h * 4) as usize);
656    for r in 0..h as usize {
657        let sidx = r * padded;
658        pixels.extend_from_slice(&data[sidx..sidx + unpadded]);
659    }
660    drop(data);
661    buf.unmap();
662    let file = File::create(path).expect("png");
663    let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
664    enc.set_color(png::ColorType::Rgba);
665    enc.set_depth(png::BitDepth::Eight);
666    let mut wr = enc.write_header().unwrap();
667    wr.write_image_data(&pixels).unwrap();
668}