tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-compositor/examples/showreel.rs
Historia
Quién tocó qué
crudo
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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}