//! **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 (82–100%) ─────────────────────────────────────────
    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 (58–80%).
    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();
}
