Comparar commits

...

4 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
Se han modificado 16 ficheros con 567 adiciones y 69 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

@@ -36,6 +36,13 @@ NAS. Esto le da al equipo un cliente nativo de verdad en Windows y Mac:
(`build.rs` + `capabilities/default.json`). El modelo se suelta de la tarjeta a los (`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: 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`. `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 - **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
@@ -273,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 la comprobación con clic en la versión de la barra, o desde el menú ⋮ ▸ *Buscar
actualización…*. 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 > ⚠️ 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 > 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 > el updater no ve versión nueva. Y al revés: publicar una etiqueta **actualiza la app

16
dist/index.html vendido
Ver fichero

@@ -333,6 +333,14 @@
border-radius: 13px; border-radius: 13px;
} }
#version:hover { color: var(--accent); } #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 ────────────────────────────────────────────────────────── /* ── Botones de ventana ──────────────────────────────────────────────────────────
Solo en Windows: allí la ventana va SIN decoración, así que esta barra es la fila 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 del título (como Chrome) y el aspa del sistema no existe. Medidas y colores del
@@ -595,7 +603,13 @@
$pct.textContent = estado.zoom + "%"; $pct.textContent = estado.zoom + "%";
$pct.classList.toggle("activo", estado.zoom !== 100); $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), // 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. // no se pone clase y siguen las media queries del sistema como arranque en frío.
if (typeof estado.dark === "boolean") { if (typeof estado.dark === "boolean") {

Ver fichero

@@ -1,6 +1,6 @@
{ {
"name": "labstream-desktop", "name": "labstream-desktop",
"version": "1.22.0", "version": "1.24.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": {

Ver fichero

@@ -2367,7 +2367,7 @@ dependencies = [
[[package]] [[package]]
name = "labstream-desktop" name = "labstream-desktop"
version = "1.22.0" version = "1.24.0"
dependencies = [ dependencies = [
"active-win-pos-rs", "active-win-pos-rs",
"device_query", "device_query",

Ver fichero

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

Ver fichero

@@ -3,7 +3,7 @@ fn main() {
// permiso en capabilities/default.json, y es lo que deja que la página remota los llame. // permiso en capabilities/default.json, y es lo que deja que la página remota los llame.
tauri_build::try_build( tauri_build::try_build(
tauri_build::Attributes::new() tauri_build::Attributes::new()
.app_manifest(tauri_build::AppManifest::new().commands(&["ia_estado", "ia_preparar", "ia_sujeto"])), .app_manifest(tauri_build::AppManifest::new().commands(&["ia_estado", "ia_preparar", "ia_sujeto", "ia_rellenar"])),
) )
.expect("tauri-build"); .expect("tauri-build");
} }

Ver fichero

@@ -21,6 +21,7 @@
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"allow-ia-estado", "allow-ia-estado",
"allow-ia-preparar", "allow-ia-preparar",
"allow-ia-sujeto" "allow-ia-sujeto",
"allow-ia-rellenar"
] ]
} }

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

@@ -1,38 +1,44 @@
// ── La IA EN EL EQUIPO: la máscara de sujeto de Labstream OS con la tarjeta NVIDIA (1.22) ────── // ── 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 // 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» // conecta y sube el resultado a la app». En el editor de revelado (modo edición de un set):
// y «Fondo» no los busca el navegador sino esta app: ONNX Runtime con DirectML sobre la tarjeta // - «Sujeto» y «Fondo» los busca esta app: ONNX Runtime con DirectML sobre la tarjeta NVIDIA y
// NVIDIA y BiRefNet en media precisión (piezas.rs): 0,3 s por foto en vez de 2,5–3 s, y sin depender // BiRefNet en media precisión (piezas.rs): 0,3 s por foto en vez de 2,5–3 s en el navegador.
// del WebGPU de la pestaña. // - «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 tres comandos, permitidos SOLO para el // 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): // 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_estado ({ que }) → ¿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 // ia_preparar ({ que }) → baja lo que falte (eventos «ia-progreso» { fase, parte } a esa pestaña)
// el modelo en la tarjeta // y abre el modelo
// ia_sujeto ← RGBA en el cuerpo (binario) + x-ancho / x-alto // ia_sujeto ← RGBA en el cuerpo (binario) + x-ancho / x-alto
// → [ancho u32][alto u32][un byte por punto] (lib/revelado/ia-local: leerRespuesta) // → [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. // 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 pide la máscara. Lo bajado vive en // Nada se baja ni se carga al arrancar la app: solo cuando alguien lo pide. Lo bajado vive en los
// los datos de la app del equipo (`carpeta`), comprobado con su huella (bajar.rs). El modelo abierto // datos de la app del equipo (`carpeta`), comprobado con su huella (bajar.rs). Cada modelo abierto
// ocupa la tarjeta (~7 GB mientras busca: DirectML guarda lo que pidió): se suelta a los SOLTAR_TRAS // ocupa memoria (el del sujeto, ~7 GB de la tarjeta mientras busca: DirectML guarda lo que pidió):
// sin usarlo, que los editores trabajan con Resolve abierto. // se suelta a los SOLTAR_TRAS sin usarlo, que los editores trabajan con Resolve abierto.
mod bajar; mod bajar;
mod imagen; mod imagen;
mod motor; mod motor;
mod piezas; mod piezas;
mod relleno;
mod tarjeta; mod tarjeta;
use piezas::Que;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, EventTarget, Manager, Runtime, Webview}; 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). /// 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); const SOLTAR_TRAS: Duration = Duration::from_secs(2 * 60);
struct Abierto { struct Abierto {
@@ -40,21 +46,32 @@ struct Abierto {
usado: Instant, usado: Instant,
} }
static MODELO: Mutex<Option<Abierto>> = Mutex::new(None); /// 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). /// Una preparación a la vez (dos pestañas que piden a la vez no bajan dos veces).
static PREPARANDO: Mutex<()> = Mutex::new(()); static PREPARANDO: Mutex<()> = Mutex::new(());
static VIGILANTE: std::sync::Once = std::sync::Once::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> { 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}")) 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 { fn faltan(dir: &std::path::Path, que: Que) -> u64 {
piezas::todas().iter().filter(|p| !bajar::esta(dir, p)).map(|p| p.bytes).sum() piezas::de(que).into_iter().filter(|p| !bajar::esta(dir, p)).map(|p| p.bytes).sum()
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct Estado { pub struct Estado {
/// de qué IA habla (así la página sabe que esta app conoce Rellenar)
que: &'static str,
disponible: bool, disponible: bool,
motivo: Option<String>, motivo: Option<String>,
listo: bool, listo: bool,
@@ -63,24 +80,34 @@ pub struct Estado {
tarjeta: Option<String>, tarjeta: Option<String>,
} }
#[tauri::command] /// Por qué esta IA no puede correr aquí (None: sí puede). El sujeto pide NVIDIA; rellenar va en la CPU.
pub fn ia_estado<R: Runtime>(app: AppHandle<R>) -> Estado { fn motivo(que: Que, tarjeta: Option<&tarjeta::Tarjeta>) -> Option<String> {
let t = tarjeta::nvidia(); if !cfg!(all(windows, target_arch = "x86_64")) {
let motivo = if !cfg!(all(windows, target_arch = "x86_64")) { Some("La IA del equipo es para la app de Windows.".to_string())
Some("La IA del equipo es para Windows con tarjeta NVIDIA.".to_string()) } else if que == Que::Sujeto && tarjeta.is_none() {
} else if t.is_none() {
Some("Este equipo no tiene tarjeta NVIDIA.".to_string()) Some("Este equipo no tiene tarjeta NVIDIA.".to_string())
} else { } else {
None 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 dir = carpeta(&app).ok();
let falta = dir.as_deref().map(faltan).unwrap_or(u64::MAX); let falta = dir.as_deref().map(|d| faltan(d, que)).unwrap_or(u64::MAX);
Estado { Estado {
que: que.nombre(),
disponible: motivo.is_none() && dir.is_some(), disponible: motivo.is_none() && dir.is_some(),
motivo, motivo,
listo: falta == 0, listo: falta == 0,
faltan: if falta == u64::MAX { 0 } else { falta }, faltan: if falta == u64::MAX { 0 } else { falta },
modelo: piezas::NOMBRE_MODELO, modelo: match que {
Que::Sujeto => piezas::NOMBRE_MODELO,
Que::Relleno => piezas::NOMBRE_RELLENO,
},
tarjeta: t.map(|t| t.nombre), tarjeta: t.map(|t| t.nombre),
} }
} }
@@ -91,7 +118,8 @@ fn avisar<R: Runtime>(app: &AppHandle<R>, label: &str, fase: &str, parte: f64) {
/// Baja lo que falte y deja el modelo abierto (en un hilo aparte: puede tardar minutos). /// Baja lo que falte y deja el modelo abierto (en un hilo aparte: puede tardar minutos).
#[tauri::command] #[tauri::command]
pub async fn ia_preparar<R: Runtime>(app: AppHandle<R>, webview: Webview<R>) -> Result<(), String> { 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(); 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 // 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. // no en el hilo de abajo: leer cookies de WebView2 desde un comando síncrono se queda colgado.
@@ -99,23 +127,23 @@ pub async fn ia_preparar<R: Runtime>(app: AppHandle<R>, webview: Webview<R>) ->
.ok() .ok()
.and_then(|u| webview.cookies_for_url(u).ok()) .and_then(|u| webview.cookies_for_url(u).ok())
.map(|cs| cs.iter().map(|c| format!("{}={}", c.name(), c.value())).collect::<Vec<_>>().join("; ")); .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())) tauri::async_runtime::spawn_blocking(move || preparar(&app, &label, sesion.as_deref(), que))
.await .await
.map_err(|e| format!("La IA no pudo prepararse: {e}"))? .map_err(|e| format!("La IA no pudo prepararse: {e}"))?
} }
fn preparar<R: Runtime>(app: &AppHandle<R>, label: &str, sesion: Option<&str>) -> Result<(), String> { fn preparar<R: Runtime>(app: &AppHandle<R>, label: &str, sesion: Option<&str>, que: Que) -> Result<(), String> {
if tarjeta::nvidia().is_none() { if let Some(m) = motivo(que, tarjeta::nvidia().as_ref()) {
return Err("Este equipo no tiene tarjeta NVIDIA.".into()); return Err(m);
} }
let _uno = PREPARANDO.lock().unwrap_or_else(|e| e.into_inner()); let _uno = PREPARANDO.lock().unwrap_or_else(|e| e.into_inner());
let dir = carpeta(app)?; let dir = carpeta(app)?;
let total = faltan(&dir); let total = faltan(&dir, que);
if total > 0 { if total > 0 {
let hecho = AtomicU64::new(0); let hecho = AtomicU64::new(0);
let mut ultimo = 0u64; let mut ultimo = 0u64;
avisar(app, label, "bajando", 0.0); avisar(app, label, "bajando", 0.0);
for p in piezas::todas() { for p in piezas::de(que) {
bajar::bajar(&dir, p, sesion, &mut |n| { bajar::bajar(&dir, p, sesion, &mut |n| {
let h = hecho.fetch_add(n, Ordering::Relaxed) + n; let h = hecho.fetch_add(n, Ordering::Relaxed) + n;
// un aviso cada ~1 % // un aviso cada ~1 %
@@ -129,40 +157,45 @@ fn preparar<R: Runtime>(app: &AppHandle<R>, label: &str, sesion: Option<&str>) -
e e
})?; })?;
} }
crate::salud::anotar("INFO", "ia", &format!("IA bajada al equipo ({} MB)", total / 1_000_000)); crate::salud::anotar("INFO", "ia", &format!("IA de {} bajada al equipo ({} MB)", que.nombre(), total / 1_000_000));
} }
let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner()); let mut m = abierto(que).lock().unwrap_or_else(|e| e.into_inner());
if m.is_none() { if m.is_none() {
avisar(app, label, "preparando", 0.0); avisar(app, label, "preparando", 0.0);
let t0 = Instant::now(); let t0 = Instant::now();
let motor = motor::Motor::abrir(&dir).map_err(|e| { let motor = match que {
crate::salud::anotar("ERROR", "ia", &format!("no se pudo abrir el modelo: {e}")); 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 e
})?; })?;
crate::salud::anotar("INFO", "ia", &format!("modelo abierto en {:.1} s ({})", t0.elapsed().as_secs_f32(), motor.donde())); 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() }); *m = Some(Abierto { motor, usado: Instant::now() });
vigilar(); vigilar();
} }
Ok(()) Ok(())
} }
/// Suelta el modelo de la tarjeta cuando lleva SOLTAR_TRAS sin usarse. /// Suelta cada modelo cuando lleva SOLTAR_TRAS sin usarse.
fn vigilar() { fn vigilar() {
VIGILANTE.call_once(|| { VIGILANTE.call_once(|| {
std::thread::spawn(|| loop { std::thread::spawn(|| loop {
std::thread::sleep(Duration::from_secs(15)); std::thread::sleep(Duration::from_secs(15));
let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner()); 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) { if m.as_ref().map(|a| a.usado.elapsed() >= SOLTAR_TRAS).unwrap_or(false) {
*m = None; *m = None;
crate::salud::anotar("INFO", "ia", "modelo soltado de la tarjeta (sin uso)"); crate::salud::anotar("INFO", "ia", &format!("modelo de {} soltado (sin uso)", que.nombre()));
}
} }
}); });
}); });
} }
/// Busca el sujeto en la foto que manda la página (RGBA en el cuerpo, medidas en las cabeceras). /// La foto que manda la página: RGBA en el cuerpo, medidas en las cabeceras.
#[tauri::command] fn foto_de(request: &tauri::ipc::Request<'_>) -> Result<(Vec<u8>, usize, usize), String> {
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 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 (ancho, alto) = (medida("x-ancho"), medida("x-alto"));
let tauri::ipc::InvokeBody::Raw(rgba) = request.body() else { let tauri::ipc::InvokeBody::Raw(rgba) = request.body() else {
@@ -171,7 +204,13 @@ pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result<tauri::ipc::R
if ancho < 2 || alto < 2 || ancho > 16_384 || alto > 16_384 || rgba.len() != ancho * alto * 4 { 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()); return Err("La foto llegó con unas medidas que no cuadran.".into());
} }
let rgba = rgba.clone(); 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)) tauri::async_runtime::spawn_blocking(move || buscar(&rgba, ancho, alto))
.await .await
.map_err(|e| format!("La IA falló: {e}"))? .map_err(|e| format!("La IA falló: {e}"))?
@@ -179,7 +218,7 @@ pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result<tauri::ipc::R
} }
fn buscar(rgba: &[u8], ancho: usize, alto: usize) -> Result<Vec<u8>, String> { 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 mut m = SUJETO.lock().unwrap_or_else(|e| e.into_inner());
let Some(a) = m.as_mut() else { let Some(a) = m.as_mut() else {
return Err("La IA no está preparada.".into()); return Err("La IA no está preparada.".into());
}; };
@@ -201,6 +240,40 @@ fn buscar(rgba: &[u8], ancho: usize, alto: usize) -> Result<Vec<u8>, String> {
} }
} }
/// 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))] #[cfg(all(test, windows))]
mod pruebas { mod pruebas {
use super::*; use super::*;
@@ -214,7 +287,7 @@ mod pruebas {
#[ignore] #[ignore]
fn de_punta_a_punta_en_la_tarjeta() { fn de_punta_a_punta_en_la_tarjeta() {
let dir = PathBuf::from(std::env::var("LS_IA_DIR").expect("LS_IA_DIR")); let dir = PathBuf::from(std::env::var("LS_IA_DIR").expect("LS_IA_DIR"));
for p in piezas::todas() { for p in piezas::de(Que::Sujeto) {
let t = Instant::now(); let t = Instant::now();
bajar::bajar(&dir, p, None, &mut |_| {}).unwrap(); bajar::bajar(&dir, p, None, &mut |_| {}).unwrap();
eprintln!("{}: listo en {:.1} s", p.nombre, t.elapsed().as_secs_f32()); eprintln!("{}: listo en {:.1} s", p.nombre, t.elapsed().as_secs_f32());
@@ -259,4 +332,39 @@ mod pruebas {
} }
} }
} }
/// 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]);
}
}
} }

Ver fichero

@@ -51,6 +51,7 @@ mod imp {
pub struct Motor { pub struct Motor {
sesion: Session, sesion: Session,
entrada: String, entrada: String,
donde: &'static str,
} }
impl Motor { impl Motor {
@@ -86,24 +87,48 @@ mod imp {
.commit_from_file(dir.join(archivo)) .commit_from_file(dir.join(archivo))
.map_err(|e| format!("No se pudo abrir el modelo: {e}"))?; .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.")?; let entrada = sesion.inputs().first().map(|i| i.name().to_string()).ok_or("El modelo no tiene entrada.")?;
Ok(Motor { sesion, 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 { pub fn donde(&self) -> &'static str {
"DirectML" self.donde
} }
/// La entrada [1, 3, lado, lado] → la salida [1, 1, lado, lado] (la última, si hay varias). /// 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> { 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 (forma, datos) = self.correr_forma(&[1, 3, lado, lado], entrada)?;
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 { if datos.len() != lado * lado {
return Err(format!("El modelo contestó {forma:?} y se esperaba {lado} × {lado}.")); return Err(format!("El modelo contestó {forma:?} y se esperaba {lado} × {lado}."));
} }
Ok(datos.to_vec()) 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()))
} }
} }
} }
@@ -118,12 +143,18 @@ mod imp {
pub fn abrir(_dir: &Path) -> Result<Motor, String> { pub fn abrir(_dir: &Path) -> Result<Motor, String> {
Err("La IA del equipo es para Windows con tarjeta NVIDIA.".into()) 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 donde(&self) -> &'static str {
"-" "-"
} }
pub fn correr(&mut self, _entrada: Vec<f32>, _lado: usize) -> Result<Vec<f32>, String> { 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()) 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())
}
} }
} }

Ver fichero

@@ -1,4 +1,6 @@
// ── Lo que la IA baja la primera vez (≈700 MB), de dónde, y con qué huella ── // ── 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 // 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. // lo que hay en esa dirección, no se usa.
@@ -11,6 +13,10 @@
// 5080: 0,3 s por foto y el mismo borde que el original en la CPU. // 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 // 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). // 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. // - 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 // - 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). // las de Xbox y las de depurar: se saca por su ruta exacta).
@@ -18,6 +24,8 @@
use super::bajar::{Forma, Pieza}; use super::bajar::{Forma, Pieza};
pub const NOMBRE_MODELO: &str = "BiRefNet"; 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. /// El lado de la entrada y de la salida del modelo.
pub const LADO: usize = 1024; pub const LADO: usize = 1024;
@@ -25,6 +33,38 @@ pub const ARCHIVO_MODELO: &str = "birefnet-general-epoch_244-fp16.onnx";
pub const DLL_ORT: &str = "onnxruntime.dll"; pub const DLL_ORT: &str = "onnxruntime.dll";
pub const DLL_DIRECTML: &str = "DirectML.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] = [ static PIEZAS: [Pieza; 3] = [
Pieza { Pieza {
nombre: "onnxruntime-directml-1.24.4", nombre: "onnxruntime-directml-1.24.4",
@@ -52,6 +92,10 @@ static PIEZAS: [Pieza; 3] = [
}, },
]; ];
pub fn todas() -> &'static [Pieza] { /// Lo que necesita cada IA: el motor (los dos primeros) y su modelo.
&PIEZAS 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]);
}
}

Ver fichero

@@ -704,6 +704,8 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
"tabs": tabs, "tabs": tabs,
"zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32, "zoom": (ZOOM_STEPS[s.zoom_idx.min(ZOOM_STEPS.len()-1)] * 100.0).round() as u32,
"version": app.package_info().version.to_string(), "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 // 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. // debe dejar libre el hueco del semáforo, que lo pinta el sistema.
"mac": cfg!(target_os = "macos"), "mac": cfg!(target_os = "macos"),
@@ -1557,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. // Busca e instala una actualización en segundo plano. Si instala, avisa y reinicia.
#[cfg(desktop)] #[cfg(desktop)]
async fn check_update(handle: tauri::AppHandle) { async fn check_update(handle: tauri::AppHandle) {
@@ -1697,7 +1740,7 @@ pub fn run() {
// 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. // 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]); .invoke_handler(tauri::generate_handler![ia::ia_estado, ia::ia_preparar, ia::ia_sujeto, ia::ia_rellenar]);
let app = builder let app = builder
.manage(ShellState::default()) .manage(ShellState::default())
@@ -1725,6 +1768,15 @@ pub fn run() {
let handle = app.handle().clone(); let handle = app.handle().clone();
tauri::async_runtime::spawn(check_update(handle)); 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>(); let shell = handle.state::<ShellState>();

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.22.0", "version": "1.24.0",
"identifier": "co.labstream.os", "identifier": "co.labstream.os",
"build": { "build": {
"frontendDist": "../dist" "frontendDist": "../dist"