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

16
dist/index.html vendido
Ver fichero

@@ -333,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
@@ -595,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.22.0",
"version": "1.24.0",
"private": true,
"description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.",
"scripts": {

Ver fichero

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

Ver fichero

@@ -1,6 +1,6 @@
[package]
name = "labstream-desktop"
version = "1.22.0"
version = "1.24.0"
description = "Cliente de escritorio de Labstream OS"
authors = ["Labstream SAS"]
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.
tauri_build::try_build(
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");
}

Ver fichero

@@ -21,6 +21,7 @@
"core:window:allow-start-dragging",
"allow-ia-estado",
"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
// conecta y sube el resultado a la app». En el editor de revelado (modo edición de un set), «Sujeto»
// y «Fondo» no los busca el navegador sino esta app: ONNX Runtime con DirectML sobre la tarjeta
// NVIDIA y BiRefNet en media precisión (piezas.rs): 0,3 s por foto en vez de 2,5–3 s, y sin depender
// del WebGPU de la pestaña.
// 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 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):
// ia_estado → ¿se puede aquí?, ¿está ya bajado?, cuánto falta, qué tarjeta
// ia_preparar → baja lo que falte (eventos «ia-progreso» { fase, parte } a esa pestaña) y abre
// el modelo en la tarjeta
// ia_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)
// 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
// los datos de la app del equipo (`carpeta`), comprobado con su huella (bajar.rs). El modelo abierto
// ocupa la tarjeta (~7 GB mientras busca: DirectML guarda lo que pidió): se suelta a los SOLTAR_TRAS
// sin usarlo, que los editores trabajan con Resolve abierto.
// 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 (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);
struct Abierto {
@@ -40,21 +46,32 @@ struct Abierto {
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).
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) -> u64 {
piezas::todas().iter().filter(|p| !bajar::esta(dir, p)).map(|p| p.bytes).sum()
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,
@@ -63,24 +80,34 @@ pub struct Estado {
tarjeta: Option<String>,
}
#[tauri::command]
pub fn ia_estado<R: Runtime>(app: AppHandle<R>) -> Estado {
let t = tarjeta::nvidia();
let motivo = if !cfg!(all(windows, target_arch = "x86_64")) {
Some("La IA del equipo es para Windows con tarjeta NVIDIA.".to_string())
} else if t.is_none() {
/// 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(faltan).unwrap_or(u64::MAX);
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: piezas::NOMBRE_MODELO,
modelo: match que {
Que::Sujeto => piezas::NOMBRE_MODELO,
Que::Relleno => piezas::NOMBRE_RELLENO,
},
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).
#[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();
// 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.
@@ -99,23 +127,23 @@ pub async fn ia_preparar<R: Runtime>(app: AppHandle<R>, webview: Webview<R>) ->
.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()))
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>) -> Result<(), String> {
if tarjeta::nvidia().is_none() {
return Err("Este equipo no tiene tarjeta NVIDIA.".into());
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);
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::todas() {
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 %
@@ -129,40 +157,45 @@ fn preparar<R: Runtime>(app: &AppHandle<R>, label: &str, sesion: Option<&str>) -
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() {
avisar(app, label, "preparando", 0.0);
let t0 = Instant::now();
let motor = motor::Motor::abrir(&dir).map_err(|e| {
crate::salud::anotar("ERROR", "ia", &format!("no se pudo abrir el modelo: {e}"));
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 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() });
vigilar();
}
Ok(())
}
/// Suelta el modelo de la tarjeta cuando lleva SOLTAR_TRAS sin usarse.
/// 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));
let mut m = MODELO.lock().unwrap_or_else(|e| e.into_inner());
if m.as_ref().map(|a| a.usado.elapsed() >= SOLTAR_TRAS).unwrap_or(false) {
*m = None;
crate::salud::anotar("INFO", "ia", "modelo soltado de la tarjeta (sin uso)");
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()));
}
}
});
});
}
/// Busca el sujeto en la foto que manda la página (RGBA en el cuerpo, medidas en las cabeceras).
#[tauri::command]
pub async fn ia_sujeto(request: tauri::ipc::Request<'_>) -> Result<tauri::ipc::Response, String> {
/// 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 {
@@ -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 {
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))
.await
.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> {
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 {
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))]
mod pruebas {
use super::*;
@@ -214,7 +287,7 @@ mod pruebas {
#[ignore]
fn de_punta_a_punta_en_la_tarjeta() {
let dir = PathBuf::from(std::env::var("LS_IA_DIR").expect("LS_IA_DIR"));
for p in piezas::todas() {
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());
@@ -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 {
sesion: Session,
entrada: String,
donde: &'static str,
}
impl Motor {
@@ -86,24 +87,48 @@ mod imp {
.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 })
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 {
"DirectML"
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 t = Tensor::from_array(([1usize, 3, lado, lado], entrada)).map_err(|e| format!("La foto no entró al modelo: {e}"))?;
let nombre = self.entrada.clone();
let salidas = self.sesion.run(ort::inputs![nombre => t]).map_err(|e| format!("La tarjeta no pudo buscar el sujeto: {e}"))?;
let ultima = salidas.len().checked_sub(1).ok_or("El modelo no contestó.")?;
let (forma, datos) = salidas[ultima].try_extract_tensor::<f32>().map_err(|e| format!("El modelo contestó algo raro: {e}"))?;
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.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> {
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())
}
}
}

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
// 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.
// 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).
@@ -18,6 +24,8 @@
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;
@@ -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_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",
@@ -52,6 +92,10 @@ static PIEZAS: [Pieza; 3] = [
},
];
pub fn todas() -> &'static [Pieza] {
&PIEZAS
/// 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]);
}
}

Ver fichero

@@ -704,6 +704,8 @@ fn broadcast<R: Runtime>(app: &AppHandle<R>) {
"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"),
@@ -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.
#[cfg(desktop)]
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.
.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]);
.invoke_handler(tauri::generate_handler![ia::ia_estado, ia::ia_preparar, ia::ia_sujeto, ia::ia_rellenar]);
let app = builder
.manage(ShellState::default())
@@ -1725,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>();

Ver fichero

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