1.7.0: recorrido paso a paso, sugerencias en Por nombrar, stock por antigüedad, Convertidor con formatos y GPU/CPU, respaldo por lotes, barra de tareas corregida

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-15 20:48:04 -05:00
padre 0895846f34
commit 6bdf6a8457
Se han modificado 4 ficheros con 220 adiciones y 60 borrados

Ver fichero

@@ -33,7 +33,7 @@ import actualizador
asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__]
VERSION = "1.6.3"
VERSION = "1.7.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
@@ -101,6 +101,7 @@ CONFIG_DEFECTO = {
"meses_minimos": 3,
"min_mb": 300,
"perfil": "equilibrado",
"formato": "h265_10bit",
"chroma": "420",
"color": "rec709",
"salida": "junto", # junto = al lado del original con sufijo _10bit ; espejo = _Archivo/<ruta>
@@ -117,6 +118,68 @@ PERFILES = {
"maxima": {"bpp": 0.14, "cq": 21, "nombre": "Máxima calidad"},
}
# Formatos de salida del convertidor. gpu/cpu = codificador de ffmpeg que lo hace; se usa la GPU si está
# disponible y si no la CPU. bpp_mult escala el bitrate objetivo del perfil (H.265 10 bits = 1.0).
# ProRes no va por bitrate: mbps_1080p30 es su peso aproximado a 1080p 30 fps para estimar.
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"},
"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"},
"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",
"cpu": "prores_ks", "prores": 1, "mbps_1080p30": 102, "que": "calidad de edición; pesa bastante"},
"prores_hq": {"nombre": "MOV ProRes 422 HQ · máster", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_HQ",
"cpu": "prores_ks", "prores": 3, "mbps_1080p30": 220, "que": "entrega o máster; pesa mucho"},
}
_MOTORES = {"ts": 0, "datos": None}
def motores():
"""Qué codificadores hay: GPU NVIDIA (NVENC) y CPU. Se prueba de verdad (un clip de 3 cuadros), no solo
por la lista de ffmpeg, porque la lista incluye nvenc aunque no haya tarjeta NVIDIA."""
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)}
disponibles = set()
if ff:
try:
r = subprocess.run([ff, "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA)
for linea in r.stdout.splitlines():
partes = linea.split()
if len(partes) >= 2 and partes[0][:1] in ("V", "A") and len(partes[0]) == 6:
disponibles.add(partes[1])
except Exception: # noqa
pass
for k in ("libx265", "libx264", "prores_ks"):
out["cpu"][k] = k in disponibles
for k, enc in (("hevc", "hevc_nvenc"), ("h264", "h264_nvenc")):
if enc in disponibles:
try:
r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=256x144:rate=30",
"-frames:v", "3", "-c:v", enc, "-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
out["gpu"][k] = r.returncode == 0
except Exception: # noqa
pass
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 ""
except Exception: # noqa
pass
for k, f in FORMATOS.items():
m = None
if f.get("gpu") and out["gpu"].get(f["codec"]):
m = "gpu"
elif f.get("cpu") and out["cpu"].get(f["cpu"]):
m = "cpu"
out["formatos"][k] = m # None = no se puede con este ffmpeg
_MOTORES.update(ts=time.time(), datos=out)
return out
def cargar_json(ruta, defecto):
try:
@@ -234,7 +297,7 @@ def init_db():
);
""")
con.commit()
for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0",):
for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0", "ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'"):
try:
con.execute(alter)
con.commit()
@@ -1350,7 +1413,19 @@ def kbps_objetivo(ancho, alto, fps, perfil):
return max(1500, int(ancho * alto * fps * bpp / 1000))
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False):
def estimar_bytes(f, perfil, formato):
"""peso aproximado de la salida para un archivo del inventario"""
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
dur = f["dur"] or 0
if fm.get("mbps_1080p30"):
pix = (f["ancho"] or 1920) * (f["alto"] or 1080) * (f["fps"] or 30)
return int(fm["mbps_1080p30"] * 1e6 / 8 * dur * pix / (1920 * 1080 * 30))
obj = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)
return int(obj * 1000 / 8 * dur) + int(256000 / 8 * dur)
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"):
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
con = db()
limite = time.time() - int(meses) * 30.4 * 86400
filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND bytes >= ? ORDER BY bytes DESC",
@@ -1365,16 +1440,18 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False):
motivo_no = "stock de Envato: mejor borrarlo (se vuelve a descargar)"
elif f["alfa"]:
motivo_no = "tiene canal alfa (HEVC lo perdería)"
elif f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * 1.3:
elif fm["codec"] == "hevc" and f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * fm.get("bpp_mult", 1.0) * 1.3:
motivo_no = "ya está en HEVC/AV1 eficiente"
elif motor == "resolve" and formato != "h265_10bit":
motivo_no = "los BRAW van por Resolve y solo salen a H.265 10 bits"
elif f["mtime"] > limite:
motivo_no = f"tiene menos de {meses} meses"
elif f["contexto"] == "EXPORTADOS" and not incluir_exportados:
motivo_no = "es un exportado / master"
elif not f["ancho"] or not f["dur"]:
motivo_no = "no se pudo leer (ffprobe)"
obj = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil)
est = int(obj * 1000 / 8 * (f["dur"] or 0)) + int(256000 / 8 * (f["dur"] or 0))
obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0))
est = estimar_bytes(f, perfil, formato)
out.append({
"ruta": f["ruta"], "rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"], "codec": f["codec"], "perfil": f["perfil"],
"pix_fmt": f["pix_fmt"], "res": f"{f['ancho']}x{f['alto']}", "fps": f["fps"], "dur": f["dur"], "kbps": f["kbps"],
@@ -1384,16 +1461,21 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False):
return out
def salida_para(ruta, raiz):
def salida_para(ruta, raiz, formato="h265_10bit"):
o = CONFIG["optimizar"]
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
base, _ = os.path.splitext(ruta)
sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"]
if o.get("salida") == "espejo":
rel = os.path.relpath(base, raiz)
return os.path.join(raiz, "_Archivo", rel + ".mp4")
return base + o.get("sufijo", "_10bit") + ".mp4"
return os.path.join(raiz, "_Archivo", rel + fm["ext"])
return base + sufijo + fm["ext"]
def encolar(rutas, perfil, chroma, color, raiz):
def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
if formato not in FORMATOS:
formato = "h265_10bit"
fm = FORMATOS[formato]
con = db()
ids = []
with con:
@@ -1401,18 +1483,20 @@ def encolar(rutas, perfil, chroma, color, raiz):
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (r,)).fetchone()
if not f:
continue
ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r,)).fetchone()
ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r, formato)).fetchone()
if ya:
continue
salida = salida_para(r, raiz)
motor = "resolve" if f["ext"] == ".braw" else "ffmpeg"
if motor == "resolve" and formato != "h265_10bit":
continue # los BRAW solo salen por Resolve a H.265 10 bits
salida = salida_para(r, raiz, formato)
# el mismo clip en MP4 y MOV daría la misma salida: el segundo pisaría al primero. No se encola.
otro = con.execute("SELECT id FROM trabajos WHERE salida = ? AND ruta != ? AND estado IN ('pendiente','corriendo','listo','verificado')", (salida, r)).fetchone()
if otro or os.path.exists(salida):
continue
motor = "resolve" if f["ext"] == ".braw" else "ffmpeg"
kb = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil)
cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz))
kb = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0))
cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz, formato))
ids.append(cur.lastrowid)
con.close()
return ids
@@ -1440,18 +1524,45 @@ def correr_ffmpeg(t):
cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"]
maxrate = int(t["kbps"] * 1.8)
salida = t["salida"]
formato = (t["formato"] if "formato" in t.keys() else None) or "h265_10bit"
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
motor = motores()["formatos"].get(formato)
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
os.makedirs(os.path.dirname(salida), exist_ok=True)
tmp = salida + ".parcial.mp4"
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?",
"-c:v", "hevc_nvenc",
"-pix_fmt", "p210le" if chroma == "422" else "p010le",
"-profile:v", "rext" if chroma == "422" else "main10",
"-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0",
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k",
"-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32",
"-bf", "3", "-b_ref_mode", "middle", "-g", str(int(round(fps * 2))),
"-tag:v", "hvc1", "-map_metadata", "0", "-movflags", "+faststart"]
if f and f["acodec"] == "aac":
tmp = salida + ".parcial" + fm["ext"]
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"]
elif motor == "gpu":
diez = fm["bits"] == 10
cmd += ["-c:v", fm["gpu"],
"-pix_fmt", ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p",
"-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0",
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k",
"-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32",
"-bf", "3", "-b_ref_mode", "middle", "-g", gop]
if fm["codec"] == "hevc":
cmd += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"]
else:
cmd += ["-profile:v", "high"]
else:
diez = fm["bits"] == 10
cmd += ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2),
"-pix_fmt", ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p",
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop]
if fm["codec"] == "hevc":
cmd += ["-tag:v", "hvc1"]
if diez:
cmd += ["-profile:v", "main422-10" if chroma == "422" else "main10"]
cmd += ["-map_metadata", "0"]
if fm["ext"] == ".mp4":
cmd += ["-movflags", "+faststart"]
if fm["codec"] == "prores":
cmd += ["-c:a", "pcm_s16le"]
elif f and f["acodec"] == "aac":
cmd += ["-c:a", "copy"]
else:
cmd += ["-c:a", "aac", "-b:a", "320k"]
@@ -1677,9 +1788,10 @@ def verificar(ids):
problemas = []
if dur_src and abs(info["dur"] - dur_src) > tol:
problemas.append(f"duración {info['dur']:.2f}s vs original {dur_src:.2f}s")
if info["codec"] != "hevc":
problemas.append(f"códec {info['codec']}")
if "10" not in info["pix_fmt"]:
fm = FORMATOS.get((t["formato"] if "formato" in t.keys() else None) or "h265_10bit", FORMATOS["h265_10bit"])
if info["codec"] != fm["codec"]:
problemas.append(f"códec {info['codec']} (se esperaba {fm['codec']})")
if fm["bits"] == 10 and "10" not in info["pix_fmt"]:
problemas.append(f"no es 10 bits ({info['pix_fmt']})")
if os.path.getsize(t["salida"]) < 100000:
problemas.append("archivo demasiado pequeño")
@@ -1704,6 +1816,9 @@ def reemplazar(ids):
if not t or t["estado"] != "verificado":
res.append({"id": id_, "ok": False, "motivo": "solo se reemplazan trabajos verificados"})
continue
if ((t["formato"] if "formato" in t.keys() else "") or "").startswith("prores"):
res.append({"id": id_, "ok": False, "motivo": "ProRes es un formato de edición: queda junto al original, no lo reemplaza"})
continue
if not os.path.exists(t["salida"]) or os.path.getsize(t["salida"]) < 1024:
res.append({"id": id_, "ok": False, "motivo": "el MP4 convertido ya no está donde se verificó; el original se queda"})
set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar")
@@ -2217,7 +2332,7 @@ def estado_general():
"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 [])},
"cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "plantillas": PLANTILLAS}
"cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "formatos": {k: {"nombre": v["nombre"], "que": v["que"], "ext": v["ext"]} for k, v in FORMATOS.items()}, "plantillas": PLANTILLAS}
def estado_carpeta(ruta):
@@ -2387,7 +2502,9 @@ class Manejador(BaseHTTPRequestHandler):
if ruta == "/api/optimizar/candidatos":
o = CONFIG["optimizar"]
return self._json({"candidatos": candidatos(os.path.normpath(q["raiz"]), q.get("meses", o["meses_minimos"]), q.get("perfil", o["perfil"]),
q.get("min_mb", o["min_mb"]), q.get("exportados") == "1")})
q.get("min_mb", o["min_mb"]), q.get("exportados") == "1", q.get("formato", o.get("formato", "h265_10bit")))})
if ruta == "/api/optimizar/motores":
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()]
@@ -2451,7 +2568,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", "chroma", "color", "salida", "sufijo"}, "respaldo": {"destino"}}
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo"}, "respaldo": {"destino"}}
for k, v in d.items():
if k in permitidas and isinstance(v, dict):
CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]})
@@ -2527,7 +2644,7 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"lanzado": True})
if ruta == "/api/optimizar/encolar":
o = CONFIG["optimizar"]
ids = encolar(d.get("rutas", []), d.get("perfil", o["perfil"]), d.get("chroma", o["chroma"]), d.get("color", o["color"]), os.path.normpath(d["raiz"]))
ids = encolar(d.get("rutas", []), d.get("perfil", o["perfil"]), d.get("chroma", o["chroma"]), d.get("color", o["color"]), os.path.normpath(d["raiz"]), d.get("formato", o.get("formato", "h265_10bit")))
if d.get("iniciar"):
iniciar_cola()
return self._json({"ids": ids})