Comparar commits

...

1 Commits

Autor SHA1 Mensaje Fecha
Munka
a725900a4a v1.22.0: la IA del equipo (máscara de sujeto en la tarjeta NVIDIA)
La máscara de «Sujeto» y «Fondo» del editor de revelado de Labstream OS la busca
la app en la tarjeta del equipo, no el navegador (src-tauri/src/ia/):

- Tres comandos para la página, permitidos solo para el origen de la app (manifiesto
  en build.rs + capabilities): ia_estado (¿hay NVIDIA?, ¿está bajado?, cuánto
  falta), ia_preparar (baja lo que falte con eventos ia-progreso y abre el modelo)
  e ia_sujeto (RGBA en binario → [ancho][alto][un byte por punto]).
- Motor: ONNX Runtime 1.24.4 con DirectML (crate ort 2.0.0-rc.14, load-dynamic,
  api-24): nada se enlaza al compilar; las DLL se bajan de NuGet la primera vez y
  DirectML.dll se carga por su ruta antes que onnxruntime.dll. Sin fundir el grafo
  (fundido, el HR no cabía en 16 GB) y sin seguir en la CPU si la tarjeta falla.
- Modelo: BiRefNet general en media precisión, del servidor de la app con la
  sesión de la pestaña (cookies de WebView2 leídas en el comando asíncrono).
- Todo fijado por huella SHA-256; los .nupkg se sacan por ruta exacta (DirectML trae
  también las DLL de Xbox y de depurar con el mismo nombre).
- ¿Hay NVIDIA?: los adaptadores de DXGI, sin cargar nada de la IA.
- El modelo se suelta de la tarjeta a los 2 min sin uso; si la tarjeta falla, se
  suelta y se abre de nuevo la próxima vez. Todo va a salud.log.

Medido en una RTX 5080: 0,3 s por foto (1,4 s la primera; el navegador tarda
2,5–3 s) y el mismo borde que el modelo original en la CPU. El HR (2048 px,
matting) se probó y se descartó: 14–15 GB de la tarjeta y de 6 a 45 s por foto.

Pruebas nuevas: zip por ruta exacta, entrada igual a la del navegador, salida y
respuesta; y una de punta a punta en la tarjeta (ignorada en CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:45:04 -05:00
Se han modificado 17 ficheros con 935 adiciones y 7 borrados

Ver fichero

@@ -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 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 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). 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%\<identificador>\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=<carpeta> cargo test --lib de_punta_a_punta -- --ignored --nocapture`.
- **Pestañas con nombre** (v1.21.0): `window.open(url, "ls-…")` o un enlace con - **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 `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 ella** en vez de abrir otra. La página recibe la URL nueva por

Ver fichero

@@ -1,6 +1,6 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.21.0", "version": "1.22.0",
"private": true, "private": true,
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.", "description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
"scripts": { "scripts": {

119
src-tauri/Cargo.lock generado
Ver fichero

@@ -1319,6 +1319,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"miniz_oxide", "miniz_oxide",
"zlib-rs",
] ]
[[package]] [[package]]
@@ -2366,15 +2367,17 @@ dependencies = [
[[package]] [[package]]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.21.0" version = "1.22.0"
dependencies = [ dependencies = [
"active-win-pos-rs", "active-win-pos-rs",
"device_query", "device_query",
"gethostname 0.5.0", "gethostname 0.5.0",
"ort",
"percent-encoding", "percent-encoding",
"rfd", "rfd",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-autostart", "tauri-plugin-autostart",
@@ -2386,6 +2389,7 @@ dependencies = [
"ureq", "ureq",
"webview2-com", "webview2-com",
"windows 0.61.3", "windows 0.61.3",
"zip",
] ]
[[package]] [[package]]
@@ -2459,6 +2463,16 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.17" version = "0.1.17"
@@ -2545,6 +2559,16 @@ dependencies = [
"web_atoms", "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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.2" version = "2.8.2"
@@ -2630,6 +2654,21 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -2693,12 +2732,30 @@ dependencies = [
"zbus", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" 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]] [[package]]
name = "num-modular" name = "num-modular"
version = "0.6.5" version = "0.6.5"
@@ -3003,6 +3060,25 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "os_pipe" name = "os_pipe"
version = "1.2.3" version = "1.2.3"
@@ -3287,6 +3363,21 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -3475,6 +3566,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]] [[package]]
name = "readkey" name = "readkey"
version = "0.2.2" version = "0.2.2"
@@ -6484,16 +6581,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [ dependencies = [
"arbitrary", "arbitrary",
"crc32fast", "crc32fast",
"flate2",
"indexmap 2.14.0", "indexmap 2.14.0",
"memchr", "memchr",
"zopfli",
] ]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" 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]] [[package]]
name = "zune-core" name = "zune-core"
version = "0.5.1" version = "0.5.1"

Ver fichero

@@ -1,6 +1,6 @@
[package] [package]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.21.0" version = "1.22.0"
description = "Cliente de escritorio de Labstream OS" description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"] authors = ["Labstream SAS"]
edition = "2021" edition = "2021"
@@ -49,6 +49,10 @@ percent-encoding = "2"
active-win-pos-rs = "0.11" active-win-pos-rs = "0.11"
ureq = { version = "2", features = ["json"] } ureq = { version = "2", features = ["json"] }
gethostname = "0.5" 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 # 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 # 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 # 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. # edición es Windows). El `windows` crate ya está en la caché por Tauri.
[target.'cfg(windows)'.dependencies] [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. # Pestañas en reposo (1.19): ICoreWebView2_3::TrySuspend. Es la misma versión que ya trae Tauri.
webview2-com = "0.38" webview2-com = "0.38"
windows = { version = "0.61", features = [ 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) # 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_UI_Input_KeyboardAndMouse",
"Win32_System_SystemInformation", "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",
] } ] }

