tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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(®istro),
128 Orden::Render {
129 registro,
130 out,
131 fps,
132 size,
133 } => {
134 let n = revelar(®istro, &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(®istro, &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}