3.4.0: los BRAW se convierten sin Resolve, y lo que no compensa no se reconvierte
Munka no podía reconvertir archivos que antes sí. Auditado sobre la carpeta de Andrea Galeano: - Los BRAW nunca habían salido: 87 clips (496 GB), tres tandas por el guion de Resolve y ni un clip convertido. Ahora los lee el decodificador de Blackmagic que instala Resolve (braw.py, en un proceso aparte), sin abrir Resolve, y ffmpeg los codifica como cualquier otro clip. Probado con clips del NAS: 15,6 GB → 394 MB con sus 7.004 cuadros, VMAF 96,0 contra el BRAW revelado sin pérdida. Las conversiones de BRAW que fallaron por Resolve pasan solas al lector. - Lo que «antes podía» eran reconversiones de lo ya convertido: ahorraban un 5 % con pérdida. Siguen sin ofrecerse, y lo que ya viene comprimido de cámara solo se ofrece si compensa. - La tarjeta codifica con «uhq»: entre un 19 y un 55 % menos de peso con la misma calidad medida, siempre H.265 de 10 bits. - Una conversión que no aligera (más del 80 % del clip, o reconversión de una conversión) no reemplaza al original: queda «no compensa» y lo que salió va a _Para borrar\descartados. De paso: los MP4 guardan las etiquetas de la cámara; un intento que no pasó la verificación se puede reintentar; un H.264 de más de 4.096 px de lado va por CPU; el motivo de un fallo se lee entero; el guion de Resolve llega con tildes y borra su ficha. Pruebas: prueba_orden.py 33 (una con un BRAW de verdad), probar.py 19, prueba_funcional.py 30, prueba_ventana.py 18 desde el código y 17 con el paquete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
629
organizador.py
629
organizador.py
@@ -28,6 +28,7 @@ import hashlib
|
||||
import sqlite3
|
||||
import threading
|
||||
import subprocess
|
||||
import tempfile
|
||||
import datetime
|
||||
import webbrowser
|
||||
from collections import Counter, defaultdict
|
||||
@@ -35,12 +36,13 @@ from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import reglas as R
|
||||
import braw
|
||||
import asistente
|
||||
import actualizador
|
||||
asistente.O = sys.modules[__name__]
|
||||
actualizador.O = sys.modules[__name__]
|
||||
|
||||
VERSION = "3.3.0"
|
||||
VERSION = "3.4.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
|
||||
@@ -118,6 +120,7 @@ CONFIG_DEFECTO = {
|
||||
"ffmpeg": "ffmpeg",
|
||||
"ffprobe": "ffprobe",
|
||||
"python_resolve": "", # vacío = el mismo intérprete
|
||||
"braw": "", # carpeta del decodificador de Blackmagic RAW; vacío = se busca sola
|
||||
},
|
||||
# las subcarpetas con las que nace una pieza nueva (las que más se repiten en las piezas del estudio)
|
||||
"pieza": {"subcarpetas": ["Guion", "Videos", "Imagenes", "Musica", "Elements", "IA", "Portada", "Exportado"]},
|
||||
@@ -152,6 +155,54 @@ FORMATOS = {
|
||||
"cpu": "prores_ks", "prores": 3, "mbps_1080p30": 220, "que": "entrega o máster; pesa mucho"},
|
||||
}
|
||||
_MOTORES = {"ts": 0, "datos": None}
|
||||
_BRAW = {"ts": 0, "datos": None}
|
||||
|
||||
|
||||
def _lector_braw(*args, espera=120):
|
||||
"""Lanza braw.py —proceso aparte: ahí se carga el decodificador de Blackmagic, no aquí— y devuelve lo que
|
||||
dijo, una línea JSON por evento."""
|
||||
carpeta = CONFIG["optimizar"].get("braw") or ""
|
||||
r = subprocess.run([sys.executable, os.path.join(BASE, "braw.py"), *args], capture_output=True, text=True, encoding="utf-8",
|
||||
errors="replace", timeout=espera, creationflags=SIN_VENTANA,
|
||||
env=dict(os.environ, ORGANILAB_BRAW=carpeta) if carpeta else None)
|
||||
out = []
|
||||
for linea in (r.stdout or "").splitlines():
|
||||
try:
|
||||
out.append(json.loads(linea))
|
||||
except ValueError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def decodificador_braw():
|
||||
"""¿Se pueden leer los BRAW sin Resolve? Lo dice braw.py después de cargar el decodificador de Blackmagic
|
||||
y pedirle las interfaces que se usan. La respuesta se recuerda diez minutos."""
|
||||
if _BRAW["datos"] and time.time() - _BRAW["ts"] < 600:
|
||||
return _BRAW["datos"]
|
||||
carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "")
|
||||
datos = {"ok": False, "carpeta": "", "motivo": "no está el decodificador de Blackmagic RAW: se instala con DaVinci Resolve (vale el gratuito)"}
|
||||
if carpeta:
|
||||
try:
|
||||
dicho = _lector_braw("sonda", carpeta, espera=60)
|
||||
datos = dicho[-1] if dicho else {"ok": False, "carpeta": carpeta, "motivo": "el decodificador de Blackmagic RAW no respondió"}
|
||||
except Exception as ex: # noqa
|
||||
datos = {"ok": False, "carpeta": carpeta, "motivo": f"el decodificador de Blackmagic RAW no respondió: {ex}"}
|
||||
_BRAW.update(ts=time.time(), datos=datos)
|
||||
return datos
|
||||
|
||||
|
||||
def medir_braw(ruta):
|
||||
"""Lo que el decodificador dice de un BRAW: su tamaño de verdad (ffprobe lo lee con relleno y sin girar:
|
||||
4128×2176 para un clip que es 2160×4096 en vertical), sus cuadros, su código de tiempo y su curva."""
|
||||
try:
|
||||
dicho = _lector_braw("info", ruta)
|
||||
except Exception as ex: # noqa
|
||||
return {"ok": False, "error": f"el lector de BRAW no respondió: {ex}"}
|
||||
return dicho[-1] if dicho else {"ok": False, "error": "el lector de BRAW no dijo nada"}
|
||||
|
||||
|
||||
def _curva(m):
|
||||
return (m.get("gamma") or "BRAW").replace("Blackmagic Design ", "")
|
||||
|
||||
|
||||
def motores():
|
||||
@@ -193,6 +244,15 @@ def motores():
|
||||
except Exception: # noqa
|
||||
out["gpu"]["chroma422"] = False
|
||||
out["chroma422"] = bool(out["gpu"].get("chroma422") or out["cpu"].get("libx265"))
|
||||
if out["gpu"]["hevc"]:
|
||||
try: # ¿la tarjeta y su controlador admiten el ajuste «uhq»? (ver _args_video)
|
||||
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", "p010le", "-tune", "uhq", "-profile:v", "main10",
|
||||
"-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA)
|
||||
out["gpu"]["uhq"] = r.returncode == 0
|
||||
except Exception: # noqa
|
||||
out["gpu"]["uhq"] = 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 ""
|
||||
@@ -205,6 +265,7 @@ def motores():
|
||||
elif f.get("cpu") and out["cpu"].get(f["cpu"]):
|
||||
m = "cpu"
|
||||
out["formatos"][k] = m # None = no se puede con este ffmpeg
|
||||
out["braw"] = decodificador_braw()
|
||||
_MOTORES.update(ts=time.time(), datos=out)
|
||||
return out
|
||||
|
||||
@@ -265,13 +326,16 @@ def ruta_herramienta(h):
|
||||
|
||||
|
||||
def herramientas():
|
||||
"""qué hay disponible: ffmpeg/ffprobe y Resolve"""
|
||||
"""qué hay disponible: ffmpeg/ffprobe, el decodificador de Blackmagic RAW y Resolve"""
|
||||
out = {}
|
||||
for h in ("ffmpeg", "ffprobe"):
|
||||
ruta = ruta_herramienta(h)
|
||||
ok = os.path.exists(ruta) or shutil.which(ruta) is not None
|
||||
out[h] = {"ruta": ruta, "ok": ok, "empaquetado": ruta.lower().startswith(BASE.lower())}
|
||||
out["resolve"] = {"ok": os.path.exists(r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll"), "ruta": r"C:\Program Files\Blackmagic Design\DaVinci Resolve"}
|
||||
# solo se mira si el archivo está: cargarlo de verdad lo hace decodificador_braw(), en un proceso aparte
|
||||
carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "")
|
||||
out["braw"] = {"ok": bool(carpeta), "ruta": carpeta}
|
||||
out["congelado"] = CONGELADO
|
||||
return out
|
||||
|
||||
@@ -2550,7 +2614,11 @@ def analizar(raiz, min_mb, reintentar=False):
|
||||
"cancelar": False, "ilegibles": 0}
|
||||
con = db()
|
||||
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 "
|
||||
lee_braw = bool(decodificador_braw().get("ok"))
|
||||
# los BRAW que solo leyó ffprobe se miden otra vez con el decodificador: se les nota en que no tienen
|
||||
# «perfil», que es donde queda apuntada su curva
|
||||
remedir = " OR (ext = '.braw' AND analizado = 1 AND COALESCE(perfil, '') = '')" if lee_braw else ""
|
||||
pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND (analizado IN {estados}{remedir}) 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)
|
||||
@@ -2574,6 +2642,9 @@ def analizar(raiz, min_mb, reintentar=False):
|
||||
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
|
||||
m = medir_braw(f["ruta"]) if lee_braw else {}
|
||||
if m.get("ok"):
|
||||
info.update(ancho=m["ancho"], alto=m["alto"], fps=m["fps"] or info["fps"], dur=m["dur"] or info["dur"], perfil=_curva(m))
|
||||
return f["ruta"], info
|
||||
|
||||
with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool:
|
||||
@@ -2604,6 +2675,35 @@ def kbps_objetivo(ancho, alto, fps, perfil):
|
||||
return max(1500, int(ancho * alto * fps * bpp / 1000))
|
||||
|
||||
|
||||
# Desde cuántas veces la tasa objetivo compensa recomprimir un clip que ya viene comprimido entre cuadros.
|
||||
# H.265, AV1 y VP9 rinden como la salida: de ahí solo se baja quitando calidad, y hace falta que el origen
|
||||
# vaya muy holgado (el doble). Un H.264 necesita unas 1,6 veces los bits de H.265 para verse igual: basta
|
||||
# con que no esté ya por debajo del objetivo. Medido el 5-oct-2026 con clips de V:\Galeano contra su original:
|
||||
# un H.264 4.6K de 33 Mb/s (1,08 veces el objetivo) queda en la mitad con nota 96,7 sobre 100; uno de
|
||||
# 121 Mb/s, en la quinta parte. Lo que guarda cada cuadro entero (XAVC-I, ProRes, BRAW) no pasa por aquí.
|
||||
RECOMPRIMIR_DESDE = {"hevc": 2.0, "av1": 2.0, "vp9": 2.0, "h264": 1.0, "mpeg4": 0.7, "mpeg2video": 0.5}
|
||||
NOMBRE_CODEC = {"hevc": "H.265", "av1": "AV1", "vp9": "VP9", "h264": "H.264", "mpeg4": "MPEG-4", "mpeg2video": "MPEG-2"}
|
||||
|
||||
|
||||
def poco_que_ganar(f, objetivo):
|
||||
"""True si el clip ya viene comprimido y tan cerca de lo que pesaría convertido que no compensa tocarlo."""
|
||||
if not f["kbps"] or f["codec"] not in RECOMPRIMIR_DESDE or "intra" in (f["perfil"] or "").lower():
|
||||
return False
|
||||
return f["kbps"] <= RECOMPRIMIR_DESDE[f["codec"]] * objetivo
|
||||
|
||||
|
||||
def _salio_del_convertidor(f, salidas):
|
||||
"""Lleva el sufijo del convertidor y además está en el códec que ese sufijo produce: un «Entrevista_HQ.mp4»
|
||||
en H.264, o un «clip_proxy.mp4» de la cámara, no los hizo este programa."""
|
||||
if os.path.normcase(os.path.normpath(f["ruta"])) in salidas:
|
||||
return True
|
||||
if not R.es_conversion(f["nombre"]):
|
||||
return False
|
||||
base = os.path.splitext(f["nombre"])[0].lower()
|
||||
sufijo = next((s for s in R.SUFIJOS_CONVERSION if base.endswith(s)), "")
|
||||
return f["codec"] == {"_h264": "h264", "_proxy": "prores", "_lt": "prores", "_hq": "prores"}.get(sufijo, "hevc")
|
||||
|
||||
|
||||
def estimar_bytes(f, perfil, formato):
|
||||
"""peso aproximado de la salida para un archivo del inventario"""
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
@@ -2617,6 +2717,7 @@ def estimar_bytes(f, perfil, formato):
|
||||
|
||||
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"):
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
lee_braw = bool(decodificador_braw().get("ok"))
|
||||
con = db()
|
||||
tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad
|
||||
# es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
|
||||
@@ -2632,28 +2733,35 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
|
||||
# pérdida y sus horas, porque una conversión con buen bitrate no pasaba por «ya está en HEVC eficiente».
|
||||
salidas = {os.path.normcase(os.path.normpath(r["salida"])) for r in con.execute(
|
||||
"SELECT salida FROM trabajos WHERE estado IN ('listo','verificado','reemplazado') AND salida IS NOT NULL").fetchall()}
|
||||
# y lo que ya se probó con esta misma calidad y no compensó, tampoco
|
||||
probados = {r["ruta"]: r["log"] or "" for r in con.execute(
|
||||
"SELECT ruta, log FROM trabajos WHERE formato = ? AND perfil = ? AND estado = 'descartado'", (formato, perfil)).fetchall()}
|
||||
con.close()
|
||||
out = []
|
||||
for f in filas:
|
||||
motivo_no = ""
|
||||
motor = "resolve" if f["ext"] == ".braw" else "ffmpeg"
|
||||
if R.es_conversion(f["nombre"]) or os.path.normcase(os.path.normpath(f["ruta"])) in salidas:
|
||||
# los BRAW los lee el decodificador de Blackmagic; solo si falta se intenta por el guion de Resolve
|
||||
motor = ("braw" if lee_braw else "resolve") if f["ext"] == ".braw" else "ffmpeg"
|
||||
obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0))
|
||||
if _salio_del_convertidor(f, salidas):
|
||||
motivo_no = "ya es una conversión hecha con este programa"
|
||||
elif f["es_stock"]:
|
||||
motivo_no = "stock de Envato: mejor borrarlo (se vuelve a descargar)"
|
||||
elif f["alfa"]:
|
||||
motivo_no = "tiene canal alfa (HEVC lo perdería)"
|
||||
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 f["ruta"] in probados:
|
||||
motivo_no = "ya se probó con esta calidad: " + probados[f["ruta"]].replace("no compensa: ", "").split(". ")[0]
|
||||
elif fm["codec"] == "hevc" and poco_que_ganar(f, obj):
|
||||
motivo_no = (f"ya viene comprimido ({NOMBRE_CODEC.get(f['codec'], f['codec'])} a {f['kbps'] / 1000:.0f} Mb/s): "
|
||||
"recomprimirlo ahorraría poco y le quitaría calidad")
|
||||
elif motor == "resolve" and formato != "h265_10bit":
|
||||
motivo_no = "los BRAW van por Resolve y solo salen a H.265 10 bits"
|
||||
motivo_no = "sin el decodificador de Blackmagic los BRAW van por Resolve, y solo salen a H.265 10 bits"
|
||||
elif tope and not es_viejo(f, tope):
|
||||
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 = 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"],
|
||||
@@ -2680,8 +2788,10 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
|
||||
if formato not in FORMATOS:
|
||||
formato = "h265_10bit"
|
||||
fm = FORMATOS[formato]
|
||||
lee_braw = bool(decodificador_braw().get("ok"))
|
||||
con = db()
|
||||
ids = []
|
||||
lote, movidos, errores, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], ""
|
||||
with con:
|
||||
for r in rutas:
|
||||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (r,)).fetchone()
|
||||
@@ -2690,19 +2800,32 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
|
||||
ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r, formato)).fetchone()
|
||||
if ya:
|
||||
continue
|
||||
motor = "resolve" if f["ext"] == ".braw" else "ffmpeg"
|
||||
motor = ("braw" if lee_braw else "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
|
||||
continue # por el guion de Resolve los BRAW solo salen a H.265 10 bits
|
||||
salida = salida_para(r, raiz, formato)
|
||||
if os.path.exists(salida):
|
||||
previo = con.execute("SELECT * FROM trabajos WHERE ruta = ? AND salida = ? AND estado = 'descartado' ORDER BY id DESC",
|
||||
(r, salida)).fetchone()
|
||||
if previo:
|
||||
try:
|
||||
if _apartar_convertido(dict(previo), lote, movidos):
|
||||
raiz_desc = raiz_desc or previo["raiz"]
|
||||
except OSError as ex:
|
||||
errores.append(f"{os.path.basename(salida)}: {ex}")
|
||||
# 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
|
||||
kb = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0))
|
||||
# los intentos fallidos de este mismo clip se sustituyen por el nuevo (el 5-oct-2026 había 174
|
||||
# filas de error para 87 clips: cada tanda dejaba las suyas)
|
||||
con.execute("DELETE FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('error','cancelado','descartado')", (r, formato))
|
||||
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()
|
||||
_cerrar_lote(lote, raiz_desc or raiz, movidos, errores)
|
||||
return ids
|
||||
|
||||
|
||||
@@ -2726,72 +2849,135 @@ def formato_de(t):
|
||||
return f or "h265_10bit"
|
||||
|
||||
|
||||
def _motor_de(formato, chroma, ancho=0, alto=0):
|
||||
"""Con qué se codifica un formato en este equipo, «gpu» o «cpu». Si no se puede: None y el porqué."""
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
_m = motores()
|
||||
motor = _m["formatos"].get(formato)
|
||||
lado = 4096 if fm["codec"] == "h264" else 8192
|
||||
if motor == "gpu" and max(ancho or 0, alto or 0) > lado:
|
||||
# la tarjeta no codifica imágenes de más de 4096 píxeles de lado en H.264 (un clip de 4.6K ya se pasa:
|
||||
# «No capable devices found») ni de más de 8192 en H.265 (un BRAW de 12K): esos van por CPU
|
||||
motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None
|
||||
if motor is None:
|
||||
return None, f"la tarjeta gráfica no codifica {fm['codec'].upper()} de más de {lado} píxeles de lado y no hay codificador por CPU"
|
||||
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:
|
||||
return None, "esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0"
|
||||
if not motor:
|
||||
return None, f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)"
|
||||
return motor, ""
|
||||
|
||||
|
||||
def _args_video(t, fm, motor, fps):
|
||||
"""Los argumentos de ffmpeg que codifican la imagen —los mismos venga el clip de un archivo o del lector
|
||||
de BRAW— y el formato de píxel en que la quiere el codificador."""
|
||||
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 + fm.get("cq_mas", 0))))
|
||||
maxrate = int(t["kbps"] * 1.8)
|
||||
gop = str(int(round((fps or 30) * 2)))
|
||||
diez = fm["bits"] == 10
|
||||
if fm["codec"] == "prores":
|
||||
# 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)
|
||||
return ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"], "yuv422p10le"
|
||||
if motor == "gpu":
|
||||
pix = ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p"
|
||||
# «uhq» es el ajuste de más rendimiento de las tarjetas recientes. Medido el 5-oct-2026 con tres clips
|
||||
# del estudio (BRAW de la URSA, BRAW vertical de la Pocket y XAVC-I 4K60 de la Sony), cada uno contra
|
||||
# su referencia sin pérdida y a la misma calidad pedida: pesa entre un 19 y un 55 % menos que «hq» con
|
||||
# la misma nota (VMAF −0,4 a +0,9 sobre 100), y en el cuadro peor sale igual o mejor en dos de los
|
||||
# tres. No deja fijar los cuadros B, y solo se usa donde se midió: H.265 de 10 bits en 4:2:0.
|
||||
uhq = bool(fm["codec"] == "hevc" and diez and chroma != "422" and motores()["gpu"].get("uhq"))
|
||||
args = ["-c:v", fm["gpu"], "-pix_fmt", pix,
|
||||
"-preset", "p6", "-tune", "uhq" if uhq else "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"]
|
||||
args += ([] if uhq else ["-bf", "3", "-b_ref_mode", "middle"]) + ["-g", gop]
|
||||
if fm["codec"] == "hevc":
|
||||
args += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"]
|
||||
else:
|
||||
args += ["-profile:v", "high"]
|
||||
return args, pix
|
||||
pix = ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p"
|
||||
args = ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2), "-pix_fmt", pix,
|
||||
"-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop]
|
||||
if fm["codec"] == "hevc":
|
||||
args += ["-tag:v", "hvc1"]
|
||||
if diez:
|
||||
args += ["-profile:v", "main422-10" if chroma == "422" else "main10"]
|
||||
return args, pix
|
||||
|
||||
|
||||
def _args_salida(fm, f):
|
||||
"""Lo que no es imagen: el contenedor y el audio."""
|
||||
# use_metadata_tags: sin ella el MP4 solo guarda las etiquetas que conoce y se pierden las de la cámara
|
||||
# (ISO, balance de blancos, obturación, óptica, rollo y toma): medido el 5-oct-2026, 48 de 188
|
||||
# conversiones de clips de Blackmagic habían perdido entre 24 y 36 etiquetas cada una.
|
||||
contenedor = ["-movflags", "+faststart+use_metadata_tags" if fm["ext"] == ".mp4" else "use_metadata_tags"]
|
||||
# …pero con ella también viajarían, como si fueran etiquetas, las marcas de formato del original
|
||||
# («major_brand = XAVC» dentro de un archivo que ya no es XAVC): esas tres se quitan
|
||||
contenedor += ["-metadata", "major_brand=", "-metadata", "minor_version=", "-metadata", "compatible_brands="]
|
||||
if fm["codec"] == "prores":
|
||||
audio = ["-c:a", "pcm_s16le"]
|
||||
elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1:
|
||||
audio = ["-c:a", "copy"]
|
||||
else:
|
||||
# con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4
|
||||
audio = ["-c:a", "aac", "-b:a", "320k"]
|
||||
return contenedor, audio
|
||||
|
||||
|
||||
def _salida_ajena(t):
|
||||
"""True si en la ruta de salida hay un archivo que no dejó este mismo trabajo: ese no se pisa. El que dejó
|
||||
él (un intento que no pasó la verificación, y que «Reintentar» rehace) sí."""
|
||||
salida = t["salida"]
|
||||
if not os.path.exists(salida):
|
||||
return False
|
||||
try:
|
||||
return not (t["bytes_out"] and os.path.getsize(salida) == t["bytes_out"])
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def _quitar_parcial(tmp):
|
||||
"""Borra el archivo a medio escribir. Si ffmpeg aún lo tiene cogido (lo acaban de cortar), espera."""
|
||||
for _ in range(40):
|
||||
try:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.25)
|
||||
|
||||
|
||||
def correr_ffmpeg(t):
|
||||
o = CONFIG["optimizar"]
|
||||
con = db()
|
||||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||||
con.close()
|
||||
dur = (f["dur"] if f else 0) or 0
|
||||
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 = formato_de(t)
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
_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
|
||||
motor, pega = _motor_de(formato, t["chroma"] or "420", f["ancho"] if f else 0, f["alto"] if f else 0)
|
||||
if not motor:
|
||||
set_trabajo(t["id"], estado="error", log=f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)")
|
||||
set_trabajo(t["id"], estado="error", log=pega)
|
||||
return
|
||||
salida = t["salida"]
|
||||
if _salida_ajena(t):
|
||||
set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola")
|
||||
return
|
||||
os.makedirs(os.path.dirname(salida), exist_ok=True)
|
||||
tmp = salida + ".parcial" + fm["ext"]
|
||||
gop = str(int(round(fps * 2)))
|
||||
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?"]
|
||||
if fm["codec"] == "prores":
|
||||
# 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"],
|
||||
"-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" 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 += video + ["-map_metadata", "0"] + contenedor + audio
|
||||
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
|
||||
set_trabajo(t["id"], log=" ".join(cmd)[:4000])
|
||||
try:
|
||||
@@ -2819,17 +3005,11 @@ def correr_ffmpeg(t):
|
||||
p.wait()
|
||||
if COLA["cancelar"]:
|
||||
set_trabajo(t["id"], estado="cancelado", pct=0)
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
_quitar_parcial(tmp)
|
||||
return
|
||||
if p.returncode != 0 or not os.path.exists(tmp):
|
||||
set_trabajo(t["id"], estado="error", log=(err or "")[-3000:])
|
||||
try:
|
||||
os.remove(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
|
||||
except OSError:
|
||||
pass
|
||||
_quitar_parcial(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video
|
||||
return
|
||||
os.replace(tmp, salida)
|
||||
try:
|
||||
@@ -2840,6 +3020,117 @@ def correr_ffmpeg(t):
|
||||
set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time())
|
||||
|
||||
|
||||
def correr_braw(t):
|
||||
"""Un BRAW. Lo lee el decodificador de Blackmagic en un proceso aparte (braw.py), que le va pasando los
|
||||
cuadros ya revelados a ffmpeg; el codificador y la calidad son los de cualquier otro clip. No hace
|
||||
falta tener Resolve abierto, y si lo está no se le toca nada."""
|
||||
formato = formato_de(t)
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
dec = decodificador_braw()
|
||||
if not dec.get("ok"):
|
||||
set_trabajo(t["id"], estado="error", log=dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||||
return
|
||||
salida = t["salida"]
|
||||
if _salida_ajena(t):
|
||||
set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola")
|
||||
return
|
||||
m = medir_braw(t["ruta"])
|
||||
if not m.get("ok"):
|
||||
set_trabajo(t["id"], estado="error", log=m.get("error") or "el lector de BRAW no pudo abrir el clip")
|
||||
return
|
||||
motor, pega = _motor_de(formato, t["chroma"] or "420", m["ancho"], m["alto"])
|
||||
if not motor:
|
||||
set_trabajo(t["id"], estado="error", log=pega)
|
||||
return
|
||||
# la tasa se calcula con el tamaño de verdad del clip, que es el que dice el decodificador
|
||||
kbps = int(kbps_objetivo(m["ancho"], m["alto"], m["fps"], t["perfil"]) * fm.get("bpp_mult", 1.0))
|
||||
t = dict(t, kbps=kbps)
|
||||
con = db()
|
||||
with con:
|
||||
con.execute("UPDATE archivos SET ancho = ?, alto = ?, fps = ?, dur = ?, perfil = ? WHERE ruta = ?",
|
||||
(m["ancho"], m["alto"], m["fps"], m["dur"], _curva(m), t["ruta"]))
|
||||
con.execute("UPDATE trabajos SET kbps = ? WHERE id = ?", (kbps, t["id"]))
|
||||
f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||||
con.close()
|
||||
os.makedirs(os.path.dirname(salida), exist_ok=True)
|
||||
tmp = salida + ".parcial" + fm["ext"]
|
||||
video, pix = _args_video(t, fm, motor, m["fps"])
|
||||
contenedor, audio = _args_salida(fm, f)
|
||||
ficha = os.path.join(DATOS, f"braw_{t['id']}.json")
|
||||
bandera = ficha + ".cancelar"
|
||||
guardar_json(ficha, {"src": t["ruta"], "tmp": tmp, "ffmpeg": ruta_herramienta("ffmpeg"), "pix": pix, "video": video,
|
||||
"contenedor": contenedor, "audio": audio, "color": t["color"] or "rec709", "cancelar": bandera,
|
||||
"sdk": dec.get("carpeta") or ""})
|
||||
try:
|
||||
os.remove(bandera)
|
||||
except OSError:
|
||||
pass
|
||||
errores = tempfile.TemporaryFile()
|
||||
try:
|
||||
p = subprocess.Popen([sys.executable, os.path.join(BASE, "braw.py"), "convertir", ficha], stdout=subprocess.PIPE, stderr=errores,
|
||||
text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA)
|
||||
except OSError as ex:
|
||||
errores.close()
|
||||
set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el lector de BRAW: {ex}")
|
||||
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
|
||||
while p.poll() is None:
|
||||
if COLA["cancelar"]:
|
||||
try:
|
||||
open(bandera, "w").close()
|
||||
p.wait(20)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
p.kill()
|
||||
return
|
||||
time.sleep(0.5)
|
||||
threading.Thread(target=vigia, daemon=True).start()
|
||||
fin, dicho = {}, ""
|
||||
for linea in p.stdout:
|
||||
try:
|
||||
ev = json.loads(linea)
|
||||
except ValueError:
|
||||
continue
|
||||
if ev.get("estado"):
|
||||
fin = ev
|
||||
elif "pct" in ev:
|
||||
set_trabajo(t["id"], pct=ev["pct"])
|
||||
elif ev.get("msg"):
|
||||
dicho = ev["msg"]
|
||||
set_trabajo(t["id"], log=dicho)
|
||||
p.wait()
|
||||
p.stdout.close()
|
||||
try:
|
||||
errores.seek(0)
|
||||
cola_err = errores.read().decode("utf-8", "replace").strip()[-2000:]
|
||||
except (OSError, ValueError):
|
||||
cola_err = ""
|
||||
errores.close()
|
||||
for resto in (ficha, bandera):
|
||||
try:
|
||||
os.remove(resto)
|
||||
except OSError:
|
||||
pass
|
||||
if COLA["cancelar"] or fin.get("estado") == "cancelado":
|
||||
set_trabajo(t["id"], estado="cancelado", pct=0)
|
||||
_quitar_parcial(tmp)
|
||||
return
|
||||
if fin.get("estado") != "listo" or not os.path.exists(tmp):
|
||||
set_trabajo(t["id"], estado="error",
|
||||
log=(fin.get("log") or cola_err or f"el lector de BRAW se cerró sin acabar el clip (código {p.returncode})")[-3000:])
|
||||
_quitar_parcial(tmp)
|
||||
return
|
||||
os.replace(tmp, salida)
|
||||
try:
|
||||
st = os.stat(t["ruta"])
|
||||
os.utime(salida, (st.st_atime, st.st_mtime))
|
||||
except OSError:
|
||||
pass
|
||||
set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time(),
|
||||
log=(f"{dicho} · {fin.get('segundos', 0)} s · " + " ".join(video))[:4000])
|
||||
|
||||
|
||||
def correr_resolve(trabajos):
|
||||
"""Manda un lote de BRAW a DaVinci Resolve por el puente (proceso aparte)."""
|
||||
o = CONFIG["optimizar"]
|
||||
@@ -2898,6 +3189,10 @@ def correr_resolve(trabajos):
|
||||
p.kill()
|
||||
break
|
||||
p.wait()
|
||||
try:
|
||||
os.remove(lote)
|
||||
except OSError:
|
||||
pass
|
||||
con = db()
|
||||
for t in trabajos:
|
||||
r = con.execute("SELECT estado FROM trabajos WHERE id = ?", (t["id"],)).fetchone()
|
||||
@@ -2923,14 +3218,14 @@ def bucle_cola():
|
||||
try:
|
||||
while COLA["activa"]:
|
||||
con = db()
|
||||
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'ffmpeg' ORDER BY id LIMIT 1").fetchone()
|
||||
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY 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"]
|
||||
set_trabajo(t["id"], estado="corriendo", pct=0)
|
||||
try:
|
||||
correr_ffmpeg(dict(t))
|
||||
(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"]])
|
||||
@@ -2976,6 +3271,33 @@ def recuperar_huerfanas():
|
||||
return len(filas)
|
||||
|
||||
|
||||
def braws_al_lector():
|
||||
"""Las conversiones de BRAW que quedaron en espera o fallidas por la vía de Resolve pasan al lector
|
||||
directo. De cada clip se queda una sola, la última; las que apuntan a un clip que ya no está en esa ruta
|
||||
—la carpeta se reordenó después— se quitan, porque reintentarlas no podía salir bien."""
|
||||
if not decodificador_braw().get("ok"):
|
||||
return 0
|
||||
con = db()
|
||||
n = 0
|
||||
with con:
|
||||
vistos = set()
|
||||
for t in con.execute("SELECT id, ruta, raiz, formato FROM trabajos WHERE motor = 'resolve' "
|
||||
"AND estado IN ('pendiente','error','cancelado') ORDER BY id DESC").fetchall():
|
||||
if not os.path.isdir(t["raiz"] or ""):
|
||||
continue # unidad desconectada: no se sabe si el clip sigue ahí
|
||||
clave = (os.path.normcase(t["ruta"]), t["formato"] or "h265_10bit")
|
||||
if clave in vistos or not os.path.isfile(t["ruta"]):
|
||||
con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],))
|
||||
else:
|
||||
vistos.add(clave)
|
||||
con.execute("UPDATE trabajos SET motor = 'braw' WHERE id = ?", (t["id"],))
|
||||
con.execute("UPDATE trabajos SET log = ? WHERE id = ? AND estado = 'error'",
|
||||
("no salió por la vía de Resolve; ahora se lee sin Resolve: pulsa «Reintentar los que fallaron»", t["id"]))
|
||||
n += 1
|
||||
con.close()
|
||||
return n
|
||||
|
||||
|
||||
class ErrorCola(ValueError):
|
||||
"""Un ValueError que además lleva datos para la interfaz (qué trabajos estorban), no solo un texto."""
|
||||
def __init__(self, mensaje, datos):
|
||||
@@ -2993,6 +3315,10 @@ def iniciar_cola():
|
||||
if COLA["hilo"] and COLA["hilo"].is_alive():
|
||||
return False
|
||||
recuperar_huerfanas()
|
||||
try:
|
||||
braws_al_lector()
|
||||
except Exception: # noqa
|
||||
pass
|
||||
COLA["activa"] = True
|
||||
COLA["cancelar"] = False
|
||||
COLA["hechos"] = 0
|
||||
@@ -3002,15 +3328,103 @@ def iniciar_cola():
|
||||
return True
|
||||
|
||||
|
||||
def _no_compensa(t, sale=None):
|
||||
"""Por qué no merece la pena dar por buena esta conversión, o «» si sí. Solo cuenta para los formatos que
|
||||
existen para pesar menos (H.265). El 5-oct-2026 había 8 conversiones verificadas esperando en la cola de
|
||||
V:\\Galeano con el botón «Reemplazar 8 verificados»: seis eran reconversiones de un clip ya convertido
|
||||
y pesaban entre el 85 y el 99 % de él. Reemplazar habría cambiado cada uno por una copia de su copia."""
|
||||
if FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])["codec"] != "hevc":
|
||||
return ""
|
||||
entra, sale = t["bytes_in"] or 0, sale or t["bytes_out"] or 0
|
||||
if not entra or not sale:
|
||||
return ""
|
||||
repetida = R.es_conversion(os.path.basename(t["ruta"]))
|
||||
if not repetida and sale <= 0.8 * entra:
|
||||
return ""
|
||||
return (f"no compensa: pesa el {round(sale / entra * 100)} % del clip del que salió"
|
||||
+ (", que ya era una conversión de este programa" if repetida else "") + ". Se queda el que estaba.")
|
||||
|
||||
|
||||
DESCARTADOS = "descartados"
|
||||
|
||||
|
||||
def _apartar_convertido(t, lote, movidos):
|
||||
"""Lo que salió de una conversión que no se usa va a _Para borrar\\descartados, con su ruta: no se borra, y
|
||||
«Deshacer» lo devuelve. Solo si sigue siendo el archivo que dejó este trabajo. Devuelve dónde quedó."""
|
||||
salida, raiz = t["salida"] or "", t["raiz"] or ""
|
||||
if not os.path.isfile(salida) or not raiz or not dentro_de(raiz, salida):
|
||||
return ""
|
||||
if t["bytes_out"] and os.path.getsize(salida) != t["bytes_out"]:
|
||||
return ""
|
||||
antes = len(movidos)
|
||||
mover(salida, os.path.join(raiz, R.PAPELERA, DESCARTADOS, os.path.relpath(salida, raiz)), lote, movidos)
|
||||
return movidos[-1][1] if len(movidos) > antes else ""
|
||||
|
||||
|
||||
def _cerrar_lote(lote, raiz, movidos, errores):
|
||||
if movidos or errores:
|
||||
actualizar_inventario(movidos)
|
||||
bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos), "movimientos": len(movidos), "errores": errores})
|
||||
|
||||
|
||||
def _descartar(t, pega, lote, movidos, errores, **kw):
|
||||
"""Marca el trabajo como «no compensa» y aparta lo que salió de él."""
|
||||
nota = ""
|
||||
try:
|
||||
if _apartar_convertido(t, lote, movidos):
|
||||
nota = f" Lo convertido quedó en «{R.PAPELERA}\\{DESCARTADOS}»."
|
||||
except OSError as ex:
|
||||
errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}")
|
||||
nota = " Lo convertido sigue junto al original (no se pudo apartar: ¿está abierto?)."
|
||||
set_trabajo(t["id"], estado="descartado", log=pega + nota, **kw)
|
||||
return pega + nota
|
||||
|
||||
|
||||
def apartar_descartados(ids):
|
||||
"""«Limpiar terminados»: lo que salió de las conversiones que no compensaron y sigue junto a su original se
|
||||
aparta antes de quitarlas de la cola. Devuelve también las que no se pudieron apartar: esas se quedan."""
|
||||
con = db()
|
||||
filas = [dict(r) for r in con.execute(f"SELECT * FROM trabajos WHERE estado = 'descartado' AND id IN ({','.join('?' * len(ids))})",
|
||||
list(ids)).fetchall()] if ids else []
|
||||
con.close()
|
||||
lote, movidos, errores, raiz, quedan = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "", []
|
||||
for t in filas:
|
||||
try:
|
||||
if _apartar_convertido(t, lote, movidos):
|
||||
raiz = raiz or t["raiz"]
|
||||
except OSError as ex:
|
||||
errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}")
|
||||
quedan.append(t["id"])
|
||||
_cerrar_lote(lote, raiz or (filas[0]["raiz"] if filas else ""), movidos, errores)
|
||||
return {"movidos": len(movidos), "errores": errores, "quedan": quedan}
|
||||
|
||||
|
||||
def repasar_verificados():
|
||||
"""Las conversiones verificadas con una versión anterior que no compensan dejan de ofrecerse para reemplazar.
|
||||
Al arrancar solo cambia su estado: lo que salió de ellas se aparta cuando alguien limpia la cola o vuelve a
|
||||
convertir el clip, no sin que nadie lo pida."""
|
||||
con = db()
|
||||
n = 0
|
||||
for t in con.execute("SELECT * FROM trabajos WHERE estado = 'verificado'").fetchall():
|
||||
pega = _no_compensa(t)
|
||||
if pega:
|
||||
with con:
|
||||
con.execute("UPDATE trabajos SET estado = 'descartado', log = ? WHERE id = ?", (pega, t["id"]))
|
||||
n += 1
|
||||
con.close()
|
||||
return n
|
||||
|
||||
|
||||
def verificar(ids):
|
||||
con = db()
|
||||
res = []
|
||||
lote, movidos, errores, raiz_lote = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], ""
|
||||
for id_ in ids:
|
||||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||||
if not t or not os.path.exists(t["salida"]):
|
||||
res.append({"id": id_, "ok": False, "motivo": "no existe la salida"})
|
||||
continue
|
||||
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||||
f = con.execute("SELECT dur, ancho, alto FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||||
info = ffprobe(t["salida"])
|
||||
if not info:
|
||||
res.append({"id": id_, "ok": False, "motivo": "ffprobe no pudo leer la salida"})
|
||||
@@ -3026,15 +3440,24 @@ def verificar(ids):
|
||||
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 t["motor"] == "braw" and f and f["ancho"] and (info["ancho"], info["alto"]) != (f["ancho"], f["alto"]):
|
||||
problemas.append(f"imagen de {info['ancho']}×{info['alto']} (el clip es de {f['ancho']}×{f['alto']})")
|
||||
if os.path.getsize(t["salida"]) < 100000:
|
||||
problemas.append("archivo demasiado pequeño")
|
||||
if problemas:
|
||||
set_trabajo(id_, estado="error", log="verificación: " + "; ".join(problemas))
|
||||
res.append({"id": id_, "ok": False, "motivo": "; ".join(problemas)})
|
||||
else:
|
||||
set_trabajo(id_, estado="verificado", bytes_out=os.path.getsize(t["salida"]))
|
||||
res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"})
|
||||
sale = os.path.getsize(t["salida"])
|
||||
pega = _no_compensa(t, sale)
|
||||
if pega: # está bien hecha, pero no merece ocupar el sitio del original
|
||||
raiz_lote = raiz_lote or t["raiz"]
|
||||
res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t, bytes_out=sale), pega, lote, movidos, errores, bytes_out=sale)})
|
||||
else:
|
||||
set_trabajo(id_, estado="verificado", bytes_out=sale)
|
||||
res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"})
|
||||
con.close()
|
||||
_cerrar_lote(lote, raiz_lote, movidos, errores)
|
||||
return res
|
||||
|
||||
|
||||
@@ -3044,6 +3467,8 @@ def reemplazar(ids):
|
||||
res = []
|
||||
lote = "optimizar-" + time.strftime("%Y%m%d-%H%M%S") # un lote por reemplazo: se deshace solo este, no toda la historia
|
||||
movimientos, raiz_lote = 0, ""
|
||||
# lo que no compensa va en su propio lote: deshacer el reemplazo no debe devolver también eso
|
||||
lote_desc, movidos_desc, errores_desc, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], ""
|
||||
for id_ in ids:
|
||||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||||
if not t or t["estado"] != "verificado":
|
||||
@@ -3052,6 +3477,11 @@ def reemplazar(ids):
|
||||
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
|
||||
pega = _no_compensa(t)
|
||||
if pega:
|
||||
raiz_desc = raiz_desc or t["raiz"]
|
||||
res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t), pega, lote_desc, movidos_desc, errores_desc)})
|
||||
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")
|
||||
@@ -3066,9 +3496,9 @@ def reemplazar(ids):
|
||||
os.rename(t["ruta"], destino)
|
||||
movimientos += 1
|
||||
bitacora({"lote": lote, "op": "mover", "de": t["ruta"], "a": destino, "trabajo": id_})
|
||||
# el sidecar .xml de Sony y el .mp4 proxy de Blackmagic se van con el original
|
||||
# el sidecar .xml de Sony y el .sidecar de un BRAW (los ajustes que le dejó Resolve) se van con el original
|
||||
base, ext = os.path.splitext(t["ruta"])
|
||||
for extra in (base + "M01.xml", base + "M01.XML"):
|
||||
for extra in (base + "M01.xml", base + "M01.XML", base + ".sidecar"):
|
||||
if os.path.exists(extra):
|
||||
d2 = os.path.join(os.path.dirname(destino), os.path.basename(extra))
|
||||
os.rename(extra, d2)
|
||||
@@ -3083,7 +3513,8 @@ def reemplazar(ids):
|
||||
con.close()
|
||||
if movimientos:
|
||||
bitacora({"lote": lote, "op": "fin", "raiz": raiz_lote, "filas": len(ids), "movimientos": movimientos,
|
||||
"errores": [f"#{r['id']}: {r['motivo']}" for r in res if not r["ok"]]})
|
||||
"errores": [f"#{r['id']}: {r['motivo']}" for r in res if not r["ok"] and not r["motivo"].startswith("no compensa")]})
|
||||
_cerrar_lote(lote_desc, raiz_desc, movidos_desc, errores_desc)
|
||||
return res
|
||||
|
||||
|
||||
@@ -4988,11 +5419,23 @@ def acciones(raiz):
|
||||
detalle="Primero hay que leerlos para saber cuáles conviene convertir. Va en segundo plano.",
|
||||
ir="convertir")
|
||||
else:
|
||||
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Ver clips",
|
||||
que="Pasan a un formato unas 10 veces más ligero. El original no se toca hasta que reemplaces.",
|
||||
cifra=f"{_plural(cam['n'], 'clip', 'clips')} · {_gb(cam['b'])}",
|
||||
detalle=f"{cam['ana']} ya están leídos" + (f" y {hechos} reemplazados." if hechos else "."),
|
||||
ir="convertir")
|
||||
# ya leídos: cuentan solo los que de verdad adelgazan (ni conversiones, ni lo que ya viene comprimido)
|
||||
cands = [c for c in candidatos(raiz, o["meses_minimos"], o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit"))
|
||||
if not c["excluido"] and not c["en_cola"] and c["rol"] in ("camara_raw", "camara_video")]
|
||||
sin_leer = cam["n"] - cam["ana"]
|
||||
if cands:
|
||||
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Ver clips",
|
||||
que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.",
|
||||
cifra=f"{_plural(len(cands), 'clip', 'clips')} · {_gb(sum(c['bytes'] for c in cands))} → unos {_gb(sum(c['estimado'] for c in cands))}",
|
||||
detalle=(f"{sum(1 for c in cands if c['motor'] != 'ffmpeg')} son BRAW. " if any(c["motor"] != "ffmpeg" for c in cands) else "")
|
||||
+ (f"{hechos} ya reemplazados." if hechos else "El resto ya viene comprimido o es reciente."),
|
||||
ir="convertir")
|
||||
elif sin_leer > 0:
|
||||
añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Leer los clips",
|
||||
que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.",
|
||||
cifra=f"{_plural(sin_leer, 'clip', 'clips')} por leer",
|
||||
detalle="Hay clips nuevos sin leer: hasta entonces no se sabe cuáles conviene convertir.",
|
||||
ir="convertir")
|
||||
|
||||
# --- 8. respaldo, al final ---
|
||||
dest = CONFIG.get("respaldo", {}).get("destino", "")
|
||||
@@ -5412,11 +5855,13 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
COLA["activa"] = False
|
||||
return self._json({"ok": True})
|
||||
if acc == "quitar":
|
||||
ap = apartar_descartados(d.get("ids", []))
|
||||
ids = [i for i in d.get("ids", []) if i not in ap["quedan"]]
|
||||
con = db()
|
||||
with con:
|
||||
con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", []))
|
||||
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})
|
||||
return self._json({"ok": True, "apartados": ap})
|
||||
if acc == "reintentar":
|
||||
con = db()
|
||||
with con:
|
||||
@@ -5989,8 +6434,16 @@ def main():
|
||||
return
|
||||
h = herramientas()
|
||||
print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"])
|
||||
print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)")
|
||||
print("BRAW:", "se leen con el decodificador de Blackmagic" if h["braw"]["ok"]
|
||||
else "falta el decodificador de Blackmagic RAW (se instala con DaVinci Resolve; solo hace falta para BRAW)")
|
||||
for resto in os.listdir(DATOS): # fichas de trabajo que dejó una conversión cortada (o el guion de Resolve, que no las borraba)
|
||||
if resto.endswith((".json", ".cancelar")) and resto.startswith(("resolve_lote_", "braw_")):
|
||||
try:
|
||||
os.remove(os.path.join(DATOS, resto))
|
||||
except OSError:
|
||||
pass
|
||||
huerfanas = recuperar_huerfanas()
|
||||
threading.Thread(target=lambda: (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