3.9.0: la cola más rápida: la tarjeta descomprime, dos clips a la vez, y las conversiones sueltas se cierran

Medido en la RTX 5080 del estudio con la cola de Vía Sumapaz (1.920 clips, unas 26 horas): el codificador iba al
46-74 % y el descompresor al 0 %. Con NVDEC un H.264 4K pasa de 0,54× a 0,86× tiempo real; dos a la vez salen a
1,29× entre los dos. Además: al arrancar se cierran las conversiones que quedaron sin su programa (una de las
10:04 seguía colgada a las 17:00), «Detener» cierra el ffmpeg del lector de BRAW, la cola se pide entera por
carpeta (antes, los 500 últimos de todas), y lo encolado que ya no compensa con la regla del 30 % no se convierte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-14 17:32:01 -05:00
padre c91473aea4
commit 00b84b4aa7
Se han modificado 4 ficheros con 505 adiciones y 126 borrados

Ver fichero

@@ -915,6 +915,31 @@ Pruebas: `prueba_orden.py` 24 casos (5 nuevos: pieza, bandeja, tarjeta con una c
que rechazar, versiones con ffprobe simulado, y la pasada automática con sus frenos); `prueba_funcional.py`
29 de 29 (3 nuevas, por la interfaz de verdad del servidor); `probar.py` 19 y `prueba_ventana.py` 18.
3.9.0, la cola más rápida. Munka: «ayúdame a mejorar todo lo que se pueda en la app». Medido en su equipo el
14-oct-2026, con 1.920 clips de Vía Sumapaz en espera (31,8 horas de clip, 1,58 TB, unas 26 horas de cola): el
codificador de la RTX 5080 iba al 46-74 %, la tarjeta entera al 9-18 % y su descompresor al 0 %.
- **La tarjeta descomprime** los H.264, H.265, AV1, VP9 y MPEG-2 (NVDEC): hasta ahora lo hacía el procesador y la
tarjeta esperaba. Con dos clips 4K de la cola de Sumapaz, un H.264 pasó de 0,54× a 0,86× tiempo real y un H.265
de 0,70× a 0,85×, con el mismo archivo de salida. Si con ella un clip no sale, se repite con el procesador.
- **Dos clips a la vez** (la RTX 5080 tiene dos codificadores): 1,29× tiempo real entre los dos, frente a unos
0,77× de uno solo. Juntando las dos cosas, un H.264 de esos va más del doble de rápido. Se cambia en «cambiar» ›
«a la vez» (automático: dos con tarjeta NVIDIA, uno sin ella). La ventana, la barra de abajo y la de Windows
enseñan los dos, y el «termina hacia las…» cuenta con ellos.
- **Las conversiones sueltas se cierran al arrancar.** Una de las 10:04, de antes de actualizar a la 3.8.0 (el
lector de BRAW y su ffmpeg), seguía colgada a las 17:00 con una sesión del codificador y memoria de la tarjeta.
Ahora, al abrir, se cierran las que no tienen quien las espere (no las de la pasada de la noche) y se borra su
«.parcial»; y «Detener» cierra también el ffmpeg del lector de BRAW, que quedaba vivo.
- **La cola, entera.** Se pedían los 500 trabajos más recientes de todas las carpetas: con 1.920 en espera en Vía
Sumapaz, los de Línea Gene no salían (ni su «Reemplazar 12 verificados») y el «termina hacia las…» contaba 500
en espera, no 1.920 (decía 7 horas y eran 26). Ahora se pide la cola de la carpeta, entera, y el cálculo mira todo.
- **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.
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

