Comparar commits

...

6 Commits

Autor SHA1 Mensaje Fecha
5eea4dc7af Licencia MIT de Labstream (LICENSE y LICENCIA-ES.md) 2026-10-14 22:06:05 -05:00
Munka
4e27406e25 1.24.0: la versión nueva también llega con la app abierta todo el día
La app solo buscaba versión nueva al abrir: un equipo que la deja abierta de la mañana
a la noche se quedaba en la de la mañana (este PC seguía en la 1.20 con la 1.23
publicada, y Rellenar con IA no le llegaba). Ahora, cada hora, mira si hay una nueva
sin instalarla: la versión de la barra pasa a «Actualizar a vX» (en el color de la
marca) y avisa una vez por versión. Al pulsarlo instala, se reinicia y vuelve a abrir
las pestañas. No se reinicia sola en medio del trabajo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:05:19 -05:00
Munka
ed4c0ed529 Rellenar: lados en potencias de 2, el resto en espejo (la FFT de la CPU solo es rápida así)
ONNX Runtime en la CPU solo hace rápida la FFT de LaMa con lados que son potencias de 2:
512 × 512 son 2,7 s y 704 × 448, casi los mismos píxeles, 10 s. El trozo se reduce lo justo
para caber en 512 (o en 1024 si pasa de 768 px) y se completa hasta la potencia de 2 con su
propio borde en espejo, sin hueco; al volver se descarta. Un trozo de 700 px pasa de 10 s
a 4 s; uno de 300 × 200, 2 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:21:16 -05:00
Munka
eec48362e5 v1.23.0: Rellenar (LaMa rellena lo pintado en el editor de revelado)
Para «Rellenar con IA» de Eliminar en Labstream OS: la página manda el trozo de la foto
alrededor de lo pintado, en 8 bits con el hueco en el alfa, y la app lo devuelve con el
hueco relleno por LaMa y lo demás byte a byte como estaba (src-tauri/src/ia/relleno.rs).

- Comando nuevo ia_rellenar (permitido solo para el origen de la app). ia_estado e
  ia_preparar llevan { que: "relleno" }; sin él siguen hablando del sujeto, como la 1.22,
  y el estado dice ahora de qué IA habla (así la página sabe que esta app rellena).
- LaMa (Apache-2.0) en la exportación ONNX de tamaño libre de g-ronimo, desde el servidor
  de la app con la sesión de la pestaña y fijada por huella (≈210 MB). Se rellena al
  tamaño del trozo en múltiplos de 32, con 1024 px de lado como mucho.
- En la CPU: las dos exportaciones de LaMa probadas fallan con DirectML en una RTX 5080
  (su FFT, sea el operador DFT o productos de matrices: 80070057, también con las formas
  fijadas y sin optimizar); en la CPU, ~3 s a 512 px y ~10 s a 1024, y no le quita
  memoria a la tarjeta.
- Las piezas por IA: el motor (ONNX Runtime + DirectML) es de las dos; cada modelo se baja
  cuando se usa la suya. Cada modelo abierto se suelta a los 2 min sin uso.

