Cerrar lo para todo, continuar sigue por donde iba, convertir antes de ordenar, y lo grande a la vista

Munka: «que al cerrar cierre todos los procesos… que al continuar la cola siga por donde iba y vuelva a generar los
videos que hagan falta… que identifique bien el material… y convertir antes de mover». Cerrar con trabajo avisa y
lo para todo (cola, lectura, respaldo, traer), cierra lo que quede con lo que lanzó y borra lo que quedó a medias.
Lo cortado vuelve a esperar en su sitio (no «cancelado») y al continuar se hace entero; también vuelven los fallos
por un corte y los convertidos que ya no están. Actualizar con la cola en marcha la detiene y sigue sola al volver.
El Convertidor enseña lo grande de la carpeta y qué pasa con cada parte (exportados sin incluir, con un botón).
Convertir va antes de ordenar en los pasos y en Inicio; lo que se está convirtiendo no se mueve, y una carpeta con
algo abierto ya no se copia entera.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-14 17:55:10 -05:00
padre 00b84b4aa7
commit 58197ffd67
Se han modificado 7 ficheros con 387 adiciones y 45 borrados

Ver fichero

@@ -936,9 +936,41 @@ codificador de la RTX 5080 iba al 46-74 %, la tarjeta entera al 9-18 % y su desc
- **Lo que ya no compensa no se convierte**: lo que se encoló con la regla de antes y con la del 30 % ya no se
ofrecería (en Sumapaz, 21 H.265) sale de la cola al llegarle el turno, y se dice cuántos.
Pruebas: `prueba_orden.py` 46 casos (cinco nuevos: dos a la vez y «Detener», las conversiones sueltas, la tarjeta
descomprime y su vuelta al procesador si falla, lo que ya no compensa); `prueba_funcional.py` 33 de 33 (la
conversión de verdad ya descomprime con la tarjeta), `probar.py` 19 y `prueba_ventana.py` 18.
Y antes de publicarla, Munka: «que al cerrar cierre todos los procesos, hasta las colas que faltan y los que quedan a
mitad; que al continuar una cola de renderizado la continúe y vuelva a generar los videos que hagan falta,
continuando por donde iba; que identifique bien el material y haga la búsqueda de grandes archivos de video; y
colocaría convertir antes de mover los videos, dado que si muevo y luego convierto no funciona bien».
- **Cerrar para todo.** Hasta ahora, cerrar con una conversión en marcha dejaba el programa oculto trabajando hasta
acabar, y al actualizar (que cerraba de golpe) quedaban procesos sueltos. Ahora cerrar avisa y lo para todo: la
cola, la lectura de clips, el respaldo y lo que se trae de una tarjeta (un «Aplicar» se deja acabar: son
segundos). Lo que no se cierra por las buenas se cierra con todo lo que lanzó, y su archivo a medias se borra.
Si se vuelve a abrir mientras se para, la ventana vuelve, ya sin nada en marcha. Probado con dos conversiones 4K
de verdad: se paró todo en 0,9 s, sin un ffmpeg vivo ni un «.parcial».
- **Continuar sigue por donde iba.** Lo que se corta a medias (Detener, cerrar, actualizar) vuelve a esperar en su
sitio, delante de los demás, y se hace entero otra vez (un MP4 a medias no se puede seguir); antes quedaba
«cancelado» y «Reanudar» no lo retomaba: dos BRAW cortados al actualizar se quedaron fuera de la cola. Al
continuar, también vuelve lo que falló por un corte («fallo inesperado») y los convertidos que ya no están (borrados
o movidos fuera de la app), en las carpetas conectadas. El botón se llama ahora «Continuar».
- **Actualizar con la cola en marcha.** Ya no hay que esperar: la cola se detiene, y al volver a abrirse sigue sola.
- **Lo grande, a la vista.** Encima de la lista del Convertidor, el vídeo de más del tamaño pedido que hay en la
carpeta y qué pasa con cada parte: lo que se ofrece y en cuánto quedaría, lo que ya está en la cola, lo convertido
por reemplazar, los exportados y masters que no se ofrecen con un botón para incluirlos, lo que ahorraría poco y lo
apartado como repetido. En sus discos: 353 GB de exportados no se ofrecían (la casilla estaba apagada) y 757 GB ya
convertidos esperaban «Reemplazar». La lectura de clips no se dejaba nada: de los vídeos de más de 300 MB, solo uno
quedaba sin leer.
- **Convertir antes de ordenar.** En los pasos de la barra lateral y en Inicio, «Convertir» va antes de «Ordenar» (y
apartar lo repetido, antes de convertir). Mover y luego convertir ya funciona desde la 3.8 (la cola sigue a los
clips), pero quedaban dos cosas: un clip que se está convirtiendo no se puede mover (ffmpeg lo tiene abierto), y
si estaba en una carpeta que el plan renombraba, la carpeta se copiaba entera y luego se borraba lo que se
dejaba. Ahora lo que se convierte en ese momento se queda (y su carpeta), con el porqué en los fallos («se estaban
convirtiendo en ese momento»), y una carpeta con algo abierto ya no se copia: se queda donde está.
Pruebas: `prueba_orden.py` 49 casos (ocho nuevos: dos a la vez y «Detener», las conversiones sueltas, la tarjeta
descomprime y su vuelta al procesador si falla, lo que ya no compensa, cerrar para todo y continuar, rehacer lo que
falta, y no mover lo que se convierte ni copiar una carpeta con algo abierto); `prueba_funcional.py` 33 de 33 (la
conversión de verdad ya descomprime con la tarjeta), `probar.py` 19 y `prueba_ventana.py` 18 (cerrar con trabajo en
marcha lo para todo).
3.8.1, convertir todo lo que ahorre un 30 %. Munka: «quiero que si un archivo se puede optimizar así sea un 30 %
se convierta, la idea es aprovechar lo máximo el espacio».

Ver fichero

