diff options
Diffstat (limited to 'crates/asist-tools/src/screen.rs')
| -rw-r--r-- | crates/asist-tools/src/screen.rs | 189 |
1 files changed, 189 insertions, 0 deletions
diff --git a/crates/asist-tools/src/screen.rs b/crates/asist-tools/src/screen.rs new file mode 100644 index 0000000..dfed923 --- /dev/null +++ b/crates/asist-tools/src/screen.rs @@ -0,0 +1,189 @@ +//! La pantalla como fuente de imágenes. +//! +//! Se diferencia de la cámara en lo único que de verdad importa aquí: **una +//! pantalla es texto**. Y con texto este modelo tiene un modo de fallo feo. +//! Medido sobre una captura con tipografía de interfaz de 13 px, preguntando +//! por datos concretos: +//! +//! | Ancho | Tiempo | Aciertos | Qué pasa cuando falla | +//! |------|--------|----------|-----------------------| +//! | 640 | 2,4 s | 1 de 3 | se inventa el contenido | +//! | 960 | 4,5 s | 2 de 3 | mezcla lo leído con lo supuesto | +//! | 1280 | 7,6 s | 3 de 3 | — | +//! +//! A 640 px no dijo «no lo leo»: dijo que el error era «no se pudo abrir el +//! archivo involution» y que la reunión era «a las 10:00». Ninguna de las dos +//! cosas estaba en la imagen. De ahí que el valor por defecto sean 1280 px +//! aunque cueste el triple que la cámara: para un asistente de voz, decir una +//! hora equivocada con aplomo es peor que tardar cuatro segundos más. + +use std::path::PathBuf; +use std::time::Duration; + +use asist_core::error::{Error, Result}; + +use crate::vision::{capture_jpeg, FrameSource}; + +const TOOL: &str = "mirar_la_pantalla"; + +#[derive(Debug, Clone)] +pub struct ScreenConfig { + /// Programa de captura y sus argumentos. `{ancho}` y `{salida}` se + /// sustituyen antes de ejecutar. Tiene que escribir un JPEG por la salida + /// estándar. + pub command: Vec<String>, + /// Ancho al que se reduce la captura antes de mandarla al modelo. + pub width: u32, + /// Monitor concreto; vacío = todo lo que haya. + pub output: String, + pub timeout: Duration, + /// Carpeta donde dejar las capturas. `None` = no se guarda ninguna. + /// + /// Aquí pesa más que en la cámara: en una captura de pantalla caben + /// contraseñas, mensajes privados y correo abierto. + pub save_dir: Option<PathBuf>, +} + +impl Default for ScreenConfig { + fn default() -> Self { + Self { + command: vec![ + "scripts/capturar-pantalla.sh".into(), + "{ancho}".into(), + "{salida}".into(), + ], + width: 1280, + output: String::new(), + timeout: Duration::from_secs(20), + save_dir: None, + } + } +} + +pub struct Screen { + config: ScreenConfig, +} + +impl Screen { + pub fn new(config: ScreenConfig) -> Self { + Self { config } + } + + fn program(&self) -> Result<&String> { + self.config.command.first().ok_or_else(|| Error::Tool { + tool: TOOL.into(), + message: "screen.command está vacío".into(), + }) + } +} + +impl FrameSource for Screen { + fn label(&self) -> &str { + "pantalla" + } + + fn available(&self) -> Result<()> { + let program = self.program()?; + // Un nombre suelto se resuelve por el PATH; una ruta tiene que existir. + if program.contains('/') && !std::path::Path::new(program).exists() { + return Err(Error::Tool { + tool: TOOL.into(), + message: format!("no existe {program}"), + }); + } + // Sin entorno gráfico no hay nada que capturar, y más vale decirlo al + // arrancar que a mitad de una pregunta. + if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { + return Err(Error::Tool { + tool: TOOL.into(), + message: "no hay sesión gráfica (ni WAYLAND_DISPLAY ni DISPLAY)".into(), + }); + } + Ok(()) + } + + fn capture(&self) -> Result<Vec<u8>> { + self.available()?; + let program = self.program()?.clone(); + let args: Vec<String> = self.config.command[1..] + .iter() + .map(|arg| { + arg.replace("{ancho}", &self.config.width.to_string()) + .replace("{salida}", &self.config.output) + }) + .collect(); + + let frame = capture_jpeg(TOOL, program.as_ref(), &args, self.config.timeout)?; + if let Some(dir) = &self.config.save_dir { + crate::camera::save(dir, &frame, "pantalla"); + } + Ok(frame) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> ScreenConfig { + ScreenConfig { + command: vec!["/bin/echo".into(), "{ancho}".into(), "{salida}".into()], + ..Default::default() + } + } + + #[test] + fn el_ancho_por_defecto_es_el_minimo_medido_para_leer_texto() { + assert_eq!( + ScreenConfig::default().width, + 1280, + "por debajo de 1280 el modelo se inventa lo que pone en pantalla" + ); + } + + #[test] + fn por_defecto_no_se_guarda_ninguna_captura() { + assert!( + ScreenConfig::default().save_dir.is_none(), + "en una captura de pantalla caben contraseñas y mensajes privados" + ); + } + + #[test] + fn un_guion_inexistente_se_detecta_antes_de_registrar_la_herramienta() { + let screen = Screen::new(ScreenConfig { + command: vec!["/no/existe/captura.sh".into()], + ..Default::default() + }); + assert!(screen + .available() + .unwrap_err() + .to_string() + .contains("no existe")); + } + + #[test] + fn una_orden_vacia_se_rechaza() { + let screen = Screen::new(ScreenConfig { + command: vec![], + ..Default::default() + }); + assert!(screen.available().is_err()); + } + + #[test] + fn los_marcadores_se_sustituyen_antes_de_ejecutar() { + // /bin/echo devuelve los argumentos, así que la captura falla por no + // ser un JPEG; lo que se comprueba es que el mensaje trae el ancho ya + // sustituido, no el marcador. + let mut config = config(); + config.width = 1280; + config.output = "eDP-1".into(); + let screen = Screen::new(config); + if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { + return; // sin sesión gráfica no hay nada que probar aquí + } + let err = screen.capture().unwrap_err().to_string(); + assert!(err.contains("no devolvió un JPEG"), "{err}"); + } +} |