Pruebas nuevas: el tamaño al que se rellena y que fuera del hueco no cambia nada; y una de
punta a punta en este equipo (ignorada en CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:17:54 -05:00
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
Munka
6539f6939c v1.21.0: la pestaña del color de su página y pestañas con nombre
Para el modo edición de los sets de fotos de Labstream OS, que se abre en su
propia pestaña:

- Color de la página: una página pide el color de su pestaña con
  <html data-ls-pestana="#rrggbb">. El script de la pestaña lo manda en
  ls-title (y lo vigila con el mismo observador del tema); Rust solo acepta
  #rrggbb (color_de_pagina) y la barra pinta la pestaña de ese color, con letra
  clara u oscura según su luminancia. Activa se funde con la barra de arriba de
  la página; detrás conserva el color. Un color del menú encima va de franja.
  Una página con color propio no manda el tema: su fondo oscuro no es el de la
  app y no debe oscurecer la barra.
- Pestañas con nombre: window.open(url, "ls-…") o target="ls-…" abre la
  pestaña la primera vez; las siguientes activan ESA y le pasan la URL por
  window.__lsAbrir (el modo edición cambia de foto sin recargar); sin él,
  navega. Una descartada se revive ya en la URL nueva. Solo nombres ls-
  alfanuméricos (nombre_de_ventana); los demás abren pestaña como siempre.
- Color de página y nombre se guardan con la sesión (paginas, nombres en
  estado.json; un estado viejo carga igual).

Prueba nueva: la_pagina_solo_pide_colores_y_nombres_de_la_app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:33:20 -05:00
Se han modificado 22 ficheros con 1607 adiciones y 36 borrados

40
LICENCIA-ES.md Archivo normal
Ver fichero

@@ -0,0 +1,40 @@
# Licencia de las apps públicas de Labstream (resumen en español)
Las apps marcadas como **públicas** en el catálogo se distribuyen bajo la **Licencia MIT**.
El texto legal que vale es el de `LICENSE` (en inglés, como es habitual en esta licencia);
esto es una explicación.
## Qué puedes hacer
- **Descargarlas, instalarlas y usarlas** gratis, para lo que quieras, también en trabajos
comerciales.
- **Copiarlas, modificarlas y redistribuirlas**, enteras o en parte, con o sin cambios.
- Incluirlas en otros programas, incluso de pago.
## Única condición
- Conservar el aviso de copyright y el texto de la licencia (`LICENSE`) en cualquier copia o
parte sustancial del programa.
## Sin garantía
Los programas se entregan **tal cual**, sin garantía de ningún tipo. Labstream S.A.S. no
responde por daños derivados de su uso.
## Lo que la licencia no cubre
- La **marca Labstream** y su logotipo: puedes usar el código, no presentar otro producto como
si fuera de Labstream.
- Las **apps privadas** y las **apps de clientes**: tienen las condiciones del contrato de cada
proyecto.
- Los **componentes de terceros** que cada app incluye: tienen su propia licencia, listada en
el `TERCEROS.md` de cada repositorio.
## Datos personales
Las apps de escritorio públicas no envían datos a internet ni a Labstream. Las apps web
guardan los datos en servidores de Labstream en Colombia y se rigen por la política de
tratamiento de datos personales de Labstream S.A.S. (Ley 1581 de 2012 y Decreto 1377 de 2013).
Consultas: hola@labstreamsas.com.
Labstream S.A.S. · NIT 900.665.024-5 · Bogotá, Colombia

21
LICENSE Archivo normal
Ver fichero

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Labstream S.A.S. (NIT 900.665.024-5), Bogotá, Colombia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Ver fichero

@@ -20,6 +20,34 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
- **Colores de pestaña** (v1.6.0): clic derecho sobre una pestaña ▸ **Color**, con
la misma paleta de nueve tonos que usa Chrome para sus grupos. Sirve para agrupar
de un vistazo (todo lo de un cliente en verde) y **se guarda con la sesión**.
- **Pestaña del color de su página** (v1.21.0): una página puede pedir el color de
su pestaña con `<html data-ls-pestana="#rrggbb">` (solo ese formato). El modo
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%\<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`.
- **Rellenar** (v1.23.0, Windows): en Eliminar del editor de revelado, lo pintado lo
rellena LaMa (`src-tauri/src/ia/relleno.rs`, comando `ia_rellenar`) con lo que debería
haber detrás; la página lo guarda como un parche de la foto. Corre en la CPU (LaMa no
corre con DirectML: su FFT falla en la tarjeta): ~3 s a 512 px, ~10 s a 1024 (el tope).
La primera vez baja el motor (si no estaba) y el modelo (≈210 MB, del servidor de la
app con la sesión de la pestaña, fijado por huella). `ia_estado` / `ia_preparar` llevan
`{ que: "relleno" }`; sin él, hablan del sujeto (como la 1.22).
- **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
`window.__lsAbrir(url)` (el modo edición cambia de foto sin recargar); si no lo
tiene, navega. Nombre y color se guardan con la sesión.
- **Zoom de interfaz**: ⌘/Ctrl `+` / `−` / `0` y Ctrl+rueda, con indicador en la
barra y **persistencia** entre sesiones (50%–250%).
- **Documentos dentro de la app**: los visores/editores del propio servidor
@@ -252,6 +280,12 @@ guardada como secret del repo (`TAURI_SIGNING_PRIVATE_KEY`). También se puede f
la comprobación con clic en la versión de la barra, o desde el menú ⋮ ▸ *Buscar
actualización…*.
Al **abrir**, la app busca versión nueva y, si la hay, la instala y se reinicia sola. Con la
app **abierta** (desde la 1.24) mira cada hora sin instalar nada: si hay una nueva, la versión
de la barra pasa a **«Actualizar a vX»** (en el color de la marca) y avisa una vez. Al pulsarlo
instala, se reinicia y vuelve a abrir las pestañas. No se reinicia sola en medio del trabajo:
se perdería lo que una página no hubiera guardado.
> ⚠️ De ahí se sigue algo que conviene tener presente: **un commit en `main` no le
> llega a nadie**. Mientras no exista la etiqueta `vX.Y.Z`, el CI no construye nada y
> el updater no ve versión nueva. Y al revés: publicar una etiqueta **actualiza la app

43
dist/index.html vendido
Ver fichero

@@ -7,7 +7,7 @@
<!--
Barra de pestañas del shell de escritorio (webview "chrome", local).
Habla con Rust por eventos (__TAURI__.event):
recibe ls-tabs {tabs:[{label,title,url,loading,active,color}], zoom, version,
recibe ls-tabs {tabs:[{label,title,url,loading,active,color,pagina}], zoom, version,
mac, win, maximized, dark}
emite ls-ready, ls-new-tab, ls-activate, ls-close-tab, ls-reload, ls-zoom, ls-cycle,
ls-activate-n, ls-nav, ls-menu, ls-check-update, ls-tab-menu, ls-reorder
@@ -208,6 +208,17 @@
}
:root.oscuro .tab[data-color]:not(.active) { background: rgba(var(--tc), 0.36); }
:root.oscuro .tab[data-color]:not(.active):hover { background: rgba(var(--tc), 0.5); }
/* ── La pestaña del COLOR DE SU PÁGINA (1.21) ──
La página lo pide (`<html data-ls-pestana="#262626">`): el modo edición de un set pinta la
suya del gris del editor. Activa, es de ese color y se funde con la barra de arriba de la
página, como la de siempre se funde con el blanco de la app. Detrás, el mismo color: entre
las demás se reconoce de un vistazo. Con color del menú encima, ese va de franja arriba.
(#tabs delante: gana a las reglas de tema y de color del menú sin !important.) */
#tabs .tab.pagina { --muted: color-mix(in srgb, var(--tp-fg) 60%, transparent); background: var(--tp); color: var(--tp-fg); }
#tabs .tab.pagina:not(.active) { box-shadow: none; }
#tabs .tab.pagina:not(.active):hover { background: color-mix(in srgb, var(--tp) 86%, var(--tp-fg)); color: var(--tp-fg); }
#tabs .tab.pagina .cerrar:hover { background: color-mix(in srgb, var(--tp-fg) 18%, transparent); color: var(--tp-fg); }
#tabs .tab.pagina[data-color] { box-shadow: inset 0 3px 0 rgb(var(--tc)); }
/* Reordenar arrastrando: NO se usa drag&drop de HTML5 (en el webview de la barra en
macOS los eventos no llegan), sino eventos de puntero. Se apaga el arrastre nativo de
WebKit para que no salga la imagen fantasma al mover una pestaña. */
@@ -322,6 +333,14 @@
border-radius: 13px;
}
#version:hover { color: var(--accent); }
/* Hay una versión nueva publicada: el botón la ofrece (clic: instala y reinicia). */
#version.nueva {
color: #fff;
background: var(--accent);
font-weight: 600;
padding: 0 10px;
}
#version.nueva:hover { color: #fff; filter: brightness(1.1); }
/* ── Botones de ventana ──────────────────────────────────────────────────────────
Solo en Windows: allí la ventana va SIN decoración, así que esta barra es la fila
del título (como Chrome) y el aspa del sistema no existe. Medidas y colores del
@@ -500,6 +519,14 @@
$tabs.classList.toggle("compactas", ancho < 112);
}
// ¿Un #rrggbb claro? (luminancia relativa): decide si la letra encima va oscura o clara.
function esClaro(hex) {
const n = parseInt(hex.slice(1), 16);
const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255);
return l > 0.35;
}
function render() {
$tabs.textContent = "";
for (const t of estado.tabs) {
@@ -508,6 +535,12 @@
el.className = "tab" + (t.active ? " active" : "");
el.dataset.label = t.label;
if (t.color) el.dataset.color = t.color; // teñido desde el menú de la pestaña
// El color que pide la página (ya validado #rrggbb en el shell), con letra que se lea encima.
if (t.pagina) {
el.classList.add("pagina");
el.style.setProperty("--tp", t.pagina);
el.style.setProperty("--tp-fg", esClaro(t.pagina) ? "#202124" : "#ececec");
}
el.title = texto;
const fav = document.createElement("span");
@@ -570,7 +603,13 @@
$pct.textContent = estado.zoom + "%";
$pct.classList.toggle("activo", estado.zoom !== 100);
$ver.textContent = estado.version ? "v" + estado.version : "";
// Con una versión nueva esperando, el botón la ofrece; si no, la versión de ahora.
const nueva = estado.actualizacion || null;
$ver.textContent = nueva ? "Actualizar a v" + nueva : estado.version ? "v" + estado.version : "";
$ver.classList.toggle("nueva", !!nueva);
$ver.title = nueva
? "Hay una versión nueva (" + nueva + "): clic para instalarla. La app se reinicia y vuelve a abrir tus pestañas."
: "Versión de la app · clic: buscar actualización";
// Tema: manda el de la APP (payload `dark`). Mientras Rust no sepa nada (null),
// no se pone clase y siguen las media queries del sistema como arranque en frío.
if (typeof estado.dark === "boolean") {

Ver fichero

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

119
src-tauri/Cargo.lock generado
Ver fichero

@@ -1319,6 +1319,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
@@ -2366,15 +2367,17 @@ dependencies = [
[[package]]
name = "labstream-desktop"
version = "1.20.0"
version = "1.24.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"

Ver fichero

@@ -1,6 +1,6 @@
[package]
name = "labstream-desktop"
version = "1.20.0"
version = "1.24.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",
] }

Ver fichero

@@ -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", "ia_rellenar"])),
)
.expect("tauri-build");
}

Ver fichero

@@ -18,6 +18,10 @@
"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",
"allow-ia-rellenar"
]
}

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-rellenar"
description = "Enables the ia_rellenar command without any pre-configured scope."
commands.allow = ["ia_rellenar"]
[[permission]]
identifier = "deny-ia-rellenar"
description = "Denies the ia_rellenar command without any pre-configured scope."
commands.deny = ["ia_rellenar"]

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);
}
}

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

@@ -0,0 +1,370 @@
// ── La IA EN EL EQUIPO: la máscara de sujeto (1.22) y Rellenar (1.23) de Labstream OS ──────────
//
// 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» los busca 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 en el navegador.
// - «Rellenar» (Eliminar con IA): LaMa rellena lo pintado (relleno.rs), en la CPU (motor.rs:
// abrir_en_cpu; LaMa no corre con DirectML).
//
// La página (lib/revelado/ia-local en labstream-os) llama a cuatro comandos, permitidos SOLO para el
// origen de la app (capabilities/default.json + el manifiesto de build.rs):
// ia_estado ({ que }) → ¿se puede aquí?, ¿está ya bajado?, cuánto falta, qué tarjeta
// ia_preparar ({ que }) → baja lo que falte (eventos «ia-progreso» { fase, parte } a esa pestaña)
// y abre el modelo
// ia_sujeto ← RGBA en el cuerpo (binario) + x-ancho / x-alto
// → [ancho u32][alto u32][un byte por punto] (lib/revelado/ia-local: leerRespuesta)
// ia_rellenar ← RGBA con el hueco en el alfa + x-ancho / x-alto
// → [ancho u32][alto u32][RGB, 3 bytes por punto]
// `que` es "sujeto" (o nada: la página de la 1.22) o "relleno". El resultado vuelve a la página, que lo
// pone en la máscara o en un parche de la foto y lo guarda con su revelado.
//
// Nada se baja ni se carga al arrancar la app: solo cuando alguien lo pide. Lo bajado vive en los
// datos de la app del equipo (`carpeta`), comprobado con su huella (bajar.rs). Cada modelo abierto
// ocupa memoria (el del sujeto, ~7 GB de la tarjeta 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 relleno;
mod tarjeta;
use piezas::Que;
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, o de la memoria el de rellenar).
const SOLTAR_TRAS: Duration = Duration::from_secs(2 * 60);
struct Abierto {
motor: motor::Motor,
usado: Instant,
}
/// El modelo abierto de cada IA: el del sujeto (DirectML) y el de rellenar (CPU).
static SUJETO: Mutex<Option<Abierto>> = Mutex::new(None);
static RELLENO: 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 abierto(que: Que) -> &'static Mutex<Option<Abierto>> {
match que {
Que::Sujeto => &SUJETO,
Que::Relleno => &RELLENO,
}
}
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, que: Que) -> u64 {
piezas::de(que).into_iter().filter(|p| !bajar::esta(dir, p)).map(|p| p.bytes).sum()
}
#[derive(serde::Serialize)]
pub struct Estado {
/// de qué IA habla (así la página sabe que esta app conoce Rellenar)
que: &'static str,
disponible: bool,
motivo: Option<String>,
listo: bool,
faltan: u64,
modelo: &'static str,
tarjeta: Option<String>,
}
/// Por qué esta IA no puede correr aquí (None: sí puede). El sujeto pide NVIDIA; rellenar va en la CPU.
fn motivo(que: Que, tarjeta: Option<&tarjeta::Tarjeta>) -> Option<String> {
if !cfg!(all(windows, target_arch = "x86_64")) {
Some("La IA del equipo es para la app de Windows.".to_string())
} else if que == Que::Sujeto && tarjeta.is_none() {
Some("Este equipo no tiene tarjeta NVIDIA.".to_string())
} else {
None
}
}
#[tauri::command]
pub fn ia_estado<R: Runtime>(app: AppHandle<R>, que: Option<String>) -> Estado {
let que = Que::de(que.as_deref());
let t = tarjeta::nvidia();
let motivo = motivo(que, t.as_ref());
let dir = carpeta(&app).ok();
let falta = dir.as_deref().map(|d| faltan(d, que)).unwrap_or(u64::MAX);
Estado {
que: que.nombre(),
disponible: motivo.is_none() && dir.is_some(),
motivo,
listo: falta == 0,
faltan: if falta == u64::MAX { 0 } else { falta },
modelo: match que {
Que::Sujeto => piezas::NOMBRE_MODELO,
Que::Relleno => piezas::NOMBRE_RELLENO,
},
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>, que: Option<String>) -> Result<(), String> {
let que = Que::de(que.as_deref());
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(), que))
.await
.map_err(|e| format!("La IA no pudo prepararse: {e}"))?
}
fn preparar<R: Runtime>(app: &AppHandle<R>, label: &str, sesion: Option<&str>, que: Que) -> Result<(), String> {
if let Some(m) = motivo(que, tarjeta::nvidia().as_ref()) {
return Err(m);
}
let _uno = PREPARANDO.lock().unwrap_or_else(|e| e.into_inner());
let dir = carpeta(app)?;
let total = faltan(&dir, que);
if total > 0 {
let hecho = AtomicU64::new(0);
let mut ultimo = 0u64;
avisar(app, label, "bajando", 0.0);
for p in piezas::de(que) {
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 de {} bajada al equipo ({} MB)", que.nombre(), total / 1_000_000));
}
let mut m = abierto(que).lock().unwrap_or_else(|e| e.into_inner());
if m.is_none() {
avisar(app, label, "preparando", 0.0);
let t0 = Instant::now();
let motor = match que {
Que::Sujeto => motor::Motor::abrir(&dir),
Que::Relleno => motor::Motor::abrir_en_cpu(&dir, piezas::ARCHIVO_RELLENO),
}
.map_err(|e| {
crate::salud::anotar("ERROR", "ia", &format!("no se pudo abrir el modelo de {}: {e}", que.nombre()));
e
})?;
crate::salud::anotar("INFO", "ia", &format!("modelo de {} abierto en {:.1} s ({})", que.nombre(), t0.elapsed().as_secs_f32(), motor.donde()));
*m = Some(Abierto { motor, usado: Instant::now() });
vigilar();
}
Ok(())
}
/// Suelta cada modelo cuando lleva SOLTAR_TRAS sin usarse.
fn vigilar() {
VIGILANTE.call_once(|| {
std::thread::spawn(|| loop {
std::thread::sleep(Duration::from_secs(15));
for que in [Que::Sujeto, Que::Relleno] {
let mut m = abierto(que).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", &format!("modelo de {} soltado (sin uso)", que.nombre()));
}
}
});
});
}
/// La foto que manda la página: RGBA en el cuerpo, medidas en las cabeceras.
fn foto_de(request: &tauri::ipc::Request<'_>) -> Result<(Vec<u8>, usize, usize), 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());
}
Ok((rgba.clone(), ancho, alto))
}
/// Busca el sujeto en la foto que manda la página.
#[tauri::command]
pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result<tauri::ipc::Response, String> {
let (rgba, ancho, alto) = foto_de(&request)?;
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 = SUJETO.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)
}
}
}
/// Rellena el hueco (el alfa) de la foto que manda la página (relleno.rs).
#[tauri::command]
pub async fn ia_rellenar(request: tauri::ipc::Request<'_>) -> Result<tauri::ipc::Response, String> {
let (rgba, ancho, alto) = foto_de(&request)?;
tauri::async_runtime::spawn_blocking(move || rellenar(&rgba, ancho, alto))
.await
.map_err(|e| format!("La IA falló: {e}"))?
.map(tauri::ipc::Response::new)
}
fn rellenar(rgba: &[u8], ancho: usize, alto: usize) -> Result<Vec<u8>, String> {
let mut m = RELLENO.lock().unwrap_or_else(|e| e.into_inner());
let Some(a) = m.as_mut() else {
return Err("La IA no está preparada.".into());
};
let p = relleno::plan(ancho, alto);
let t0 = Instant::now();
let r = a.motor.correr_forma(&[1, 4, p.ph, p.pw], relleno::entrada(rgba, ancho, alto, p));
a.usado = Instant::now();
match r {
Ok((_, planos)) if planos.len() == 3 * p.pw * p.ph => {
crate::salud::anotar("INFO", "ia", &format!("relleno de {ancho}×{alto} (a {}×{} en {}×{}) en {:.1} s", p.sw, p.sh, p.pw, p.ph, t0.elapsed().as_secs_f32()));
let rgb = relleno::salida(&planos, p, rgba, ancho, alto);
Ok(imagen::respuesta(ancho as u32, alto as u32, &rgb))
}
Ok((forma, _)) => Err(format!("El modelo contestó {forma:?} y se esperaba [1, 3, {}, {}].", p.ph, p.pw)),
Err(e) => {
*m = None;
crate::salud::anotar("ERROR", "ia", &format!("no se pudo rellenar: {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::de(Que::Sujeto) {
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();
}
}
}
}
/// Rellenar de punta a punta en ESTE equipo: LS_IA_DIR con el motor ya bajado y el modelo de
/// rellenar (piezas::ARCHIVO_RELLENO); unas rayas con un hueco cuadrado, al tamaño LS_IA_LADOS.
#[test]
#[ignore]
fn relleno_de_punta_a_punta() {
let dir = PathBuf::from(std::env::var("LS_IA_DIR").expect("LS_IA_DIR"));
let t = Instant::now();
let mut m = motor::Motor::abrir_en_cpu(&dir, piezas::ARCHIVO_RELLENO).unwrap();
eprintln!("lama abierto en {:.1} s ({})", t.elapsed().as_secs_f32(), m.donde());
let lados: Vec<usize> = std::env::var("LS_IA_LADOS").unwrap_or("600,1400".into()).split(',').filter_map(|v| v.trim().parse().ok()).collect();
for &lado in &lados {
let (w, h) = (lado, lado * 2 / 3);
let mut rgba = vec![0u8; w * h * 4];
for y in 0..h {
for x in 0..w {
let i = (y * w + x) * 4;
let raya: u8 = if (x / 16) % 2 == 0 { 40 } else { 0 };
rgba[i] = 80 + raya;
rgba[i + 1] = 120;
rgba[i + 2] = 50 + raya;
let hueco = x > w * 2 / 5 && x < w * 3 / 5 && y > h * 2 / 5 && y < h * 3 / 5;
rgba[i + 3] = if hueco { 255 } else { 0 };
}
}
let p = relleno::plan(w, h);
let t = Instant::now();
let (_, planos) = m.correr_forma(&[1, 4, p.ph, p.pw], relleno::entrada(&rgba, w, h, p)).unwrap();
let rgb = relleno::salida(&planos, p, &rgba, w, h);
let c = (h / 2 * w + w / 2) * 3;
eprintln!("{w}×{h} ({p:?}): {:.2} s, centro {:?}, esquina {:?}", t.elapsed().as_secs_f32(), &rgb[c..c + 3], &rgb[..3]);
// fuera del hueco, lo que había
assert_eq!(&rgb[..3], &rgba[..3]);
}
}
}

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

@@ -0,0 +1,161 @@
// ── 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,
donde: &'static str,
}
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, donde: "DirectML" })
}
/// En la CPU (Rellenar: LaMa). Las exportaciones de LaMa no corren con DirectML —su FFT, sea el
/// operador DFT o productos de matrices, falla con 80070057 en una RTX 5080 (14-oct-2026)— y en
/// la CPU sí: 2,7 s a 512 px y 10 s a 1024. Y no le quita memoria a la tarjeta (Resolve).
pub fn abrir_en_cpu(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)?
.with_execution_providers([ep::CPU::default().build()])
.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, donde: "CPU" })
}
pub fn donde(&self) -> &'static str {
self.donde
}
/// 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 (forma, datos) = self.correr_forma(&[1, 3, lado, lado], entrada)?;
if datos.len() != lado * lado {
return Err(format!("El modelo contestó {forma:?} y se esperaba {lado} × {lado}."));
}
Ok(datos)
}
/// Su única entrada con esta forma → la última salida (su forma y sus datos).
pub fn correr_forma(&mut self, forma: &[usize], entrada: Vec<f32>) -> Result<(Vec<i64>, Vec<f32>), String> {
let t = Tensor::from_array((forma.to_vec(), 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 con la foto: {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}"))?;
Ok((forma.iter().copied().collect(), 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 abrir_en_cpu(_dir: &Path, _archivo: &str) -> Result<Motor, String> {
Err("La IA del equipo es para Windows.".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 fn correr_forma(&mut self, _forma: &[usize], _entrada: Vec<f32>) -> Result<(Vec<i64>, Vec<f32>), String> {
Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into())
}
}
}
pub use imp::Motor;

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

@@ -0,0 +1,101 @@
// ── Lo que la IA baja la primera vez, de dónde, y con qué huella ──
// El motor (≈215 MB) es de las dos IA; cada modelo se baja solo cuando se usa la suya: el del sujeto
// (≈490 MB) o el de rellenar (≈210 MB).
//
// 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).
// - Rellenar (1.23): LaMa (Apache-2.0, advimman/big-lama), la exportación ONNX de tamaño libre de
// g-ronimo (Hugging Face g-ronimo/lama, `lama.onnx`, la que usa wipe.photos), copiada al servidor de
// la app (api/modelos/relleno-equipo). Entrada `input` [1, 4, alto, ancho] (múltiplos de 32),
// salida `output` [1, 3, alto, ancho], 0..1. Corre en la CPU (ver motor.rs: abrir_en_cpu).
// - 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";
pub const NOMBRE_RELLENO: &str = "LaMa";
pub const ARCHIVO_RELLENO: &str = "lama-g-ronimo-44bb92b2.onnx";
/// 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";
/// De qué IA se habla: la máscara de sujeto o rellenar.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Que {
Sujeto,
Relleno,
}
impl Que {
pub fn de(v: Option<&str>) -> Que {
if v == Some("relleno") {
Que::Relleno
} else {
Que::Sujeto
}
}
pub fn nombre(self) -> &'static str {
match self {
Que::Sujeto => "sujeto",
Que::Relleno => "relleno",
}
}
}
static RELLENO: Pieza = Pieza {
nombre: "lama-g-ronimo-44bb92b2",
url: "https://os.labstreamsas.com/api/modelos/relleno-equipo",
sha256: "44bb92b2693db40bb088dda226365a3aa8080a561584b8b0287ab11a97f9e9e7",
bytes: 209_181_361,
forma: Forma::Archivo(ARCHIVO_RELLENO),
con_sesion: true,
};
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,
},
];
/// Lo que necesita cada IA: el motor (los dos primeros) y su modelo.
pub fn de(que: Que) -> Vec<&'static Pieza> {
match que {
Que::Sujeto => PIEZAS.iter().collect(),
Que::Relleno => vec![&PIEZAS[0], &PIEZAS[1], &RELLENO],
}
}

163
src-tauri/src/ia/relleno.rs Archivo normal
Ver fichero

@@ -0,0 +1,163 @@
// ── RELLENAR: el hueco pintado en el editor, rellenado por LaMa (1.23) ──
//
// La página (lib/revelado/relleno y revelado-relleno de labstream-os) manda el trozo de la foto
// alrededor de lo pintado, en 8 bits, con el hueco en el alfa (255 = rellenar). Aquí se lleva al
// tamaño del modelo, se rellena, se vuelve a su tamaño y se devuelve el trozo con el hueco relleno y
// lo demás tal cual estaba (byte a byte).
//
// El tamaño (`plan`): LaMa hace FFT de lo que ve, y la de ONNX Runtime en la CPU solo es rápida con
// lados que son potencias de 2: 512 × 512 son 2,7 s y 704 × 448 —casi los mismos píxeles— 10 s
// (14-oct-2026). Así que el trozo se reduce lo justo para caber en 512 (o en 1024 si pasa de 768 px:
// LaMa generaliza bien hasta ahí, 10 s) y se completa hasta la potencia de 2 con su propio borde en
// espejo (sin hueco: es contexto). Lo de fuera de la foto reducida se descarta al volver.
//
// El modelo (LaMa, Apache-2.0; la exportación ONNX de tamaño libre de g-ronimo): una entrada
// [1, 4, alto, ancho] —RGB 0..1 con el hueco a cero, y el hueco 0/1— y una salida [1, 3, alto, ancho]
// RGB 0..1, la foto entera.
/// El lado mayor al que se rellena.
pub const LADO_MAX: usize = 1024;
/// Cómo entra un trozo de `ancho` × `alto` al modelo: reducido a `sw` × `sh` y completado en espejo
/// hasta `pw` × `ph` (potencias de 2).
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct Plan {
pub sw: usize,
pub sh: usize,
pub pw: usize,
pub ph: usize,
}
pub fn plan(ancho: usize, alto: usize) -> Plan {
let mayor = ancho.max(alto);
let lado = if mayor <= 768 { 512 } else { LADO_MAX };
let k = (lado as f64 / mayor as f64).min(1.0);
let sw = ((ancho as f64 * k).round() as usize).clamp(8, lado);
let sh = ((alto as f64 * k).round() as usize).clamp(8, lado);
Plan { sw, sh, pw: sw.next_power_of_two().max(64), ph: sh.next_power_of_two().max(64) }
}
/// Remuestrea `de` (`canales` por punto, `aw` × `ah`) a `bw` × `bh` entre sus cuatro vecinos.
pub fn remuestrear(de: &[f32], aw: usize, ah: usize, canales: usize, bw: usize, bh: usize) -> Vec<f32> {
let mut out = vec![0f32; bw * bh * canales];
for y in 0..bh {
let fy = (((y as f32 + 0.5) / bh as f32) * ah as f32 - 0.5).clamp(0.0, (ah - 1) as f32);
let y0 = fy.floor() as usize;
let y1 = (y0 + 1).min(ah - 1);
let wy = fy - y0 as f32;
for x in 0..bw {
let fx = (((x as f32 + 0.5) / bw as f32) * aw as f32 - 0.5).clamp(0.0, (aw - 1) as f32);
let x0 = fx.floor() as usize;
let x1 = (x0 + 1).min(aw - 1);
let wx = fx - x0 as f32;
for c in 0..canales {
let p = |xx: usize, yy: usize| de[(yy * aw + xx) * canales + c];
let arriba = p(x0, y0) + (p(x1, y0) - p(x0, y0)) * wx;
let abajo = p(x0, y1) + (p(x1, y1) - p(x0, y1)) * wx;
out[(y * bw + x) * canales + c] = arriba + (abajo - arriba) * wy;
}
}
}
out
}
/// El índice en espejo de `i` dentro de 0..n (…2 1 0 1 2…n-2 n-1 n-2…).
fn espejo(i: usize, n: usize) -> usize {
if n <= 1 {
return 0;
}
let periodo = 2 * (n - 1);
let r = i % periodo;
if r < n { r } else { periodo - r }
}
/// La entrada del modelo (planos [4, ph, pw]) a partir del RGBA de la página.
pub fn entrada(rgba: &[u8], ancho: usize, alto: usize, p: Plan) -> Vec<f32> {
let n = ancho * alto;
let mut rgb = Vec::with_capacity(n * 3);
let mut hueco = Vec::with_capacity(n);
for i in 0..n {
rgb.extend_from_slice(&[rgba[i * 4] as f32 / 255.0, rgba[i * 4 + 1] as f32 / 255.0, rgba[i * 4 + 2] as f32 / 255.0]);
hueco.push(if rgba[i * 4 + 3] >= 128 { 1.0 } else { 0.0 });
}
let rgb = remuestrear(&rgb, ancho, alto, 3, p.sw, p.sh);
let hueco = remuestrear(&hueco, ancho, alto, 1, p.sw, p.sh);
let m = p.pw * p.ph;
let mut out = vec![0f32; 4 * m];
for y in 0..p.ph {
let dentro_y = y < p.sh;
let sy = espejo(y, p.sh);
for x in 0..p.pw {
let dentro = dentro_y && x < p.sw;
let sx = espejo(x, p.sw);
let s = sy * p.sw + sx;
// lo que toca el hueco, aunque sea un poco al reducir, es hueco; el espejo, nunca
let h = if dentro && hueco[s] > 0.02 { 1.0 } else { 0.0 };
let i = y * p.pw + x;
for c in 0..3 {
out[c * m + i] = if h > 0.0 { 0.0 } else { rgb[s * 3 + c] };
}
out[3 * m + i] = h;
}
}
out
}
/// La salida del modelo (planos [3, ph, pw], 0..1) de vuelta a `ancho` × `alto`: dentro del hueco lo
/// rellenado; fuera, el RGB que llegó, tal cual. RGB 8 bits.
pub fn salida(planos: &[f32], p: Plan, rgba: &[u8], ancho: usize, alto: usize) -> Vec<u8> {
let m = p.pw * p.ph;
let mut intercalado = vec![0f32; p.sw * p.sh * 3];
for y in 0..p.sh {
for x in 0..p.sw {
for c in 0..3 {
intercalado[(y * p.sw + x) * 3 + c] = planos[c * m + y * p.pw + x];
}
}
}
let grande = remuestrear(&intercalado, p.sw, p.sh, 3, ancho, alto);
let n = ancho * alto;
let mut out = vec![0u8; n * 3];
for i in 0..n {
let dentro = rgba[i * 4 + 3] >= 128;
for c in 0..3 {
out[i * 3 + c] = if dentro { (grande[i * 3 + c].clamp(0.0, 1.0) * 255.0).round() as u8 } else { rgba[i * 4 + c] };
}
}
out
}
#[cfg(test)]
mod pruebas {
use super::*;
#[test]
fn el_plan_reduce_lo_justo_y_completa_hasta_potencias_de_2() {
assert_eq!(plan(700, 460), Plan { sw: 512, sh: 336, pw: 512, ph: 512 });
assert_eq!(plan(500, 300), Plan { sw: 500, sh: 300, pw: 512, ph: 512 });
assert_eq!(plan(1400, 933), Plan { sw: 1024, sh: 682, pw: 1024, ph: 1024 });
assert_eq!(plan(200, 100), Plan { sw: 200, sh: 100, pw: 256, ph: 128 });
assert_eq!(plan(20, 10), Plan { sw: 20, sh: 10, pw: 64, ph: 64 });
assert_eq!(espejo(5, 4), 1);
assert_eq!(espejo(3, 4), 3);
}
#[test]
fn fuera_del_hueco_queda_lo_que_habia() {
// 2 × 1: el primero es hueco
let rgba = [10, 20, 30, 255, 40, 50, 60, 0];
let p = plan(2, 1);
let e = entrada(&rgba, 2, 1, p);
let m = p.pw * p.ph;
assert_eq!(e.len(), 4 * m);
// el hueco (0,0): RGB a cero y el canal del hueco a 1; el espejo (fuera de 2 × 1) no es hueco
assert_eq!(e[0], 0.0);
assert_eq!(e[3 * m], 1.0);
assert_eq!(e[3 * m + 10], 0.0);
// lo de la red: todo gris
let planos = vec![0.5f32; 3 * m];
let s = salida(&planos, p, &rgba, 2, 1);
assert_eq!(&s[..3], &[128, 128, 128]);
assert_eq!(&s[3..], &[40, 50, 60]);
}
}

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 salud;
mod memoria;
mod ia;
use std::{
collections::HashMap,
@@ -81,6 +82,19 @@ fn es_pestana_interna(target: &Url, server: &Url) -> bool {
target.origin() == server.origin()
}
// El color que pide una página para su pestaña: solo `#rrggbb` (va a un estilo de la barra; nada
// que no sea un color llega allí).
fn color_de_pagina(c: &str) -> Option<String> {
let hex = c.strip_prefix('#')?;
(hex.len() == 6 && hex.chars().all(|ch| ch.is_ascii_hexdigit())).then(|| c.to_ascii_lowercase())
}
// Un nombre de ventana que identifica pestaña: los de la app (`ls-…`, p. ej. `ls-edicion-<set>`).
// Los reservados del navegador (_blank, _self…) y cualquier otro abren pestaña nueva como siempre.
fn nombre_de_ventana(n: &str) -> Option<String> {
(n.starts_with("ls-") && n.len() <= 80 && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')).then(|| n.to_string())
}
#[cfg(test)]
mod navigation_tests {
use super::*;
@@ -95,6 +109,20 @@ mod navigation_tests {
assert!(is_google_host("docs.google.com"));
assert!(!is_google_host("docs.google.com.evil.example"));
}
#[test]
fn la_pagina_solo_pide_colores_y_nombres_de_la_app() {
assert_eq!(color_de_pagina("#262626").as_deref(), Some("#262626"));
assert_eq!(color_de_pagina("#A0b1C2").as_deref(), Some("#a0b1c2"));
for malo in ["", "262626", "#26262", "#2626266", "red", "#26262g", "#262626;background:url(x)"] {
assert_eq!(color_de_pagina(malo), None, "{malo}");
}
assert_eq!(nombre_de_ventana("ls-edicion-cmg123").as_deref(), Some("ls-edicion-cmg123"));
for malo in ["", "_blank", "_self", "edicion", "ls-a b", "ls-\"<x>"] {
assert_eq!(nombre_de_ventana(malo), None, "{malo}");
}
assert_eq!(nombre_de_ventana(&format!("ls-{}", "a".repeat(80))), None);
}
}
const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome)
const CHROME: &str = "chrome";
@@ -122,6 +150,12 @@ struct TabRec {
// Ahorro de memoria (memoria.rs): su webview se cerró tras DESCARTE_TRAS dormida. Sigue en la
// barra con título, URL y color, y se crea otra vez al volver a ella.
descartada: bool,
// El color que PIDE la página (`<html data-ls-pestana="#262626">`, ver `color_de_pagina`): el
// modo edición de un set pinta su pestaña del gris del editor. Lo manda la página, no el menú.
pagina: Option<String>,
// El nombre de ventana con que se abrió (`window.open(url, nombre)`): abrir otra vez con el
// mismo nombre lleva a esta pestaña en vez de abrir otra (ver `reusar_pestana`).
nombre: Option<String>,
}
// Paleta de colores de pestaña, la misma idea que los grupos de Chrome: sirve para agrupar de
@@ -198,6 +232,12 @@ struct Persisted {
// cargar (ver `registrar_diferida`) y la barra necesita algo que enseñar mientras tanto.
#[serde(default)]
titles: Vec<String>,
// El color que pidió la página y el nombre de ventana de cada una (mismo orden; "" = ninguno):
// la pestaña del modo edición vuelve del gris del editor y se sigue reutilizando.
#[serde(default)]
paginas: Vec<String>,
#[serde(default)]
nombres: Vec<String>,
#[serde(default)]
active: usize,
#[serde(default)]
@@ -230,6 +270,8 @@ fn save_persisted<R: Runtime>(app: &AppHandle<R>) {
tabs: s.tabs.iter().map(|t| t.url.clone()).collect(),
colors: s.tabs.iter().map(|t| t.color.clone().unwrap_or_default()).collect(),
titles: s.tabs.iter().map(|t| t.title.clone()).collect(),
paginas: s.tabs.iter().map(|t| t.pagina.clone().unwrap_or_default()).collect(),
nombres: s.tabs.iter().map(|t| t.nombre.clone().unwrap_or_default()).collect(),
active: s.active,
autostart: s.autostart,
dark: s.dark,
@@ -287,7 +329,9 @@ const INIT_JS_TPL: &str = r#"
try { T.core.invoke('plugin:opener|open_url', { url: url, with: null }); }
catch (e) { try { T.opener.openUrl(url); } catch (_) {} }
}
function newTab(url) { emit('ls-new-tab', { url: url }); }
function newTab(url, nombre) { emit('ls-new-tab', nombre ? { url: url, name: nombre } : { url: url }); }
// Una ventana con nombre de la app (`ls-…`): reabrirla lleva a la pestaña que ya la tiene.
function nombreVentana(n) { return typeof n === 'string' && /^ls-[A-Za-z0-9_-]{1,77}$/.test(n) ? n : null; }
// ── Errores de la página → diario de salud del shell (salud.log) ──
// Solo el mensaje, el archivo:línea y la ruta (sin datos del formulario ni de la sesión). Como
@@ -373,13 +417,25 @@ const INIT_JS_TPL: &str = r#"
} catch (e) { return null; }
}
// ── El color que la página pide para SU pestaña ── `<html data-ls-pestana='#262626'>` (el modo
// edición de un set, del gris del editor). "" = ninguno.
function colorPestana() {
try {
var c = document.documentElement && document.documentElement.getAttribute('data-ls-pestana');
return c && /^#[0-9a-fA-F]{6}$/.test(c) ? c : '';
} catch (e) { return ''; }
}
// Título, URL y TEMA → barra de pestañas (y para restaurar la sesión al reabrir).
// El tema viaja aquí porque la web decide su modo con su propio botón y NO sigue al
// sistema: la barra tiene que copiarle a ELLA.
// sistema: la barra tiene que copiarle a ELLA. Una página con color propio no lo dice: su
// fondo oscuro es el de su pestaña, no el tema de la app.
function report() {
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href };
var carga = { label: LABEL, title: document.title || 'Labstream OS', url: location.href, pestana: colorPestana() };
if (!carga.pestana) {
var oscuro = temaOscuro();
if (oscuro !== null) carga.dark = oscuro;
}
emit('ls-title', carga);
}
function watchTitle() {
@@ -404,7 +460,7 @@ const INIT_JS_TPL: &str = r#"
// por si el tema aterriza tarde con la hidratación.
function vigilarTema() {
if (!window.MutationObserver) return;
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] };
var conf = { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-ls-pestana'] };
if (document.documentElement) new MutationObserver(report).observe(document.documentElement, conf);
if (document.body) new MutationObserver(report).observe(document.body, conf);
}
@@ -421,7 +477,8 @@ const INIT_JS_TPL: &str = r#"
if (!u) return;
if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; }
var mod = e.metaKey || e.ctrlKey;
if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); }
var nombre = mod ? null : nombreVentana(a.target);
if (isApp(u) && (mod || a.target === '_blank' || nombre)) { e.preventDefault(); newTab(u.href, nombre); }
}, true);
// Clic con la rueda (botón central) en un enlace interno → pestaña nueva, como en el navegador.
@@ -438,7 +495,7 @@ const INIT_JS_TPL: &str = r#"
window.open = function (url, name, feats) {
var u = url ? abs(url) : null;
if (isExt(u)) { openExternal(u.href); return null; }
if (isInterno(u)) { newTab(u.href); return null; }
if (isInterno(u)) { newTab(u.href, nombreVentana(name)); return null; }
return _open ? _open.call(window, url, name, feats) : null;
};
@@ -641,12 +698,14 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
.tabs
.iter()
.enumerate()
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color }))
.map(|(i, t)| json!({ "label": t.label, "title": t.title, "url": t.url, "loading": t.loading, "active": i == s.active, "color": t.color, "pagina": t.pagina }))
.collect();
json!({
"tabs": tabs,
"zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32,
"version": app.package_info().version.to_string(),
// La versión nueva que ya está publicada y espera a que se pulse «Actualizar» (o null).
"actualizacion": version_pendiente(),
// En macOS la barra ocupa la fila del título (ventana con título en overlay) y
// debe dejar libre el hueco del semáforo, que lo pinta el sistema.
"mac": cfg!(target_os = "macos"),
@@ -814,12 +873,17 @@ fn avisar_descarga_terminada<R: Runtime>(webview: &Webview<R>, url: &Url, path:
// Crea una pestaña nueva cargando `url` (o el servidor si viene vacío).
fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool) {
crear_pestana(app, url, activate, None);
}
// Lo mismo, con su nombre de ventana (ver `reusar_pestana`). Devuelve la etiqueta de la nueva.
fn crear_pestana<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: bool, nombre: Option<String>) -> Option<String> {
let target = url.unwrap_or_else(|| SERVER_URL.to_string());
// Solo el origen de la app puede abrirse en pestaña; cualquier otra cosa va al navegador.
let Ok(parsed) = Url::parse(&target) else { return };
let Ok(server) = Url::parse(SERVER_URL) else { return };
let parsed = Url::parse(&target).ok()?;
let server = Url::parse(SERVER_URL).ok()?;
if !es_pestana_interna(&parsed, &server) {
return;
return None;
}
let label = {
@@ -827,7 +891,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let mut s = shell.lock().unwrap();
let label = format!("tab-{}", s.next_id);
s.next_id += 1;
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false, descartada: false });
s.tabs.push(TabRec { label: label.clone(), title: "Labstream OS".into(), url: target.clone(), loading: true, color: None, oculta_desde: if activate { None } else { Some(Instant::now()) }, dormida: false, descartada: false, pagina: None, nombre });
label
};
@@ -839,6 +903,7 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
broadcast(app);
save_persisted(app);
}
Some(label)
}
Err(e) => {
salud::anotar("ERROR", "pestaña", &format!("no se pudo crear el webview: {e}"));
@@ -846,10 +911,46 @@ fn create_tab<R: Runtime>(app: &AppHandle<R>, url: Option<String>, activate: boo
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
s.tabs.retain(|t| t.label != label);
None
}
}
}
// ── Pestañas con NOMBRE (1.21) ──
// `window.open(url, "ls-edicion-<set>")` (el modo edición de un set) abre la pestaña la primera
// vez; las siguientes llevan a ESA pestaña, como hace el navegador con una ventana con nombre, en
// vez de amontonar una por foto. A la página se le pasa la URL nueva por `__lsAbrir` (el modo
// edición cambia de foto sin recargar, guardando lo de la que tenía); si no lo conoce, navega.
// Devuelve false si no hay pestaña con ese nombre (entonces se crea).
fn reusar_pestana<R: Runtime>(app: &AppHandle<R>, nombre: &str, url: Option<&str>) -> bool {
let Ok(server) = Url::parse(SERVER_URL) else { return false };
let destino = match url.map(Url::parse) {
None => None,
Some(Ok(u)) if es_pestana_interna(&u, &server) => Some(u),
Some(_) => return false,
};
let (label, descartada) = {
let shell = app.state::<ShellState>();
let mut s = shell.lock().unwrap();
let Some(t) = s.tabs.iter_mut().find(|t| t.nombre.as_deref() == Some(nombre)) else { return false };
// Descartada (sin webview): se crea ya en la URL nueva al activarla.
if t.descartada {
if let Some(d) = &destino {
t.url = d.to_string();
}
}
(t.label.clone(), t.descartada)
};
activate_tab(app, &label);
if let (Some(d), false) = (destino, descartada) {
if let Some(wv) = get_webview(app, &label) {
let u = serde_json::to_string(d.as_str()).unwrap_or_else(|_| "\"\"".into());
let _ = wv.eval(format!("(function(u){{try{{if(window.__lsAbrir&&window.__lsAbrir(u))return}}catch(e){{}}location.assign(u)}})({u})"));
}
}
true
}
// Crea el webview de una pestaña: una nueva (create_tab) o una descartada que vuelve
// (revivir_pestana, al activarla). Lo vigila salud.rs: si su proceso muere, se recarga sola.
fn montar_webview<R: Runtime>(app: &AppHandle<R>, label: &str, parsed: Url) -> tauri::Result<Webview<R>> {
@@ -935,7 +1036,7 @@ fn registrar_diferida<R: Runtime>(app: &AppHandle<R>, url: &str, title: &str) {
let label = format!("tab-{}", s.next_id);
s.next_id += 1;
let title = if title.is_empty() { "Labstream OS".to_string() } else { title.to_string() };
s.tabs.push(TabRec { label, title, url: url.to_string(), loading: false, color: None, oculta_desde: Some(Instant::now()), dormida: false, descartada: true });
s.tabs.push(TabRec { label, title, url: url.to_string(), loading: false, color: None, oculta_desde: Some(Instant::now()), dormida: false, descartada: true, pagina: None, nombre: None });
}
// Vuelve a crear el webview de una pestaña descartada, en su última URL. Devuelve false si no se
@@ -1458,6 +1559,47 @@ fn show_main<R: Runtime>(app: &AppHandle<R>) {
}
}
// ── La versión nueva, también con la app abierta todo el día (1.24, 14-oct-2026) ──
// Al abrir, la app busca versión nueva y, si hay, la instala y se reinicia (antes de que nadie
// trabaje). Pero solo al abrir: un equipo que deja la app abierta de la mañana a la noche se
// quedaba en la versión de la mañana (este PC seguía en la 1.20 con la 1.23 publicada, y Rellenar
// con IA no le llegaba). Ahora, cada hora, mira si hay una nueva SIN instalarla —reiniciar en
// medio del trabajo perdería lo que no se guardó en una página—: la barra cambia la versión por
// «Actualizar a vX» (el mismo botón de siempre: instala y reinicia, y las pestañas vuelven) y
// avisa una vez por versión.
const BUSCAR_CADA: Duration = Duration::from_secs(60 * 60);
static PENDIENTE: Mutex<Option<String>> = Mutex::new(None);
fn version_pendiente() -> Option<String> {
PENDIENTE.lock().ok().and_then(|p| p.clone())
}
#[cfg(desktop)]
async fn buscar_sin_instalar(handle: tauri::AppHandle) {
use tauri_plugin_updater::UpdaterExt;
let Ok(updater) = handle.updater() else { return };
let nueva = match updater.check().await {
Ok(Some(u)) => u.version,
Ok(None) => return,
Err(e) => {
salud::anotar("AVISO", "actualizacion", &format!("no se pudo comprobar: {e}"));
return;
}
};
let ya = PENDIENTE.lock().map(|mut p| p.replace(nueva.clone())).unwrap_or(None);
if ya.as_deref() == Some(nueva.as_str()) {
return;
}
salud::anotar("INFO", "actualizacion", &format!("hay versión nueva ({nueva}): espera a que se pulse «Actualizar»"));
broadcast(&handle);
let _ = handle
.notification()
.builder()
.title("Labstream OS")
.body(format!("Hay una versión nueva ({nueva}). Pulsa «Actualizar» en la barra de pestañas: la app se reinicia y vuelve a abrir tus pestañas."))
.show();
}
// Busca e instala una actualización en segundo plano. Si instala, avisa y reinicia.
#[cfg(desktop)]
async fn check_update(handle: tauri::AppHandle) {
@@ -1596,7 +1738,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, ia::ia_rellenar]);
let app = builder
.manage(ShellState::default())
@@ -1624,6 +1768,15 @@ pub fn run() {
let handle = app.handle().clone();
tauri::async_runtime::spawn(check_update(handle));
}
// Y después, cada hora, sin instalarla: la barra ofrece «Actualizar» (ver PENDIENTE).
#[cfg(desktop)]
{
let handle = app.handle().clone();
std::thread::spawn(move || loop {
std::thread::sleep(BUSCAR_CADA);
tauri::async_runtime::block_on(buscar_sin_instalar(handle.clone()));
});
}
{
let shell = handle.state::<ShellState>();
@@ -1815,7 +1968,7 @@ pub fn run() {
// registran descartadas (título, URL y color en la barra) y se crean al hacer clic en
// ellas, como hacen Chrome y Edge. Antes se cargaban todas a la vez: con 5 pestañas, 5
// copias enteras de la web app arrancando juntas.
let restore: Vec<(String, String, String)> = {
let restore: Vec<(String, String, String, String, String)> = {
let server = Url::parse(SERVER_URL).expect("URL del servidor válida");
saved
.tabs
@@ -1824,7 +1977,8 @@ pub fn run() {
.filter(|(_, u)| Url::parse(u).map(|p| es_pestana_interna(&p, &server)).unwrap_or(false))
.take(12)
.map(|(i, u)| {
(u.clone(), saved.colors.get(i).cloned().unwrap_or_default(), saved.titles.get(i).cloned().unwrap_or_default())
let de = |v: &Vec<String>| v.get(i).cloned().unwrap_or_default();
(u.clone(), de(&saved.colors), de(&saved.titles), de(&saved.paginas), de(&saved.nombres))
})
.collect()
};
@@ -1832,21 +1986,21 @@ pub fn run() {
create_tab(&handle, None, true);
} else {
let idx = saved.active.min(restore.len() - 1);
for (i, (url, color, title)) in restore.iter().enumerate() {
for (i, (url, color, title, pagina, nombre)) in restore.iter().enumerate() {
if i == idx {
create_tab(&handle, Some(url.clone()), false);
} else {
registrar_diferida(&handle, url, title);
}
if !color.is_empty() {
// La pestaña se acaba de empujar al final de la lista.
let shell = handle.state::<ShellState>();
let mut s = shell.lock().unwrap();
if let Some(t) = s.tabs.last_mut() {
if t.url == *url {
if let Some(t) = s.tabs.last_mut().filter(|t| t.url == *url) {
if !color.is_empty() {
t.color = Some(color.clone());
}
}
t.pagina = color_de_pagina(pagina);
t.nombre = nombre_de_ventana(nombre);
}
}
let label = {
@@ -1881,7 +2035,13 @@ pub fn run() {
let h = handle.clone();
handle.listen_any("ls-new-tab", move |e| {
let url = field(e.payload(), "url");
create_tab(&h, url, true);
let nombre = field(e.payload(), "name").and_then(|n| nombre_de_ventana(&n));
if let Some(n) = &nombre {
if reusar_pestana(&h, n, url.as_deref()) {
return;
}
}
crear_pestana(&h, url, true, nombre);
});
let h = handle.clone();
handle.listen_any("ls-activate", move |e| {
@@ -1982,11 +2142,12 @@ pub fn run() {
});
let h = handle.clone();
handle.listen_any("ls-title", move |e| {
let (label, title, url, dark) = (
let (label, title, url, dark, pestana) = (
field(e.payload(), "label"),
field(e.payload(), "title"),
field(e.payload(), "url"),
field_bool(e.payload(), "dark"),
field(e.payload(), "pestana"),
);
if let Some(label) = label {
let shell = h.state::<ShellState>();
@@ -1998,6 +2159,10 @@ pub fn run() {
if let Some(url) = url {
t.url = url;
}
// (una página de antes de la 1.21 no lo manda: se deja como estaba)
if let Some(p) = pestana {
t.pagina = color_de_pagina(&p);
}
}
// El tema es global (todas las pestañas del origen comparten modo): el
// último reporte manda, venga de la pestaña que venga.

Ver fichero

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