//! Mirar por la cámara. //! //! El servidor ya tiene cargado el proyector multimodal, así que el mismo //! modelo que conversa puede describir una imagen. La herramienta hace tres //! cosas: capturar un fotograma, preguntarle al modelo por él y devolver la //! respuesta como texto. //! //! Que la pregunta viaje hasta la cámara importa: «¿de qué color es mi //! camiseta?» y «¿cuánta gente hay?» necesitan la misma imagen pero //! descripciones muy distintas, y pedir una descripción genérica para luego //! interrogarla pierde justo el detalle que se buscaba. //! //! La captura no se guarda en disco salvo que se pida expresamente: un //! asistente que deja fotogramas por ahí es un problema de privacidad, no una //! comodidad de depuración. use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::Arc; use std::time::{Duration, Instant}; use asist_core::error::{Error, Result}; use asist_core::http::Cancel; use asist_core::tools::Tool; use asist_llm::LlmClient; use serde_json::{json, Value}; /// Cómo se captura el fotograma. #[derive(Debug, Clone)] pub struct CaptureConfig { /// Dispositivo V4L2. pub device: PathBuf, pub width: u32, pub height: u32, /// Fotogramas que se descartan antes de quedarse con uno. /// /// La cámara arranca con la exposición automática sin asentar y el primer /// fotograma suele salir quemado. Descartar unos pocos es prácticamente /// gratis —medido, 0,45 s frente a 0,53 s— y en penumbra se nota. pub warmup_frames: u32, /// Plazo máximo de la captura. pub timeout: Duration, /// Carpeta donde dejar los fotogramas. Vacío = no se guarda ninguno. pub save_dir: Option, } impl Default for CaptureConfig { fn default() -> Self { Self { device: PathBuf::from("/dev/video0"), // 640x480 es el punto de equilibrio medido: el modelo tarda 2,9 s // y sigue describiendo bien. A 1280x720 tarda 7,8 s, y a 320x240 // baja a 1,3 s pero deja de distinguir detalles. width: 640, height: 480, warmup_frames: 5, timeout: Duration::from_secs(15), save_dir: None, } } } /// Captura un fotograma en JPEG. /// /// Se apoya en ffmpeg en vez de hablar con V4L2 directamente: una cámara USB /// entrega MJPEG, YUYV o lo que le parezca, y reimplementar esa negociación /// para ahorrarse un proceso no sale a cuenta. pub fn capture(config: &CaptureConfig) -> Result> { let fail = |message: String| Error::Tool { tool: "mirar_por_la_camara".into(), message, }; if !config.device.exists() { return Err(fail(format!( "no existe el dispositivo {}. Comprueba con «v4l2-ctl --list-devices»", config.device.display() ))); } let started = Instant::now(); let mut command = Command::new("ffmpeg"); command .args(["-hide_banner", "-loglevel", "error", "-nostdin"]) .args(["-f", "v4l2"]) .args([ "-video_size", &format!("{}x{}", config.width, config.height), ]) .arg("-i") .arg(&config.device); if config.warmup_frames > 0 { // Se leen N fotogramas y se conserva el último: es la forma de dejar // que la exposición se asiente sin abrir el dispositivo dos veces. command.args(["-vf", &format!("select=eq(n\\,{})", config.warmup_frames)]); } command .args(["-frames:v", "1"]) .args(["-f", "image2", "-c:v", "mjpeg"]) .arg("-") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); let mut child = command.spawn().map_err(|e| { fail(format!( "no se pudo ejecutar ffmpeg ({e}); hace falta para leer la cámara" )) })?; let deadline = Instant::now() + config.timeout; loop { match child.try_wait().map_err(|e| fail(e.to_string()))? { Some(_) => break, None if Instant::now() >= deadline => { let _ = child.kill(); let _ = child.wait(); return Err(fail(format!( "la cámara no respondió en {} s", config.timeout.as_secs() ))); } None => std::thread::sleep(Duration::from_millis(20)), } } let output = child.wait_with_output().map_err(|e| fail(e.to_string()))?; if !output.status.success() || output.stdout.is_empty() { let stderr = String::from_utf8_lossy(&output.stderr); let hint = if stderr.contains("Permission denied") { ". Tu usuario necesita estar en el grupo «video»" } else if stderr.contains("Device or resource busy") { ". Otra aplicación está usando la cámara" } else { "" }; return Err(fail(format!( "ffmpeg no capturó nada{hint}: {}", stderr.trim().lines().next_back().unwrap_or("sin detalles") ))); } if let Some(dir) = &config.save_dir { // Sólo si se ha pedido: por defecto la imagen no toca el disco. if let Err(err) = std::fs::create_dir_all(dir).and_then(|()| { let name = format!( "frame-{}.jpg", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) ); std::fs::write(dir.join(name), &output.stdout) }) { tracing::warn!(target: "camara", %err, "no se pudo guardar el fotograma"); } } tracing::info!( target: "camara", dispositivo = %config.device.display(), resolucion = format!("{}x{}", config.width, config.height), kb = output.stdout.len() / 1024, ms = started.elapsed().as_millis(), "fotograma capturado" ); Ok(output.stdout) } pub struct Camera { config: CaptureConfig, llm: Arc, } impl Camera { pub fn new(config: CaptureConfig, llm: Arc) -> Self { Self { config, llm } } /// `true` si el dispositivo está presente. Sin esto no tiene sentido /// declarar la herramienta: el modelo la llamaría y fallaría siempre. pub fn available(config: &CaptureConfig) -> bool { config.device.exists() } } impl Tool for Camera { fn name(&self) -> &str { "mirar_por_la_camara" } fn description(&self) -> &str { "Toma una foto con la cámara del equipo y responde a una pregunta sobre \ lo que se ve. Úsala cuando te pregunten qué ves, qué hay delante, de qué \ color es algo o cuántas cosas hay." } fn parameters(&self) -> Value { json!({ "type": "object", "properties": { "pregunta": { "type": "string", "description": "La pregunta del usuario tal cual, sin concretarla más de lo que él dijo. Si sólo quiere saber qué hay delante, pon: ¿Qué se ve?" } }, "required": ["pregunta"] }) } /// Enciende la cámara, así que se anuncia como tal. fn is_side_effecting(&self) -> bool { true } fn acknowledgement(&self) -> Option<&str> { Some("Voy a mirar.") } fn call(&self, args: &Value) -> Result { let question = args .get("pregunta") .and_then(Value::as_str) .map(str::trim) .filter(|q| !q.is_empty()) .unwrap_or("¿Qué se ve en esta imagen?"); let frame = capture(&self.config)?; // Se le pide al modelo el estilo hablado aquí y no en la conversación: // esta petición va fuera del historial, así que la instrucción de voz // del asistente no le llega. let prompt = format!( "{question}\n\nResponde en una o dos frases cortas en español, en texto \ plano, describiendo sólo lo que se ve de verdad en la imagen. Si no se \ distingue, dilo." ); let started = Instant::now(); let answer = self.llm.look(&frame, &prompt, &Cancel::new())?; tracing::info!( target: "camara", ms = started.elapsed().as_millis(), "el modelo describió el fotograma" ); Ok(answer) } } #[cfg(test)] mod tests { use super::*; #[test] fn un_dispositivo_inexistente_se_detecta_antes_de_registrar_la_herramienta() { let config = CaptureConfig { device: PathBuf::from("/dev/video-que-no-existe"), ..Default::default() }; assert!(!Camera::available(&config)); } #[test] fn capturar_de_un_dispositivo_inexistente_da_un_error_util() { let config = CaptureConfig { device: PathBuf::from("/dev/video-que-no-existe"), ..Default::default() }; let err = capture(&config).unwrap_err().to_string(); assert!(err.contains("no existe el dispositivo"), "{err}"); assert!( err.contains("v4l2-ctl"), "el error debe decir cómo comprobarlo: {err}" ); } #[test] fn por_defecto_no_se_guarda_ningun_fotograma() { assert!( CaptureConfig::default().save_dir.is_none(), "guardar imágenes por defecto sería una fuga de privacidad" ); } #[test] fn la_resolucion_por_defecto_es_la_medida_como_equilibrada() { let config = CaptureConfig::default(); assert_eq!((config.width, config.height), (640, 480)); } }