// ============================================================================= // llimphi-scene — grabar la escena de una app, medirla y revelarla a video // ----------------------------------------------------------------------------- // Los cuatro verbos son las cuatro cosas que se hacen con un master: // // record una app enjaulada compone; sus cuadros van al archivo. SIN GPU. // info cuánto pesa, medido. Es lo que contesta «¿gana contra un mp4?». // render cada cuadro a PNG, offline, al tamaño que se pida. CON GPU. // film lo mismo y ffmpeg al final: el mp4 de la portada. CON GPU. // // Que `record` no necesite GPU no es un detalle: grabar es barato, se puede // hacer en una máquina sin pantalla, y el revelado —que es lo caro— se repite // cuantas veces haga falta sin volver a molestar a nadie. // // El huésped se lanza como lo lanza cualquier anfitrión de la casa // (`llimphi_cable::vida::lanzar`): el socket va como ÚLTIMO argumento, que es // la convención que ya usan `nada_enjaulado` y `nahual_enjaulado`. // ============================================================================= #![forbid(unsafe_code)] use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use clap::{Parser, Subcommand}; use llimphi_cable::{vida, Anfitrion}; use llimphi_entrada::{Key, KeyEvent, KeyState, Modifiers, NamedKey}; use llimphi_hal::{wgpu, Hal}; use llimphi_raster::{peniko, vello, Renderer}; use llimphi_registro::{cargar, cuadro_en, resumir, Cabecera, Grabador, Lector}; use llimphi_text::Typesetter; use llimphi_wire_escena::EscenaWire; #[derive(Parser)] #[command( name = "llimphi-scene", about = "El master de escena de una app Llimphi: grabar, medir, revelar, filmar." )] struct Cli { #[command(subcommand)] orden: Orden, } #[derive(Subcommand)] enum Orden { /// Graba la escena de una app enjaulada a un registro. Record { /// Dónde queda el registro. #[arg(long)] out: PathBuf, /// Tamaño del tablado, `ANCHOxALTO`. #[arg(long, default_value = "1280x720")] size: String, /// Cuánto grabar, en segundos. Con `--script`, el guión manda si es más largo. #[arg(long, default_value_t = 20)] seconds: u64, /// Guión de entrada: qué se toca y cuándo. Sin él la app se queda quieta. #[arg(long)] script: Option, /// Qué se está grabando, para la cabecera. #[arg(long, default_value = "")] app: String, /// Con qué tema se compuso, para la cabecera. #[arg(long, default_value = "")] theme: String, /// El huésped y sus argumentos. El socket se le agrega al final. #[arg(last = true, required = true)] guest: Vec, }, /// Cuánto pesa un registro, medido. Info { registro: PathBuf, }, /// Rasteriza cada cuadro a PNG. Render { registro: PathBuf, /// Directorio de salida (se crea). #[arg(long)] out: PathBuf, /// Cadencia del revelado. Los cuadros se remuestrean a ella. #[arg(long, default_value_t = 30)] fps: u32, /// Tamaño del revelado; por defecto, el de la cabecera. #[arg(long)] size: Option, }, /// Revela y arma el video. Film { registro: PathBuf, /// El video de salida. #[arg(long)] out: PathBuf, #[arg(long, default_value_t = 30)] fps: u32, #[arg(long)] size: Option, /// `h264` (mp4 que abre cualquier navegador) o `av1` (el de la casa). #[arg(long, default_value = "h264")] codec: String, /// Calidad: menor es mejor y más pesado. #[arg(long, default_value_t = 28)] crf: u8, /// Dejar los PNG acá en vez de en un temporal que se borra. #[arg(long)] keep: Option, }, } fn main() { if let Err(e) = correr(Cli::parse()) { eprintln!("llimphi-scene: {e}"); std::process::exit(1); } } fn correr(cli: Cli) -> Result<(), String> { match cli.orden { Orden::Record { out, size, seconds, script, app, theme, guest, } => grabar(&out, &size, seconds, script.as_deref(), &app, &theme, &guest), Orden::Info { registro } => informar(®istro), Orden::Render { registro, out, fps, size, } => { let n = revelar(®istro, &out, fps, size.as_deref())?; println!("{n} cuadros en {}", out.display()); Ok(()) } Orden::Film { registro, out, fps, size, codec, crf, keep, } => filmar(®istro, &out, fps, size.as_deref(), &codec, crf, keep), } } // ───────────────────────────────────────────────────────────────────────────── // El guión: qué se toca y cuándo // ───────────────────────────────────────────────────────────────────────────── /// Un paso del guión, ya resuelto a un evento del cable. enum Paso { Mover(f32, f32), Clic(f32, f32), Tecla(KeyEvent), Tamano(f32, f32), } /// Lee el guión. Formato de una línea por paso, porque el que lo va a escribir /// es una persona afinando una portada y va a editarlo veinte veces: /// /// ```text /// # los segundos son acumulativos: `wait` corre el reloj /// wait 500 /// move 640 360 /// click 200 120 /// text hola /// key Enter /// resize 1280 720 /// ``` fn leer_guion(ruta: &Path) -> Result, String> { let texto = std::fs::read_to_string(ruta).map_err(|e| format!("{}: {e}", ruta.display()))?; let mut pasos = Vec::new(); let mut reloj = 0u32; for (n, linea) in texto.lines().enumerate() { let linea = linea.trim(); if linea.is_empty() || linea.starts_with('#') { continue; } let mal = |q: &str| format!("{}:{}: {q}", ruta.display(), n + 1); let mut campos = linea.split_whitespace(); let verbo = campos.next().unwrap_or(""); let num = |c: Option<&str>| -> Result { c.ok_or_else(|| mal("falta un número"))? .parse::() .map_err(|_| mal("eso no es un número")) }; match verbo { "wait" => reloj += num(campos.next())? as u32, "move" => pasos.push((reloj, Paso::Mover(num(campos.next())?, num(campos.next())?))), "click" => pasos.push((reloj, Paso::Clic(num(campos.next())?, num(campos.next())?))), "resize" => pasos.push((reloj, Paso::Tamano(num(campos.next())?, num(campos.next())?))), "text" => { let resto = linea.strip_prefix("text").unwrap_or("").trim_start(); if resto.is_empty() { return Err(mal("`text` sin texto")); } // Un carácter por evento, con `text` puesto: es lo que un editor // mira para insertar. Separados en el tiempo a propósito — si // entraran todos en el mismo milisegundo, el registro tendría UN // cuadro con la palabra ya escrita y en pantalla se vería como un // pegado, no como alguien tipeando. for (i, c) in resto.chars().enumerate() { let s = c.to_string(); pasos.push(( reloj + i as u32 * 60, Paso::Tecla(KeyEvent { key: Key::Character(s.clone()), state: KeyState::Pressed, text: Some(s), modifiers: Modifiers::default(), repeat: false, }), )); } reloj += resto.chars().count() as u32 * 60; } "key" => { let nombre = campos.next().ok_or_else(|| mal("`key` sin tecla"))?; let named = tecla_por_nombre(nombre).ok_or_else(|| mal("no conozco esa tecla"))?; pasos.push(( reloj, Paso::Tecla(KeyEvent { key: Key::Named(named), state: KeyState::Pressed, text: None, modifiers: Modifiers::default(), repeat: false, }), )); } otro => return Err(mal(&format!("no conozco el verbo `{otro}`"))), } } pasos.sort_by_key(|(t, _)| *t); Ok(pasos) } /// Las teclas con nombre que un guión de portada necesita. La lista es corta a /// propósito: `NamedKey` tiene treinta y una variantes y las que faltan se /// agregan cuando una portada las pida, no antes. fn tecla_por_nombre(n: &str) -> Option { Some(match n { "Enter" => NamedKey::Enter, "Escape" => NamedKey::Escape, "Tab" => NamedKey::Tab, "Backspace" => NamedKey::Backspace, "Space" => NamedKey::Space, "Delete" => NamedKey::Delete, "Home" => NamedKey::Home, "End" => NamedKey::End, "PageUp" => NamedKey::PageUp, "PageDown" => NamedKey::PageDown, "ArrowUp" => NamedKey::ArrowUp, "ArrowDown" => NamedKey::ArrowDown, "ArrowLeft" => NamedKey::ArrowLeft, "ArrowRight" => NamedKey::ArrowRight, _ => return None, }) } fn medida(s: &str) -> Result<(u32, u32), String> { let (a, b) = s .split_once(['x', 'X']) .ok_or_else(|| format!("`{s}` no es un tamaño (se escribe 1280x720)"))?; let a = a.trim().parse::().map_err(|_| format!("`{s}`: ancho"))?; let b = b.trim().parse::().map_err(|_| format!("`{s}`: alto"))?; if a == 0 || b == 0 { return Err(format!("`{s}`: un lado en cero")); } Ok((a, b)) } // ───────────────────────────────────────────────────────────────────────────── // record // ───────────────────────────────────────────────────────────────────────────── fn grabar( salida: &Path, size: &str, segundos: u64, guion: Option<&Path>, app: &str, tema: &str, huesped: &[String], ) -> Result<(), String> { let (w, h) = medida(size)?; let pasos = match guion { Some(g) => leer_guion(g)?, None => Vec::new(), }; // El guión manda si es más largo que el plazo: una portada de treinta // segundos con `--seconds 20` por omisión saldría cortada justo antes del // final, que es el defecto más difícil de ver en un video. let hasta_ms = pasos .last() .map(|(t, _)| *t + 500) .unwrap_or(0) .max(segundos as u32 * 1000); let archivo = std::fs::File::create(salida).map_err(|e| format!("{}: {e}", salida.display()))?; let cab = Cabecera { ancho: w, alto: h, fps_sugerido: 30, app: if app.is_empty() { huesped[0].clone() } else { app.into() }, tema: tema.into(), }; let mut grab = Grabador::nuevo(std::io::BufWriter::new(archivo), &cab).map_err(|e| e.to_string())?; let (mut hijo, flujo) = vida::lanzar("registro", |sock| { let mut c = std::process::Command::new(&huesped[0]); c.args(&huesped[1..]); c.arg(sock); c }) .map_err(|m| m.frase())?; let mut anf = Anfitrion::new(flujo); let mut ts = Typesetter::new(); anf.enviar_tamano(w as f32, h as f32) .map_err(|e| format!("no se pudo avisar el tamaño: {e}"))?; let t0 = Instant::now(); let mut proximo = 0usize; let mut motivo = "se cumplió el plazo"; loop { let ms = t0.elapsed().as_millis() as u32; if ms >= hasta_ms { break; } while let Some((t, paso)) = pasos.get(proximo) { if *t > ms { break; } let r = match paso { Paso::Mover(x, y) => anf.enviar_movimiento(*x, *y), // Un clic son DOS mensajes: `Puntero{apretado:true}` y la // soltada. Mandando sólo la pulsación, el huésped se queda con // el botón hundido para siempre y el clic siguiente no es un // clic — medido: el editor recibía los eventos, componía 33 // veces y devolvía la misma escena. Paso::Clic(x, y) => anf.enviar_clic(*x, *y).and_then(|()| anf.enviar_movimiento(*x, *y)), Paso::Tecla(k) => anf.enviar_tecla(k.clone()), Paso::Tamano(a, b) => anf.enviar_tamano(*a, *b), }; r.map_err(|e| format!("el huésped no recibe: {e}"))?; proximo += 1; } match anf.atender_hasta(Duration::from_millis(50), |p| { llimphi_reproductor::medir_texto(&mut ts, &p.cadena, &p.estilo, p.ancho_max) }) { Ok(Some(escena)) => { grab.cuadro(ms, &escena).map_err(|e| e.to_string())?; } // Nadie compuso en 50 ms: no pasó nada y no es un error. Es el caso // normal de un escritorio quieto, y es justo el que hace que esto // pese poco. Ok(None) => {} Err(e) => { motivo = "se cortó el caño"; eprintln!("llimphi-scene: {e}"); break; } } if let Some(m) = vida::como_murio(&mut hijo) { motivo = "el huésped se fue"; eprintln!("llimphi-scene: {}", m.frase()); break; } } let _ = anf.despedirse(); let _ = hijo.kill(); let _ = hijo.wait(); let (cuadros, repetidos, bytes, dur) = ( grab.cuadros(), grab.repetidos(), grab.bytes(), grab.ultimo_t_ms(), ); grab.cerrar().map_err(|e| e.to_string())?; println!( "{}: {cuadros} cuadros, {repetidos} repetidos que no se escribieron, \ {bytes} bytes de escena en {dur} ms ({motivo})", salida.display() ); Ok(()) } // ───────────────────────────────────────────────────────────────────────────── // info // ───────────────────────────────────────────────────────────────────────────── fn informar(registro: &Path) -> Result<(), String> { let f = std::fs::File::open(registro).map_err(|e| format!("{}: {e}", registro.display()))?; let (cab, mut lec) = Lector::abrir(std::io::BufReader::new(f)).map_err(|e| format!("{}: {e}", registro.display()))?; let r = resumir(&mut lec).map_err(|e| e.to_string())?; println!("app {}", cab.app); println!("tema {}", cab.tema); println!("tablado {}x{}", cab.ancho, cab.alto); println!("cuadros {}", r.cuadros); println!("duración {:.1} s", r.dur_ms as f64 / 1000.0); println!( "escena {} bytes ({} por cuadro; menor {}, mayor {})", r.bytes, r.por_cuadro(), r.menor, r.mayor ); // El careo que pidió la pregunta. Un screencast 1080p razonable anda entre // 125 y 600 KB/s; el número de acá es el que decide, y sale del archivo. println!( "ritmo {} B/s ({:.1} KB/s)", r.por_segundo(), r.por_segundo() as f64 / 1024.0 ); Ok(()) } // ───────────────────────────────────────────────────────────────────────────── // render / film // ───────────────────────────────────────────────────────────────────────────── /// El nombre de los PNG. `%06d` es lo que después lee ffmpeg, así que el /// formato de acá y el patrón de allá son **el mismo dato** y no se pueden /// separar sin que la película salga vacía y ffmpeg no diga por qué. fn nombre_cuadro(dir: &Path, i: usize) -> PathBuf { dir.join(format!("cuadro_{i:06}.png")) } fn revelar(registro: &Path, dir: &Path, fps: u32, size: Option<&str>) -> Result { let f = std::fs::File::open(registro).map_err(|e| format!("{}: {e}", registro.display()))?; let (cab, mut lec) = Lector::abrir(std::io::BufReader::new(f)).map_err(|e| format!("{}: {e}", registro.display()))?; let cuadros = cargar(&mut lec).map_err(|e| e.to_string())?; if cuadros.is_empty() { return Err("el registro no tiene un solo cuadro".into()); } let (w, h) = match size { Some(s) => medida(s)?, None => (cab.ancho, cab.alto), }; let fps = fps.max(1); let dur_ms = cuadros.last().map(|(t, _)| *t).unwrap_or(0); let total = ((dur_ms as u64 * fps as u64) / 1000) as usize + 1; std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut camara = Camara::nueva(w, h)?; let mut ts = Typesetter::new(); for i in 0..total { let t = (i as u64 * 1000 / fps as u64) as u32; // Antes del primer cuadro no hay nada que sostener: se pinta el primero, // que es lo único honesto que se puede hacer con el tiempo muerto del // arranque. let idx = cuadro_en(&cuadros, t).unwrap_or(0); camara.pintar(&cuadros[idx].1, &mut ts)?; camara.escribir(&nombre_cuadro(dir, i))?; } Ok(total) } fn filmar( registro: &Path, salida: &Path, fps: u32, size: Option<&str>, codec: &str, crf: u8, keep: Option, ) -> Result<(), String> { let temporal = keep.is_none(); let dir = keep.unwrap_or_else(|| { std::env::temp_dir().join(format!("llimphi-scene-{}", std::process::id())) }); let n = revelar(registro, &dir, fps, size)?; let patron = dir.join("cuadro_%06d.png"); let r = match codec { // El de la portada: un mp4 que abre cualquier navegador sin preguntar. "h264" => foreign_av::encode_frames_h264(&patron, fps, crf, None, salida), // El de la casa: AV1, el que reproduce `media-source-av1`. "av1" => foreign_av::encode_frames(&patron, fps, crf, None, salida), otro => return Err(format!("no conozco el codec `{otro}` (h264 | av1)")), }; if temporal { let _ = std::fs::remove_dir_all(&dir); } r.map_err(|e| format!("ffmpeg: {e:?}"))?; println!("{}: {n} cuadros a {fps} fps", salida.display()); Ok(()) } /// La cámara headless: una textura, el reproductor y la bajada a PNG. /// /// Es la misma forma que la `Camara` de `ende-render` —y no se reusa aquélla /// porque es de ende y sabe de sellos, aspectos y películas—, pero el trípode /// es literalmente el mismo: `Hal` sin ventana + `Renderer` de `llimphi-raster`. struct Camara { hal: Hal, renderer: Renderer, target: wgpu::Texture, vista: wgpu::TextureView, w: u32, h: u32, } impl Camara { fn nueva(w: u32, h: u32) -> Result { let hal = pollster::block_on(Hal::new_headless(false)) .map_err(|e| format!("no hay GPU headless: {e:?}"))?; let renderer = Renderer::new(&hal).map_err(|e| format!("rasterizador: {e:?}"))?; let target = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-scene-cuadro"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let vista = target.create_view(&wgpu::TextureViewDescriptor::default()); Ok(Self { hal, renderer, target, vista, w, h, }) } fn pintar(&mut self, wire: &EscenaWire, ts: &mut Typesetter) -> Result<(), String> { let mut escena = vello::Scene::new(); let st = llimphi_reproductor::reproducir(wire, &mut escena, ts); // Un cuadro al que le faltan blobs se pinta igual, con agujeros. Se avisa // y no se aborta: enterarse al final de que la película salió sin las // imágenes es peor que una línea por cuadro. if st.imagenes_sin_blob > 0 { eprintln!( "llimphi-scene: {} imágenes sin blob en este cuadro", st.imagenes_sin_blob ); } self.renderer .render_to_view( &self.hal, &escena, &self.vista, self.w, self.h, peniko::Color::BLACK, ) .map_err(|e| format!("render: {e:?}")) } /// Baja la textura a RAM y la escribe como PNG. El `padding` de filas es de /// wgpu, no del PNG: `COPY_BYTES_PER_ROW_ALIGNMENT` obliga a alinear cada /// fila y hay que sacarlo antes de escribir, o la imagen sale inclinada. fn escribir(&self, salida: &Path) -> Result<(), String> { let sin_pad = (self.w * 4) as usize; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; let con_pad = sin_pad.div_ceil(align) * align; let buf = self.hal.device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-scene-readback"), size: (con_pad * self.h as usize) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let mut enc = self .hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: &self.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(con_pad as u32), rows_per_image: Some(self.h), }, }, wgpu::Extent3d { width: self.w, height: self.h, depth_or_array_layers: 1, }, ); self.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 _ = self.hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv() .map_err(|e| e.to_string())? .map_err(|e| format!("{e:?}"))?; let datos = slice.get_mapped_range(); let mut pixeles = Vec::with_capacity((self.w * self.h * 4) as usize); for fila in 0..self.h as usize { let s = fila * con_pad; pixeles.extend_from_slice(&datos[s..s + sin_pad]); } drop(datos); buf.unmap(); let f = std::fs::File::create(salida).map_err(|e| format!("{}: {e}", salida.display()))?; let mut enc = png::Encoder::new(std::io::BufWriter::new(f), self.w, self.h); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight); enc.write_header() .map_err(|e| e.to_string())? .write_image_data(&pixeles) .map_err(|e| e.to_string()) } }