@@ -122,9 +122,10 @@ def _sha256(ruta):
return h.hexdigest()
def tarea_en_marcha():
"""Texto de la tarea que impide actualizar ahora, o '' si se puede."""
if O.COLA.get("activa"):
def tarea_en_marcha(sin_cola=False):
"""Texto de la tarea que está en marcha, o ''. Para actualizar (`sin_cola`), la cola no cuenta: se detiene, y
sigue sola al volver a abrirse (ver O.detener_todo)."""
if O.COLA.get("activa") and not sin_cola:
return "hay una conversión en marcha"
if O.RESPALDO.get("estado") == "corriendo":
return "hay una copia de respaldo en marcha"
@@ -173,7 +174,7 @@ def _descargar_y_aplicar(relanzar_args):
ESTADO["estado"] = "error"
ESTADO["error"] = "la actualización automática solo funciona en el paquete (OrganizadorLabstream.exe); desde el código se actualiza con git"
return False
ocupado = tarea_en_marcha()
ocupado = tarea_en_marcha(sin_cola=True)
if ocupado:
ESTADO["estado"] = "error"
ESTADO["error"] = f"no se actualiza ahora: {ocupado}. Espera a que termine."
@@ -270,6 +271,8 @@ def _descargar_y_aplicar(relanzar_args):
f"rmdir /s /q \"{_bat(carpeta)}\" >nul 2>&1\r\n"
"timeout /t 2 /nobreak >nul\r\n"
"(goto) 2>nul & del \"%~f0\"\r\n")
ESTADO["estado"] = "deteniendo"
O.detener_todo(seguir=True) # la conversión que va se corta y vuelve a la cola; sigue sola al volver
ESTADO["estado"] = "reiniciando"
ESTADO["aplicando"] = True
# con su PROPIA consola: sin ella, timeout/tasklist se quedan colgados y la copia nunca ocurre

Ver fichero

