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:
Labstream
2026-10-05 13:24:34 -05:00
padre 477857db45
commit 0819f2da89
Se han modificado 12 ficheros con 1767 adiciones y 147 borrados

Ver fichero

@@ -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: