Files
llimphi/llimphi-compositor/examples/showreel.rs
T
Sergio ccab39f140 refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
Re-sincroniza las fuentes desde el monorepo (estaba en vello 0.5/wgpu 24 y con la
estructura vieja de eventloop) y suma el 3D:

- bump del workspace a vello 0.7 / wgpu 27 / parley 0.6, + accesskit 0.24 /
  accesskit_winit 0.33 / vello_hybrid 0.0.9.
- nuevos crates: llimphi-3d (voxels ray-march + mallas en un depth compartido,
  montable dentro de un View 2D vía set_viewport+scissor) y llimphi-voxel
  (world-gen, personajes, director de escenas) + shared/foreign-vox (puente .vox).
- README: sección "Not just 2D — a 3D voxel engine" + GIF (docs/llimphi_voxel.gif).
- excluido modules/allichay (arrastra deps fuera del alcance del front-door).
- cargo check --workspace: verde.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:40:00 +00:00

742 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **Showreel** del motor Llimphi — para r/rust. NO es eye-candy abstracto:
//! es una vitrina de **widgets reales** del toolkit, *en acción*. Cada frame
//! reconstruye un árbol `View` con widgets de verdad (`llimphi-widget-switch`,
//! `-slider`, `-progress`, `-button`, `-segmented`) cuyo **estado** se deriva
//! del tiempo normalizado `t∈[0,1]` — el toggle se enciende, el slider sube,
//! la barra avanza, el segmented cambia de pestaña. Se montan con el `mount` /
//! `paint` / `compute_with_measure` reales (taffy + parley + vello), idéntico
//! al eventloop. No se dibujan a mano: si existe el widget, se usa el widget.
//!
//! El render es **headless y determinista** (sin reloj, sin runtime, sin
//! winit): frame `i` de `N` → `t = i/(N-1)` → View → layout → vello::Scene →
//! wgpu → PNG. El cold-open (trazo bezier draw-on) y el wordmark de cierre
//! son `paint_with` sobre un nodo full-screen, superpuestos sobre los widgets.
//!
//! ```text
//! cargo run -p llimphi-compositor --example showreel --release -- \
//! [out_dir] [n_frames] [W] [H]
//! ```
//! Defaults: `out_dir=showreel_frames`, `n_frames=360`, `W=1600`, `H=900`.
use std::fs::{create_dir_all, File};
use std::io::BufWriter;
use llimphi_compositor::{
measure_text_node, mount, paint, DragPhase, PaintRect, Shadow, View,
};
use llimphi_hal::{wgpu, Hal};
use llimphi_layout::taffy;
use llimphi_layout::taffy::prelude::{
auto, length, percent, AlignItems, FlexDirection, JustifyContent, Position, Size, Style,
};
use llimphi_layout::taffy::Rect;
use llimphi_layout::LayoutTree;
use llimphi_raster::peniko::{self, Color, Gradient};
use llimphi_raster::{vello, Renderer};
use llimphi_text::{draw_layout_brush_xf, measurement, Alignment, Typesetter};
use llimphi_theme::motion;
use vello::kurbo::{Affine, BezPath, Circle, Point, Stroke};
use llimphi_widget_button::{button_view, ButtonPalette};
use llimphi_widget_progress::{linear_progress_view, radial_progress_view};
use llimphi_widget_segmented::{segmented_view, SegmentedPalette};
use llimphi_widget_slider::{slider_view, SliderPalette};
use llimphi_widget_switch::{switch_view, SwitchPalette};
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
// ───────────────────────── utilidades ─────────────────────────
/// Color con alpha escalado a `a∈[0,1]` (para fade del overlay vector).
fn with_alpha(c: Color, a: f32) -> Color {
let [r, g, b, _] = c.components;
Color::new([r, g, b, a.clamp(0.0, 1.0)])
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
/// Reescala `t` desde el subintervalo `[lo,hi]` de la timeline a `[0,1]`,
/// clampado. Fuera del intervalo devuelve 0 (antes) o 1 (después).
fn seg(t: f32, lo: f32, hi: f32) -> f32 {
((t - lo) / (hi - lo)).clamp(0.0, 1.0)
}
// ───────────────────────── tema / paleta ─────────────────────────
#[derive(Clone)]
struct Skin {
theme: llimphi_theme::Theme,
accent: Color,
panel: Color,
panel_hi: Color,
border: Color,
border_accent: Color,
fg: Color,
fg_muted: Color,
bg: Color,
}
// ───────────────────────── geometría de las tarjetas ─────────────────────────
#[derive(Clone, Copy)]
struct CardRect {
x: f64,
y: f64,
w: f64,
h: f64,
}
impl CardRect {
fn lerp(self, b: CardRect, t: f64) -> CardRect {
CardRect {
x: lerp(self.x, b.x, t),
y: lerp(self.y, b.y, t),
w: lerp(self.w, b.w, t),
h: lerp(self.h, b.h, t),
}
}
}
const N_CARDS: usize = 6;
/// Disposición A — grilla 3×2 centrada (beat de ensamblado).
fn layout_grid(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
let card_w = 360.0;
let card_h = 196.0;
let gap = 40.0;
let cols = 3.0;
let rows = 2.0;
let total_w = cols * card_w + (cols - 1.0) * gap;
let total_h = rows * card_h + (rows - 1.0) * gap;
let x0 = (cw - total_w) / 2.0;
let y0 = (ch - total_h) / 2.0;
let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: card_h }; N_CARDS];
for (i, c) in out.iter_mut().enumerate() {
let col = (i % 3) as f64;
let row = (i / 3) as f64;
c.x = x0 + col * (card_w + gap);
c.y = y0 + row * (card_h + gap);
}
out
}
/// Disposición B — fila única ancha, alturas escalonadas (beat de morph).
/// Los MISMOS widgets adentro, otra geometría: "cualquier layout con taffy".
fn layout_row(cw: f64, ch: f64) -> [CardRect; N_CARDS] {
let gap = 22.0;
let n = N_CARDS as f64;
let card_w = (cw - 2.0 * 90.0 - (n - 1.0) * gap) / n;
let x0 = 90.0;
let cy = ch / 2.0;
// alturas tipo "ecualizador" — silueta dinámica al reacomodar.
let hs = [240.0, 300.0, 210.0, 320.0, 260.0, 230.0];
let mut out = [CardRect { x: 0.0, y: 0.0, w: card_w, h: 220.0 }; N_CARDS];
for (i, c) in out.iter_mut().enumerate() {
c.x = x0 + i as f64 * (card_w + gap);
c.h = hs[i];
c.y = cy - c.h / 2.0;
c.w = card_w;
}
out
}
// ───────────────────────── contenido de cada card ─────────────────────────
/// Header de card: chip de acento + título.
fn card_header(title: &str, s: &Skin, accented: bool) -> View<()> {
let chip = View::new(Style {
size: Size { width: length(28.0), height: length(8.0) },
flex_shrink: 0.0,
..Default::default()
})
.radius(4.0)
.fill(if accented { s.accent } else { s.fg_muted });
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(10.0), height: length(0.0) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![
chip,
View::new(Style { flex_grow: 1.0, ..Default::default() })
.text_aligned(title.to_string(), 12.5, s.fg_muted, Alignment::Start)
.bold(),
])
}
/// Línea de "valor" grande (estado legible) bajo el control.
fn value_line(text: &str, color: Color, size: f32) -> View<()> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(size + 6.0) },
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(text.to_string(), size, color, Alignment::Start)
.bold()
}
/// Cuerpo de una card según índice — cada una hospeda widgets REALES cuyo
/// estado deriva de `p∈[0,1]` (progreso del beat de widgets).
fn card_body(i: usize, p: f32, s: &Skin) -> Vec<View<()>> {
match i {
// ── 0: Switch (off → on) ──────────────────────────────────────
0 => {
// El thumb se desliza en una rampa centrada del beat.
let prog = motion::ease_in_out_cubic(seg(p, 0.15, 0.6));
let on = prog > 0.5;
let pal = SwitchPalette::from_theme(&s.theme);
let sw_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(26.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
gap: Size { width: length(14.0), height: length(0.0) },
..Default::default()
})
.children(vec![
switch_view(prog, (), &pal),
View::new(Style { flex_grow: 1.0, ..Default::default() })
.text_aligned(
"Sincronizar".to_string(),
13.0,
s.fg,
Alignment::Start,
),
]);
vec![
card_header("switch", s, true),
spacer(8.0),
sw_row,
spacer(10.0),
value_line(if on { "ENCENDIDO" } else { "apagado" }, if on { s.accent } else { s.fg_muted }, 22.0),
]
}
// ── 1: Slider (20% → 75%) ─────────────────────────────────────
1 => {
let v = lerp(0.2, 0.75, motion::ease_in_out_cubic(seg(p, 0.1, 0.7)) as f64) as f32;
let mut pal = SliderPalette::from_theme(&s.theme);
pal.track_width = 168.0;
pal.label_width = 0.0;
pal.value_width = 50.0;
pal.track_thickness = 8.0;
pal.row_height = 26.0;
let sld = slider_view::<(), _>(
"",
v,
0.0,
1.0,
&pal,
|_phase: DragPhase, _dv: f32| None,
);
vec![
card_header("slider", s, false),
spacer(10.0),
sld,
spacer(12.0),
value_line(&format!("{:>3.0}%", v * 100.0), s.fg, 26.0),
]
}
// ── 2: Linear progress (avanza) ───────────────────────────────
2 => {
let v = motion::ease_out_cubic(seg(p, 0.05, 0.85));
let bar = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(12.0) },
position: Position::Relative,
..Default::default()
})
.fill(s.theme.bg_button)
.radius(6.0)
.children(vec![linear_progress_view(
v,
s.theme.bg_button,
s.accent,
12.0,
)]);
vec![
card_header("progress", s, true),
spacer(14.0),
bar,
spacer(14.0),
value_line(&format!("{:>3.0}% · compilando", v * 100.0), s.fg_muted, 13.0),
]
}
// ── 3: Segmented control (cambia de pestaña activa) ───────────
3 => {
// 3 segmentos; el activo recorre 0 → 1 → 2 a lo largo del beat.
let phase = seg(p, 0.1, 0.95);
let active = ((phase * 3.0).floor() as usize).min(2);
let labels = ["Día", "Semana", "Mes"];
let pal = SegmentedPalette::from_theme(&s.theme);
let seg_ctrl = segmented_view::<(), _>(&labels, active, |_| (), &pal);
vec![
card_header("segmented", s, false),
spacer(14.0),
seg_ctrl,
spacer(14.0),
value_line(labels[active], s.accent, 22.0),
]
}
// ── 4: Botones (primario teal + ghost) ────────────────────────
4 => {
// Paleta primaria: fondo teal, texto sobre fondo.
let mut prim = ButtonPalette::from_theme(&s.theme);
prim.bg = s.accent;
prim.bg_hover = s.accent;
prim.fg = s.bg; // texto oscuro sobre teal
prim.radius = 8.0;
let mut ghost = ButtonPalette::from_theme(&s.theme);
ghost.bg = s.theme.bg_button;
ghost.fg = s.fg;
ghost.radius = 8.0;
let row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(38.0) },
flex_direction: FlexDirection::Row,
gap: Size { width: length(12.0), height: length(0.0) },
..Default::default()
})
.children(vec![
View::new(Style {
size: Size { width: length(132.0), height: length(38.0) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![button_view("Regenerar", &prim, ())]),
View::new(Style {
size: Size { width: length(110.0), height: length(38.0) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![button_view("Difundir", &ghost, ())]),
]);
vec![
card_header("button", s, true),
spacer(14.0),
row,
spacer(14.0),
value_line("primario · ghost", s.fg_muted, 13.0),
]
}
// ── 5: Radial progress (anillo que se llena) ──────────────────
_ => {
let v = motion::ease_out_cubic(seg(p, 0.1, 0.9));
let ring = View::new(Style {
size: Size { width: length(96.0), height: length(96.0) },
position: Position::Relative,
..Default::default()
})
.children(vec![radial_progress_view(
v,
s.theme.bg_button,
s.accent,
0.14,
)]);
let ring_row = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(96.0) },
flex_direction: FlexDirection::Row,
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
..Default::default()
})
.children(vec![ring]);
vec![
card_header("radial", s, false),
spacer(6.0),
ring_row,
]
}
}
}
/// Espaciador vertical de alto fijo.
fn spacer(h: f32) -> View<()> {
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(h) },
flex_shrink: 0.0,
..Default::default()
})
}
/// Una card como contenedor absoluto, hospedando widgets reales.
fn card_view(i: usize, rect: CardRect, alpha: f32, scale: f64, p: f32, s: &Skin) -> View<()> {
let accented = i == 0 || i == 2 || i == 4;
let border_col = if accented { s.border_accent } else { s.border };
// Pop de entrada: escala desde el centro de la card.
let cx = rect.x + rect.w / 2.0;
let cy = rect.y + rect.h / 2.0;
let xf = Affine::translate((cx, cy)) * Affine::scale(scale) * Affine::translate((-cx, -cy));
View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(rect.x as f32),
top: length(rect.y as f32),
right: auto(),
bottom: auto(),
},
size: Size { width: length(rect.w as f32), height: length(rect.h as f32) },
flex_direction: FlexDirection::Column,
gap: Size { width: length(0.0), height: length(0.0) },
padding: Rect {
left: length(22.0),
right: length(22.0),
top: length(20.0),
bottom: length(18.0),
},
..Default::default()
})
.fill_gradient(
Gradient::new_linear(Point::new(0.0, 0.0), Point::new(0.0, 1.0))
.with_stops([s.panel_hi, s.panel].as_slice()),
)
.radius(18.0)
.border(if accented { 1.4 } else { 1.0 }, border_col)
.shadow(Shadow::soft(120, 26.0).offset(0.0, 12.0))
.transform(xf)
.alpha(alpha)
.children(card_body(i, p, s))
}
// ───────────────────────── overlays vector (cold-open + wordmark) ─────────────────────────
/// Curva bezier "firma" del cold-open.
fn signature_path(cw: f64, ch: f64) -> BezPath {
let cx = cw / 2.0;
let cy = ch / 2.0;
let mut p = BezPath::new();
p.move_to((cx - 360.0, cy + 40.0));
p.curve_to(
(cx - 150.0, cy - 220.0),
(cx + 150.0, cy + 220.0),
(cx + 360.0, cy - 40.0),
);
p
}
/// Recorta un `BezPath` cúbico a su fracción inicial `prog`. Devuelve la
/// cabeza del trazo para anclar el punto teal.
fn trim_path(full: &BezPath, prog: f64) -> (BezPath, Point) {
use vello::kurbo::ParamCurve;
let prog = prog.clamp(0.0, 1.0);
let mut cubic = None;
let mut start = Point::ZERO;
for el in full.elements() {
match el {
vello::kurbo::PathEl::MoveTo(p) => start = *p,
vello::kurbo::PathEl::CurveTo(c1, c2, p) => {
cubic = Some(vello::kurbo::CubicBez::new(start, *c1, *c2, *p));
}
_ => {}
}
}
let mut out = BezPath::new();
let mut head = start;
if let Some(cb) = cubic {
out.move_to(cb.p0);
let steps = 96;
for i in 1..=steps {
let u = (i as f64 / steps as f64) * prog;
let pt = cb.eval(u);
out.line_to(pt);
head = pt;
}
}
(out, head)
}
/// Dibuja los overlays vector (cold-open + wordmark + punto firma) sobre un
/// nodo full-screen, en función de `t`. Los widgets ya están pintados debajo.
fn draw_overlays(scene: &mut vello::Scene, ts: &mut Typesetter, t: f32, cw: f64, ch: f64, s: &Skin) {
// ── COLD OPEN (0–12%) ──────────────────────────────────────────
let b1 = seg(t, 0.0, 0.12);
let line_vis = 1.0 - seg(t, 0.12, 0.20);
if line_vis > 0.001 {
let path = signature_path(cw, ch);
let draw_on = motion::ease_out_cubic(seg(t, 0.02, 0.13)) as f64;
let (trimmed, head) = trim_path(&path, draw_on);
let line_col = with_alpha(s.accent, 0.9 * line_vis);
scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, line_col, None, &trimmed);
let pop = motion::ease_out_back(b1);
let r = (4.0 + 7.0 * pop as f64).max(0.0);
let dot_a = (b1 * line_vis).clamp(0.0, 1.0);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.18 * dot_a),
None,
&Circle::new(head, r * 3.2),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, dot_a),
None,
&Circle::new(head, r),
);
}
// ── WORDMARK (82100%) ─────────────────────────────────────────
let word_in = seg(t, 0.84, 0.95);
let word_a = motion::ease_out_cubic(word_in);
if word_a > 0.001 {
let size = 132.0_f32;
let layout = ts.layout(
"Llimphi", size, None, Alignment::Start, 1.0, false, None, 800.0, false, false, 0.0, 0.0,
);
let m = measurement(&layout);
let rise = lerp(24.0, 0.0, word_a as f64);
let ox = (cw - m.width as f64) / 2.0;
let oy = (ch - m.height as f64) / 2.0 - 18.0 + rise;
let brush = peniko::Brush::Solid(with_alpha(s.fg, word_a));
draw_layout_brush_xf(scene, &layout, &brush, Affine::translate((ox, oy)));
let sub_a = motion::ease_out_cubic(seg(t, 0.88, 0.99));
if sub_a > 0.001 {
let ssz = 26.0_f32;
let sub = ts.layout(
"a Rust GUI framework", ssz, None, Alignment::Start, 1.0, false, None, 400.0,
false, false, 0.0, 0.0,
);
let sm = measurement(&sub);
let dot_r = 6.0;
let block_w = sm.width as f64 + dot_r * 2.0 + 14.0;
let sx = (cw - block_w) / 2.0;
let sy = oy + m.height as f64 + 18.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, sub_a),
None,
&Circle::new(Point::new(sx + dot_r, sy + ssz as f64 * 0.42), dot_r as f64),
);
let sbrush = peniko::Brush::Solid(with_alpha(s.fg_muted, sub_a));
draw_layout_brush_xf(
scene,
&sub,
&sbrush,
Affine::translate((sx + dot_r * 2.0 + 14.0, sy)),
);
}
}
// ── punto teal de firma (esquina inf-der), ancla de marca ───────
let corner_a = seg(t, 0.04, 0.12) * (1.0 - seg(t, 0.80, 0.86));
if corner_a > 0.001 {
let cx = cw - 54.0;
let cy = ch - 54.0;
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.16 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 18.0),
);
scene.fill(
peniko::Fill::NonZero,
Affine::IDENTITY,
with_alpha(s.accent, 0.9 * corner_a),
None,
&Circle::new(Point::new(cx, cy), 6.0),
);
}
}
// ───────────────────────── la escena por frame ─────────────────────────
/// Construye el árbol `View` completo del frame `t`: las cards con widgets
/// reales (con su estado derivado de t) + un nodo overlay full-screen que
/// pinta cold-open / wordmark encima.
fn build_view(t: f32, cw: f64, ch: f64, s: &Skin) -> View<()> {
let grid = layout_grid(cw, ch);
let row = layout_row(cw, ch);
// Progreso del "estado" de los widgets (toggle/slider/progress/…).
let widget_p = seg(t, 0.16, 0.58);
// Morph grid → fila (5880%).
let morph = motion::ease_in_out_cubic(seg(t, 0.60, 0.80)) as f64;
// Fade-out de las cards antes del wordmark.
let cards_fade = 1.0 - seg(t, 0.80, 0.86);
let mut children: Vec<View<()>> = Vec::new();
if cards_fade > 0.001 {
for i in 0..N_CARDS {
// Stagger de entrada: cada card arranca con retraso incremental.
let delay = i as f32 * 0.035;
let enter = motion::ease_out_back(seg(t, 0.12 + delay, 0.12 + delay + 0.16));
if enter <= 0.001 {
continue;
}
let rect = grid[i].lerp(row[i], morph);
let scale = lerp(0.88, 1.0, enter.min(1.0) as f64);
let alpha = (enter.min(1.0) * cards_fade).clamp(0.0, 1.0);
children.push(card_view(i, rect, alpha, scale, widget_p, s));
}
}
// Nodo overlay full-screen para el vector (cold-open + wordmark).
let overlay = View::new(Style {
position: Position::Absolute,
inset: Rect {
left: length(0.0),
top: length(0.0),
right: length(0.0),
bottom: length(0.0),
},
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
..Default::default()
})
.paint_with({
let s = s.clone();
move |scene, ts, _rect: PaintRect| {
draw_overlays(scene, ts, t, cw, ch, &s);
}
});
children.push(overlay);
View::new(Style {
size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
position: Position::Relative,
..Default::default()
})
.fill(s.bg)
.children(children)
}
fn main() {
let mut args = std::env::args().skip(1);
let out_dir = args.next().unwrap_or_else(|| "showreel_frames".to_string());
let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(360);
let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
create_dir_all(&out_dir).expect("mkdir out_dir");
let theme = llimphi_theme::Theme::by_name("Tawa").expect("tema Tawa");
let accent = Color::from_rgba8(0x2B, 0xD9, 0xA6, 0xFF); // teal #2BD9A6 (acento firma)
let skin = Skin {
accent,
panel: theme.bg_panel,
panel_hi: theme.bg_button,
border: theme.border,
border_accent: with_alpha(accent, 0.55),
fg: theme.fg_text,
fg_muted: theme.fg_muted,
bg: theme.bg_app,
theme,
};
let [br, bg, bb, _] = skin.bg.components;
let base = Color::from_rgba8((br * 255.0) as u8, (bg * 255.0) as u8, (bb * 255.0) as u8, 255);
// GPU una sola vez; reusar device/renderer/target/buffer para los N frames.
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let mut renderer = Renderer::new(&hal).expect("renderer");
let target = hal.device.create_texture(&wgpu::TextureDescriptor {
label: Some("showreel"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FMT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&wgpu::TextureViewDescriptor::default());
let mut ts = Typesetter::new();
let cw = w as f64;
let ch = h as f64;
for i in 0..n {
let t = if n <= 1 { 0.0 } else { i as f32 / (n as f32 - 1.0) };
let root = build_view(t, cw, ch, &skin);
// view → layout (con medición de texto real) → scene — idéntico al eventloop.
let mut layout = LayoutTree::new();
let mounted = mount(&mut layout, root);
let computed = {
let tmap = &mounted.text_measures;
layout
.compute_with_measure(mounted.root, (w as f32, h as f32), |nid, known, avail| {
match tmap.get(&nid) {
Some(tm) => measure_text_node(&mut ts, tm, known, avail),
None => taffy::Size::ZERO,
}
})
.expect("layout")
};
let mut scene = vello::Scene::new();
paint(&mut scene, &mounted, &computed, &mut ts, None, None);
renderer
.render_to_view(&hal, &scene, &view, w, h, base)
.expect("render_to_view");
let path = format!("{out_dir}/frame_{i:04}.png");
write_png(&hal, &target, &path, w, h);
if i % 30 == 0 || i == n - 1 {
eprintln!("showreel: frame {}/{} (t={:.3})", i + 1, n, t);
}
}
eprintln!("showreel: {n} frames en {out_dir}/ ({w}x{h})");
}
fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32) {
let unpadded = (w * 4) as usize;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
let padded = unpadded.div_ceil(align) * align;
let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size: (padded * h as usize) as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: target,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded as u32),
rows_per_image: Some(h),
},
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
hal.queue.submit(std::iter::once(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
rx.recv().unwrap().unwrap();
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((w * h * 4) as usize);
for r in 0..h as usize {
let sidx = r * padded;
pixels.extend_from_slice(&data[sidx..sidx + unpadded]);
}
drop(data);
buf.unmap();
let file = File::create(path).expect("png");
let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut wr = enc.write_header().unwrap();
wr.write_image_data(&pixels).unwrap();
}