@@ -2443,6 +2443,12 @@ def mover(origen, destino, lote, movidos):
except OSError:
# entre unidades o con el archivo bloqueado: copia, comprueba el tamaño y solo entonces borra el origen
if os.path.isdir(origen):
# Una carpeta en la misma unidad que no se deja renombrar tiene algo abierto (un clip que se está
# convirtiendo, un proyecto de Resolve). Antes se copiaba entera y luego se borraba lo que se dejaba:
# media carpeta duplicada y el resto a medias. Ahora se queda donde está.
if os.path.splitdrive(origen)[0].lower() == os.path.splitdrive(destino)[0].lower():
raise OSError(f"«{os.path.basename(origen)}» tiene algo en uso dentro (¿un clip que se está convirtiendo, o abierto "
"en Resolve?): la carpeta se queda donde está")
shutil.move(origen, destino)
else:
shutil.copy2(origen, destino)
@@ -2473,6 +2479,8 @@ def _motivo_fallo(ex):
w = getattr(ex, "winerror", None)
if isinstance(ex, FileExistsError) or "ya existe" in t:
return "existe"
if "se está convirtiendo" in t:
return "convirtiendo"
if "en uso" in t or w in (32, 33):
return "en_uso"
if isinstance(ex, PermissionError) or w == 5 or "acceso denegado" in t:
@@ -2510,6 +2518,16 @@ def aplicar(raiz, filas):
con.close()
en_plan = {os.path.normpath(x).lower() for f in filas for x in (f.get("archivos") or ())}
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
# lo que se está convirtiendo ahora mismo no se puede mover (ffmpeg lo tiene abierto), ni la carpeta que lo
# tiene: se queda, y se dice por qué. Lo que solo espera en la cola sí se mueve (la cola lo sigue).
con = db()
convirtiendo = [os.path.normpath(r[0]).lower() for r in con.execute("SELECT ruta FROM trabajos WHERE estado = 'corriendo'")]
con.close()
en_conversion = set(convirtiendo)
def con_conversion(carpeta):
pre = os.path.normpath(carpeta).lower() + os.sep
return any(c.startswith(pre) for c in convirtiendo)
fallos = 0
APLICANDO.update(hechas=0, total=len(filas), actual="", archivo="", raiz=_clave(raiz), unidades=0,
unidades_total=unidades_total, movidos=0, fallos=0, lote=lote, inicio=time.time(), fin=0)
@@ -2532,6 +2550,8 @@ def aplicar(raiz, filas):
if not os.path.exists(a):
continue
try:
if os.path.normpath(a).lower() in en_conversion:
raise OSError(f"«{os.path.basename(a)}» se está convirtiendo ahora mismo: se queda hasta que acabe")
antes = len(movidos)
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
llego = movidos[-1][1] if len(movidos) > antes else ""
@@ -2551,6 +2571,8 @@ def aplicar(raiz, filas):
hechas += 1
else:
try:
if con_conversion(f["origen"]) or os.path.normpath(f["origen"]).lower() in en_conversion:
raise OSError(f"«{os.path.basename(f['origen'])}» tiene un clip que se está convirtiendo ahora mismo: se queda hasta que acabe")
if os.path.exists(f["origen"]):
mover(f["origen"], f["destino"], lote, movidos)
hechas += 1
@@ -4115,6 +4137,7 @@ def correr_ffmpeg(t):
except OSError as ex:
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}")
return
_HIJOS.add(p)
ultimo = 0
for linea in p.stdout:
if COLA["cancelar"]:
@@ -4131,9 +4154,10 @@ def correr_ffmpeg(t):
pass
err = p.stderr.read()
p.wait()
_HIJOS.discard(p)
if COLA["cancelar"]:
set_trabajo(t["id"], estado="cancelado", pct=0)
_quitar_parcial(tmp)
_devolver_a_la_cola(t["id"])
return
if p.returncode == 0 and os.path.exists(tmp):
break
@@ -4204,6 +4228,7 @@ def correr_braw(t):
errores.close()
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el lector de BRAW: {ex}")
return
_HIJOS.add(p)
def vigia():
# Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se
@@ -4244,9 +4269,10 @@ def correr_braw(t):
os.remove(resto)
except OSError:
pass
_HIJOS.discard(p)
if COLA["cancelar"] or fin.get("estado") == "cancelado":
set_trabajo(t["id"], estado="cancelado", pct=0)
_quitar_parcial(tmp)
_devolver_a_la_cola(t["id"])
return
if fin.get("estado") != "listo" or not os.path.exists(tmp):
set_trabajo(t["id"], estado="error",
@@ -4328,9 +4354,10 @@ def correr_resolve(trabajos):
con = db()
for t in trabajos:
r = con.execute("SELECT estado FROM trabajos WHERE id = ?", (t["id"],)).fetchone()
if r and r["estado"] == "corriendo":
set_trabajo(t["id"], estado="cancelado" if COLA["cancelar"] else "error",
log=("\n".join(log_general))[-3000:] or "el puente terminó sin reportar el trabajo")
if r and r["estado"] == "corriendo" and COLA["cancelar"]:
_devolver_a_la_cola(t["id"])
elif r and r["estado"] == "corriendo":
set_trabajo(t["id"], estado="error", log=("\n".join(log_general))[-3000:] or "el puente terminó sin reportar el trabajo")
con.close()
@@ -4699,6 +4726,108 @@ def calidad_de(id_, segundos=10, ancho=1920):
return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)}
_HIJOS = set() # las conversiones en marcha (ffmpeg, el lector de BRAW): se cierran al cerrar o actualizar
def _parcial_de(t):
fm = FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])
return (t["salida"] or "") + ".parcial" + fm["ext"] if t["salida"] else ""
def _devolver_a_la_cola(id_, motivo="se detuvo a mitad"):
"""Lo que se cortó a medias (Detener, cerrar el programa, actualizar) vuelve a esperar en su sitio, delante de
los demás: al continuar se hace entero otra vez (un MP4 a medio escribir no se puede seguir), y su archivo a
medias se borra. Hasta la 3.9 quedaba «cancelado» y «Reanudar» no lo retomaba: el 14-oct-2026, dos BRAW
cortados al actualizar se quedaron fuera de la cola."""
con = db()
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
con.close()
if not t:
return
parcial = _parcial_de(dict(t))
if parcial:
_quitar_parcial(parcial)
set_trabajo(id_, estado="pendiente", pct=0, log=f"{motivo} (iba al {round(t['pct'] or 0)} %): al continuar se hace entero otra vez")
def _ruta_seguir():
return os.path.join(DATOS, "cola_seguir.json")
def detener_todo(seguir=False, espera=30):
"""Para todo lo que está en marcha, para cerrar el programa o actualizarlo. Munka, 14-oct-2026: «que al cerrar
cierre todos los procesos, hasta las colas que faltan y los que quedan a mitad». La cola se detiene y lo que iba
vuelve a esperar, la lectura de clips, el respaldo y lo que se trae de una tarjeta se cortan; un «Aplicar» se
deja acabar (son segundos, y a medias dejaría el plan a medias). Lo que no se cierre por las buenas se cierra
a la fuerza, con lo que haya lanzado. Con `seguir` (al actualizar), la cola sigue sola al volver a abrirse."""
habia_cola = bool(COLA["activa"] or COLA["actuales"])
if habia_cola:
COLA["cancelar"] = True
COLA["activa"] = False
for a in list(ANALISIS.values()):
if a and a.get("estado") == "corriendo":
a["cancelar"] = True
if RESPALDO.get("estado") == "corriendo":
RESPALDO["cancelar"] = True
if TRAER.get("estado") in ("copiando", "ordenando"):
TRAER["cancelar"] = True
def ocupado():
hilo = COLA.get("hilo")
return bool((hilo and hilo.is_alive()) or (APLICANDO.get("total") and APLICANDO.get("hechas", 0) < APLICANDO["total"]))
limite = time.time() + espera
while time.time() < limite and ocupado():
time.sleep(0.3)
for p in list(_HIJOS): # lo que no se cerró por las buenas
if p.poll() is None:
_matar_arbol(p.pid)
_HIJOS.discard(p)
con = db()
cortados = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'corriendo'")]
con.close()
for i in cortados:
_devolver_a_la_cola(i)
con = db()
pendientes = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0]
con.close()
if seguir and habia_cola and pendientes:
guardar_json(_ruta_seguir(), {"ts": time.time(), "pendientes": pendientes})
return {"cola": habia_cola, "pendientes": pendientes}
def _seguir_al_abrir():
"""Si la cola se detuvo para actualizar, sigue sola al volver a abrirse, por donde iba."""
ruta = _ruta_seguir()
if not os.path.exists(ruta):
return False
try:
os.remove(ruta)
except OSError:
pass
return iniciar_cola()
def rehacer_perdidos():
"""Los convertidos que ya no están (alguien los borró o los movió fuera de la app) se vuelven a hacer: el trabajo
vuelve a la cola. Solo en carpetas conectadas (si no, que no esté no dice nada), y después de reengancharlos por
si solo se movieron. Munka: «que al continuar … vuelva a generar los videos que hagan falta»."""
con = db()
ts = [dict(r) for r in con.execute("SELECT id, raiz, salida FROM trabajos WHERE estado IN ('listo','verificado') AND salida IS NOT NULL")]
con.close()
conectadas = {}
ts = [t for t in ts if conectadas.setdefault(t["raiz"], os.path.isdir(t["raiz"] or ""))]
if not ts:
return 0
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=16) as ex:
estan = list(ex.map(lambda t: os.path.isfile(t["salida"]), ts))
perdidos = [t for t, esta in zip(ts, estan) if not esta]
for t in perdidos:
set_trabajo(t["id"], estado="pendiente", pct=0, bytes_out=None, nota=None, nota_min=None,
log="el convertido ya no estaba: se vuelve a hacer")
return len(perdidos)
def _matar_arbol(pid):
"""Cierra un proceso y lo que lanzó (el lector de BRAW lanza su propio ffmpeg)."""
try:
@@ -4772,20 +4901,19 @@ def cerrar_conversores_sueltos():
def recuperar_huerfanas():
"""Trabajos que quedaron «corriendo» sin una cola viva: el programa se cerró (o se actualizó) a mitad de
una conversión y nadie volvía a tocarlos. Bloqueaban «Desconectar», no salían en «En curso» y el paso
Convertir decía «nada» (visto el 2026-09-15: 5 clips de V:\\Galeano al 18-89 % desde el día 10).
Vuelven a «pendiente» con el motivo en el log; Reanudar los convierte desde cero."""
"""Al arrancar y al continuar, vuelve a esperar en su sitio lo que quedó a medias: lo que estaba «corriendo» sin
una cola viva (el programa se cerró a mitad), lo «cancelado» por un «Detener» de antes de la 3.9 y lo que falló
por un corte (un «fallo inesperado»: el 14-oct-2026, el lector de BRAW chocó con su propio archivo de una
conversión anterior que había quedado colgada). Su archivo a medias se borra; al continuar se hace entero."""
if COLA["hilo"] and COLA["hilo"].is_alive():
return 0
con = db()
with con:
filas = con.execute("SELECT id, pct FROM trabajos WHERE estado = 'corriendo'").fetchall()
for f in filas:
con.execute("UPDATE trabajos SET estado = 'pendiente', pct = 0, log = ? WHERE id = ?",
(f"se interrumpió al cerrar OrganiLAB (iba al {round(f['pct'] or 0)} %); pulsa Reanudar para convertirlo de nuevo", f["id"]))
ids = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado IN ('corriendo','cancelado') "
"OR (estado = 'error' AND log LIKE 'fallo inesperado%')")]
con.close()
return len(filas)
for i in ids:
_devolver_a_la_cola(i, "se interrumpió")
return len(ids)
def braws_al_lector():
@@ -4834,6 +4962,7 @@ def iniciar_cola():
recuperar_huerfanas()
try:
braws_al_lector()
rehacer_perdidos()
except Exception: # noqa
pass
COLA["activa"] = True
@@ -6920,8 +7049,10 @@ def _nombre_tipo(t):
# el orden de la lista de Inicio: lo hecho se queda en su sitio, no se va al final ni desaparece
_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "relojes", "fechas", "resto", "banco", "vacias", "stock",
"repetidos", "convertir", "juntar", "respaldar"]
# Convertir va antes de ordenar (Munka, 14-oct-2026: «colocaría convertir antes de mover los videos»), y lo repetido
# se aparta antes de convertir: no se convierte lo que se va a borrar.
_ORDEN_ACCIONES = ["escanear", "tipo", "entrada", "limpiar", "nombrar", "relojes", "repetidos", "convertir", "fechas", "resto", "banco",
"vacias", "stock", "juntar", "respaldar"]
def _historial_carpeta(raiz):
@@ -8453,6 +8584,7 @@ def _ventana(url):
base=BASE, marca=_marca(), relanzar=instalar.orden(BASE),
depurar=bool(os.environ.get("ORGANIZADOR_DEPURAR")))
v.al_cerrar, v.al_soltar = en_marcha, _al_soltar
v.al_detener = lambda: detener_todo(seguir=False)
v.al_abrir = lambda: _recien_abierta(v)
fin = threading.Event()
threading.Thread(target=_vigilar, args=(v, fin), daemon=True).start()
@@ -8546,10 +8678,10 @@ def main():
except OSError:
pass
huerfanas = recuperar_huerfanas()
threading.Thread(target=lambda: (cerrar_conversores_sueltos(), reubicar_trabajos(), repasar_verificados(), braws_al_lector()),
daemon=True).start()
threading.Thread(target=lambda: (cerrar_conversores_sueltos(), reubicar_trabajos(), repasar_verificados(), braws_al_lector(),
rehacer_perdidos(), _seguir_al_abrir()), daemon=True).start()
if huerfanas:
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).")
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Continuar).")
try:
srv = ServidorLocal(("127.0.0.1", PUERTO), Manejador)
except OSError:

Ver fichero

@@ -1045,7 +1045,8 @@ class Convertidor(unittest.TestCase):
hilo.join(40)
self.assertFalse(hilo.is_alive())
t = self.trabajo(ids[2])
self.assertEqual(t["estado"], "cancelado")
self.assertEqual(t["estado"], "pendiente") # vuelve a esperar: al continuar se hace entero
self.assertTrue(t["log"].startswith("se detuvo a mitad"), t["log"])
self.assertFalse(os.path.exists(t["salida"]) or os.path.exists(t["salida"] + ".parcial.mp4"))
self.assertEqual([x for x in os.listdir(O.DATOS) if x.startswith("braw_")], [])
@@ -1317,8 +1318,9 @@ class Convertidor(unittest.TestCase):
self.assertTrue(os.path.isfile(os.path.join(nueva, os.path.basename(tb["salida"]))))
def _cola_falsa(self, durante=0.4):
"""Sustituye la conversión por una que tarda `durante` segundos y cuenta cuántas van a la vez."""
cuenta = {"vivos": 0, "maximo": 0, "vistos": []}
"""Sustituye la conversión por una que tarda cuenta["durante"] segundos (deja su «.parcial» mientras) y cuenta
cuántas van a la vez."""
cuenta = {"vivos": 0, "maximo": 0, "vistos": [], "durante": durante}
candado = threading.Lock()
def falso(t):
@@ -1326,14 +1328,19 @@ class Convertidor(unittest.TestCase):
cuenta["vivos"] += 1
cuenta["maximo"] = max(cuenta["maximo"], cuenta["vivos"])
cuenta["vistos"].append(t["id"])
fin = time.time() + durante
with open(t["salida"] + ".parcial.mp4", "wb") as fh:
fh.write(b"p" * 100)
O.set_trabajo(t["id"], pct=40)
fin = time.time() + cuenta["durante"]
while time.time() < fin and not O.COLA["cancelar"]:
time.sleep(0.02)
with candado:
cuenta["vivos"] -= 1
if O.COLA["cancelar"]:
O.set_trabajo(t["id"], estado="cancelado", pct=0)
O._quitar_parcial(t["salida"] + ".parcial.mp4")
O._devolver_a_la_cola(t["id"])
return
os.remove(t["salida"] + ".parcial.mp4")
with open(t["salida"], "wb") as fh:
fh.write(b"x" * 1000)
O.set_trabajo(t["id"], estado="listo", pct=100, bytes_out=1000, fin=time.time())
@@ -1360,7 +1367,7 @@ class Convertidor(unittest.TestCase):
con = O.db()
otros = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'pendiente'")]
with con:
con.execute("UPDATE trabajos SET estado = 'cancelado' WHERE estado = 'pendiente'")
con.execute("UPDATE trabajos SET estado = 'aparte' WHERE estado = 'pendiente'")
con.close()
def devolver():
@@ -1389,13 +1396,128 @@ class Convertidor(unittest.TestCase):
O.COLA["cancelar"], O.COLA["activa"] = True, False # lo que hace «Detener»
O.COLA["hilo"].join(30)
estados = sorted(self.trabajo(i)["estado"] for i in ids)
self.assertEqual(estados, ["cancelado", "cancelado", "pendiente"])
self.assertEqual(estados, ["pendiente", "pendiente", "pendiente"]) # los cortados vuelven a esperar
self.assertFalse(O.COLA["cancelar"])
con = O.db()
with con:
con.execute(f"DELETE FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)
con.close()
def test_cerrar_para_todo_y_continuar(self):
"""Munka, 14-oct-2026: «que al cerrar cierre todos los procesos, hasta las colas que faltan y los que quedan a
mitad; que al continuar una cola la continúe y vuelva a generar los videos que hagan falta, por donde iba»."""
raiz = cliente("CerrarTodo")
nr = os.path.normpath(raiz)
g = "2025/11- Noviembre/00_Grabacion/SONY/"
clips = [mk(raiz, g + f"C01{i:02d}.MP4", 2025, 11, 11, tam=310000 + i) for i in range(3)]
O.escanear(raiz)
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
cuenta = self._cola_falsa(durante=30)
O.CONFIG["optimizar"]["simultaneas"] = 2
con = O.db()
otros = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'pendiente'")]
with con:
con.execute("UPDATE trabajos SET estado = 'aparte' WHERE estado = 'pendiente'")
con.close()
def devolver():
c = O.db()
with c:
c.executemany("UPDATE trabajos SET estado = 'pendiente' WHERE id = ?", [(i,) for i in otros])
c.close()
self.addCleanup(devolver)
ids = O.encolar(clips, "equilibrado", "420", "rec709", nr)
self.assertTrue(O.iniciar_cola())
fin = time.time() + 10
while time.time() < fin and len(cuenta["vistos"]) < 2:
time.sleep(0.05)
parciales = [self.trabajo(i)["salida"] + ".parcial.mp4" for i in cuenta["vistos"]]
self.assertTrue(all(os.path.exists(p) for p in parciales))
# cerrar (o actualizar): para todo, lo que iba vuelve a esperar sin su archivo a medias
t0 = time.time()
r = O.detener_todo(seguir=True)
self.assertLess(time.time() - t0, 10)
self.assertEqual((r["cola"], r["pendientes"]), (True, 3))
self.assertFalse(O.COLA["activa"] or O.COLA["actuales"])
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["pendiente"] * 3)
self.assertTrue(all(self.trabajo(i)["log"].startswith("se detuvo a mitad (iba al 40 %)") for i in cuenta["vistos"]))
self.assertFalse(any(os.path.exists(p) for p in parciales))
self.assertTrue(os.path.exists(O._ruta_seguir()))
# al volver a abrir, la cola sigue sola por donde iba: primero los que se cortaron
cuenta.update(vistos=[], durante=0.2)
primeros = sorted(ids)[:2]
self.assertTrue(O._seguir_al_abrir())
self.assertFalse(os.path.exists(O._ruta_seguir()))
O.COLA["hilo"].join(30)
self.assertEqual(sorted(cuenta["vistos"][:2]), primeros)
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["listo"] * 3)
self.assertFalse(O._seguir_al_abrir()) # sin nada pendiente de seguir, no arranca
def test_al_continuar_se_rehace_lo_que_falta(self):
"""Lo cortado por un «Detener» de antes (cancelado), lo que falló por un corte («fallo inesperado») y el
convertido que ya no está vuelven a la cola; un error de verdad y lo que sigue en su sitio, no."""
raiz = cliente("Rehacer")
nr = os.path.normpath(raiz)
g = "2025/11- Noviembre/00_Grabacion/SONY/"
clips = [mk(raiz, g + f"C02{i:02d}.MP4", 2025, 11, 11, tam=320000 + i) for i in range(5)]
O.escanear(raiz)
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
ids = O.encolar(clips, "equilibrado", "420", "rec709", nr)
O.set_trabajo(ids[0], estado="cancelado")
O.set_trabajo(ids[1], estado="error", log="fallo inesperado: [WinError 5] Acceso denegado: braw_961.json")
O.set_trabajo(ids[2], estado="error", log="el decodificador no pudo abrir el clip")
for i in ids[3:]:
O.set_trabajo(i, estado="verificado", bytes_out=1000)
with open(self.trabajo(ids[4])["salida"], "wb") as fh:
fh.write(b"x" * 1000)
self.assertEqual(O.recuperar_huerfanas(), 2)
self.assertEqual(O.rehacer_perdidos(), 1)
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["pendiente", "pendiente", "error", "pendiente", "verificado"])
self.assertEqual(self.trabajo(ids[3])["log"], "el convertido ya no estaba: se vuelve a hacer")
con = O.db()
with con:
con.execute(f"DELETE FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)
con.close()
def test_no_se_mueve_lo_que_se_convierte(self):
"""«Si muevo y luego convierto no funciona bien»: lo que espera en la cola se mueve y la cola lo sigue, pero
lo que se está convirtiendo ahora (ffmpeg lo tiene abierto) se queda, diciendo por qué. Y una carpeta con
algo abierto ya no se copia entera para luego dejarla a medias."""
raiz = cliente("NoMover")
nr = os.path.normpath(raiz)
a = mk(raiz, "Material Sony/20250722_Labstream5001.MP4", 2025, 7, 22, tam=300000)
b = mk(raiz, "Material Sony/20250731_Labstream5002.MP4", 2025, 7, 31, tam=300001)
O.escanear(raiz)
self.leidos(raiz, {os.path.basename(x): {"codec": "h264", "perfil": "High", "kbps": 120000} for x in (a, b)})
ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr)
O.set_trabajo(ia, estado="corriendo")
p = O.Plan(O.raiz_cfg(raiz)).correr()
r = O.aplicar(raiz, [f for f in p["filas"] if f["marcado"]])
self.assertTrue(os.path.exists(a)) # el que se convierte, en su sitio
self.assertFalse(os.path.exists(b)) # el que espera, movido (y la cola con él)
self.assertEqual(r["motivos"]["convirtiendo"]["n"], 1)
self.assertIn("00_Grabacion", self.trabajo(ib)["ruta"])
con = O.db()
with con:
con.execute("DELETE FROM trabajos WHERE id IN (?, ?)", (ia, ib))
con.close()
# una carpeta con un archivo abierto: no se copia
d = os.path.join(nr, "Abierta")
os.makedirs(d)
f = os.path.join(d, "clip.mov")
with open(f, "wb") as fh:
fh.write(b"x" * 1000)
abierto = open(f, "rb")
self.addCleanup(abierto.close)
try:
O.mover(d, os.path.join(nr, "Destino Abierta"), "prueba", [])
except OSError as ex:
self.assertIn("en uso", str(ex))
self.assertFalse(os.path.exists(os.path.join(nr, "Destino Abierta")))
self.assertTrue(os.path.isfile(f))
else:
self.skipTest("este disco deja renombrar una carpeta con un archivo abierto")
def test_conversiones_sueltas(self):
"""14-oct-2026: el lector de BRAW y el ffmpeg de una conversión de antes de actualizar seguían colgados siete
horas después. Al arrancar se cierran los que no tienen quien los espere, y no los de un programa vivo."""

