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:
433
organizador.py
433
organizador.py
@@ -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:
|
||||
|
||||
Referencia en una nueva incidencia
Block a user