aboutsummaryrefslogtreecommitdiffstats
path: root/crates/asist-tools/src/screen.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/asist-tools/src/screen.rs')
-rw-r--r--crates/asist-tools/src/screen.rs189
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}");
+ }
+}