Ver fichero

@@ -278,7 +278,8 @@ def desde_el_codigo(tmp):
anota("abrirlo otra vez no abre una segunda copia", p.returncode == 0 and len(V._ventanas(O._marca())) == 1,
f"código {p.returncode} · {len(V._ventanas(O._marca()))} ventana(s)")
# cerrar con trabajo en marcha: avisa, se oculta, vuelve si se abre otra vez y se cierra sola al acabar
# cerrar con trabajo en marcha: avisa, se quita de la vista y lo para todo (un movimiento de archivos se deja
# acabar); si se abre otra vez mientras tanto, vuelve; y al acabar de parar se cierra sola
O.APLICANDO.update(total=10, hechas=3, raiz="ensayo")
U.PostMessageW(h, WM_CLOSE, 0, 0)
d = espera(lambda: dialogo_de(h), 8)
@@ -286,7 +287,7 @@ def desde_el_codigo(tmp):
anota("al cerrar con trabajo en marcha, avisa de qué está haciendo", "moviendo archivos" in aviso, aviso[:90])
if d:
U.PostMessageW(d, WM_COMMAND, 1, 0)
anota("la ventana se quita de la vista y el programa sigue", espera(lambda: v.oculta and not U.IsWindowVisible(h), 8))
anota("la ventana se quita de la vista mientras se para todo", espera(lambda: v.oculta and not U.IsWindowVisible(h), 8))
otra_copia()
anota("abrirlo otra vez devuelve esa misma ventana", espera(lambda: not v.oculta and U.IsWindowVisible(h), 8))
U.PostMessageW(h, WM_CLOSE, 0, 0)
@@ -294,7 +295,7 @@ def desde_el_codigo(tmp):
if d:
U.PostMessageW(d, WM_COMMAND, 1, 0)
espera(lambda: v.oculta, 8)
O.APLICANDO.update(hechas=10) # el trabajo termina: el programa tiene que cerrarse solo
O.APLICANDO.update(hechas=10) # el movimiento termina: con todo parado, el programa se cierra solo
fin, forzado = threading.Event(), []