Ver fichero

@@ -1,3 +1,9 @@
fn main() { 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");
} }

Ver fichero

@@ -18,6 +18,9 @@
"core:default", "core:default",
"opener:default", "opener:default",
"notification:default", "notification:default",
"core:window:allow-start-dragging" "core:window:allow-start-dragging",
"allow-ia-estado",
"allow-ia-preparar",
"allow-ia-sujeto"
] ]
} }

Ver fichero

@@ -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"]

Ver fichero

@@ -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"]

Ver fichero

@@ -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"]

170
src-tauri/src/ia/bajar.rs Archivo normal
Ver fichero

@@ -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 `<nombre>.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::<String>();
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);
}
}

88
src-tauri/src/ia/imagen.rs Archivo normal
Ver fichero

@@ -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<f32> {
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<u8> {
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<u8> {
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);
}
}

262
src-tauri/src/ia/mod.rs Archivo normal
Ver fichero

@@ -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<Option<Abierto>> = 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<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, String> {
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<String>,
listo: bool,
faltan: u64,
modelo: &'static str,
tarjeta: Option<String>,
}
#[tauri::command]
pub fn ia_estado<R: Runtime>(app: AppHandle<R>) -> 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<R: Runtime>(app: &AppHandle<R>, 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<R: Runtime>(app: AppHandle<R>, webview: Webview<R>) -> 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::<Vec<_>>().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<R: Runtime>(app: &AppHandle<R>, 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<tauri::ipc::Response, String> {
let medida = |k: &str| request.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<usize>().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<Vec<u8>, 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::<Vec<u8>>()).unwrap();
}
}
}
}
}

130
src-tauri/src/ia/motor.rs Archivo normal
Ver fichero

@@ -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<Result<Environment, String>> = 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<u16> = 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<Motor, String> {
Self::abrir_archivo(dir, piezas::ARCHIVO_MODELO)
}
pub fn abrir_archivo(dir: &Path, archivo: &str) -> Result<Motor, String> {
let env = entorno(dir)?;
let e = |e: ort::Error<ort::session::builder::SessionBuilder>| 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<f32>, lado: usize) -> Result<Vec<f32>, 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::<f32>().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<Motor, String> {
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<f32>, _lado: usize) -> Result<Vec<f32>, String> {
Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into())
}
}
}
pub use imp::Motor;

57
src-tauri/src/ia/piezas.rs Archivo normal
Ver fichero

@@ -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
}

36
src-tauri/src/ia/tarjeta.rs Archivo normal
Ver fichero

@@ -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<Tarjeta> {
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE};
let factory: IDXGIFactory1 = unsafe { CreateDXGIFactory1() }.ok()?;
let mut mejor: Option<Tarjeta> = 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<Tarjeta> {
None
}

Ver fichero

@@ -38,6 +38,7 @@ mod cache;
mod tracker; mod tracker;
mod salud; mod salud;
mod memoria; mod memoria;
mod ia;
use std::{ use std::{
collections::HashMap, collections::HashMap,
@@ -1694,7 +1695,9 @@ pub fn run() {
.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, None)) .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 // 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. // 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 let app = builder
.manage(ShellState::default()) .manage(ShellState::default())

Ver fichero

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Labstream OS", "productName": "Labstream OS",
"version": "1.21.0", "version": "1.22.0",
"identifier": "co.labstream.os", "identifier": "co.labstream.os",
"build": { "build": {
"frontendDist": "../dist" "frontendDist": "../dist"