@@ -42,7 +42,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "3.8.1"
VERSION = "3.9.0"
BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__))
# Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es
@@ -254,6 +254,11 @@ def motores():
out["gpu"]["uhq"] = r.returncode == 0
except Exception: # noqa
out["gpu"]["uhq"] = False
try: # ¿descomprime la tarjeta (NVDEC)? ver _decodificar_con_tarjeta
r = subprocess.run([ff, "-hide_banner", "-hwaccels"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA)
out["gpu"]["nvdec"] = bool(out["gpu"]["hevc"] or out["gpu"]["h264"]) and "cuda" in (r.stdout or "").split()
except Exception: # noqa
out["gpu"]["nvdec"] = False
try:
r = subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True, text=True, timeout=10, creationflags=SIN_VENTANA)
out["gpu"]["nombre"] = (r.stdout or "").strip().splitlines()[0] if r.returncode == 0 and r.stdout.strip() else ""
@@ -3747,7 +3752,7 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
# empezo: cuándo arrancó el clip que va; ritmo: (segundos de clip, segundos reales) de cada clip acabado en
# esta tanda, para decir cuánto falta
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0, "empezo": 0, "ritmo": [],
"esperando": "", "hechos_ids": [], "ultima": None}
"esperando": "", "hechos_ids": [], "ultima": None, "actuales": {}, "saltados": 0}
def _opt(k, defecto=None):
@@ -4063,6 +4068,22 @@ def _quitar_parcial(tmp):
time.sleep(0.25)
# Los códecs que descomprime la tarjeta. ProRes, DNxHD y MJPEG no: esos los sigue leyendo el procesador.
NVDEC = {"h264", "hevc", "av1", "vp9", "mpeg2video"}
def _decodificar_con_tarjeta(f, motor):
"""["-hwaccel", "cuda"] si el clip lo puede descomprimir la tarjeta, o []. Hasta la 3.8 lo descomprimía el
procesador y la tarjeta esperaba: medido el 14-oct-2026 en la RTX 5080 del estudio, con la cola en marcha,
el codificador iba al 46-74 % y el descompresor de la tarjeta al 0 %. Con dos clips 4K de Vía Sumapaz, un
H.264 pasó de 0,54× a 0,86× tiempo real y un H.265 de 0,70× a 0,85×, con el mismo archivo de salida. Los
cuadros vuelven a la memoria (sin «-hwaccel_output_format cuda»): el formato de píxel y los filtros siguen
igual, y hacerlo todo en la tarjeta no ganó nada medible. Si con ella falla, se repite con el procesador."""
if motor != "gpu" or not f or (f["codec"] or "") not in NVDEC or _opt("sin_nvdec", False):
return []
return ["-hwaccel", "cuda"] if motores()["gpu"].get("nvdec") else []
def correr_ffmpeg(t):
con = db()
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
@@ -4083,7 +4104,9 @@ def correr_ffmpeg(t):
tmp = salida + ".parcial" + fm["ext"]
video, _ = _args_video(t, fm, motor, fps)
contenedor, audio = _args_salida(fm, f)
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"]
con_tarjeta = _decodificar_con_tarjeta(f, motor)
for entrada in ([con_tarjeta, []] if con_tarjeta else [[]]):
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", *entrada, "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"]
cmd += video + ["-map_metadata", "0"] + contenedor + audio
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
set_trabajo(t["id"], log=" ".join(cmd)[:4000])
@@ -4099,9 +4122,7 @@ def correr_ffmpeg(t):
break
if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="):
try:
us = int(linea.split("=")[1])
if linea.startswith("out_time_ms="):
us = us # ffmpeg reporta microsegundos en ambas claves
us = int(linea.split("=")[1]) # ffmpeg reporta microsegundos en las dos claves
pct = min(99.0, us / 1e6 / dur * 100) if dur else 0
if time.time() - ultimo > 1:
set_trabajo(t["id"], pct=pct)
@@ -4114,10 +4135,13 @@ def correr_ffmpeg(t):
set_trabajo(t["id"], estado="cancelado", pct=0)
_quitar_parcial(tmp)
return
if p.returncode != 0 or not os.path.exists(tmp):
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
if p.returncode == 0 and os.path.exists(tmp):
break
_quitar_parcial(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
if not entrada:
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
return
# con la tarjeta descomprimiendo no salió: se repite con el procesador, como hasta la 3.8
os.replace(tmp, salida)
try:
st = os.stat(t["ruta"])
@@ -4182,14 +4206,15 @@ def correr_braw(t):
return
def vigia():
# Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se corta
# Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se
# corta, y con él su ffmpeg (p.kill() solo cerraba el lector, y su ffmpeg seguía vivo)
while p.poll() is None:
if COLA["cancelar"]:
try:
open(bandera, "w").close()
p.wait(20)
except (OSError, subprocess.TimeoutExpired):
p.kill()
_matar_arbol(p.pid)
return
time.sleep(0.5)
threading.Thread(target=vigia, daemon=True).start()
@@ -4342,61 +4367,146 @@ def _clip_presente(t):
return None
def bucle_cola():
def simultaneas():
"""Cuántos clips convierte la cola a la vez: optimizar.simultaneas, o, si es 0, dos con tarjeta NVIDIA y uno
sin ella. Medido el 14-oct-2026 en la RTX 5080 del estudio, que tiene dos codificadores: con un clip a la vez
el codificador iba al 46-74 % y la tarjeta entera al 9-18 %; dos clips 4K a la vez salen a 1,29× tiempo real
entre los dos, frente a unos 0,77× de uno solo."""
try:
while COLA["activa"]:
n = int(_opt("simultaneas", 0) or 0)
except (TypeError, ValueError):
n = 0
if n <= 0:
g = motores()["gpu"]
n = 2 if (g.get("hevc") or g.get("h264")) else 1
return max(1, min(3, n))
_COLA_TOMA = threading.Lock() # para que dos trabajadores no cojan el mismo clip
def _fijar_actual():
"""«actual» es el primero de los que van (lo que enseñan la barra de abajo y la de Windows); «actuales», todos."""
ids = sorted(COLA["actuales"])
COLA["actual"] = ids[0] if ids else None
COLA["empezo"] = COLA["actuales"].get(ids[0], 0) if ids else 0
def _soltar(*ids):
with _COLA_TOMA:
for i in ids:
COLA["actuales"].pop(i, None)
_fijar_actual()
def _tomar(n):
"""El siguiente clip para el trabajador n, ya apartado para que no lo coja otro. Los BRAW que van por el guion
de Resolve (un lote de hasta 40) solo los coge el primero, y solo cuando no queda nada más."""
with _COLA_TOMA:
ocupados = list(COLA["actuales"])
filtro = f" AND id NOT IN ({','.join('?' * len(ocupados))})" if ocupados else ""
con = db()
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY orden DESC, id LIMIT 1").fetchone()
lote_resolve = [dict(r) for r in con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve' ORDER BY id LIMIT 40").fetchall()]
try:
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw')" + filtro +
" ORDER BY orden DESC, id LIMIT 1", ocupados).fetchone()
lote = [] if t or n else [dict(r) for r in con.execute(
"SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve'" + filtro + " ORDER BY id LIMIT 40", ocupados).fetchall()]
finally:
con.close()
if t and not _esperar_turno():
break
if t:
t = _clip_presente(dict(t))
if not t:
continue
if not t and lote_resolve:
lote_resolve = [x for x in map(_clip_presente, lote_resolve) if x]
if not lote_resolve:
for i in ([t["id"]] if t else [r["id"] for r in lote]):
COLA["actuales"][i] = time.time()
_fijar_actual()
return (dict(t) if t else None), lote
def _ya_no_compensa(t):
"""Una conversión en espera que con la regla de ahorro de ahora no se ofrecería (se encoló con la de antes): no
se convierte. Solo en los formatos que existen para pesar menos. Devuelve el porqué, o «»."""
if FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])["codec"] != "hevc" or t["motor"] != "ffmpeg":
return ""
con = db()
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
con.close()
if not f or not f["dur"] or not f["kbps"]:
return ""
gana = round((1 - estimar_bytes(f, t["perfil"], formato_de(t)) / (f["kbps"] * 1000 / 8 * f["dur"])) * 100)
ahorro = ahorro_minimo()
return f"convertido ahorraría solo un {max(gana, 0)} %, y se convierte desde un {ahorro} %" if gana < ahorro else ""
def _trabajador(n):
"""Uno de los que convierten a la vez: coge el siguiente clip, lo convierte y vuelve a por otro. Si no queda
nada pero otro aún trabaja, espera un poco por si se encola algo más (así no se pierde el «a la vez»)."""
while COLA["activa"] and not COLA["cancelar"]:
t, lote = _tomar(n)
if not t and not lote:
if any(COLA["actuales"]):
time.sleep(3)
continue
return
ids = [t["id"]] if t else [r["id"] for r in lote]
if not _esperar_turno():
_soltar(*ids)
return
if t:
COLA["actual"] = t["id"]
COLA["empezo"] = time.time()
t = _clip_presente(t)
pega = _ya_no_compensa(t) if t else ""
if pega:
con = db()
with con:
con.execute("DELETE FROM trabajos WHERE id = ? AND estado = 'pendiente'", (t["id"],))
con.close()
COLA["saltados"] += 1
COLA["mensaje"] = f"{COLA['saltados']} sin convertir: con la regla de ahorro de ahora ya no compensaban ({pega})"
if not t or pega:
_soltar(*ids)
continue
COLA["actuales"][t["id"]] = empezo = time.time() # el tiempo cuenta desde que empieza de verdad
_fijar_actual()
set_trabajo(t["id"], estado="corriendo", pct=0)
try:
(correr_braw if t["motor"] == "braw" else correr_ffmpeg)(dict(t))
(correr_braw if t["motor"] == "braw" else correr_ffmpeg)(t)
except Exception as ex: # noqa
set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}")
_verificar_listos([t["id"]])
_anotar_ritmo(t["id"], time.time() - COLA["empezo"])
_anotar_ritmo(t["id"], time.time() - empezo)
if CONFIG["optimizar"].get("auto_reemplazar"):
try: # solo reemplaza lo verificado; lo demás lo deja como está
reemplazar([t["id"]])
except Exception as ex: # noqa
COLA["mensaje"] = f"no se pudo reemplazar: {ex}"
COLA["hechos"] += 1
COLA["hechos_ids"].append(t["id"])
elif lote_resolve:
COLA["actual"] = lote_resolve[0]["id"]
try:
correr_resolve(lote_resolve)
except Exception as ex: # noqa
for r in lote_resolve:
set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}")
_verificar_listos([r["id"] for r in lote_resolve])
COLA["hechos"] += len(lote_resolve)
COLA["hechos_ids"].extend(r["id"] for r in lote_resolve)
hechos = [t["id"]]
else:
break
COLA["actual"] = None
if COLA["cancelar"]:
COLA["cancelar"] = False
break
lote = [x for x in map(_clip_presente, lote) if x]
try:
if lote:
correr_resolve(lote)
except Exception as ex: # noqa
for r in lote:
set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}")
_verificar_listos([r["id"] for r in lote])
hechos = [r["id"] for r in lote]
with _COLA_TOMA:
COLA["hechos"] += len(hechos)
COLA["hechos_ids"].extend(hechos)
_soltar(*ids)
def bucle_cola():
"""La cola: arranca los trabajadores (ver simultaneas) y espera a que acaben todos."""
try:
hilos = [threading.Thread(target=_trabajador, args=(i,), daemon=True) for i in range(simultaneas())]
for h in hilos:
h.start()
for h in hilos:
h.join()
except Exception as ex: # noqa
COLA["mensaje"] = f"la cola se detuvo por un error: {ex}"
finally:
# pase lo que pase, la cola no queda «activa» para siempre (bloqueaba el cierre del programa)
COLA["activa"] = False
COLA["cancelar"] = False
COLA["actuales"] = {}
COLA["actual"] = None
COLA["hilo"] = None
COLA["esperando"] = ""
@@ -4420,42 +4530,50 @@ def _anotar_ritmo(id_, segundos):
pass
def eta_cola(trabajos):
def eta_cola(trabajos=None):
"""Cuánto falta para que acabe la cola: segundos de clip pendientes partidos por el ritmo medido (clips ya
hechos en esta tanda; si no hay ninguno, el avance del que va; si tampoco, a tiempo real los BRAW y a 2,5×
la tarjeta). Devuelve también el clip que va, con lo que le queda."""
pend = [t for t in trabajos if t["estado"] in ("pendiente", "corriendo")]
hechos en esta tanda; si no hay ninguno, el avance de los que van; si tampoco, a tiempo real los BRAW y a 2,5×
la tarjeta), por cuántos van a la vez. Sin lista, mira toda la cola: hasta la 3.8 contaba solo los 500 últimos
trabajos, y con 1.920 en espera en Vía Sumapaz decía que acababa en 7 horas y no en 26. Devuelve también los
clips que van, con lo que le queda a cada uno."""
con = db()
if trabajos is None:
pend = [dict(r) for r in con.execute("SELECT t.id, t.estado, t.pct, t.motor, a.dur AS dur FROM trabajos t "
"LEFT JOIN archivos a ON a.ruta = t.ruta WHERE t.estado IN ('pendiente','corriendo')")]
else:
pend = [dict(t) for t in trabajos if t["estado"] in ("pendiente", "corriendo")]
for t in pend:
if t.get("dur") is None:
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
t["dur"] = f["dur"] if f else 0
con.close()
if not pend:
return None
con = db()
durs = {}
for t in pend:
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
durs[t["id"]] = float((f["dur"] if f else 0) or 0)
con.close()
ritmo = COLA.get("ritmo") or []
clip_s, real_s = sum(d for d, _ in ritmo), sum(s for _, s in ritmo)
vel, medido = (clip_s / real_s if clip_s > 0 and real_s > 0 else 0.0), "tanda" if ritmo else ""
falta, actual, ahora = 0.0, None, time.time()
falta, actuales, ahora = 0.0, [], time.time()
for t in pend:
d = durs[t["id"]]
d = float(t.get("dur") or 0)
if t["estado"] == "corriendo":
pct = float(t["pct"] or 0)
resto = d * max(0.0, 1 - pct / 100)
falta += resto
llevado = ahora - (COLA.get("empezo") or ahora)
llevado = ahora - (COLA["actuales"].get(t["id"]) or COLA.get("empezo") or ahora)
if not vel and pct >= 3 and llevado > 5:
vel, medido = (d * pct / 100) / llevado, "clip"
actual = {"id": t["id"], "dur": d, "resto": resto, "llevado": llevado}
actuales.append({"id": t["id"], "dur": d, "resto": resto, "llevado": llevado})
else:
falta += d
if not vel:
vel = 1.0 if any(t["motor"] == "braw" for t in pend) else 2.5
seg = falta / vel if falta else 0.0
if actual:
actual["resto_seg"] = actual["resto"] / vel
return {"clip_seg": round(falta), "seg": round(seg), "vel": round(vel, 2), "medido": medido, "termina": ahora + seg if seg else 0,
"actual": actual, "en_espera": sum(1 for t in pend if t["estado"] == "pendiente"), "hechos": len(ritmo)}
paralelo = max(1, min(simultaneas() if COLA["activa"] else 1, len(pend)))
seg = falta / (vel * paralelo) if falta else 0.0
for a in actuales:
a["resto_seg"] = a["resto"] / vel
return {"clip_seg": round(falta), "seg": round(seg), "vel": round(vel * paralelo, 2), "vel_clip": round(vel, 2), "paralelo": paralelo,
"medido": medido, "termina": ahora + seg if seg else 0, "actual": actuales[0] if actuales else None, "actuales": actuales,
"en_espera": sum(1 for t in pend if t["estado"] == "pendiente"), "hechos": len(ritmo)}
def _ruta_original(t):
@@ -4581,6 +4699,78 @@ 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)}
def _matar_arbol(pid):
"""Cierra un proceso y lo que lanzó (el lector de BRAW lanza su propio ffmpeg)."""
try:
if os.name == "nt":
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, timeout=30, creationflags=SIN_VENTANA)
else:
os.kill(pid, 9)
except (OSError, subprocess.SubprocessError):
pass
def _conversores_sueltos(procesos, base, yo):
"""De una lista de procesos ({ProcessId, ParentProcessId, Name, CommandLine}), los de conversión de esta
instalación (un ffmpeg o un lector de BRAW) que ya no tienen quien los espere: su padre murió, o el número
lo reusó otro proceso. Los de un programa vivo (este, u otra instancia como la pasada de la noche) no."""
vivos = {p.get("ProcessId"): p for p in procesos}
base = base.lower()
def conversor(p):
cmd = (p.get("CommandLine") or "").lower()
return base in cmd and ((p.get("Name") or "").lower() == "ffmpeg.exe" or "braw.py" in cmd)
def programa(p):
cmd = (p.get("CommandLine") or "").lower()
return p.get("ProcessId") == yo or (not conversor(p) and (base in cmd or "organizador" in cmd or "ventana.py" in cmd))
sueltos = []
for p in procesos:
if not conversor(p):
continue
padre = vivos.get(p.get("ParentProcessId"))
if padre and (conversor(padre) or programa(padre)):
continue # el ffmpeg de un lector va con su lector; lo demás lo espera un programa vivo
sueltos.append(p)
return sueltos
def cerrar_conversores_sueltos():
"""Al arrancar, se cierran las conversiones que quedaron vivas sin el programa que las lanzó, y se borra lo que
dejaron a medio escribir. Medido el 14-oct-2026: el lector de BRAW y el ffmpeg de una conversión de las 10:04,
de antes de actualizar a la 3.8.0, seguían a las 17:00 colgados, con una sesión del codificador de la tarjeta
y su memoria, y su «.parcial.mp4» en la carpeta de Galeano."""
if os.name != "nt" or os.environ.get("ORGANILAB_ENSAYO"):
return []
filtro = "Name='ffmpeg.exe' or Name='OrganizadorLabstream.exe' or Name like 'python%'"
try:
r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command",
f'Get-CimInstance Win32_Process -Filter "{filtro}" | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress'],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, creationflags=SIN_VENTANA)
procesos = json.loads(r.stdout or "[]")
except (OSError, subprocess.SubprocessError, ValueError):
return []
if isinstance(procesos, dict):
procesos = [procesos]
sueltos = _conversores_sueltos(procesos, BASE, os.getpid())
if not sueltos:
return []
cerrados = {p["ProcessId"] for p in sueltos}
for p in sueltos:
_matar_arbol(p["ProcessId"])
# lo que dejaron a medias: el último argumento de cada ffmpeg cerrado (el suyo o el de su lector)
parciales = []
for p in procesos:
if (p.get("Name") or "").lower() == "ffmpeg.exe" and (p.get("ProcessId") in cerrados or p.get("ParentProcessId") in cerrados):
m = re.search(r'(?:"([^"]*\.parcial\.\w+)"|(\S*\.parcial\.\w+))\s*$', p.get("CommandLine") or "")
if m:
parciales.append(m.group(1) or m.group(2))
for tmp in parciales:
_quitar_parcial(tmp)
bitacora({"op": "conversores_sueltos", "cerrados": sorted(cerrados), "parciales": parciales})
return sorted(cerrados)
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
@@ -4653,6 +4843,8 @@ def iniciar_cola():
COLA["ritmo"] = []
COLA["hechos_ids"] = []
COLA["esperando"] = ""
COLA["actuales"] = {}
COLA["saltados"] = 0
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
COLA["hilo"].start()
return True
@@ -5567,14 +5759,13 @@ def estado_general():
con = db()
esc = {r["raiz"]: dict(r) for r in con.execute("SELECT * FROM escaneos").fetchall()}
cola_n = {r["estado"]: r["n"] for r in con.execute("SELECT estado, COUNT(*) n FROM trabajos GROUP BY estado").fetchall()}
actual = None
if COLA["actual"]:
t = con.execute("SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone()
if t:
actual = dict(t)
ids = sorted(COLA["actuales"])
actuales = [dict(t) for t in con.execute(f"SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id IN ({','.join('?' * len(ids))})",
ids)] if ids else []
actual = next((a for a in actuales if a["id"] == COLA["actual"]), actuales[0] if actuales else None)
con.close()
# «clip 3 de 12»: los hechos en esta tanda + el que va + los que esperan
total_tanda = COLA["hechos"] + (1 if actual else 0) + cola_n.get("pendiente", 0)
# «clip 3 de 12»: los hechos en esta tanda + los que van + los que esperan
total_tanda = COLA["hechos"] + len(actuales) + cola_n.get("pendiente", 0)
esc_ci = {k.lower(): v for k, v in esc.items()}
raices = []
for r in CONFIG["raices"]:
@@ -5582,7 +5773,7 @@ def estado_general():
raices.append({**r, "clave": k, "escaneo": esc_ci.get(k.lower()), "progreso": PROGRESO.get(k), "analisis": ANALISIS.get(k)})
return {"config": CONFIG, "raices": raices, "herramientas": herramientas(),
"version": VERSION, "actualizacion": actualizador.ESTADO, "congelado": CONGELADO,
"cola": {"activa": COLA["activa"], "actual": actual, "mensaje": COLA["mensaje"], "n": cola_n,
"cola": {"activa": COLA["activa"], "actual": actual, "actuales": actuales, "mensaje": COLA["mensaje"], "n": cola_n,
"hechos": COLA["hechos"], "total": total_tanda, "inicio": COLA["inicio"]},
"aplicando": APLICANDO, "asistente": asistente.ESTADO,
"respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])},
@@ -7464,9 +7655,13 @@ class Manejador(BaseHTTPRequestHandler):
return self._json(motores())
if ruta == "/api/optimizar/cola":
con = db()
# con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar»
# con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar». Con la
# carpeta, solo los suyos y todos: hasta la 3.8 eran los 500 últimos de todas, y con 1.920 en espera
# en Vía Sumapaz los de Línea Gene no salían (ni su «Reemplazar 12 verificados»)
k = (q.get("raiz") or "").strip()
filtro, args = ("WHERE lower(t.raiz) = ? ", [os.path.normpath(k).lower()]) if k else ("", [])
filas = [dict(r) for r in con.execute("SELECT t.*, a.dur AS dur, a.fps AS fps FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
"ORDER BY t.id DESC LIMIT 500").fetchall()]
+ filtro + "ORDER BY t.id DESC LIMIT " + ("10000" if k else "500"), args).fetchall()]
for f in filas: # un original ya reemplazado vive en _Para borrar/originales: su ficha está ahí
if f.get("dur") is None and f["estado"] == "reemplazado":
otra = _ruta_original(f)
@@ -7475,7 +7670,7 @@ class Manejador(BaseHTTPRequestHandler):
f["dur"], f["fps"] = a["dur"], a["fps"]
con.close()
try:
eta = eta_cola(filas) if COLA["activa"] else None
eta = eta_cola() if COLA["activa"] else None
except Exception: # noqa
eta = None
if COLA.get("ultima") is None:
@@ -7627,7 +7822,7 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"ok": True})
if ruta == "/api/config":
# solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json
permitidas = {"optimizar": {"meses_minimos", "min_mb", "ahorro_minimo", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar",
permitidas = {"optimizar": {"meses_minimos", "min_mb", "ahorro_minimo", "simultaneas", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar",
"horario", "pausar_si_edito", "aligerar", "avisar"},
"respaldo": {"destino"}, "actualizaciones": {"auto"}, "escaneo": {"profundo"}}
for k, v in d.items():
@@ -8119,12 +8314,13 @@ def avance():
try:
con = db()
espera = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0]
t = con.execute("SELECT pct FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() if COLA["actual"] else None
ids = list(COLA["actuales"])
pcts = [r[0] or 0 for r in con.execute(f"SELECT pct FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)] if ids else []
con.close()
except sqlite3.Error:
return -1
total = COLA["hechos"] + (1 if t else 0) + espera
return (COLA["hechos"] + ((t[0] or 0) / 100 if t else 0)) / total if total else -1
total = COLA["hechos"] + len(pcts) + espera
return (COLA["hechos"] + sum(pcts) / 100) / total if total else -1
if RESPALDO.get("estado") == "corriendo":
return RESPALDO["bytes"] / RESPALDO["bytes_total"] if RESPALDO.get("bytes_total") else -1
if TRAER.get("estado") == "copiando":
@@ -8350,7 +8546,8 @@ def main():
except OSError:
pass
huerfanas = recuperar_huerfanas()
threading.Thread(target=lambda: (reubicar_trabajos(), repasar_verificados(), braws_al_lector()), daemon=True).start()
threading.Thread(target=lambda: (cerrar_conversores_sueltos(), reubicar_trabajos(), repasar_verificados(), braws_al_lector()),
daemon=True).start()
if huerfanas:
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).")
try:

Ver fichero

@@ -12,6 +12,7 @@ import time
import shutil
import tempfile
import threading
import subprocess
import unittest
AQUI = os.path.dirname(os.path.abspath(__file__))
@@ -1315,6 +1316,157 @@ class Convertidor(unittest.TestCase):
self.assertTrue(r["ok"], r)
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": []}
candado = threading.Lock()
def falso(t):
with candado:
cuenta["vivos"] += 1
cuenta["maximo"] = max(cuenta["maximo"], cuenta["vivos"])
cuenta["vistos"].append(t["id"])
fin = time.time() + 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)
return
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())
for k, v in {"correr_ffmpeg": falso, "_verificar_listos": lambda ids: None, "editando": lambda: "",
"_ya_no_compensa": lambda t: ""}.items():
self.addCleanup(setattr, O, k, getattr(O, k))
setattr(O, k, v)
self.addCleanup(O.CONFIG["optimizar"].pop, "simultaneas", None)
return cuenta
def test_dos_a_la_vez(self):
"""RTX 5080 del estudio, 14-oct-2026: con un clip a la vez el codificador iba al 46-74 %; dos a la vez salen
a 1,29× tiempo real entre los dos, frente a 0,77×. Nunca van más de los pedidos, ningún clip se convierte
dos veces, y «Detener» para a todos."""
raiz = cliente("ALaVez")
nr = os.path.normpath(raiz)
g = "2025/11- Noviembre/00_Grabacion/SONY/"
clips = [mk(raiz, g + f"C00{i:02d}.MP4", 2025, 11, 11, tam=300000 + i) for i in range(8)]
O.escanear(raiz)
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
cuenta = self._cola_falsa()
O.CONFIG["optimizar"]["simultaneas"] = 2
# lo que otras pruebas dejaran esperando no entra en esta tanda (y se devuelve al acabar)
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.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[:5], "equilibrado", "420", "rec709", nr)
self.assertEqual(len(ids), 5)
self.assertTrue(O.iniciar_cola())
time.sleep(0.2)
e = O.eta_cola()
self.assertEqual(e["paralelo"], 2)
self.assertEqual(len(e["actuales"]), 2)
O.COLA["hilo"].join(30)
self.assertEqual(cuenta["maximo"], 2)
self.assertEqual(sorted(cuenta["vistos"]), sorted(ids))
self.assertEqual({self.trabajo(i)["estado"] for i in ids}, {"listo"})
self.assertEqual((O.COLA["actuales"], O.COLA["activa"], O.COLA["actual"]), ({}, False, None))
# Detener: los dos que van se cortan, los que esperan se quedan esperando
cuenta.update(maximo=0, vistos=[])
ids = O.encolar(clips[5:], "equilibrado", "420", "rec709", nr)
self.assertTrue(O.iniciar_cola())
time.sleep(0.2)
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.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_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."""
base = r"C:\Programas\OrganiLAB"
P = lambda pid, ppid, nombre, cmd: {"ProcessId": pid, "ParentProcessId": ppid, "Name": nombre, "CommandLine": cmd}
ff = base + r"\ffmpeg\ffmpeg.exe"
procesos = [
P(100, 50, "OrganizadorLabstream.exe", f'"{base}\\OrganizadorLabstream.exe"'), # este programa
P(101, 100, "OrganizadorLabstream.exe", f"{base}\\OrganizadorLabstream.exe {base}\\braw.py convertir x.json"), # su lector
P(102, 101, "ffmpeg.exe", f'{ff} -i pipe:0 "V:\\G\\a_10bit.mp4.parcial.mp4"'), # el ffmpeg del lector
P(103, 100, "ffmpeg.exe", f"{ff} -i a.mp4 b.parcial.mp4"), # uno suyo
P(200, 65492, "OrganizadorLabstream.exe", f"{base}\\OrganizadorLabstream.exe {base}\\braw.py convertir y.json"), # lector suelto
P(201, 200, "ffmpeg.exe", f"{ff} -i pipe:0 V:\\G\\b.parcial.mp4"), # y su ffmpeg
P(300, 60, "OrganizadorLabstream.exe", f'"{base}\\OrganizadorLabstream.exe" --automatico'), # la pasada de la noche
P(301, 300, "ffmpeg.exe", f"{ff} -i c.mp4 c.parcial.mp4"), # y su ffmpeg
P(401, 4000, "ffmpeg.exe", f"{ff} -i d.mp4 d.parcial.mp4"), # su programa ya no está
P(500, 1, "ffmpeg.exe", r"C:\otro\ffmpeg.exe -i e.mp4 e.mp4"), # de otro programa
]
self.assertEqual(sorted(p["ProcessId"] for p in O._conversores_sueltos(procesos, base, 100)), [200, 401])
def test_la_tarjeta_descomprime(self):
"""Con la cola en marcha, el descompresor de la tarjeta estaba al 0 %: un H.264 4K pasa de 0,54× a 0,86× tiempo
real si lo descomprime ella. Lo que ella no lee (ProRes) sigue por el procesador, y si con ella falla, se repite."""
real = O.motores
self.addCleanup(setattr, O, "motores", real)
O.motores = lambda: {"gpu": {"nvdec": True, "hevc": True}}
self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "gpu"), ["-hwaccel", "cuda"])
self.assertEqual(O._decodificar_con_tarjeta({"codec": "hevc"}, "gpu"), ["-hwaccel", "cuda"])
self.assertEqual(O._decodificar_con_tarjeta({"codec": "prores"}, "gpu"), [])
self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "cpu"), [])
O.motores = lambda: {"gpu": {"nvdec": False}}
self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "gpu"), [])
O.motores = real
ff = shutil.which("ffmpeg")
if not ff:
self.skipTest("sin ffmpeg")
raiz = cliente("Descomprime")
nr = os.path.normpath(raiz)
clip = os.path.join(nr, "2025", "11- Noviembre", "00_Grabacion", "SONY", "C0001.MP4")
os.makedirs(os.path.dirname(clip))
subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=320x180:rate=30:duration=1",
"-c:v", "libx264", "-pix_fmt", "yuv420p", clip], check=True)
O.escanear(raiz)
self.leidos(raiz, {"C0001.MP4": {"codec": "h264", "perfil": "High", "ancho": 320, "alto": 180, "fps": 30, "dur": 1, "kbps": 900}})
id_ = O.encolar([clip], "equilibrado", "420", "rec709", nr)[0]
# una descompresión que no arranca: se repite con el procesador y sale igual
self.addCleanup(setattr, O, "_decodificar_con_tarjeta", O._decodificar_con_tarjeta)
O._decodificar_con_tarjeta = lambda f, motor: ["-hwaccel", "no_existe"]
O.correr_ffmpeg(self.trabajo(id_))
t = self.trabajo(id_)
self.assertEqual(t["estado"], "listo", t["log"])
self.assertTrue(os.path.isfile(t["salida"]))
self.assertNotIn("-hwaccel", t["log"])
def test_lo_que_ya_no_compensa_no_se_convierte(self):
"""Se encolaron con la regla de antes: un H.265 de cámara que se quedaría en el 93 % no se convierte."""
raiz = cliente("YaNoCompensa")
nr = os.path.normpath(raiz)
g = "2025/11- Noviembre/00_Grabacion/SONY/"
a, b = mk(raiz, g + "C0001.MP4", 2025, 11, 11, tam=300000), mk(raiz, g + "C0002.MP4", 2025, 11, 11, tam=300001)
O.escanear(raiz)
self.leidos(raiz, {"C0001.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "fps": 59.94, "kbps": 72000},
"C0002.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000}})
ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr)
self.assertTrue(O._ya_no_compensa(self.trabajo(ia)).startswith("convertido ahorraría solo un 7 %"))
self.assertEqual(O._ya_no_compensa(self.trabajo(ib)), "")
con = O.db()
with con:
con.execute("DELETE FROM trabajos WHERE id IN (?, ?)", (ia, ib))
con.close()
def test_el_codificador_que_toca(self):
fm = O.FORMATOS["h265_10bit"]
t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000}