Ver fichero

@@ -446,6 +446,9 @@
.cmp .marco.con .lupa{display:block}
/* en vertical (móvil, cámara girada): los dos cuadros a lo alto, uno junto al otro, y la nota y lo demás en una
columna a su derecha. Antes el marco era siempre apaisado y el clip quedaba como una tira en medio */
.grandes{border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:12.5px;display:flex;flex-wrap:wrap;gap:6px 18px;align-items:baseline}
.grandes b{font-size:13px}.grandes .t{color:var(--mut);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;font-weight:600;flex-basis:100%}
.grandes button{font-size:12px;padding:2px 8px}
.cmpcuerpo.vert{display:grid;grid-template-columns:auto minmax(260px,1fr);gap:22px;align-items:start}
.cmpcuerpo.vert .cmp{grid-template-columns:auto auto;justify-content:start}
.cmpcuerpo.vert .cmp .lado{min-width:auto}
@@ -716,6 +719,7 @@
<button class="lnk" id="btnQuitarEspera" style="display:none">Quitar los que esperan</button>
</div>
<div id="anaEstado" class="mut" style="margin:-6px 0 10px"></div>
<div id="convGrandes" class="grandes" style="display:none"></div>
<div id="tanda"></div>
<details class="mas" id="colaAjustes" style="margin-bottom:12px"><summary>Cuándo trabaja la cola y qué mete sola</summary><div class="ajcola" id="colaAjustesCuerpo"></div></details>
<div class="box" id="convCaja"><div id="convLista"><span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span></div></div>
@@ -1401,6 +1405,7 @@ function pasosDe(C, r){
: verif ? {e:'listo', t:'Convertir', d:`${verif} verificado${verif > 1 ? 's' : ''} por reemplazar`, corto: `${verif} por reemplazar`}
: reemp ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya convertido${reemp > 1 ? 's' : ''}`, corto: `${reemp} hechos`}
: {e:'pend', t:'Convertir', d:`${gb(cv.camara_bytes)} de cámara${cv.raw_bytes ? ' · ' + gb(cv.raw_bytes) + ' BRAW' : ''}`, corto: gb(cv.camara_bytes)});
{ const io = P.findIndex(x => x.t === 'Ordenar'), ic = P.findIndex(x => x.t === 'Convertir'); if (io >= 0 && ic > io) P.splice(io, 0, P.splice(ic, 1)[0]); } // convertir antes de mover
const rp = C.respaldo || {};
P.push(!esc ? {e:'bloq', t:'Respaldar', d:'Después de escanear'} : rp.estado === 'corriendo' ? {e:'curso', t:'Respaldar', d:'Copiando…', corto: 'copiando'}
: rp.plan ? {e:'listo', t:'Respaldar', d:`${num(rp.plan.n)} archivos · ${gb(rp.plan.bytes)} listos para copiar`, corto: gb(rp.plan.bytes)}
@@ -1813,6 +1818,7 @@ const MOTIVO_FALLO = {
en_uso: ['estaban abiertos en otro programa', 'Cierra Resolve, Premiere o la ventana del Explorador que los tenga abiertos, pulsa «Calcular de nuevo» y aplica: solo intentará lo que faltó.'],
permiso: ['Windows no dio permiso para moverlos', 'Suele ser una carpeta del NAS de solo lectura o de otro usuario. Revisa los permisos de esa carpeta compartida.'],
no_esta: ['ya no estaban donde decía el plan', 'Alguien los movió o los borró después de calcular. Pulsa «Calcular de nuevo».'],
convirtiendo: ['se estaban convirtiendo en ese momento', 'Un clip que se convierte no se puede mover, ni la carpeta que lo tiene: se quedaron donde estaban. Cuando acabe la cola, pulsa «Calcular de nuevo» y aplica: se moverán junto con su convertido.'],
copia: ['la copia entre discos no quedó completa', 'El original no se tocó. Vuelve a aplicar; si se repite, mira el espacio libre y la conexión con el NAS.'],
red: ['se cortó la conexión con el disco de red', 'Comprueba que la unidad sigue conectada y vuelve a aplicar: solo intentará lo que faltó.'],
otro: ['por otra razón', 'Mira la lista con «ver cuáles».'],
@@ -1954,9 +1960,32 @@ async function cargarCandidatos(){
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}&formato=${$('#optFormato').value}&ahorro=${ahorroPedido()}`;
const d = await api('/api/optimizar/candidatos?' + q); CAND = d.candidatos.map(c => ({...c, sel: !c.excluido && !c.en_cola}));
$('#anaEstado').textContent = '';
pintarGrandes();
if (!CAND.length) aviso('No se encontraron clips con esos criterios. Prueba a cambiar los meses o el tamaño mínimo.', '');
await cargarCola();
}
// Lo que pesa de la carpeta, de un vistazo: cuánto vídeo grande hay y qué pasa con cada parte. Munka, 14-oct-2026:
// «que identifique bien el material y haga la búsqueda de grandes archivos de video». En sus discos, 353 GB de
// exportados y masters no se ofrecían porque la casilla estaba apagada, y 757 GB ya convertidos esperaban «Reemplazar».
function pintarGrandes(){
const caja = $('#convGrandes'); if (!caja) return;
if (!CAND.length) { caja.style.display = 'none'; return; }
const g = {}; const suma = (k, c) => { const x = g[k] = g[k] || {n: 0, b: 0, e: 0}; x.n++; x.b += c.bytes || 0; x.e += c.estimado || 0; };
CAND.forEach(c => { const m = c.excluido || ''; suma(c.en_cola ? (['listo', 'verificado'].includes(c.en_cola) ? 'hechos' : 'cola') : !m ? 'ofrece' : m.startsWith('es un exportado') ? 'export' : m.startsWith('está apartado') ? 'repes' : m.startsWith('convertido ahorraría') ? 'poco' : m.startsWith('ya es una conversión') ? 'ya' : 'otros', c); });
const tot = CAND.reduce((s, c) => s + (c.bytes || 0), 0), v = k => g[k] || {n: 0, b: 0, e: 0};
const trozo = (k, txt, extra = '') => v(k).n ? `<span><b>${num(v(k).n)}</b> ${txt} · ${gb(v(k).b)}${extra}</span>` : '';
caja.innerHTML = `<span class="t">Vídeo de más de ${+$('#optMin').value || 0} MB en esta carpeta: ${num(CAND.length)} archivos · ${gb(tot)}</span>`
+ trozo('ofrece', 'se ofrecen', ` → ≈ ${gb(v('ofrece').e)}`)
+ trozo('cola', 'en la cola')
+ trozo('hechos', 'ya convertidos, por reemplazar', ' (se liberan al reemplazar)')
+ trozo('export', 'exportados y masters sin incluir', ` → ≈ ${gb(v('export').e)} <button id="grInclExp">incluirlos</button>`)
+ trozo('poco', 'ahorrarían poco')
+ trozo('repes', 'apartados como repetidos (para borrar)')
+ trozo('ya', 'convertidos por el programa')
+ trozo('otros', 'por otros motivos (alfa, stock, recientes…)');
caja.style.display = '';
const b = $('#grInclExp'); if (b) b.onclick = () => { $('#optExport').checked = true; cargarCandidatos().catch(e => aviso(e.message, 'bad')); };
}
function resumenCand(){ const s = CAND.filter(c => c.sel); const validos = CAND.filter(c => !c.excluido && !c.en_cola); bloquear('btnEncolar', validos.length ? (s.length ? '' : 'x') : 'x'); $('#btnEncolar').textContent = s.length ? `Convertir ${s.length} marcado${s.length > 1 ? 's' : ''}` : 'Convertir'; }
$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a <b>${esc(fm.nombre)}</b>, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, ahorro_minimo: ahorroPedido(), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola o su salida ya existe (el mismo clip en MP4 y MOV, o una conversión anterior).`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = 'Convertir'; };
const EST_TXT = {pendiente:'en espera', corriendo:'convirtiendo', listo:'convertido', verificado:'verificado', reemplazado:'reemplazado', error:'falló', cancelado:'cancelado', descartado:'no compensa'};
@@ -1970,7 +1999,7 @@ async function cargarCola(){
const n = k => ts.filter(t => t.estado === k).length;
const pend = n('pendiente'), listos = n('listo'), verif = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores')).length, err = n('error') + n('cancelado');
const est = $('#colaEstado'); est.className = 'est ' + (d.cola.activa ? 'corriendo' : err ? 'error' : verif ? 'verificado' : 'pendiente'); est.textContent = d.cola.activa ? `convirtiendo${d.cola.mensaje ? ' · ' + d.cola.mensaje : ''}` : pend ? `${pend} en espera, parada` : ts.length ? `${verif} verificados · ${n('reemplazado')} reemplazados${err ? ` · ${err} con error` : ''}${n('descartado') ? ` · ${n('descartado')} que no compensan` : ''}` : 'sin actividad';
const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Reanudar';
const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Continuar';
$('#btnReintentar').style.display = err ? '' : 'none'; $('#btnQuitar').style.display = ts.some(t => ['error','cancelado','reemplazado','descartado'].includes(t.estado)) ? '' : 'none'; $('#btnVerificar').style.display = listos ? '' : 'none';
$('#btnQuitarEspera').style.display = (!d.cola.activa && pend) ? '' : 'none';
bloquear('btnReemplazar', verif ? '' : (listos ? 'hay convertidos sin verificar' : 'x')); $('#btnReemplazar').textContent = verif ? `Reemplazar ${verif} verificado${verif > 1 ? 's' : ''}` : 'Reemplazar';
@@ -2043,7 +2072,7 @@ async function cargarCola(){
const ahora = corrs.length > 1 ? `a la vez: ${corrs.map(x => `<b>${esc((x.ruta || '').split(/[\\/]/).pop())}</b> ${Math.round(x.pct || 0)} %${restoDe(x)}`).join(' · ')} · ${eta ? eta.en_espera : pend} en espera` : '';
mo = `<div class="momento corre"><div class="l1"><span class="spin"></span><b>Convirtiendo ${corrs.length > 1 ? `${Math.min(hechosT + 1, total)} y ${Math.min(hechosT + 2, total)}` : Math.min(hechosT + 1, total)} de ${total}</b><span class="mut">${eta && eta.seg ? `quedan unas <b>${tiempoLargo(eta.seg)}</b> · termina hacia las <b>${hora(eta.termina)}</b>${eta.medido ? ` · va a ${eta.vel.toFixed(1)}× tiempo real` : ' · estimado, hasta que acabe el primero'}` : 'calculando cuánto falta…'}</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div><div class="bar grande"><i style="width:${pctTot}%"></i></div><div class="l3 mut">${ahora || (corr ? `ahora: <b>${esc((corr.ruta || '').split(/[\\/]/).pop())}</b> · ${Math.round(corr.pct || 0)} %${restoDe(corr)} de este clip · ${eta ? eta.en_espera : pend} en espera` : 'empezando…')}</div></div>`;
} else if (pend) {
mo = `<div class="momento"><div class="l1"><b>${pend} clip${pend > 1 ? 's' : ''} en espera, con la cola parada</b><span class="mut">lo que ya iba se retoma desde cero</span><span class="sep"></span><button class="p grande" id="moReanudar">Reanudar la cola</button></div></div>`;
mo = `<div class="momento"><div class="l1"><b>${pend} clip${pend > 1 ? 's' : ''} en espera, con la cola parada</b><span class="mut">sigue por donde iba; lo que quedó a medias se hace entero otra vez</span><span class="sep"></span><button class="p grande" id="moReanudar">Continuar la cola</button></div></div>`;
} else if (vers.length) {
const bIn = vers.reduce((s, t) => s + (t.bytes_in || 0), 0), bOut = vers.reduce((s, t) => s + (t.bytes_out || 0), 0);
mo = `<div class="momento listo"><div class="l1"><span class="ico">✓</span><span><b>${vers.length} clip${vers.length > 1 ? 's' : ''} convertido${vers.length > 1 ? 's' : ''} y verificado${vers.length > 1 ? 's' : ''}</b> · ${gb(bIn)} → ${gb(bOut)}${notaMedia ? ` · nota media ${notaMedia} / 100` : ''}</span><span class="sep"></span><button class="lnk" id="moComparar">comparar unos cuantos</button><button class="ok grande" id="moReemplazar">Reemplazar ${vers.length > 1 ? 'los ' + vers.length + ' originales' : 'el original'} · libera ${gb(Math.max(0, bIn - bOut))}</button></div><div class="l3 mut">Los originales van a _Para borrar/originales, no se borran, y se deshace desde Historial. Si quieres verlo antes, «comparar» enseña el mismo cuadro en los dos y mide la calidad.</div></div>`;
@@ -2686,8 +2715,8 @@ function pintarActualizacion(){
if (a.aplicando || a.estado === 'descargando' || a.estado === 'verificando' || a.estado === 'reiniciando') { if (!ACT_SIGUIENDO) seguirActualizacion(); return; }
if (!a.disponible) { caja.style.display = 'none'; return; }
caja.style.display = ''; caja.classList.add('actualizacion');
caja.innerHTML = `<h3>Hay una versión nueva: ${esc(a.disponible.version)} <span class="mut">(tienes la ${esc(ESTADO.version)})</span></h3><p class="mut">${esc(a.disponible.notes || '')}</p><div class="toolbar"><button id="btnActualizar" class="p">Actualizar ahora</button><span class="mut">${(a.disponible.size / 1e6).toFixed(0)} MB · el programa se cierra y vuelve a abrirse solo; tus datos no se tocan. No lo hagas con una conversión o copia en marcha.</span></div>` + (a.error ? `<p class="bad">${esc(a.error)}</p>` : '');
$('#btnActualizar').onclick = () => accion($('#btnActualizar'), async () => { if (huella(ESTADO).cola || huella(ESTADO).resp || huella(ESTADO).asis) throw new ApiError('Hay una tarea en marcha. Espera a que termine antes de actualizar.'); const r = await api('/api/actualizacion/instalar', {}); if (!r.lanzado) throw new ApiError(r.error || 'No se pudo lanzar la actualización'); seguirActualizacion(); });
caja.innerHTML = `<h3>Hay una versión nueva: ${esc(a.disponible.version)} <span class="mut">(tienes la ${esc(ESTADO.version)})</span></h3><p class="mut">${esc(a.disponible.notes || '')}</p><div class="toolbar"><button id="btnActualizar" class="p">Actualizar ahora</button><span class="mut">${(a.disponible.size / 1e6).toFixed(0)} MB · el programa se cierra y vuelve a abrirse solo; tus datos no se tocan. Si está convirtiendo, la cola se detiene y sigue sola al volver (el clip que iba se hace entero otra vez).</span></div>` + (a.error ? `<p class="bad">${esc(a.error)}</p>` : '');
$('#btnActualizar').onclick = () => accion($('#btnActualizar'), async () => { if (huella(ESTADO).resp || huella(ESTADO).asis) throw new ApiError('Hay una copia o el asistente en marcha. Espera a que termine antes de actualizar.'); const r = await api('/api/actualizacion/instalar', {}); if (!r.lanzado) throw new ApiError(r.error || 'No se pudo lanzar la actualización'); seguirActualizacion(); });
}
async function seguirActualizacion(){
ACT_SIGUIENDO = true; const caja = $('#actualizacionCaja'); caja.style.display = ''; irA('carpeta');

Ver fichero

@@ -391,7 +391,7 @@ class Ventana:
self.url, self.titulo, self.icono, self.perfil, self.memoria = url, titulo, icono, perfil, memoria
self.base, self.marca, self.tamano, self.minimo, self.fondo = base, marca, tamano, minimo, fondo
self.relanzar, self.depurar = relanzar, depurar
self.al_cerrar = self.al_soltar = self.al_abrir = None
self.al_cerrar = self.al_soltar = self.al_abrir = self.al_detener = None
self.hwnd = None
self.oculta = False # cerrada a la vista, pero viva hasta que termine el trabajo
self.lista = False # la interfaz está cargada y a la vista
@@ -500,6 +500,7 @@ class Ventana:
if not que:
return self._destruir()
u = _dll().u
if not self.al_detener:
r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras la ventana, el trabajo sigue hasta "
"terminar y después el programa se cierra solo. Para verlo, vuelve a abrir OrganiLAB.",
self.titulo, MB_OKCANCEL | MB_ICONINFORMATION)
@@ -509,6 +510,28 @@ class Ventana:
self.oculta = True
self.progreso(None)
u.ShowWindow(self.hwnd, SW_HIDE)
return
# Cerrar para todo (Munka, 14-oct-2026): la conversión que va se corta y vuelve a la cola, y la próxima vez
# «Continuar» sigue por donde iba. Antes el trabajo seguía oculto y, al actualizar, quedaban procesos sueltos.
r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras, se detiene todo. Lo que iba a medias "
"vuelve a la cola, y la próxima vez «Continuar» sigue por donde iba (lo cortado se hace entero otra vez).",
self.titulo, MB_OKCANCEL | MB_ICONQUESTION)
if r != IDOK:
return
self._recordar_sitio()
self.oculta = True
self.progreso(None)
u.ShowWindow(self.hwnd, SW_HIDE)
def parar():
try:
self.al_detener()
except Exception as ex: # noqa
_log("al_detener falló:", repr(ex))
finally:
if self.oculta: # si mientras se paraba la volvieron a abrir, se queda abierta (ya sin nada en marcha)
self.cerrar()
threading.Thread(target=parar, daemon=True).start()
def _volver(self):
"""La abrieron otra vez mientras trabajaba oculta: vuelve a la vista."""