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

@@ -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,41 +4104,44 @@ 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?"]
cmd += video + ["-map_metadata", "0"] + contenedor + audio
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
set_trabajo(t["id"], log=" ".join(cmd)[:4000])
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
except OSError as ex:
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}")
return
ultimo = 0
for linea in p.stdout:
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])
try:
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
except OSError as ex:
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}")
return
ultimo = 0
for linea in p.stdout:
if COLA["cancelar"]:
p.kill()
break
if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="):
try:
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)
ultimo = time.time()
except ValueError:
pass
err = p.stderr.read()
p.wait()
if COLA["cancelar"]:
p.kill()
set_trabajo(t["id"], estado="cancelado", pct=0)
_quitar_parcial(tmp)
return
if p.returncode == 0 and os.path.exists(tmp):
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
pct = min(99.0, us / 1e6 / dur * 100) if dur else 0
if time.time() - ultimo > 1:
set_trabajo(t["id"], pct=pct)
ultimo = time.time()
except ValueError:
pass
err = p.stderr.read()
p.wait()
if COLA["cancelar"]:
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:])
_quitar_parcial(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
return
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"]:
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()]
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()
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:
continue
if t:
COLA["actual"] = t["id"]
COLA["empezo"] = time.time()
set_trabajo(t["id"], estado="corriendo", pct=0)
try:
(correr_braw if t["motor"] == "braw" else correr_ffmpeg)(dict(t))
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:
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)(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() - 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
set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}")
_verificar_listos([t["id"]])
_anotar_ritmo(t["id"], time.time() - COLA["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)
else:
break
COLA["actual"] = None
if COLA["cancelar"]:
COLA["cancelar"] = False
break
COLA["mensaje"] = f"no se pudo reemplazar: {ex}"
hechos = [t["id"]]
else:
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: