1.9.0: convertidor, análisis en paralelo y respaldo

- analizar(): 8 hilos, se puede detener, informa de los ilegibles. ffprobe es
  sobre todo espera de red (0,23 s por clip en el NAS).
- Los formatos pesan de verdad distinto (cq_mas por formato): archivo 14,3 MB,
  ligero 8,2 MB, H.264 18,3 MB sobre el mismo clip.
- ProRes sin -qscale: el perfil vuelve a gobernar la tasa.
- motores(): se prueba el croma 4:2:2 de verdad; si la GPU no puede, va por CPU.
- Audio multipista: -c:a copy solo con una pista AAC (nueva columna pistas_audio).
- Raíces anidadas rechazadas al registrarlas.
- Respaldo: incremental contra el archivo de origen, e índice acumulativo.
- Interfaz: las casillas del plan no repintan la lista ni pierden el foco; el
  buscador de Ajustes conserva lo editado; la lista de stock no salta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 11:08:33 -05:00
padre b492881123
commit 2d7fe55265
Se han modificado 4 ficheros con 208 adiciones y 46 borrados

Ver fichero

@@ -34,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "1.8.1"
VERSION = "1.9.0"
CONGELADO = getattr(sys, "frozen", False)
SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller
@@ -126,9 +126,11 @@ FORMATOS = {
"h265_10bit": {"nombre": "MP4 H.265 10 bits · archivo (recomendado)", "ext": ".mp4", "codec": "hevc", "bits": 10, "sufijo": "_10bit",
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 1.0, "que": "unas 10 veces más ligero que el original, sin perder calidad visible; el que usa el respaldo"},
"h265_ligero": {"nombre": "MP4 H.265 8 bits · ligero para compartir", "ext": ".mp4", "codec": "hevc", "bits": 8, "sufijo": "_ligero",
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 0.55, "que": "para enviar por WhatsApp o Drive; pierde latitud de color"},
"gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 0.55, "cq_mas": 5,
"que": "para enviar por WhatsApp o Drive; pierde latitud de color"},
"h264": {"nombre": "MP4 H.264 8 bits · compatible con todo", "ext": ".mp4", "codec": "h264", "bits": 8, "sufijo": "_h264",
"gpu": "h264_nvenc", "cpu": "libx264", "bpp_mult": 1.5, "que": "se abre en cualquier equipo o televisor; pesa más que H.265"},
"gpu": "h264_nvenc", "cpu": "libx264", "bpp_mult": 1.5, "cq_mas": -2,
"que": "se abre en cualquier equipo o televisor; pesa más que H.265"},
"prores_proxy": {"nombre": "MOV ProRes Proxy · edición ligera", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_proxy",
"cpu": "prores_ks", "prores": 0, "mbps_1080p30": 45, "que": "para editar suave en Resolve o Premiere; luego se reenlaza al original"},
"prores_lt": {"nombre": "MOV ProRes 422 LT · edición", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_LT",
@@ -145,7 +147,9 @@ def motores():
if _MOTORES["datos"] and time.time() - _MOTORES["ts"] < 600:
return _MOTORES["datos"]
ff = ruta_herramienta("ffmpeg")
out = {"gpu": {"nombre": "", "hevc": False, "h264": False}, "cpu": {"libx265": False, "libx264": False, "prores_ks": False}, "formatos": {}, "ffmpeg": bool(ff)}
out = {"gpu": {"nombre": "", "hevc": False, "h264": False, "chroma422": False},
"cpu": {"libx265": False, "libx264": False, "prores_ks": False},
"formatos": {}, "chroma422": False, "ffmpeg": bool(ff)}
disponibles = set()
if ff:
try:
@@ -166,6 +170,16 @@ def motores():
out["gpu"][k] = r.returncode == 0
except Exception: # noqa
pass
if out["gpu"]["hevc"]:
try:
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
"-i", "testsrc=size=256x144:rate=30", "-frames:v", "3",
"-c:v", "hevc_nvenc", "-pix_fmt", "p210le", "-profile:v", "rext",
"-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
out["gpu"]["chroma422"] = r.returncode == 0
except Exception: # noqa
out["gpu"]["chroma422"] = False
out["chroma422"] = bool(out["gpu"].get("chroma422") or out["cpu"].get("libx265"))
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 ""
@@ -303,7 +317,9 @@ def init_db():
);
""")
con.commit()
for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0", "ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'"):
for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0",
"ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'",
"ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1"):
try:
con.execute(alter)
con.commit()
@@ -487,8 +503,8 @@ def escanear(ruta):
with con:
# conservar el análisis de ffprobe de los archivos que no cambiaron
previo = {r["ruta"]: r for r in con.execute(
"SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, analizado "
"FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()}
"SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, "
"pistas_audio, analizado FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()}
con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,))
con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,))
con.executemany(
@@ -498,8 +514,8 @@ def escanear(ruta):
for f in filas:
pv = previo.get(f[0])
if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2:
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, analizado=1 WHERE ruta=?",
(pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], f[0]))
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
(pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0]))
cfilas = []
for d, info in dirs.items():
n, b, fechas, p, bs, nombre = info
@@ -1616,28 +1632,65 @@ def ffprobe(ruta):
"ancho": int(v.get("width") or 0), "alto": int(v.get("height") or 0), "fps": round(fps, 3), "dur": dur,
"kbps": kbps, "alfa": 1 if ("a" in pix and pix not in ("yuva", "") and ("rgba" in pix or "argb" in pix or "bgra" in pix or "yuva" in pix)) else 0,
"acodec": (a or {}).get("codec_name") or "",
"pistas_audio": sum(1 for x in j.get("streams", []) if x.get("codec_type") == "audio"),
}
def analizar(raiz, min_mb):
prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time()}
HILOS_ANALISIS = 8 # ffprobe pasa casi todo el tiempo esperando al disco de red
def analizar(raiz, min_mb, reintentar=False):
"""Lee con ffprobe los clips que faltan (códec, duración, resolución). Va en paralelo porque cada
lectura es sobre todo espera de red: medido, 0,23 s por clip; 15.423 clips en serie pasan de la hora
y con ocho a la vez bajan a unos ocho minutos.
Con `reintentar` también vuelve sobre los que no se pudieron leer (analizado = 2), por si el problema
era un ffprobe viejo o una unidad que estaba desconectada."""
prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time(),
"cancelar": False, "ilegibles": 0}
con = db()
pend = con.execute("SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado = 0 AND es_proxy = 0 AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock')",
(raiz, int(min_mb) * 1024 * 1024)).fetchall()
estados = "(0, 2)" if reintentar else "(0)"
pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado IN {estados} AND es_proxy = 0 "
"AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock') "
"ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall()
prog["total"] = len(pend)
try:
for f in pend:
info = ffprobe(f["ruta"])
import concurrent.futures as cf
lote = []
def guardar(lote):
with con:
if info:
if f["ext"] == ".braw":
info["codec"] = "braw"
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, analizado=1 WHERE ruta=?",
(info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"], f["ruta"]))
else:
con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (f["ruta"],))
prog["hechos"] += 1
prog["estado"] = "listo"
for ruta, info in lote:
if info:
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, "
"kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
(info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"],
info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"],
info.get("pistas_audio", 1), ruta))
else:
con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (ruta,))
def leer(f):
info = ffprobe(f["ruta"])
if info and f["ext"] == ".braw":
info["codec"] = "braw" # ffprobe lee el BRAW pero no le pone nombre al códec
return f["ruta"], info
with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool:
for ruta, info in pool.map(leer, pend):
if prog["cancelar"]:
break
if info is None:
prog["ilegibles"] += 1
lote.append((ruta, info))
prog["hechos"] += 1
if len(lote) >= 50: # se guarda por tandas: una transacción por clip es mucho más lento
guardar(lote)
lote = []
guardar(lote)
prog["estado"] = "cancelado" if prog["cancelar"] else "listo"
if prog["ilegibles"]:
prog["error"] = f"{prog['ilegibles']} clips no se pudieron leer con ffprobe (no saldrán como convertibles)"
except Exception as ex: # noqa
prog["estado"] = "error"
prog["error"] = str(ex)
@@ -1757,6 +1810,15 @@ def set_trabajo(id_, **kw):
con.close()
def formato_de(t):
"""el formato de un trabajo, tolerando filas viejas sin columna"""
try:
f = t["formato"] if "formato" in t.keys() else None
except AttributeError:
f = t.get("formato")
return f or "h265_10bit"
def correr_ffmpeg(t):
o = CONFIG["optimizar"]
con = db()
@@ -1766,11 +1828,21 @@ def correr_ffmpeg(t):
fps = (f["fps"] if f else 30) or 30
chroma = t["chroma"] or "420"
cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"]
# el formato también mueve la calidad, no solo el techo de tasa: sin esto «ligero para compartir»
# salía prácticamente del mismo tamaño que el de archivo (mismo cq, y el techo casi nunca se toca)
cq = max(14, min(38, round(cq + FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"]).get("cq_mas", 0))))
maxrate = int(t["kbps"] * 1.8)
salida = t["salida"]
formato = (t["formato"] if "formato" in t.keys() else None) or "h265_10bit"
formato = formato_de(t)
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
motor = motores()["formatos"].get(formato)
_m = motores()
motor = _m["formatos"].get(formato)
if motor == "gpu" and chroma == "422" and fm["bits"] == 10 and not _m["gpu"].get("chroma422"):
# la tarjeta no admite 4:2:2 de 10 bits (solo desde Blackwell): en vez de fallar, va por CPU
motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None
if motor is None:
set_trabajo(t["id"], estado="error", log="esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0")
return
if not motor:
set_trabajo(t["id"], estado="error", log=f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)")
return
@@ -1779,7 +1851,9 @@ def correr_ffmpeg(t):
gop = str(int(round(fps * 2)))
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"]
if fm["codec"] == "prores":
cmd += ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0", "-qscale:v", "9"]
# sin -qscale: con cuantizador fijo, prores_ks se salta la tasa del perfil y un «máster HQ» no
# salía a la tasa de un HQ (que es justo lo que promete el formato)
cmd += ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"]
elif motor == "gpu":
diez = fm["bits"] == 10
cmd += ["-c:v", fm["gpu"],
@@ -1806,9 +1880,10 @@ def correr_ffmpeg(t):
cmd += ["-movflags", "+faststart"]
if fm["codec"] == "prores":
cmd += ["-c:a", "pcm_s16le"]
elif f and f["acodec"] == "aac":
elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1:
cmd += ["-c:a", "copy"]
else:
# con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4
cmd += ["-c:a", "aac", "-b:a", "320k"]
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
set_trabajo(t["id"], log=" ".join(cmd)[:4000])
@@ -2427,6 +2502,18 @@ def copiar_respaldo(raiz, plan):
RESPALDO.update(estado="corriendo", hechos=0, total=plan["n"], bytes=0, bytes_total=plan["bytes"], actual="", errores=[],
destino=plan["base"], cancelar=False, inicio=time.time(), copiados=0, saltados=0)
indice = []
# un respaldo por lotes (solo lo de más de 6 meses, luego el resto) reescribía el índice con lo de la
# última tanda y dejaba fuera todo lo anterior: se arranca de lo que ya había.
previo = {}
try:
import csv as _csv
with open(os.path.join(plan["base"], "INDICE.csv"), newline="", encoding="utf-8-sig") as fh:
filas = list(_csv.reader(fh, delimiter=";"))
for fila in filas[1:]:
if len(fila) >= 5:
previo[fila[0]] = tuple(fila)
except (OSError, ValueError):
pass
for it in plan["items"]:
if RESPALDO["cancelar"]:
RESPALDO["estado"] = "cancelado"
@@ -2438,13 +2525,20 @@ def copiar_respaldo(raiz, plan):
ya = False
if os.path.exists(d):
st = os.stat(d)
ya = st.st_size == it["bytes"] and abs(st.st_mtime - it["mtime"]) < 2
# contra el archivo de ORIGEN tal como está ahora, no contra lo que decía el último escaneo:
# si se editó después de escanear, la copia vieja «coincidía» y no se volvía a copiar nunca
try:
so = os.stat(it["origen"])
tam_o, mt_o = so.st_size, so.st_mtime
except OSError:
tam_o, mt_o = it["bytes"], it["mtime"]
ya = st.st_size == tam_o and abs(st.st_mtime - mt_o) < 2
if ya:
RESPALDO["saltados"] += 1
else:
tmp = d + ".parcial"
shutil.copy2(it["origen"], tmp)
if os.path.getsize(tmp) != it["bytes"]:
if os.path.getsize(tmp) != os.path.getsize(it["origen"]):
raise OSError("tamaño distinto tras copiar")
os.replace(tmp, d)
RESPALDO["copiados"] += 1
@@ -2456,15 +2550,19 @@ def copiar_respaldo(raiz, plan):
try:
os.makedirs(plan["base"], exist_ok=True)
import csv
# lo de esta tanda manda sobre lo que ya había; lo anterior se conserva
for fila in indice:
previo[fila[0]] = fila
todo = sorted(previo.values(), key=lambda x: str(x[0]).lower())
with open(os.path.join(plan["base"], "INDICE.csv"), "w", newline="", encoding="utf-8-sig") as fh:
w = csv.writer(fh, delimiter=";")
w.writerow(["ruta en el respaldo", "ruta original", "tipo", "bytes", "fecha"])
w.writerows(indice)
w.writerows(todo)
resumen = defaultdict(lambda: defaultdict(lambda: [0, 0]))
for rel_d, rel_o, tipo, b, _ in indice:
for rel_d, rel_o, tipo, b, _ in todo:
carpeta = os.path.dirname(os.path.dirname(rel_d)) if os.sep in rel_d else ""
resumen[carpeta][tipo][0] += 1
resumen[carpeta][tipo][1] += b
resumen[carpeta][tipo][1] += int(b or 0)
with open(os.path.join(plan["base"], "_MAPA.md"), "w", encoding="utf-8") as fh:
fh.write(f"# Respaldo de {plan['cliente']}\n\nOrigen: `{raiz}` \nFecha: {time.strftime('%Y-%m-%d %H:%M')} \n"
f"Archivos: {len(indice)} · {sum(i[3] for i in indice)/1e9:.1f} GB\n\n")
@@ -2517,6 +2615,18 @@ def elegir_carpeta():
_ELIGIENDO.release()
def _anidadas(raices):
"""(externa, interna) si alguna carpeta de la lista está dentro de otra. El inventario va por raíz y
`archivos.ruta` es única: al escanear la externa, las filas de la interna cambian de dueño y la interna
se queda vacía sin decir nada (su respaldo copiaría cero archivos y terminaría «bien»)."""
rutas = [os.path.normpath(r["ruta"]) for r in raices if r.get("ruta")]
for a in rutas:
for b in rutas:
if a is not b and a.lower() != b.lower() and dentro_de(a, b):
return a, b
return None
def quitar_raiz(ruta, borrar_datos=False, descartar_cola=False):
"""Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear).
Con descartar_cola se quitan de la cola sus conversiones EN ESPERA (no toca archivos); las que se están
@@ -2814,6 +2924,10 @@ class Manejador(BaseHTTPRequestHandler):
except ValueError as e:
return self._json({"error": str(e)}, 409)
if ruta == "/api/raices":
choque = _anidadas(d.get("raices") or [])
if choque:
return self._json({"error": f"«{choque[1]}» está dentro de «{choque[0]}», que ya está en la lista. "
"Dos carpetas anidadas se pisan el inventario: deja solo una de las dos."}, 400)
CONFIG["raices"] = d["raices"]
guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True})
@@ -2891,6 +3005,11 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"lanzado": False, "error": "ya hay un asistente en marcha"})
asistente.ejecutar(raiz, d.get("decisiones", {}), asistente.PLAN_CACHE.pop(raiz, None), en_hilo=True)
return self._json({"lanzado": True})
if ruta == "/api/optimizar/analizar/detener":
a = ANALISIS.get(os.path.normpath(d["raiz"]))
if a:
a["cancelar"] = True
return self._json({"ok": bool(a)})
if ruta == "/api/optimizar/analizar":
raiz = os.path.normpath(d["raiz"])
p = ANALISIS.get(raiz)