tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main llimphi-registro/src/main.rs Rust · 637 lines · 24.4 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1// =============================================================================
2//  llimphi-scene — grabar la escena de una app, medirla y revelarla a video
3// -----------------------------------------------------------------------------
4//  Los cuatro verbos son las cuatro cosas que se hacen con un master:
5//
6//    record   una app enjaulada compone; sus cuadros van al archivo.  SIN GPU.
7//    info     cuánto pesa, medido. Es lo que contesta «¿gana contra un mp4?».
8//    render   cada cuadro a PNG, offline, al tamaño que se pida.       CON GPU.
9//    film     lo mismo y ffmpeg al final: el mp4 de la portada.        CON GPU.
10//
11//  Que `record` no necesite GPU no es un detalle: grabar es barato, se puede
12//  hacer en una máquina sin pantalla, y el revelado —que es lo caro— se repite
13//  cuantas veces haga falta sin volver a molestar a nadie.
14//
15//  El huésped se lanza como lo lanza cualquier anfitrión de la casa
16//  (`llimphi_cable::vida::lanzar`): el socket va como ÚLTIMO argumento, que es
17//  la convención que ya usan `nada_enjaulado` y `nahual_enjaulado`.
18// =============================================================================
19
20#![forbid(unsafe_code)]
21
22use std::path::{Path, PathBuf};
23use std::time::{Duration, Instant};
24
25use clap::{Parser, Subcommand};
26use llimphi_cable::{vida, Anfitrion};
27use llimphi_entrada::{Key, KeyEvent, KeyState, Modifiers, NamedKey};
28use llimphi_hal::{wgpu, Hal};
29use llimphi_raster::{peniko, vello, Renderer};
30use llimphi_registro::{cargar, cuadro_en, resumir, Cabecera, Grabador, Lector};
31use llimphi_text::Typesetter;
32use llimphi_wire_escena::EscenaWire;
33
34#[derive(Parser)]
35#[command(
36    name = "llimphi-scene",
37    about = "El master de escena de una app Llimphi: grabar, medir, revelar, filmar."
38)]
39struct Cli {
40    #[command(subcommand)]
41    orden: Orden,
42}
43
44#[derive(Subcommand)]
45enum Orden {
46    /// Graba la escena de una app enjaulada a un registro.
47    Record {
48        /// Dónde queda el registro.
49        #[arg(long)]
50        out: PathBuf,
51        /// Tamaño del tablado, `ANCHOxALTO`.
52        #[arg(long, default_value = "1280x720")]
53        size: String,
54        /// Cuánto grabar, en segundos. Con `--script`, el guión manda si es más largo.
55        #[arg(long, default_value_t = 20)]
56        seconds: u64,
57        /// Guión de entrada: qué se toca y cuándo. Sin él la app se queda quieta.
58        #[arg(long)]
59        script: Option<PathBuf>,
60        /// Qué se está grabando, para la cabecera.
61        #[arg(long, default_value = "")]
62        app: String,
63        /// Con qué tema se compuso, para la cabecera.
64        #[arg(long, default_value = "")]
65        theme: String,
66        /// El huésped y sus argumentos. El socket se le agrega al final.
67        #[arg(last = true, required = true)]
68        guest: Vec<String>,
69    },
70    /// Cuánto pesa un registro, medido.
71    Info {
72        registro: PathBuf,
73    },
74    /// Rasteriza cada cuadro a PNG.
75    Render {
76        registro: PathBuf,
77        /// Directorio de salida (se crea).
78        #[arg(long)]
79        out: PathBuf,
80        /// Cadencia del revelado. Los cuadros se remuestrean a ella.
81        #[arg(long, default_value_t = 30)]
82        fps: u32,
83        /// Tamaño del revelado; por defecto, el de la cabecera.
84        #[arg(long)]
85        size: Option<String>,
86    },
87    /// Revela y arma el video.
88    Film {
89        registro: PathBuf,
90        /// El video de salida.
91        #[arg(long)]
92        out: PathBuf,
93        #[arg(long, default_value_t = 30)]
94        fps: u32,
95        #[arg(long)]
96        size: Option<String>,
97        /// `h264` (mp4 que abre cualquier navegador) o `av1` (el de la casa).
98        #[arg(long, default_value = "h264")]
99        codec: String,
100        /// Calidad: menor es mejor y más pesado.
101        #[arg(long, default_value_t = 28)]
102        crf: u8,
103        /// Dejar los PNG acá en vez de en un temporal que se borra.
104        #[arg(long)]
105        keep: Option<PathBuf>,
106    },
107}
108
109fn main() {
110    if let Err(e) = correr(Cli::parse()) {
111        eprintln!("llimphi-scene: {e}");
112        std::process::exit(1);
113    }
114}
115
116fn correr(cli: Cli) -> Result<(), String> {
117    match cli.orden {
118        Orden::Record {
119            out,
120            size,
121            seconds,
122            script,
123            app,
124            theme,
125            guest,
126        } => grabar(&out, &size, seconds, script.as_deref(), &app, &theme, &guest),
127        Orden::Info { registro } => informar(&registro),
128        Orden::Render {
129            registro,
130            out,
131            fps,
132            size,
133        } => {
134            let n = revelar(&registro, &out, fps, size.as_deref())?;
135            println!("{n} cuadros en {}", out.display());
136            Ok(())
137        }
138        Orden::Film {
139            registro,
140            out,
141            fps,
142            size,
143            codec,
144            crf,
145            keep,
146        } => filmar(&registro, &out, fps, size.as_deref(), &codec, crf, keep),
147    }
148}
149
150// ─────────────────────────────────────────────────────────────────────────────
151//  El guión: qué se toca y cuándo
152// ─────────────────────────────────────────────────────────────────────────────
153
154/// Un paso del guión, ya resuelto a un evento del cable.
155enum Paso {
156    Mover(f32, f32),
157    Clic(f32, f32),
158    Tecla(KeyEvent),
159    Tamano(f32, f32),
160}
161
162/// Lee el guión. Formato de una línea por paso, porque el que lo va a escribir
163/// es una persona afinando una portada y va a editarlo veinte veces:
164///
165/// ```text
166/// # los segundos son acumulativos: `wait` corre el reloj
167/// wait 500
168/// move 640 360
169/// click 200 120
170/// text hola
171/// key Enter
172/// resize 1280 720
173/// ```
174fn leer_guion(ruta: &Path) -> Result<Vec<(u32, Paso)>, String> {
175    let texto = std::fs::read_to_string(ruta).map_err(|e| format!("{}: {e}", ruta.display()))?;
176    let mut pasos = Vec::new();
177    let mut reloj = 0u32;
178    for (n, linea) in texto.lines().enumerate() {
179        let linea = linea.trim();
180        if linea.is_empty() || linea.starts_with('#') {
181            continue;
182        }
183        let mal = |q: &str| format!("{}:{}: {q}", ruta.display(), n + 1);
184        let mut campos = linea.split_whitespace();
185        let verbo = campos.next().unwrap_or("");
186        let num = |c: Option<&str>| -> Result<f32, String> {
187            c.ok_or_else(|| mal("falta un número"))?
188                .parse::<f32>()
189                .map_err(|_| mal("eso no es un número"))
190        };
191        match verbo {
192            "wait" => reloj += num(campos.next())? as u32,
193            "move" => pasos.push((reloj, Paso::Mover(num(campos.next())?, num(campos.next())?))),
194            "click" => pasos.push((reloj, Paso::Clic(num(campos.next())?, num(campos.next())?))),
195            "resize" => pasos.push((reloj, Paso::Tamano(num(campos.next())?, num(campos.next())?))),
196            "text" => {
197                let resto = linea.strip_prefix("text").unwrap_or("").trim_start();
198                if resto.is_empty() {
199                    return Err(mal("`text` sin texto"));
200                }
201                // Un carácter por evento, con `text` puesto: es lo que un editor
202                // mira para insertar. Separados en el tiempo a propósito — si
203                // entraran todos en el mismo milisegundo, el registro tendría UN
204                // cuadro con la palabra ya escrita y en pantalla se vería como un
205                // pegado, no como alguien tipeando.
206                for (i, c) in resto.chars().enumerate() {
207                    let s = c.to_string();
208                    pasos.push((
209                        reloj + i as u32 * 60,
210                        Paso::Tecla(KeyEvent {
211                            key: Key::Character(s.clone()),
212                            state: KeyState::Pressed,
213                            text: Some(s),
214                            modifiers: Modifiers::default(),
215                            repeat: false,
216                        }),
217                    ));
218                }
219                reloj += resto.chars().count() as u32 * 60;
220            }
221            "key" => {
222                let nombre = campos.next().ok_or_else(|| mal("`key` sin tecla"))?;
223                let named = tecla_por_nombre(nombre).ok_or_else(|| mal("no conozco esa tecla"))?;
224                pasos.push((
225                    reloj,
226                    Paso::Tecla(KeyEvent {
227                        key: Key::Named(named),
228                        state: KeyState::Pressed,
229                        text: None,
230                        modifiers: Modifiers::default(),
231                        repeat: false,
232                    }),
233                ));
234            }
235            otro => return Err(mal(&format!("no conozco el verbo `{otro}`"))),
236        }
237    }
238    pasos.sort_by_key(|(t, _)| *t);
239    Ok(pasos)
240}
241
242/// Las teclas con nombre que un guión de portada necesita. La lista es corta a
243/// propósito: `NamedKey` tiene treinta y una variantes y las que faltan se
244/// agregan cuando una portada las pida, no antes.
245fn tecla_por_nombre(n: &str) -> Option<NamedKey> {
246    Some(match n {
247        "Enter" => NamedKey::Enter,
248        "Escape" => NamedKey::Escape,
249        "Tab" => NamedKey::Tab,
250        "Backspace" => NamedKey::Backspace,
251        "Space" => NamedKey::Space,
252        "Delete" => NamedKey::Delete,
253        "Home" => NamedKey::Home,
254        "End" => NamedKey::End,
255        "PageUp" => NamedKey::PageUp,
256        "PageDown" => NamedKey::PageDown,
257        "ArrowUp" => NamedKey::ArrowUp,
258        "ArrowDown" => NamedKey::ArrowDown,
259        "ArrowLeft" => NamedKey::ArrowLeft,
260        "ArrowRight" => NamedKey::ArrowRight,
261        _ => return None,
262    })
263}
264
265fn medida(s: &str) -> Result<(u32, u32), String> {
266    let (a, b) = s
267        .split_once(['x', 'X'])
268        .ok_or_else(|| format!("`{s}` no es un tamaño (se escribe 1280x720)"))?;
269    let a = a.trim().parse::<u32>().map_err(|_| format!("`{s}`: ancho"))?;
270    let b = b.trim().parse::<u32>().map_err(|_| format!("`{s}`: alto"))?;
271    if a == 0 || b == 0 {
272        return Err(format!("`{s}`: un lado en cero"));
273    }
274    Ok((a, b))
275}
276
277// ─────────────────────────────────────────────────────────────────────────────
278//  record
279// ─────────────────────────────────────────────────────────────────────────────
280
281fn grabar(
282    salida: &Path,
283    size: &str,
284    segundos: u64,
285    guion: Option<&Path>,
286    app: &str,
287    tema: &str,
288    huesped: &[String],
289) -> Result<(), String> {
290    let (w, h) = medida(size)?;
291    let pasos = match guion {
292        Some(g) => leer_guion(g)?,
293        None => Vec::new(),
294    };
295    // El guión manda si es más largo que el plazo: una portada de treinta
296    // segundos con `--seconds 20` por omisión saldría cortada justo antes del
297    // final, que es el defecto más difícil de ver en un video.
298    let hasta_ms = pasos
299        .last()
300        .map(|(t, _)| *t + 500)
301        .unwrap_or(0)
302        .max(segundos as u32 * 1000);
303
304    let archivo = std::fs::File::create(salida).map_err(|e| format!("{}: {e}", salida.display()))?;
305    let cab = Cabecera {
306        ancho: w,
307        alto: h,
308        fps_sugerido: 30,
309        app: if app.is_empty() { huesped[0].clone() } else { app.into() },
310        tema: tema.into(),
311    };
312    let mut grab =
313        Grabador::nuevo(std::io::BufWriter::new(archivo), &cab).map_err(|e| e.to_string())?;
314
315    let (mut hijo, flujo) = vida::lanzar("registro", |sock| {
316        let mut c = std::process::Command::new(&huesped[0]);
317        c.args(&huesped[1..]);
318        c.arg(sock);
319        c
320    })
321    .map_err(|m| m.frase())?;
322
323    let mut anf = Anfitrion::new(flujo);
324    let mut ts = Typesetter::new();
325    anf.enviar_tamano(w as f32, h as f32)
326        .map_err(|e| format!("no se pudo avisar el tamaño: {e}"))?;
327
328    let t0 = Instant::now();
329    let mut proximo = 0usize;
330    let mut motivo = "se cumplió el plazo";
331    loop {
332        let ms = t0.elapsed().as_millis() as u32;
333        if ms >= hasta_ms {
334            break;
335        }
336        while let Some((t, paso)) = pasos.get(proximo) {
337            if *t > ms {
338                break;
339            }
340            let r = match paso {
341                Paso::Mover(x, y) => anf.enviar_movimiento(*x, *y),
342                // Un clic son DOS mensajes: `Puntero{apretado:true}` y la
343                // soltada. Mandando sólo la pulsación, el huésped se queda con
344                // el botón hundido para siempre y el clic siguiente no es un
345                // clic — medido: el editor recibía los eventos, componía 33
346                // veces y devolvía la misma escena.
347                Paso::Clic(x, y) => anf.enviar_clic(*x, *y).and_then(|()| anf.enviar_movimiento(*x, *y)),
348                Paso::Tecla(k) => anf.enviar_tecla(k.clone()),
349                Paso::Tamano(a, b) => anf.enviar_tamano(*a, *b),
350            };
351            r.map_err(|e| format!("el huésped no recibe: {e}"))?;
352            proximo += 1;
353        }
354        match anf.atender_hasta(Duration::from_millis(50), |p| {
355            llimphi_reproductor::medir_texto(&mut ts, &p.cadena, &p.estilo, p.ancho_max)
356        }) {
357            Ok(Some(escena)) => {
358                grab.cuadro(ms, &escena).map_err(|e| e.to_string())?;
359            }
360            // Nadie compuso en 50 ms: no pasó nada y no es un error. Es el caso
361            // normal de un escritorio quieto, y es justo el que hace que esto
362            // pese poco.
363            Ok(None) => {}
364            Err(e) => {
365                motivo = "se cortó el caño";
366                eprintln!("llimphi-scene: {e}");
367                break;
368            }
369        }
370        if let Some(m) = vida::como_murio(&mut hijo) {
371            motivo = "el huésped se fue";
372            eprintln!("llimphi-scene: {}", m.frase());
373            break;
374        }
375    }
376
377    let _ = anf.despedirse();
378    let _ = hijo.kill();
379    let _ = hijo.wait();
380
381    let (cuadros, repetidos, bytes, dur) = (
382        grab.cuadros(),
383        grab.repetidos(),
384        grab.bytes(),
385        grab.ultimo_t_ms(),
386    );
387    grab.cerrar().map_err(|e| e.to_string())?;
388    println!(
389        "{}: {cuadros} cuadros, {repetidos} repetidos que no se escribieron, \
390         {bytes} bytes de escena en {dur} ms ({motivo})",
391        salida.display()
392    );
393    Ok(())
394}
395
396// ─────────────────────────────────────────────────────────────────────────────
397//  info
398// ─────────────────────────────────────────────────────────────────────────────
399
400fn informar(registro: &Path) -> Result<(), String> {
401    let f = std::fs::File::open(registro).map_err(|e| format!("{}: {e}", registro.display()))?;
402    let (cab, mut lec) =
403        Lector::abrir(std::io::BufReader::new(f)).map_err(|e| format!("{}: {e}", registro.display()))?;
404    let r = resumir(&mut lec).map_err(|e| e.to_string())?;
405    println!("app        {}", cab.app);
406    println!("tema       {}", cab.tema);
407    println!("tablado    {}x{}", cab.ancho, cab.alto);
408    println!("cuadros    {}", r.cuadros);
409    println!("duración   {:.1} s", r.dur_ms as f64 / 1000.0);
410    println!(
411        "escena     {} bytes ({} por cuadro; menor {}, mayor {})",
412        r.bytes,
413        r.por_cuadro(),
414        r.menor,
415        r.mayor
416    );
417    // El careo que pidió la pregunta. Un screencast 1080p razonable anda entre
418    // 125 y 600 KB/s; el número de acá es el que decide, y sale del archivo.
419    println!(
420        "ritmo      {} B/s  ({:.1} KB/s)",
421        r.por_segundo(),
422        r.por_segundo() as f64 / 1024.0
423    );
424    Ok(())
425}
426
427// ─────────────────────────────────────────────────────────────────────────────
428//  render / film
429// ─────────────────────────────────────────────────────────────────────────────
430
431/// El nombre de los PNG. `%06d` es lo que después lee ffmpeg, así que el
432/// formato de acá y el patrón de allá son **el mismo dato** y no se pueden
433/// separar sin que la película salga vacía y ffmpeg no diga por qué.
434fn nombre_cuadro(dir: &Path, i: usize) -> PathBuf {
435    dir.join(format!("cuadro_{i:06}.png"))
436}
437
438fn revelar(registro: &Path, dir: &Path, fps: u32, size: Option<&str>) -> Result<usize, String> {
439    let f = std::fs::File::open(registro).map_err(|e| format!("{}: {e}", registro.display()))?;
440    let (cab, mut lec) =
441        Lector::abrir(std::io::BufReader::new(f)).map_err(|e| format!("{}: {e}", registro.display()))?;
442    let cuadros = cargar(&mut lec).map_err(|e| e.to_string())?;
443    if cuadros.is_empty() {
444        return Err("el registro no tiene un solo cuadro".into());
445    }
446    let (w, h) = match size {
447        Some(s) => medida(s)?,
448        None => (cab.ancho, cab.alto),
449    };
450    let fps = fps.max(1);
451    let dur_ms = cuadros.last().map(|(t, _)| *t).unwrap_or(0);
452    let total = ((dur_ms as u64 * fps as u64) / 1000) as usize + 1;
453
454    std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
455    let mut camara = Camara::nueva(w, h)?;
456    let mut ts = Typesetter::new();
457
458    for i in 0..total {
459        let t = (i as u64 * 1000 / fps as u64) as u32;
460        // Antes del primer cuadro no hay nada que sostener: se pinta el primero,
461        // que es lo único honesto que se puede hacer con el tiempo muerto del
462        // arranque.
463        let idx = cuadro_en(&cuadros, t).unwrap_or(0);
464        camara.pintar(&cuadros[idx].1, &mut ts)?;
465        camara.escribir(&nombre_cuadro(dir, i))?;
466    }
467    Ok(total)
468}
469
470fn filmar(
471    registro: &Path,
472    salida: &Path,
473    fps: u32,
474    size: Option<&str>,
475    codec: &str,
476    crf: u8,
477    keep: Option<PathBuf>,
478) -> Result<(), String> {
479    let temporal = keep.is_none();
480    let dir = keep.unwrap_or_else(|| {
481        std::env::temp_dir().join(format!("llimphi-scene-{}", std::process::id()))
482    });
483    let n = revelar(registro, &dir, fps, size)?;
484    let patron = dir.join("cuadro_%06d.png");
485    let r = match codec {
486        // El de la portada: un mp4 que abre cualquier navegador sin preguntar.
487        "h264" => foreign_av::encode_frames_h264(&patron, fps, crf, None, salida),
488        // El de la casa: AV1, el que reproduce `media-source-av1`.
489        "av1" => foreign_av::encode_frames(&patron, fps, crf, None, salida),
490        otro => return Err(format!("no conozco el codec `{otro}` (h264 | av1)")),
491    };
492    if temporal {
493        let _ = std::fs::remove_dir_all(&dir);
494    }
495    r.map_err(|e| format!("ffmpeg: {e:?}"))?;
496    println!("{}: {n} cuadros a {fps} fps", salida.display());
497    Ok(())
498}
499
500/// La cámara headless: una textura, el reproductor y la bajada a PNG.
501///
502/// Es la misma forma que la `Camara` de `ende-render` —y no se reusa aquélla
503/// porque es de ende y sabe de sellos, aspectos y películas—, pero el trípode
504/// es literalmente el mismo: `Hal` sin ventana + `Renderer` de `llimphi-raster`.
505struct Camara {
506    hal: Hal,
507    renderer: Renderer,
508    target: wgpu::Texture,
509    vista: wgpu::TextureView,
510    w: u32,
511    h: u32,
512}
513
514impl Camara {
515    fn nueva(w: u32, h: u32) -> Result<Self, String> {
516        let hal = pollster::block_on(Hal::new_headless(false))
517            .map_err(|e| format!("no hay GPU headless: {e:?}"))?;
518        let renderer = Renderer::new(&hal).map_err(|e| format!("rasterizador: {e:?}"))?;
519        let target = hal.device.create_texture(&wgpu::TextureDescriptor {
520            label: Some("llimphi-scene-cuadro"),
521            size: wgpu::Extent3d {
522                width: w,
523                height: h,
524                depth_or_array_layers: 1,
525            },
526            mip_level_count: 1,
527            sample_count: 1,
528            dimension: wgpu::TextureDimension::D2,
529            format: wgpu::TextureFormat::Rgba8Unorm,
530            usage: wgpu::TextureUsages::STORAGE_BINDING
531                | wgpu::TextureUsages::RENDER_ATTACHMENT
532                | wgpu::TextureUsages::COPY_SRC,
533            view_formats: &[],
534        });
535        let vista = target.create_view(&wgpu::TextureViewDescriptor::default());
536        Ok(Self {
537            hal,
538            renderer,
539            target,
540            vista,
541            w,
542            h,
543        })
544    }
545
546    fn pintar(&mut self, wire: &EscenaWire, ts: &mut Typesetter) -> Result<(), String> {
547        let mut escena = vello::Scene::new();
548        let st = llimphi_reproductor::reproducir(wire, &mut escena, ts);
549        // Un cuadro al que le faltan blobs se pinta igual, con agujeros. Se avisa
550        // y no se aborta: enterarse al final de que la película salió sin las
551        // imágenes es peor que una línea por cuadro.
552        if st.imagenes_sin_blob > 0 {
553            eprintln!(
554                "llimphi-scene: {} imágenes sin blob en este cuadro",
555                st.imagenes_sin_blob
556            );
557        }
558        self.renderer
559            .render_to_view(
560                &self.hal,
561                &escena,
562                &self.vista,
563                self.w,
564                self.h,
565                peniko::Color::BLACK,
566            )
567            .map_err(|e| format!("render: {e:?}"))
568    }
569
570    /// Baja la textura a RAM y la escribe como PNG. El `padding` de filas es de
571    /// wgpu, no del PNG: `COPY_BYTES_PER_ROW_ALIGNMENT` obliga a alinear cada
572    /// fila y hay que sacarlo antes de escribir, o la imagen sale inclinada.
573    fn escribir(&self, salida: &Path) -> Result<(), String> {
574        let sin_pad = (self.w * 4) as usize;
575        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
576        let con_pad = sin_pad.div_ceil(align) * align;
577        let buf = self.hal.device.create_buffer(&wgpu::BufferDescriptor {
578            label: Some("llimphi-scene-readback"),
579            size: (con_pad * self.h as usize) as u64,
580            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
581            mapped_at_creation: false,
582        });
583        let mut enc = self
584            .hal
585            .device
586            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
587        enc.copy_texture_to_buffer(
588            wgpu::TexelCopyTextureInfo {
589                texture: &self.target,
590                mip_level: 0,
591                origin: wgpu::Origin3d::ZERO,
592                aspect: wgpu::TextureAspect::All,
593            },
594            wgpu::TexelCopyBufferInfo {
595                buffer: &buf,
596                layout: wgpu::TexelCopyBufferLayout {
597                    offset: 0,
598                    bytes_per_row: Some(con_pad as u32),
599                    rows_per_image: Some(self.h),
600                },
601            },
602            wgpu::Extent3d {
603                width: self.w,
604                height: self.h,
605                depth_or_array_layers: 1,
606            },
607        );
608        self.hal.queue.submit(std::iter::once(enc.finish()));
609        let slice = buf.slice(..);
610        let (tx, rx) = std::sync::mpsc::channel();
611        slice.map_async(wgpu::MapMode::Read, move |r| {
612            let _ = tx.send(r);
613        });
614        let _ = self.hal.device.poll(wgpu::PollType::wait_indefinitely());
615        rx.recv()
616            .map_err(|e| e.to_string())?
617            .map_err(|e| format!("{e:?}"))?;
618
619        let datos = slice.get_mapped_range();
620        let mut pixeles = Vec::with_capacity((self.w * self.h * 4) as usize);
621        for fila in 0..self.h as usize {
622            let s = fila * con_pad;
623            pixeles.extend_from_slice(&datos[s..s + sin_pad]);
624        }
625        drop(datos);
626        buf.unmap();
627
628        let f = std::fs::File::create(salida).map_err(|e| format!("{}: {e}", salida.display()))?;
629        let mut enc = png::Encoder::new(std::io::BufWriter::new(f), self.w, self.h);
630        enc.set_color(png::ColorType::Rgba);
631        enc.set_depth(png::BitDepth::Eight);
632        enc.write_header()
633            .map_err(|e| e.to_string())?
634            .write_image_data(&pixeles)
635            .map_err(|e| e.to_string())
636    }
637}