Convertir: lista por carpetas con cuánto falta, «comparar» con nota de calidad, y un botón principal según el momento
Munka eligió tres de seis propuestas dibujadas en un lienzo (quedan el horario nocturno, el resumen al acabar y marcar con criterio): - La lista va por carpetas (la ruta una sola vez), el clip que se convierte lleva su barra y lo que le queda, «primero este» adelanta uno, y los excluidos salen en grupos con su porqué. Antes y después en una barra. La cola mide su ritmo y dice cuánto falta y a qué hora acaba (eta en /api/optimizar/cola). - «Comparar»: el mismo cuadro del original y del convertido en tres instantes, con lupa, y «Medir la calidad» (VMAF) que se guarda en el trabajo; braw.py cuadro y braw.py calidad revelan el BRAW con la misma curva. Medido: 97,8 en 14 s con un BRAW de 16,5 GB. - Un solo botón grande según el momento: buscar, convertir (con «reemplazar solo cuando estén verificados», optimizar.auto_reemplazar), la tanda con Detener, reanudar, o reemplazar lo verificado. Pruebas: probar 19, prueba_funcional 31 (nueva: comparar), prueba_ventana 18. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
237
organizador.py
237
organizador.py
@@ -436,7 +436,11 @@ def init_db():
|
||||
"ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'",
|
||||
"ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1",
|
||||
# con qué versión de las reglas se hizo el inventario (R.VERSION_REGLAS)
|
||||
"ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0"):
|
||||
"ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0",
|
||||
# «primero este»: la cola va por orden descendente y luego por id
|
||||
"ALTER TABLE trabajos ADD COLUMN orden INTEGER DEFAULT 0",
|
||||
# la nota de calidad (VMAF) medida en «comparar»: media y cuadro peor
|
||||
"ALTER TABLE trabajos ADD COLUMN nota REAL", "ALTER TABLE trabajos ADD COLUMN nota_min REAL"):
|
||||
try:
|
||||
con.execute(alter)
|
||||
con.commit()
|
||||
@@ -2844,7 +2848,9 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
|
||||
return ids
|
||||
|
||||
|
||||
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0}
|
||||
# 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": []}
|
||||
|
||||
|
||||
def set_trabajo(id_, **kw):
|
||||
@@ -3233,17 +3239,24 @@ def bucle_cola():
|
||||
try:
|
||||
while COLA["activa"]:
|
||||
con = db()
|
||||
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY id LIMIT 1").fetchone()
|
||||
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()]
|
||||
con.close()
|
||||
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))
|
||||
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
|
||||
elif lote_resolve:
|
||||
COLA["actual"] = lote_resolve[0]["id"]
|
||||
@@ -3269,6 +3282,180 @@ def bucle_cola():
|
||||
COLA["hilo"] = None
|
||||
|
||||
|
||||
def _anotar_ritmo(id_, segundos):
|
||||
"""Cuánto clip salió por segundo real en este trabajo, si acabó bien: con eso se calcula cuánto falta."""
|
||||
try:
|
||||
con = db()
|
||||
t = con.execute("SELECT estado, ruta FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||||
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||||
con.close()
|
||||
if t and t["estado"] in ("listo", "verificado", "reemplazado", "descartado") and f and (f["dur"] or 0) > 0 and segundos > 0:
|
||||
COLA["ritmo"].append((float(f["dur"]), float(segundos)))
|
||||
except Exception: # noqa
|
||||
pass
|
||||
|
||||
|
||||
def eta_cola(trabajos):
|
||||
"""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")]
|
||||
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()
|
||||
for t in pend:
|
||||
d = durs[t["id"]]
|
||||
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)
|
||||
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}
|
||||
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)}
|
||||
|
||||
|
||||
def _ruta_original(t):
|
||||
"""Dónde está hoy el original de un trabajo: en su sitio o, si ya se reemplazó, en _Para borrar/originales."""
|
||||
if os.path.isfile(t["ruta"]):
|
||||
return t["ruta"]
|
||||
try:
|
||||
otra = os.path.join(t["raiz"], R.PAPELERA, "originales", os.path.relpath(t["ruta"], t["raiz"]))
|
||||
except ValueError:
|
||||
return ""
|
||||
return otra if os.path.isfile(otra) else ""
|
||||
|
||||
|
||||
def cuadro_de(id_, cual, segundo, ancho=1600):
|
||||
"""Un cuadro (JPEG) del original o del convertido de un trabajo, en el segundo pedido; se guarda en
|
||||
datos/cuadros para no sacarlo dos veces. Devuelve la ruta del JPEG o lanza ValueError."""
|
||||
con = db()
|
||||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||||
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||||
con.close()
|
||||
if not t:
|
||||
raise ValueError("no existe ese trabajo")
|
||||
segundo = max(0.0, float(segundo or 0))
|
||||
carpeta = os.path.join(DATOS, "cuadros")
|
||||
os.makedirs(carpeta, exist_ok=True)
|
||||
salida = os.path.join(carpeta, f"{id_}_{'o' if cual == 'original' else 'c'}_{int(round(segundo * 10))}.jpg")
|
||||
if os.path.isfile(salida) and os.path.getsize(salida) > 0:
|
||||
return salida
|
||||
if cual == "original":
|
||||
src = _ruta_original(dict(t))
|
||||
if not src:
|
||||
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
|
||||
else:
|
||||
src = t["salida"]
|
||||
if not os.path.isfile(src):
|
||||
raise ValueError("el convertido ya no está donde se hizo")
|
||||
if cual == "original" and t["motor"] == "braw":
|
||||
dec = decodificador_braw()
|
||||
if not dec.get("ok"):
|
||||
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||||
ficha = os.path.join(DATOS, f"cuadro_{id_}.json")
|
||||
guardar_json(ficha, {"src": src, "segundo": segundo, "salida": salida, "ffmpeg": ruta_herramienta("ffmpeg"),
|
||||
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho})
|
||||
try:
|
||||
ev = [e for e in _lector_braw("cuadro", ficha, espera=180) if e.get("estado")]
|
||||
finally:
|
||||
try:
|
||||
os.remove(ficha)
|
||||
except OSError:
|
||||
pass
|
||||
if not ev or ev[-1].get("estado") != "listo":
|
||||
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo sacar el cuadro")
|
||||
else:
|
||||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{segundo:.3f}", "-i", src,
|
||||
"-frames:v", "1", "-vf", f"scale={ancho}:-2:flags=bicubic,format=yuvj420p", "-q:v", "2", "-f", "image2", salida]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, creationflags=SIN_VENTANA)
|
||||
if r.returncode != 0 or not os.path.isfile(salida):
|
||||
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo sacar el cuadro")
|
||||
return salida
|
||||
|
||||
|
||||
def calidad_de(id_, segundos=10, ancho=1920):
|
||||
"""Nota de calidad (VMAF, 0 a 100) del convertido de un trabajo contra su original, sobre un tramo del medio
|
||||
del clip. Se guarda en el trabajo (nota, nota_min). Lanza ValueError si no se puede."""
|
||||
con = db()
|
||||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||||
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||||
con.close()
|
||||
if not t:
|
||||
raise ValueError("no existe ese trabajo")
|
||||
src = _ruta_original(dict(t))
|
||||
if not src:
|
||||
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
|
||||
if not os.path.isfile(t["salida"]):
|
||||
raise ValueError("el convertido ya no está donde se hizo")
|
||||
fps = float((f["fps"] if f else 0) or 0) or 25.0
|
||||
dur = float((f["dur"] if f else 0) or 0)
|
||||
total = int(dur * fps) if dur else 0
|
||||
n = max(24, min(int(segundos * fps), 300, total or 300))
|
||||
desde = max(0, (total - n) // 2) if total else 0
|
||||
hilos = max(2, min(16, (os.cpu_count() or 8) - 2))
|
||||
carpeta = os.path.join(DATOS, "cuadros")
|
||||
os.makedirs(carpeta, exist_ok=True)
|
||||
log = f"calidad_{id_}.json"
|
||||
t0 = time.time()
|
||||
if t["motor"] == "braw":
|
||||
dec = decodificador_braw()
|
||||
if not dec.get("ok"):
|
||||
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||||
ficha = os.path.join(DATOS, f"calidad_{id_}.ficha.json")
|
||||
guardar_json(ficha, {"src": src, "conv": t["salida"], "desde": desde, "cuadros": n, "ffmpeg": ruta_herramienta("ffmpeg"),
|
||||
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho, "log": log, "cwd": carpeta, "vmaf_hilos": hilos})
|
||||
try:
|
||||
ev = [e for e in _lector_braw("calidad", ficha, espera=900) if e.get("estado")]
|
||||
finally:
|
||||
try:
|
||||
os.remove(ficha)
|
||||
except OSError:
|
||||
pass
|
||||
if not ev or ev[-1].get("estado") != "listo":
|
||||
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo medir")
|
||||
nota, minimo, cuadros = ev[-1]["vmaf"], ev[-1]["minimo"], ev[-1]["medidos"]
|
||||
else:
|
||||
fc = (f"[0:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r];"
|
||||
f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d];"
|
||||
f"[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={log}")
|
||||
ss = f"{desde / fps:.6f}"
|
||||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", ss, "-i", src, "-ss", ss, "-i", t["salida"],
|
||||
"-filter_complex", fc, "-frames:v", str(n), "-an", "-f", "null", "-"]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900, cwd=carpeta, creationflags=SIN_VENTANA)
|
||||
ruta_log = os.path.join(carpeta, log)
|
||||
if r.returncode != 0 or not os.path.isfile(ruta_log):
|
||||
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo medir (¿este ffmpeg trae libvmaf?)")
|
||||
with open(ruta_log, "r", encoding="utf-8") as fh:
|
||||
j = json.load(fh)
|
||||
v = (j.get("pooled_metrics") or {}).get("vmaf") or {}
|
||||
nota, minimo, cuadros = round(float(v.get("mean") or 0), 1), round(float(v.get("min") or 0), 1), len(j.get("frames") or [])
|
||||
try:
|
||||
os.remove(os.path.join(carpeta, log))
|
||||
except OSError:
|
||||
pass
|
||||
set_trabajo(id_, nota=nota, nota_min=minimo)
|
||||
return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)}
|
||||
|
||||
|
||||
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
|
||||
@@ -3338,6 +3525,7 @@ def iniciar_cola():
|
||||
COLA["cancelar"] = False
|
||||
COLA["hechos"] = 0
|
||||
COLA["inicio"] = time.time()
|
||||
COLA["ritmo"] = []
|
||||
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
|
||||
COLA["hilo"].start()
|
||||
return True
|
||||
@@ -5665,9 +5853,35 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json(motores())
|
||||
if ruta == "/api/optimizar/cola":
|
||||
con = db()
|
||||
filas = [dict(r) for r in con.execute("SELECT * FROM trabajos ORDER BY id DESC LIMIT 500").fetchall()]
|
||||
# con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar»
|
||||
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()]
|
||||
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)
|
||||
a = con.execute("SELECT dur, fps FROM archivos WHERE ruta = ?", (otra,)).fetchone() if otra else None
|
||||
if a:
|
||||
f["dur"], f["fps"] = a["dur"], a["fps"]
|
||||
con.close()
|
||||
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"]}})
|
||||
try:
|
||||
eta = eta_cola(filas) if COLA["activa"] else None
|
||||
except Exception: # noqa
|
||||
eta = None
|
||||
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta}})
|
||||
if ruta == "/api/optimizar/cuadro": # un cuadro del original o del convertido, para «comparar»
|
||||
try:
|
||||
jpg = cuadro_de(int(q.get("id") or 0), q.get("cual") or "convertido", float(q.get("t") or 0))
|
||||
except (ValueError, TypeError) as ex:
|
||||
return self._json({"error": str(ex)}, 400)
|
||||
with open(jpg, "rb") as fh:
|
||||
cuerpo = fh.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/jpeg")
|
||||
self.send_header("Content-Length", str(len(cuerpo)))
|
||||
self.send_header("Cache-Control", "private, max-age=86400")
|
||||
self.end_headers()
|
||||
self.wfile.write(cuerpo)
|
||||
return
|
||||
if ruta == "/api/stock":
|
||||
tipos = [t for t in (q.get("tipos") or "Video").split(",") if t]
|
||||
return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos))
|
||||
@@ -5757,7 +5971,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", "perfil", "formato", "chroma", "color", "salida", "sufijo"},
|
||||
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar"},
|
||||
"respaldo": {"destino"}, "actualizaciones": {"auto"}}
|
||||
for k, v in d.items():
|
||||
if k in permitidas and isinstance(v, dict):
|
||||
@@ -5879,6 +6093,12 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado','descartado') AND id IN ({','.join('?' * len(ids))})", ids)
|
||||
con.close()
|
||||
return self._json({"ok": True, "apartados": ap})
|
||||
if acc == "primero": # «primero este»: pasa delante de los que esperan
|
||||
con = db()
|
||||
with con:
|
||||
con.execute("UPDATE trabajos SET orden = (SELECT COALESCE(MAX(orden), 0) + 1 FROM trabajos) WHERE id = ? AND estado = 'pendiente'", (int(d.get("id") or 0),))
|
||||
con.close()
|
||||
return self._json({"ok": True})
|
||||
if acc == "reintentar":
|
||||
con = db()
|
||||
with con:
|
||||
@@ -5887,6 +6107,11 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json({"ok": True})
|
||||
if ruta == "/api/optimizar/verificar":
|
||||
return self._json({"resultados": verificar(d.get("ids", []))})
|
||||
if ruta == "/api/optimizar/calidad": # la nota VMAF de un convertido: tarda unos segundos
|
||||
try:
|
||||
return self._json(calidad_de(int(d.get("id") or 0)))
|
||||
except (ValueError, TypeError, subprocess.TimeoutExpired) as ex:
|
||||
return self._json({"error": str(ex)}, 400)
|
||||
if ruta == "/api/optimizar/reemplazar":
|
||||
return self._json({"resultados": reemplazar(d.get("ids", []))})
|
||||
if ruta == "/api/stock/papelera":
|
||||
|
||||
Referencia en una nueva incidencia
Block a user