Ver fichero

@@ -697,6 +697,7 @@
<label>croma <select id="optChroma"><option value="420">4:2:0 10 bits (compatible)</option><option value="422">4:2:2 10 bits (solo ffmpeg, pesa más)</option></select></label>
<label>BRAW en <select id="optColor"><option value="rec709">Rec.709 (como se vio al grabar)</option><option value="log">Log de cámara (plano, más latitud)</option></select></label>
<label><input type="checkbox" id="optExport"> incluir exportados</label>
<label title="Cuántos clips convierte la cola a la vez. Automático: dos con tarjeta NVIDIA, uno sin ella. Cuenta desde la próxima vez que arranque la cola.">a la vez <select id="optSimult"><option value="0">automático</option><option value="1">1</option><option value="2">2</option><option value="3">3</option></select></label>
</div></details></span></div>
<p class="ayuda">Convierte los clips de proyectos ya entregados a un formato más ligero. <b>El original no se toca</b> hasta que reemplaces. <span class="q">?</span><span class="mas">Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a <span class="path">_Para borrar/originales</span> y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW se leen con el decodificador de Blackmagic que instala DaVinci Resolve, sin tener que abrirlo; todo se codifica con ffmpeg, con la tarjeta gráfica cuando la hay. Se ofrece todo lo que convertido pesaría al menos un 30 % menos (se cambia en «cambiar»); lo calcula por el códec de cada clip: un H.264 o un AV1 de cámara suelen quedar en la décima parte, y un H.265 de cámara casi no adelgaza. Si al terminar ahorra menos de lo pedido (con 10 puntos de margen), se descarta y se queda el original. Nada se borra.</span></p>
<div class="toolbar" style="margin-bottom:10px"><label>formato de salida <select id="optFormato"></select></label><span id="fmtQue" class="mut" style="font-size:12px"></span><span class="sep"></span><span id="motores" class="mut" style="font-size:12px"><span class="spin"></span> detectando GPU y CPU…</span></div>
@@ -994,7 +995,7 @@ async function cargarEstado(){
sel.value = (antes && ESTADO.raices.some(r => r.clave === antes)) ? antes : '';
pintarIndice();
const fmt = $('#optFormato'); if (!fmt.options.length && ESTADO.formatos) { fmt.innerHTML = Object.entries(ESTADO.formatos).map(([k, v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); fmt.value = ESTADO.config.optimizar.formato || 'h265_10bit'; pintarFormato(); cargarMotores(); }
const perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); perf.value = ESTADO.config.optimizar.perfil; $('#optMeses').value = ESTADO.config.optimizar.meses_minimos; $('#optMin').value = ESTADO.config.optimizar.min_mb; $('#optAhorro').value = ESTADO.config.optimizar.ahorro_minimo ?? 30; $('#optChroma').value = ESTADO.config.optimizar.chroma; $('#optColor').value = ESTADO.config.optimizar.color; pintarCriterio(); }
const perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); perf.value = ESTADO.config.optimizar.perfil; $('#optMeses').value = ESTADO.config.optimizar.meses_minimos; $('#optMin').value = ESTADO.config.optimizar.min_mb; $('#optAhorro').value = ESTADO.config.optimizar.ahorro_minimo ?? 30; $('#optSimult').value = String(ESTADO.config.optimizar.simultaneas ?? 0); $('#optChroma').value = ESTADO.config.optimizar.chroma; $('#optColor').value = ESTADO.config.optimizar.color; pintarCriterio(); }
const r = cfgRaiz(); const a = r && r.analisis;
if (a && a.estado === 'corriendo') $('#anaEstado').innerHTML = `<span class="spin"></span> leyendo clips: ${a.hechos} de ${a.total} <button class="lnk" id="btnAnaDetener">Detener</button>`;
else if (a && a.error && vistaActual() === 'convertir') $('#anaEstado').innerHTML = `<span class="warn">${esc(a.error)}</span>`;
@@ -1164,7 +1165,7 @@ function tareasDe(E){
if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); }
if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); }
});
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const vs = (E.cola.actuales && E.cola.actuales.length) ? E.cola.actuales : (a ? [a] : []); const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctVan = vs.reduce((s, x) => s + (x.pct || 0), 0); const pctTot = tot ? Math.round((hechos + pctVan / 100) / tot * 100) : null; const numeros = vs.length > 1 ? `${Math.min(hechos + 1, tot)} y ${Math.min(hechos + 2, tot)}` : `${Math.min(hechos + 1, tot)}`; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${numeros} de ${tot}` : 'Convirtiendo', d: `${vs.map(x => (x.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(x.pct || 0) + ' %').join(' · ')}${vs.length ? ' · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); }
else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.unidades_total ? E.aplicando.unidades * 100 / E.aplicando.unidades_total : E.aplicando.hechas * 100 / E.aplicando.total)});
if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); }
@@ -1894,6 +1895,7 @@ $('#btnStockPapelera').onclick = () => { const s = STOCK.filter(g => g.sel && g.
const ahorroPedido = () => { const v = $('#optAhorro').value; return v === '' ? 30 : Math.max(0, Math.min(90, Math.round(+v) || 0)); };
function pintarCriterio(){ $('#convCriterio').textContent = `con más de ${+$('#optMeses').value || 0} meses, más de ${+$('#optMin').value || 0} MB y que ahorren al menos un ${ahorroPedido()} %`; }
['optMeses','optMin','optAhorro'].forEach(id => $('#' + id).oninput = pintarCriterio);
$('#optSimult').onchange = () => api('/api/config', {optimizar: {simultaneas: +$('#optSimult').value}}).then(() => aviso(`La cola convertirá ${+$('#optSimult').value || 'automáticamente uno o dos'}${+$('#optSimult').value === 1 ? ' clip' : (+$('#optSimult').value ? ' clips' : '')} a la vez desde la próxima vez que arranque.`, 'ok')).catch(e => aviso(e.message, 'bad'));
// el ahorro mínimo vale también para la pasada automática y para Inicio: se guarda al cambiarlo
$('#optAhorro').onchange = () => api('/api/config', {optimizar: {ahorro_minimo: ahorroPedido()}}).then(() => { if (CAND.length) return cargarCandidatos(); }).catch(e => aviso(e.message, 'bad'));
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'carpeta', inverso: false}, CONV_EXC = '', CONV_PLEGADAS = new Set(), CONV_BUSCA = '', CONV_CRITERIO = '';
@@ -1959,8 +1961,8 @@ function resumenCand(){ const s = CAND.filter(c => c.sel); const validos = CAND.
$('#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'};
async function cargarCola(){
const d = await api('/api/optimizar/cola');
const raiz = raizActual();
const d = await api('/api/optimizar/cola' + (raiz ? '?raiz=' + encodeURIComponent(raiz) : ''));
const ts = d.trabajos.filter(t => !raiz || (t.raiz || '').toLowerCase() === raiz.toLowerCase());
const orden = {corriendo:0, pendiente:1, listo:2, verificado:2, error:3, descartado:4, reemplazado:5, cancelado:6};
ts.sort((x, y) => (orden[x.estado] ?? 9) - (orden[y.estado] ?? 9) || y.id - x.id);
@@ -2011,7 +2013,7 @@ async function cargarCola(){
const corre = t && t.estado === 'corriendo', pct = corre ? Math.max(0, Math.min(100, Math.round(t.pct || 0))) : 0;
const est = corre ? `<span class="est prog ${pct >= 50 ? 'media' : ''}" style="--p:${pct}%" title="convirtiendo: ${pct} %">${pct} %</span>` : t ? `<span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}</span>` : `<span class="est candidato">${c.excluido ? 'no' : 'candidato'}</span>`;
let sub = c ? `${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${motorT}` : motorT;
if (corre) { const a = eta && eta.actual && eta.actual.id === t.id ? eta.actual : null; sub = `<span class="acc">convirtiendo${a && a.dur ? ` · ${tiempoLargo(a.dur - a.resto)} de ${tiempoLargo(a.dur)} · quedan ${tiempoLargo(a.resto_seg)}` : ''}${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''}</span> · ${motorT}`; }
if (corre) { const a = eta && ((eta.actuales || []).find(x => x.id === t.id) || (eta.actual && eta.actual.id === t.id ? eta.actual : null)); sub = `<span class="acc">convirtiendo${a && a.dur ? ` · ${tiempoLargo(a.dur - a.resto)} de ${tiempoLargo(a.dur)} · quedan ${tiempoLargo(a.resto_seg)}` : ''}${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''}</span> · ${motorT}`; }
else if (t && t.estado === 'error') sub = `<span class="bad" title="${esc(t.log || '')}">${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}</span>`;
else if (t && t.estado === 'descartado') sub = `<span class="mut">${esc(t.log || '')}</span>`;
else if (t && t.estado === 'pendiente' && t.log) sub = `<span class="mut">${esc(t.log)}</span>`;
@@ -2034,9 +2036,12 @@ async function cargarCola(){
if (d.cola.activa && d.cola.esperando) {
mo = `<div class="momento"><div class="l1"><span class="spin"></span><b>La cola está en pausa</b><span class="mut">${esc(d.cola.esperando)} · ${pend} en espera · se cambia en «Cuándo trabaja la cola»</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div></div>`;
} else if (d.cola.activa) {
const total = E && E.total ? E.total : (pend + (corr ? 1 : 0)), hechosT = E ? (E.hechos || 0) : 0;
const pctTot = total ? Math.round((hechosT + (corr ? (corr.pct || 0) / 100 : 0)) / total * 100) : 0;
mo = `<div class="momento corre"><div class="l1"><span class="spin"></span><b>Convirtiendo ${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">${corr ? `ahora: <b>${esc((corr.ruta || '').split(/[\\/]/).pop())}</b> · ${Math.round(corr.pct || 0)} %${eta && eta.actual ? ` · quedan ${tiempoLargo(eta.actual.resto_seg)} de este clip` : ''} · ${eta ? eta.en_espera : pend} en espera` : 'empezando…'}</div></div>`;
const corrs = ts.filter(t => t.estado === 'corriendo');
const total = E && E.total ? E.total : (pend + (corrs.length || (corr ? 1 : 0))), hechosT = E ? (E.hechos || 0) : 0;
const pctTot = total ? Math.round((hechosT + corrs.reduce((s, x) => s + (x.pct || 0), 0) / 100) / total * 100) : 0;
const restoDe = x => { const a = eta && (eta.actuales || []).find(y => y.id === x.id); return a ? ` · quedan ${tiempoLargo(a.resto_seg)}` : ''; };
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>`;
} else if (vers.length) {
@@ -2196,7 +2201,7 @@ function comparar(id){
const br = $('#cmpReemplazar'); if (br) br.onclick = () => accion(br, async () => { const r = await api('/api/optimizar/reemplazar', {ids: [id]}); const x = r.resultados[0] || {}; aviso(x.ok ? `Reemplazado: ${x.motivo}` : `No se reemplazó: ${x.motivo}`, x.ok ? 'ok' : 'bad', x.ok ? {texto: 'Ver historial', ir: () => irA('historial')} : null); $('#modal').classList.remove('on'); cargarCola(); });
}
$('#btnColaToggle').onclick = () => accion($('#btnColaToggle'), async () => { await api('/api/optimizar/control', {accion: $('#btnColaToggle').textContent === 'Detener' ? 'detener' : 'iniciar'}); await cargarEstado(); cargarCola(); });
const idsPorEstado = async (...est) => { const d = await api('/api/optimizar/cola'); const raiz = raizActual().toLowerCase(); return d.trabajos.filter(t => est.includes(t.estado) && (t.raiz || '').toLowerCase() === raiz && !(est.includes('verificado') && (t.formato || '').startsWith('prores'))).map(t => t.id); };
const idsPorEstado = async (...est) => { const d = await api('/api/optimizar/cola?raiz=' + encodeURIComponent(raizActual())); const raiz = raizActual().toLowerCase(); return d.trabajos.filter(t => est.includes(t.estado) && (t.raiz || '').toLowerCase() === raiz && !(est.includes('verificado') && (t.formato || '').startsWith('prores'))).map(t => t.id); };
$('#btnVerificar').onclick = () => accion($('#btnVerificar'), async () => { const ids = await idsPorEstado('listo'); const r = await api('/api/optimizar/verificar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} verificados.`, ok === r.resultados.length ? 'ok' : 'bad'); if (ok < r.resultados.length) modal('Verificación', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'FALLA'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); });
$('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `<p><b>${ids.length}</b> originales se apartan a <span class="path">_Para borrar/originales</span> dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.</p><p>Cuando estés seguro, una persona vacía _Para borrar a mano.</p>`, () => accion($('#btnReemplazar'), async () => { const r = await api('/api/optimizar/reemplazar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} reemplazados.`, ok === r.resultados.length ? 'ok' : 'bad', {texto: 'Ver historial', ir: () => irA('historial')}); if (ok < r.resultados.length) modal('Reemplazo', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); })); $('#modalSi').textContent = 'Reemplazar'; });
$('#btnReintentar').onclick = () => accion($('#btnReintentar'), async () => { const ids = await idsPorEstado('error', 'cancelado'); await api('/api/optimizar/control', {accion: 'reintentar', ids}); await api('/api/optimizar/control', {accion: 'iniciar'}); cargarCola(); });