diff --git a/README.md b/README.md index 09a5d0e..9faf581 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,17 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac: edición de un set la pinta del gris del editor: activa se funde con su barra de arriba y detrás se reconoce entre las demás. Esa página no cambia el tema de la barra (su fondo oscuro no es el de la app). +- **La IA del equipo** (v1.22.0, Windows con tarjeta NVIDIA): la máscara de «Sujeto» y + «Fondo» del editor de revelado la busca la app, no el navegador — ONNX Runtime con + DirectML sobre la tarjeta y BiRefNet en media precisión (`src-tauri/src/ia/`): 0,3 s + por foto en vez de 2,5–3 s. La primera vez baja ≈700 MB a los datos de la app + (`%LOCALAPPDATA%\\ia`): ONNX Runtime 1.24.4 DirectML y DirectML 1.15.4 + de NuGet y el modelo del servidor de la app (`/api/modelos/sujeto-equipo`, con la + sesión de la pestaña), todo fijado por huella SHA-256. La página llama a + `ia_estado` / `ia_preparar` / `ia_sujeto`, permitidos solo para el origen de la app + (`build.rs` + `capabilities/default.json`). El modelo se suelta de la tarjeta a los + 2 min sin uso. Prueba de punta a punta en la tarjeta del equipo: + `LS_IA_DIR= cargo test --lib de_punta_a_punta -- --ignored --nocapture`. - **Pestañas con nombre** (v1.21.0): `window.open(url, "ls-…")` o un enlace con `target="ls-…"` abre la pestaña la primera vez y las siguientes **vuelven a ella** en vez de abrir otra. La página recibe la URL nueva por diff --git a/package.json b/package.json index dddae6e..f2a4a35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "labstream-desktop", - "version": "1.21.0", + "version": "1.22.0", "private": true, "description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2790d40..ffb2e24 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1319,6 +1319,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2366,15 +2367,17 @@ dependencies = [ [[package]] name = "labstream-desktop" -version = "1.21.0" +version = "1.22.0" dependencies = [ "active-win-pos-rs", "device_query", "gethostname 0.5.0", + "ort", "percent-encoding", "rfd", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -2386,6 +2389,7 @@ dependencies = [ "ureq", "webview2-com", "windows 0.61.3", + "zip", ] [[package]] @@ -2459,6 +2463,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libredox" version = "0.1.17" @@ -2545,6 +2559,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.2" @@ -2630,6 +2654,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2693,12 +2732,30 @@ dependencies = [ "zbus", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.5" @@ -3003,6 +3060,25 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ca9287c05aace7e95250104a00d54b7da0fc9ac5f25efa424b80827d2e25eb" +dependencies = [ + "libloading 0.9.0", + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4af4763e553f916650b0bfe4b8d217d61c28607f6117e48b8ab5584d1138f22f" + [[package]] name = "os_pipe" version = "1.2.3" @@ -3287,6 +3363,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3475,6 +3566,12 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "readkey" version = "0.2.2" @@ -6484,16 +6581,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", + "flate2", "indexmap 2.14.0", "memchr", + "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 94bc25e..644093a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "labstream-desktop" -version = "1.21.0" +version = "1.22.0" description = "Cliente de escritorio de Labstream OS" authors = ["Labstream SAS"] edition = "2021" @@ -49,6 +49,10 @@ percent-encoding = "2" active-win-pos-rs = "0.11" ureq = { version = "2", features = ["json"] } gethostname = "0.5" +# ── La IA del equipo (src/ia, 1.22) ── +# sha2: la huella de lo que se baja (modelo y motor); zip: sacar las DLL del paquete del motor. +sha2 = "0.10" +zip = { version = "4", default-features = false, features = ["deflate"] } # device_query solo en Windows/Linux: la huella de entrada (posición del mouse + cuántas teclas # hay presionadas). En macOS NO se usa —leer el teclado global dispara el permiso de @@ -64,6 +68,10 @@ device_query = "4" # Solo Windows: en macOS no hay API pública sencilla de nivel de audio por app (el equipo de # edición es Windows). El `windows` crate ya está en la caché por Tauri. [target.'cfg(windows)'.dependencies] +# IA del equipo (1.22, src/ia/motor.rs): ONNX Runtime con DirectML sobre la tarjeta. `load-dynamic`: +# nada se enlaza al compilar; la DLL (la de DirectML 1.24, de NuGet) se baja la primera vez y se +# carga desde la carpeta de la IA. `api-24`: la versión de la API que trae esa DLL. +ort = { version = "=2.0.0-rc.14", default-features = false, features = ["std", "load-dynamic", "directml", "api-24"] } # Pestañas en reposo (1.19): ICoreWebView2_3::TrySuspend. Es la misma versión que ya trae Tauri. webview2-com = "0.38" windows = { version = "0.61", features = [ @@ -83,4 +91,8 @@ windows = { version = "0.61", features = [ # Memoria (1.20): ¿cuánto lleva el equipo sin tocar? (para reciclar el proceso gráfico sin que se note) "Win32_UI_Input_KeyboardAndMouse", "Win32_System_SystemInformation", + # IA del equipo (1.22): ¿hay tarjeta NVIDIA? (los adaptadores de DXGI) + "Win32_Graphics_Dxgi", + # y cargar DirectML.dll por su ruta antes que ONNX Runtime (src/ia/motor.rs) + "Win32_System_LibraryLoader", ] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..07213e6 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,9 @@ fn main() { - tauri_build::build() + // Los comandos de la app (la IA del equipo, src/ia): con su manifiesto, cada uno necesita su + // permiso en capabilities/default.json, y es lo que deja que la página remota los llame. + tauri_build::try_build( + tauri_build::Attributes::new() + .app_manifest(tauri_build::AppManifest::new().commands(&["ia_estado", "ia_preparar", "ia_sujeto"])), + ) + .expect("tauri-build"); } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index ac959e4..c9532a3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -18,6 +18,9 @@ "core:default", "opener:default", "notification:default", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "allow-ia-estado", + "allow-ia-preparar", + "allow-ia-sujeto" ] } diff --git a/src-tauri/permissions/autogenerated/ia_estado.toml b/src-tauri/permissions/autogenerated/ia_estado.toml new file mode 100644 index 0000000..7bcca34 --- /dev/null +++ b/src-tauri/permissions/autogenerated/ia_estado.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-ia-estado" +description = "Enables the ia_estado command without any pre-configured scope." +commands.allow = ["ia_estado"] + +[[permission]] +identifier = "deny-ia-estado" +description = "Denies the ia_estado command without any pre-configured scope." +commands.deny = ["ia_estado"] diff --git a/src-tauri/permissions/autogenerated/ia_preparar.toml b/src-tauri/permissions/autogenerated/ia_preparar.toml new file mode 100644 index 0000000..66b4a8f --- /dev/null +++ b/src-tauri/permissions/autogenerated/ia_preparar.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-ia-preparar" +description = "Enables the ia_preparar command without any pre-configured scope." +commands.allow = ["ia_preparar"] + +[[permission]] +identifier = "deny-ia-preparar" +description = "Denies the ia_preparar command without any pre-configured scope." +commands.deny = ["ia_preparar"] diff --git a/src-tauri/permissions/autogenerated/ia_sujeto.toml b/src-tauri/permissions/autogenerated/ia_sujeto.toml new file mode 100644 index 0000000..2cb2c7b --- /dev/null +++ b/src-tauri/permissions/autogenerated/ia_sujeto.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-ia-sujeto" +description = "Enables the ia_sujeto command without any pre-configured scope." +commands.allow = ["ia_sujeto"] + +[[permission]] +identifier = "deny-ia-sujeto" +description = "Denies the ia_sujeto command without any pre-configured scope." +commands.deny = ["ia_sujeto"] diff --git a/src-tauri/src/ia/bajar.rs b/src-tauri/src/ia/bajar.rs new file mode 100644 index 0000000..d44f8fe --- /dev/null +++ b/src-tauri/src/ia/bajar.rs @@ -0,0 +1,170 @@ +// ── Bajar las piezas de la IA: comprobadas con su huella y, si vienen en un zip, sacadas de él ── +// +// Todo va a la carpeta de la IA en los datos de la app del equipo (`ia::carpeta`). Cada pieza se +// baja a un `.parte`, se le saca la huella SHA-256 MIENTRAS llega y solo si cuadra con la anotada +// en el código pasa a su sitio; al acabar se deja una marca `.ok` con la huella, que es lo +// que se mira después para saber si está (un archivo a medias nunca tiene marca). + +use sha2::{Digest, Sha256}; +use std::fs::{self, File}; +use std::io::{self, BufReader, BufWriter, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// Una pieza que se baja: un archivo tal cual o un zip del que se sacan unos archivos. +pub struct Pieza { + /// Nombre corto (la marca `.ok` y los avisos). + pub nombre: &'static str, + pub url: &'static str, + /// Huella SHA-256 del archivo bajado, en hexadecimal. + pub sha256: &'static str, + pub bytes: u64, + pub forma: Forma, + /// Se pide con la sesión de la pestaña (lo sirve el servidor de la app solo al equipo). + pub con_sesion: bool, +} + +pub enum Forma { + /// El archivo, con este nombre, en la carpeta de la IA. + Archivo(&'static str), + /// Un zip (un .nupkg lo es): de él, estos archivos —por su ruta EXACTA dentro: un paquete trae + /// varias DLL con el mismo nombre (x86, arm64, Xbox…)— a la carpeta de la IA, con su nombre. + Zip(&'static [&'static str]), +} + +fn marca(dir: &Path, p: &Pieza) -> PathBuf { + dir.join(format!("{}.ok", p.nombre)) +} + +/// ¿Está ya en el equipo (con su marca y la huella de ahora)? +pub fn esta(dir: &Path, p: &Pieza) -> bool { + fs::read_to_string(marca(dir, p)).map(|s| s.trim() == p.sha256).unwrap_or(false) +} + +/// Baja la pieza (si no está) avisando de lo que lleva con `avance(bytes_nuevos)`. `sesion`: las +/// cookies de la pestaña para el servidor de la app (solo viajan a él: `con_sesion` y su origen). +pub fn bajar(dir: &Path, p: &Pieza, sesion: Option<&str>, avance: &mut dyn FnMut(u64)) -> Result<(), String> { + if esta(dir, p) { + return Ok(()); + } + fs::create_dir_all(dir).map_err(|e| format!("No se pudo crear la carpeta de la IA: {e}"))?; + let parte = dir.join(format!("{}.parte", p.nombre)); + let mut pedido = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(20)) + .timeout_read(Duration::from_secs(60)) + .build() + .get(p.url); + if p.con_sesion && p.url.starts_with(&format!("{}/", crate::SERVER_URL)) { + if let Some(c) = sesion.filter(|c| !c.is_empty()) { + pedido = pedido.set("Cookie", c); + } + } + let resp = pedido.call().map_err(|e| match e { + ureq::Error::Status(401, _) => format!("No se pudo bajar {}: la sesión de la app caducó (vuelve a entrar).", p.nombre), + e => format!("No se pudo bajar {}: {e}", p.nombre), + })?; + let mut leer = resp.into_reader(); + let mut escribir = BufWriter::new(File::create(&parte).map_err(|e| format!("No se pudo escribir {}: {e}", p.nombre))?); + let mut huella = Sha256::new(); + let mut buf = vec![0u8; 1 << 20]; + let mut total: u64 = 0; + loop { + let n = leer.read(&mut buf).map_err(|e| format!("La descarga de {} se cortó: {e}", p.nombre))?; + if n == 0 { + break; + } + huella.update(&buf[..n]); + escribir.write_all(&buf[..n]).map_err(|e| format!("No se pudo escribir {}: {e}", p.nombre))?; + total += n as u64; + avance(n as u64); + } + escribir.flush().map_err(|e| format!("No se pudo escribir {}: {e}", p.nombre))?; + drop(escribir); + let hex = huella.finalize().iter().map(|b| format!("{b:02x}")).collect::(); + if hex != p.sha256 { + let _ = fs::remove_file(&parte); + return Err(format!("{} llegó distinto de lo esperado ({total} bytes). Vuelve a intentarlo.", p.nombre)); + } + match p.forma { + Forma::Archivo(destino) => { + let d = dir.join(destino); + let _ = fs::remove_file(&d); + fs::rename(&parte, &d).map_err(|e| format!("No se pudo guardar {}: {e}", p.nombre))?; + } + Forma::Zip(quiero) => { + let r = extraer(&parte, dir, quiero); + let _ = fs::remove_file(&parte); + r?; + } + } + fs::write(marca(dir, p), p.sha256).map_err(|e| format!("No se pudo guardar {}: {e}", p.nombre))?; + Ok(()) +} + +/// Saca del zip los archivos de `quiero` (rutas exactas dentro del zip) a `dir`, con su nombre. +fn extraer(zip: &Path, dir: &Path, quiero: &[&str]) -> Result<(), String> { + let f = File::open(zip).map_err(|e| format!("No se pudo abrir lo bajado: {e}"))?; + let mut z = zip::ZipArchive::new(BufReader::new(f)).map_err(|e| format!("Lo bajado no es un zip válido: {e}"))?; + let mut faltan: Vec<&str> = quiero.to_vec(); + for i in 0..z.len() { + let mut e = z.by_index(i).map_err(|e| format!("El zip está dañado: {e}"))?; + if e.is_dir() { + continue; + } + let ruta = e.name().replace('\\', "/"); + let nombre = ruta.rsplit('/').next().unwrap_or("").to_string(); + if nombre.is_empty() || nombre.contains("..") { + continue; + } + if let Some(k) = faltan.iter().position(|q| q.eq_ignore_ascii_case(&ruta)) { + faltan.remove(k); + let destino = dir.join(&nombre); + let tmp = dir.join(format!("{nombre}.parte")); + let mut out = BufWriter::new(File::create(&tmp).map_err(|e| format!("No se pudo escribir {nombre}: {e}"))?); + io::copy(&mut e, &mut out).map_err(|e| format!("No se pudo sacar {nombre}: {e}"))?; + out.flush().map_err(|e| format!("No se pudo escribir {nombre}: {e}"))?; + drop(out); + let _ = fs::remove_file(&destino); + fs::rename(&tmp, &destino).map_err(|e| format!("No se pudo guardar {nombre}: {e}"))?; + } + } + if faltan.is_empty() { + Ok(()) + } else { + Err(format!("En lo bajado no vinieron: {}", faltan.join(", "))) + } +} + +#[cfg(test)] +mod pruebas { + use super::*; + + #[test] + fn saca_del_zip_solo_lo_pedido_y_dice_lo_que_falta() { + let dir = std::env::temp_dir().join(format!("ls-ia-prueba-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let zp = dir.join("x.zip"); + { + let mut w = zip::ZipWriter::new(File::create(&zp).unwrap()); + let o: zip::write::SimpleFileOptions = Default::default(); + w.start_file("bin/x64-xbox/a.dll", o).unwrap(); + w.write_all(b"XBOX").unwrap(); + w.start_file("bin/x64-win/a.dll", o).unwrap(); + w.write_all(b"AAA").unwrap(); + w.start_file("bin/x64-win/b.dll", o).unwrap(); + w.write_all(b"BBB").unwrap(); + w.finish().unwrap(); + } + // la de la ruta pedida, no la primera con ese nombre + extraer(&zp, &dir, &["bin/x64-win/a.dll"]).unwrap(); + assert_eq!(fs::read(dir.join("a.dll")).unwrap(), b"AAA"); + assert!(!dir.join("b.dll").exists()); + assert!(extraer(&zp, &dir, &["a.dll"]).unwrap_err().contains("a.dll")); + let p = Pieza { nombre: "x", url: "", sha256: "abc", bytes: 1, forma: Forma::Archivo("x"), con_sesion: false }; + assert!(!esta(&dir, &p)); + fs::write(marca(&dir, &p), "abc").unwrap(); + assert!(esta(&dir, &p)); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/ia/imagen.rs b/src-tauri/src/ia/imagen.rs new file mode 100644 index 0000000..cc7dd53 --- /dev/null +++ b/src-tauri/src/ia/imagen.rs @@ -0,0 +1,88 @@ +// ── La foto como la quiere el modelo, y su respuesta como la quiere la página ── +// Lo mismo que hace el navegador (lib/revelado/modelo-sujeto: entradaDelModelo), aquí en Rust: +// la foto RGBA (como se ve en el editor) a `lado` × `lado` entre sus cuatro píxeles más cercanos, +// en tres planos RGB normalizados como ImageNet. Y la salida, a un byte por punto. + +const MEDIA: [f32; 3] = [0.485, 0.456, 0.406]; +const DESVIACION: [f32; 3] = [0.229, 0.224, 0.225]; + +/// RGBA (`ancho` × `alto`) → [1, 3, lado, lado] en un vector plano. +pub fn entrada(rgba: &[u8], ancho: usize, alto: usize, lado: usize) -> Vec { + let plano = lado * lado; + let mut out = vec![0f32; 3 * plano]; + for y in 0..lado { + let fy = (((y as f32 + 0.5) / lado as f32) * alto as f32 - 0.5).clamp(0.0, (alto - 1) as f32); + let y0 = fy.floor() as usize; + let y1 = (y0 + 1).min(alto - 1); + let wy = fy - y0 as f32; + for x in 0..lado { + let fx = (((x as f32 + 0.5) / lado as f32) * ancho as f32 - 0.5).clamp(0.0, (ancho - 1) as f32); + let x0 = fx.floor() as usize; + let x1 = (x0 + 1).min(ancho - 1); + let wx = fx - x0 as f32; + for c in 0..3 { + let p = |xx: usize, yy: usize| rgba[(yy * ancho + xx) * 4 + c] as f32; + let arriba = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * wx; + let abajo = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * wx; + let v = arriba + (abajo - arriba) * wy; + out[c * plano + y * lado + x] = (v / 255.0 - MEDIA[c]) / DESVIACION[c]; + } + } + } + out +} + +/// ¿La salida viene sin sigmoide? (BiRefNet la aplica fuera de la red; si algo cae fuera de 0..1, +/// son logits y hay que aplicarla aquí.) +pub fn son_logits(salida: &[f32]) -> bool { + salida.iter().any(|&v| !(-0.001..=1.001).contains(&v)) +} + +/// La salida del modelo a un byte por punto. `logits`: la salida va sin sigmoide (BiRefNet la +/// aplica fuera de la red). +pub fn a_bytes(salida: &[f32], logits: bool) -> Vec { + salida + .iter() + .map(|&v| { + let p = if logits { 1.0 / (1.0 + (-v).exp()) } else { v }; + (p.clamp(0.0, 1.0) * 255.0).round() as u8 + }) + .collect() +} + +/// La respuesta de `ia_sujeto` para la página: [ancho u32][alto u32][bytes] (little endian). +pub fn respuesta(ancho: u32, alto: u32, bytes: &[u8]) -> Vec { + let mut out = Vec::with_capacity(8 + bytes.len()); + out.extend_from_slice(&ancho.to_le_bytes()); + out.extend_from_slice(&alto.to_le_bytes()); + out.extend_from_slice(bytes); + out +} + +#[cfg(test)] +mod pruebas { + use super::*; + + #[test] + fn la_entrada_es_la_del_navegador() { + // 2 × 1: rojo puro y blanco; a 2 × 2 cada columna toma su píxel + let rgba = [255, 0, 0, 255, 255, 255, 255, 255]; + let e = entrada(&rgba, 2, 1, 2); + let norm = |v: f32, c: usize| (v / 255.0 - MEDIA[c]) / DESVIACION[c]; + assert!((e[0] - norm(255.0, 0)).abs() < 1e-5); // R de (0,0) + assert!((e[4] - norm(0.0, 1)).abs() < 1e-5); // G de (0,0) + assert!((e[5] - norm(255.0, 1)).abs() < 1e-5); // G de (1,0) + assert!((e[8 + 3] - norm(255.0, 2)).abs() < 1e-5); // B de (1,1) + assert_eq!(e.len(), 12); + } + + #[test] + fn la_salida_a_bytes_y_la_respuesta() { + assert_eq!(a_bytes(&[-20.0, 0.0, 20.0], true), vec![0, 128, 255]); + assert_eq!(a_bytes(&[0.0, 0.5, 1.2], false), vec![0, 128, 255]); + assert!(son_logits(&[0.2, -3.0]) && !son_logits(&[0.0, 0.5, 1.0])); + let r = respuesta(3, 2, &[1, 2, 3, 4, 5, 6]); + assert_eq!(&r[..8], &[3, 0, 0, 0, 2, 0, 0, 0]); + assert_eq!(r.len(), 14); + } +} diff --git a/src-tauri/src/ia/mod.rs b/src-tauri/src/ia/mod.rs new file mode 100644 index 0000000..670c01a --- /dev/null +++ b/src-tauri/src/ia/mod.rs @@ -0,0 +1,262 @@ +// ── La IA EN EL EQUIPO: la máscara de sujeto de Labstream OS con la tarjeta NVIDIA (1.22) ────── +// +// Munka: «los pcs que usan esta función tienen Nvidia de 16 GB… el modelo se ejecuta en local y se +// conecta y sube el resultado a la app». En el editor de revelado (modo edición de un set), «Sujeto» +// y «Fondo» no los busca el navegador sino esta app: ONNX Runtime con DirectML sobre la tarjeta +// NVIDIA y BiRefNet en media precisión (piezas.rs): 0,3 s por foto en vez de 2,5–3 s, y sin depender +// del WebGPU de la pestaña. +// +// La página (lib/revelado/ia-local en labstream-os) llama a tres comandos, permitidos SOLO para el +// origen de la app (capabilities/default.json + el manifiesto de build.rs): +// ia_estado → ¿se puede aquí?, ¿está ya bajado?, cuánto falta, qué tarjeta +// ia_preparar → baja lo que falte (eventos «ia-progreso» { fase, parte } a esa pestaña) y abre +// el modelo en la tarjeta +// ia_sujeto ← RGBA en el cuerpo (binario) + x-ancho / x-alto +// → [ancho u32][alto u32][un byte por punto] (lib/revelado/ia-local: leerRespuesta) +// El resultado vuelve a la página, que lo pone en la máscara y lo guarda con el revelado de la foto. +// +// Nada se baja ni se carga al arrancar la app: solo cuando alguien pide la máscara. Lo bajado vive en +// los datos de la app del equipo (`carpeta`), comprobado con su huella (bajar.rs). El modelo abierto +// ocupa la tarjeta (~7 GB mientras busca: DirectML guarda lo que pidió): se suelta a los SOLTAR_TRAS +// sin usarlo, que los editores trabajan con Resolve abierto. + +mod bajar; +mod imagen; +mod motor; +mod piezas; +mod tarjeta; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, EventTarget, Manager, Runtime, Webview}; + +/// Sin usarlo este rato, el modelo se suelta de la tarjeta (la siguiente foto tarda ~2 s más). +const SOLTAR_TRAS: Duration = Duration::from_secs(2 * 60); + +struct Abierto { + motor: motor::Motor, + usado: Instant, +} + +static MODELO: Mutex> = Mutex::new(None); +/// Una preparación a la vez (dos pestañas que piden a la vez no bajan dos veces). +static PREPARANDO: Mutex<()> = Mutex::new(()); +static VIGILANTE: std::sync::Once = std::sync::Once::new(); + +fn carpeta(app: &AppHandle) -> Result { + app.path().app_local_data_dir().map(|d| d.join("ia")).map_err(|e| format!("No hay carpeta de datos de la app: {e}")) +} + +fn faltan(dir: &std::path::Path) -> u64 { + piezas::todas().iter().filter(|p| !bajar::esta(dir, p)).map(|p| p.bytes).sum() +} + +#[derive(serde::Serialize)] +pub struct Estado { + disponible: bool, + motivo: Option, + listo: bool, + faltan: u64, + modelo: &'static str, + tarjeta: Option, +} + +#[tauri::command] +pub fn ia_estado(app: AppHandle) -> Estado { + let t = tarjeta::nvidia(); + let motivo = if !cfg!(all(windows, target_arch = "x86_64")) { + Some("La IA del equipo es para Windows con tarjeta NVIDIA.".to_string()) + } else if t.is_none() { + Some("Este equipo no tiene tarjeta NVIDIA.".to_string()) + } else { + None + }; + let dir = carpeta(&app).ok(); + let falta = dir.as_deref().map(faltan).unwrap_or(u64::MAX); + Estado { + disponible: motivo.is_none() && dir.is_some(), + motivo, + listo: falta == 0, + faltan: if falta == u64::MAX { 0 } else { falta }, + modelo: piezas::NOMBRE_MODELO, + tarjeta: t.map(|t| t.nombre), + } +} + +fn avisar(app: &AppHandle, label: &str, fase: &str, parte: f64) { + let _ = app.emit_to(EventTarget::webview(label), "ia-progreso", serde_json::json!({ "fase": fase, "parte": parte })); +} + +/// Baja lo que falte y deja el modelo abierto (en un hilo aparte: puede tardar minutos). +#[tauri::command] +pub async fn ia_preparar(app: AppHandle, webview: Webview) -> Result<(), String> { + let label = webview.label().to_string(); + // La sesión de la pestaña, para bajar el modelo del servidor de la app (solo viaja a él). Aquí y + // no en el hilo de abajo: leer cookies de WebView2 desde un comando síncrono se queda colgado. + let sesion = tauri::Url::parse(crate::SERVER_URL) + .ok() + .and_then(|u| webview.cookies_for_url(u).ok()) + .map(|cs| cs.iter().map(|c| format!("{}={}", c.name(), c.value())).collect::>().join("; ")); + tauri::async_runtime::spawn_blocking(move || preparar(&app, &label, sesion.as_deref())) + .await + .map_err(|e| format!("La IA no pudo prepararse: {e}"))? +} + +fn preparar(app: &AppHandle, label: &str, sesion: Option<&str>) -> Result<(), String> { + if tarjeta::nvidia().is_none() { + return Err("Este equipo no tiene tarjeta NVIDIA.".into()); + } + let _uno = PREPARANDO.lock().unwrap_or_else(|e| e.into_inner()); + let dir = carpeta(app)?; + let total = faltan(&dir); + if total > 0 { + let hecho = AtomicU64::new(0); + let mut ultimo = 0u64; + avisar(app, label, "bajando", 0.0); + for p in piezas::todas() { + bajar::bajar(&dir, p, sesion, &mut |n| { + let h = hecho.fetch_add(n, Ordering::Relaxed) + n; + // un aviso cada ~1 % + if h - ultimo > total / 100 { + ultimo = h; + avisar(app, label, "bajando", (h as f64 / total as f64).min(1.0)); + } + }) + .map_err(|e| { + crate::salud::anotar("ERROR", "ia", &e); + e + })?; + } + crate::salud::anotar("INFO", "ia", &format!("IA bajada al equipo ({} MB)", total / 1_000_000)); + } + let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner()); + if m.is_none() { + avisar(app, label, "preparando", 0.0); + let t0 = Instant::now(); + let motor = motor::Motor::abrir(&dir).map_err(|e| { + crate::salud::anotar("ERROR", "ia", &format!("no se pudo abrir el modelo: {e}")); + e + })?; + crate::salud::anotar("INFO", "ia", &format!("modelo abierto en {:.1} s ({})", t0.elapsed().as_secs_f32(), motor.donde())); + *m = Some(Abierto { motor, usado: Instant::now() }); + vigilar(); + } + Ok(()) +} + +/// Suelta el modelo de la tarjeta cuando lleva SOLTAR_TRAS sin usarse. +fn vigilar() { + VIGILANTE.call_once(|| { + std::thread::spawn(|| loop { + std::thread::sleep(Duration::from_secs(15)); + let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner()); + if m.as_ref().map(|a| a.usado.elapsed() >= SOLTAR_TRAS).unwrap_or(false) { + *m = None; + crate::salud::anotar("INFO", "ia", "modelo soltado de la tarjeta (sin uso)"); + } + }); + }); +} + +/// Busca el sujeto en la foto que manda la página (RGBA en el cuerpo, medidas en las cabeceras). +#[tauri::command] +pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result { + let medida = |k: &str| request.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.parse::().ok()).unwrap_or(0); + let (ancho, alto) = (medida("x-ancho"), medida("x-alto")); + let tauri::ipc::InvokeBody::Raw(rgba) = request.body() else { + return Err("La foto no llegó (se esperaban sus píxeles).".into()); + }; + if ancho < 2 || alto < 2 || ancho > 16_384 || alto > 16_384 || rgba.len() != ancho * alto * 4 { + return Err("La foto llegó con unas medidas que no cuadran.".into()); + } + let rgba = rgba.clone(); + tauri::async_runtime::spawn_blocking(move || buscar(&rgba, ancho, alto)) + .await + .map_err(|e| format!("La IA falló: {e}"))? + .map(tauri::ipc::Response::new) +} + +fn buscar(rgba: &[u8], ancho: usize, alto: usize) -> Result, String> { + let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner()); + let Some(a) = m.as_mut() else { + return Err("La IA no está preparada.".into()); + }; + let lado = piezas::LADO; + let t0 = Instant::now(); + let salida = a.motor.correr(imagen::entrada(rgba, ancho, alto, lado), lado); + a.usado = Instant::now(); + match salida { + Ok(s) => { + crate::salud::anotar("INFO", "ia", &format!("sujeto en {:.2} s", t0.elapsed().as_secs_f32())); + Ok(imagen::respuesta(lado as u32, lado as u32, &imagen::a_bytes(&s, imagen::son_logits(&s)))) + } + Err(e) => { + // una tarjeta que se perdió: se suelta para abrirla de nuevo la próxima vez + *m = None; + crate::salud::anotar("ERROR", "ia", &format!("no se pudo buscar el sujeto: {e}")); + Err(e) + } + } +} + +#[cfg(all(test, windows))] +mod pruebas { + use super::*; + + /// De punta a punta en la tarjeta de ESTE equipo (no corre en CI: `cargo test -- --ignored`). + /// LS_IA_DIR: carpeta de la IA (se baja ahí lo que falte). LS_IA_ENTRADA: opcional, un .f32 con + /// la entrada [1,3,lado,lado] ya hecha (para comparar con la CPU); la salida va a LS_IA_SALIDA. + /// LS_IA_MODELO / LS_IA_LADO: probar otro modelo de la carpeta; LS_IA_ESPERA: segundos con el + /// modelo abierto entre vueltas (para mirar la memoria de la tarjeta). + #[test] + #[ignore] + fn de_punta_a_punta_en_la_tarjeta() { + let dir = PathBuf::from(std::env::var("LS_IA_DIR").expect("LS_IA_DIR")); + for p in piezas::todas() { + let t = Instant::now(); + bajar::bajar(&dir, p, None, &mut |_| {}).unwrap(); + eprintln!("{}: listo en {:.1} s", p.nombre, t.elapsed().as_secs_f32()); + } + let t = Instant::now(); + let mut m = match std::env::var("LS_IA_MODELO") { + Ok(f) => motor::Motor::abrir_archivo(&dir, &f).unwrap(), + Err(_) => motor::Motor::abrir(&dir).unwrap(), + }; + eprintln!("modelo abierto en {:.1} s", t.elapsed().as_secs_f32()); + let lado = std::env::var("LS_IA_LADO").ok().and_then(|v| v.parse().ok()).unwrap_or(piezas::LADO); + let entrada = match std::env::var("LS_IA_ENTRADA") { + Ok(f) => std::fs::read(f).unwrap().chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(), + Err(_) => { + let (w, h) = (1600usize, 1067usize); + let mut rgba = vec![255u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let i = (y * w + x) * 4; + let d = ((x as f32 - 800.0).powi(2) + (y as f32 - 560.0).powi(2)).sqrt() / 300.0; + let g = (90.0 + 60.0 * y as f32 / h as f32) as u8; + let c = if d < 1.0 { [220, 120, 60] } else { [g, g, g + 10] }; + rgba[i..i + 3].copy_from_slice(&c); + } + } + imagen::entrada(&rgba, w, h, lado) + } + }; + for vuelta in 0..2 { + let t = Instant::now(); + let s = m.correr(entrada.clone(), lado).unwrap(); + let (min, max) = s.iter().fold((f32::MAX, f32::MIN), |(a, b), &v| (a.min(v), b.max(v))); + eprintln!("vuelta {vuelta}: {:.2} s, salida {min:.2}..{max:.2}, logits {}", t.elapsed().as_secs_f32(), imagen::son_logits(&s)); + if let Ok(seg) = std::env::var("LS_IA_ESPERA") { + eprintln!("esperando {seg} s con el modelo abierto"); + std::thread::sleep(Duration::from_secs(seg.parse().unwrap_or(0))); + } + if vuelta == 1 { + if let Ok(f) = std::env::var("LS_IA_SALIDA") { + std::fs::write(f, s.iter().flat_map(|v| v.to_le_bytes()).collect::>()).unwrap(); + } + } + } + } +} diff --git a/src-tauri/src/ia/motor.rs b/src-tauri/src/ia/motor.rs new file mode 100644 index 0000000..7d8b3df --- /dev/null +++ b/src-tauri/src/ia/motor.rs @@ -0,0 +1,130 @@ +// ── El motor: ONNX Runtime con DirectML sobre la tarjeta (Windows) ── +// +// DirectML y no CUDA: corre en cualquier tarjeta con DirectX 12 —las NVIDIA del equipo incluidas— +// sin pedir versión de controlador, y lo que hay que bajar son ~200 MB de Microsoft (NuGet) en vez +// de ~1,3 GB de bibliotecas de NVIDIA. La exportación oficial de BiRefNet lleva la convolución +// deformable deshecha en GatherND (lo que DirectML sabe hacer; DeformConv no). +// +// Las DLL no van en el instalador: las baja `ia_preparar` (piezas.rs) a la carpeta de la IA y aquí +// se cargan desde ahí. DirectML.dll se carga ANTES por su ruta: onnxruntime.dll la pide por nombre +// y, ya cargada, Windows le da esa y no la vieja de System32. ONNX Runtime se inicia UNA vez por +// proceso (`ENTORNO`); la sesión (el modelo en la tarjeta) se abre y se suelta según se use. + +#[cfg(windows)] +mod imp { + use super::super::piezas; + use ort::{ + ep, + environment::Environment, + session::{builder::GraphOptimizationLevel, Session}, + value::Tensor, + }; + use std::path::Path; + use std::sync::OnceLock; + + static ENTORNO: OnceLock> = OnceLock::new(); + + fn entorno(dir: &Path) -> Result<&'static Environment, String> { + ENTORNO + .get_or_init(|| { + cargar_por_ruta(&dir.join(piezas::DLL_DIRECTML))?; + let env = ort::init_from(dir.join(piezas::DLL_ORT)) + .map_err(|e| format!("No se pudo cargar ONNX Runtime: {e}"))? + .with_name("labstream-ia") + .build() + .map_err(|e| format!("No se pudo iniciar ONNX Runtime: {e}"))?; + Ok(env) + }) + .as_ref() + .map_err(|e| e.clone()) + } + + /// Carga una DLL por su ruta completa y la deja cargada para siempre. + fn cargar_por_ruta(ruta: &Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::System::LibraryLoader::LoadLibraryW; + let w: Vec = ruta.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); + unsafe { LoadLibraryW(PCWSTR(w.as_ptr())) }.map(|_| ()).map_err(|e| format!("No se pudo cargar {}: {e}", ruta.display())) + } + + pub struct Motor { + sesion: Session, + entrada: String, + } + + impl Motor { + pub fn abrir(dir: &Path) -> Result { + Self::abrir_archivo(dir, piezas::ARCHIVO_MODELO) + } + + pub fn abrir_archivo(dir: &Path, archivo: &str) -> Result { + let env = entorno(dir)?; + let e = |e: ort::Error| format!("No se pudo abrir el modelo: {e}"); + let sesion = Session::builder(env) + .map_err(|e| format!("No se pudo abrir el modelo: {e}"))? + .with_optimization_level(GraphOptimizationLevel::Level3) + .map_err(e)? + // lo que pide DirectML: sin patrones de memoria y en serie + .with_memory_pattern(false) + .map_err(e)? + .with_parallel_execution(false) + .map_err(e)? + // El grafo FUNDIDO de DirectML pide la memoria de toda la red de una vez: con el modelo + // HR no cabía ni en 16 GB (E_OUTOFMEMORY en DmlFusedNode, RTX 5080, 14-oct-2026) y en + // media precisión iba 6 veces más lento. Nodo a nodo, cada tensor se suelta cuando ya + // no hace falta (el general: 0,3 s). + .with_config_entry("ep.dml.disable_graph_fusion", "1") + .map_err(e)? + .with_execution_providers([ep::DirectML::default() + .with_device_filter(ep::directml::DeviceFilter::Gpu) + .with_performance_preference(ep::directml::PerformancePreference::HighPerformance) + .build() + // sin la tarjeta NO se sigue en la CPU: serían 30 s por foto + .error_on_failure()]) + .map_err(e)? + .commit_from_file(dir.join(archivo)) + .map_err(|e| format!("No se pudo abrir el modelo: {e}"))?; + let entrada = sesion.inputs().first().map(|i| i.name().to_string()).ok_or("El modelo no tiene entrada.")?; + Ok(Motor { sesion, entrada }) + } + + pub fn donde(&self) -> &'static str { + "DirectML" + } + + /// La entrada [1, 3, lado, lado] → la salida [1, 1, lado, lado] (la última, si hay varias). + pub fn correr(&mut self, entrada: Vec, lado: usize) -> Result, String> { + let t = Tensor::from_array(([1usize, 3, lado, lado], entrada)).map_err(|e| format!("La foto no entró al modelo: {e}"))?; + let nombre = self.entrada.clone(); + let salidas = self.sesion.run(ort::inputs![nombre => t]).map_err(|e| format!("La tarjeta no pudo buscar el sujeto: {e}"))?; + let ultima = salidas.len().checked_sub(1).ok_or("El modelo no contestó.")?; + let (forma, datos) = salidas[ultima].try_extract_tensor::().map_err(|e| format!("El modelo contestó algo raro: {e}"))?; + if datos.len() != lado * lado { + return Err(format!("El modelo contestó {forma:?} y se esperaba {lado} × {lado}.")); + } + Ok(datos.to_vec()) + } + } +} + +#[cfg(not(windows))] +mod imp { + use std::path::Path; + + pub struct Motor; + + impl Motor { + pub fn abrir(_dir: &Path) -> Result { + Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into()) + } + pub fn donde(&self) -> &'static str { + "-" + } + pub fn correr(&mut self, _entrada: Vec, _lado: usize) -> Result, String> { + Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into()) + } + } +} + +pub use imp::Motor; diff --git a/src-tauri/src/ia/piezas.rs b/src-tauri/src/ia/piezas.rs new file mode 100644 index 0000000..509f275 --- /dev/null +++ b/src-tauri/src/ia/piezas.rs @@ -0,0 +1,57 @@ +// ── Lo que la IA baja la primera vez (≈700 MB), de dónde, y con qué huella ── +// +// Fijado por versión y huella SHA-256 (comprobadas al bajarlo el 14-oct-2026): si algún día cambia +// lo que hay en esa dirección, no se usa. +// +// - El modelo: BiRefNet general (MIT, ZhengPeng7; el mismo del navegador) en media precisión, del +// servidor de la app (api/modelos/sujeto-equipo, con la sesión de la pestaña). Sale de la +// exportación ONNX oficial del autor (release v1 de su GitHub, float32) pasada a float16 con la +// entrada y la salida en float32 (scripts/modelo-sujeto-equipo.py de labstream-os). Entrada +// `input_image` [1, 3, 1024, 1024], salida `output_image` [1, 1, 1024, 1024] (logits). En una RTX +// 5080: 0,3 s por foto y el mismo borde que el original en la CPU. +// El HR (2048 px, matting) se probó y NO va: su convolución deformable, deshecha en GatherND en la +// exportación oficial, ocupa 14–15 GB con DirectML y tarda de 6 a 45 s (14-oct-2026). +// - ONNX Runtime 1.24.4 con DirectML (NuGet de Microsoft): onnxruntime.dll para x64. +// - DirectML 1.15.4 (NuGet de Microsoft): DirectML.dll para Windows x64 (el paquete trae también +// las de Xbox y las de depurar: se saca por su ruta exacta). + +use super::bajar::{Forma, Pieza}; + +pub const NOMBRE_MODELO: &str = "BiRefNet"; +/// El lado de la entrada y de la salida del modelo. +pub const LADO: usize = 1024; + +pub const ARCHIVO_MODELO: &str = "birefnet-general-epoch_244-fp16.onnx"; +pub const DLL_ORT: &str = "onnxruntime.dll"; +pub const DLL_DIRECTML: &str = "DirectML.dll"; + +static PIEZAS: [Pieza; 3] = [ + Pieza { + nombre: "onnxruntime-directml-1.24.4", + url: "https://api.nuget.org/v3-flatcontainer/microsoft.ml.onnxruntime.directml/1.24.4/microsoft.ml.onnxruntime.directml.1.24.4.nupkg", + sha256: "57e9f11b73437bef7a309496135d4c1f96b1a8e9ddba60013fa27bfc1d788681", + bytes: 12_458_649, + forma: Forma::Zip(&["runtimes/win-x64/native/onnxruntime.dll", "runtimes/win-x64/native/onnxruntime_providers_shared.dll"]), + con_sesion: false, + }, + Pieza { + nombre: "directml-1.15.4", + url: "https://api.nuget.org/v3-flatcontainer/microsoft.ai.directml/1.15.4/microsoft.ai.directml.1.15.4.nupkg", + sha256: "4e7cb7ddce8cf837a7a75dc029209b520ca0101470fcdf275c1f49736a3615b9", + bytes: 202_292_617, + forma: Forma::Zip(&["bin/x64-win/DirectML.dll"]), + con_sesion: false, + }, + Pieza { + nombre: "birefnet-general-epoch_244-fp16", + url: "https://os.labstreamsas.com/api/modelos/sujeto-equipo", + sha256: "323232ec73a04ac4d0ef8c325a75aa8d69ed7062235a7cf9941769fae4c9709f", + bytes: 489_666_838, + forma: Forma::Archivo(ARCHIVO_MODELO), + con_sesion: true, + }, +]; + +pub fn todas() -> &'static [Pieza] { + &PIEZAS +} diff --git a/src-tauri/src/ia/tarjeta.rs b/src-tauri/src/ia/tarjeta.rs new file mode 100644 index 0000000..37a9ea6 --- /dev/null +++ b/src-tauri/src/ia/tarjeta.rs @@ -0,0 +1,36 @@ +// ── ¿Hay tarjeta NVIDIA en este equipo? ── (DXGI: lo que Windows sabe de sus adaptadores, sin +// cargar nada de la IA). Es lo que decide si la máscara de sujeto la busca la app o el navegador. + +/// Una tarjeta NVIDIA del equipo: su nombre y su memoria propia (bytes). +pub struct Tarjeta { + pub nombre: String, + pub memoria: u64, +} + +const NVIDIA: u32 = 0x10DE; + +#[cfg(windows)] +pub fn nvidia() -> Option { + use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE}; + let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?; + let mut mejor: Option = None; + for i in 0.. { + let Ok(a) = (unsafe { factory.EnumAdapters1(i) }) else { break }; + let Ok(d) = (unsafe { a.GetDesc1() }) else { continue }; + if d.VendorId != NVIDIA || (d.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32) != 0 { + continue; + } + let largo = d.Description.iter().position(|&c| c == 0).unwrap_or(d.Description.len()); + let t = Tarjeta { nombre: String::from_utf16_lossy(&d.Description[..largo]).trim().to_string(), memoria: d.DedicatedVideoMemory as u64 }; + // con dos, la de más memoria + if mejor.as_ref().map(|m| t.memoria > m.memoria).unwrap_or(true) { + mejor = Some(t); + } + } + mejor +} + +#[cfg(not(windows))] +pub fn nvidia() -> Option { + None +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 471c7c5..c9f01ba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,7 @@ mod cache; mod tracker; mod salud; mod memoria; +mod ia; use std::{ collections::HashMap, @@ -1694,7 +1695,9 @@ pub fn run() { .plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)) // El cajón de miniaturas (ver el módulo `cache` y el handler de arriba). Registrado en el // builder para que TODAS las webviews de pestaña puedan usarlo. - .register_asynchronous_uri_scheme_protocol("lsthumb", responder_lsthumb); + .register_asynchronous_uri_scheme_protocol("lsthumb", responder_lsthumb) + // La IA del equipo (src/ia): la máscara de sujeto con la tarjeta NVIDIA, para la página. + .invoke_handler(tauri::generate_handler![ia::ia_estado, ia::ia_preparar, ia::ia_sujeto]); let app = builder .manage(ShellState::default()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a5e4dbd..f2054c2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Labstream OS", - "version": "1.21.0", + "version": "1.22.0", "identifier": "co.labstream.os", "build": { "frontendDist": "../dist"