Primer commit del proyecto. Incluye el rediseño 1.6 (riel de pasos en la barra lateral), el actualizador con reintentos y respaldo, publicar.sh y la nueva opción de desconectar carpetas (1.6.1). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
286 líneas
13 KiB
Python
286 líneas
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Puente con DaVinci Resolve Studio: convierte clips BRAW a MP4 H.265 10 bits
|
|
usando el codificador NVIDIA de Resolve.
|
|
|
|
Se ejecuta como proceso aparte: python resolve_bridge.py lote.json
|
|
El lote es una lista de trabajos {id, src, dst, w, h, fps, kbps, color}.
|
|
Reporta por stdout una línea JSON por evento: {"id":..,"pct":..,"estado":..}
|
|
|
|
Requisitos: Resolve abierto, con "Scripting externo" en Local
|
|
(Preferencias > Sistema > General). Usa el proyecto "Labstream Archivo" de la
|
|
base de datos actual (lo crea si no existe) y al terminar vuelve al proyecto
|
|
que estaba abierto.
|
|
"""
|
|
import os
|
|
import sys
|
|
import json
|
|
import time
|
|
import shutil
|
|
from collections import defaultdict
|
|
|
|
sys.path.append(r"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\Developer\Scripting\Modules")
|
|
os.environ.setdefault("RESOLVE_SCRIPT_API", r"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\Developer\Scripting")
|
|
os.environ.setdefault("RESOLVE_SCRIPT_LIB", r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll")
|
|
|
|
PROYECTO = "Labstream Archivo"
|
|
|
|
|
|
def emitir(**kw):
|
|
print(json.dumps(kw, ensure_ascii=False), flush=True)
|
|
|
|
|
|
def fps_texto(fps):
|
|
if not fps:
|
|
return "30"
|
|
for candidato in (23.976, 24, 25, 29.97, 30, 47.952, 48, 50, 59.94, 60, 119.88, 120):
|
|
if abs(fps - candidato) < 0.02:
|
|
return f"{candidato:g}"
|
|
return f"{fps:g}"
|
|
|
|
|
|
def main(ruta_lote=None):
|
|
ruta_lote = ruta_lote or (sys.argv[1] if len(sys.argv) > 1 else None)
|
|
if not ruta_lote:
|
|
emitir(msg="uso: resolve_bridge.py lote.json")
|
|
return 2
|
|
with open(ruta_lote, "r", encoding="utf-8") as f:
|
|
trabajos = json.load(f)
|
|
try:
|
|
import DaVinciResolveScript as dvr
|
|
resolve = dvr.scriptapp("Resolve")
|
|
except Exception as ex: # noqa
|
|
for t in trabajos:
|
|
emitir(id=t["id"], estado="error", log=f"no se pudo conectar con Resolve: {ex!r}")
|
|
return 1
|
|
if not resolve:
|
|
for t in trabajos:
|
|
emitir(id=t["id"], estado="error", log="Resolve no responde. ¿Está abierto y con scripting externo en Local?")
|
|
return 1
|
|
emitir(msg=f"conectado a {resolve.GetProductName()} {resolve.GetVersionString()}")
|
|
if resolve.GetCurrentPage() is None:
|
|
for t in trabajos:
|
|
emitir(id=t["id"], estado="error",
|
|
log="Resolve está en la ventana del Gestor de proyectos (o con un diálogo abierto): "
|
|
"abre un proyecto, cierra el diálogo y pulsa «Reintentar errores»")
|
|
return 1
|
|
pm = resolve.GetProjectManager()
|
|
anterior = pm.GetCurrentProject()
|
|
nombre_anterior = anterior.GetName() if anterior else None
|
|
if anterior:
|
|
try:
|
|
pm.SaveProject()
|
|
except Exception: # noqa
|
|
pass
|
|
color = (trabajos[0].get("color") or "rec709") if trabajos else "rec709"
|
|
color = color if color in ("rec709", "log") else "rec709"
|
|
|
|
# Plan A: proyecto propio «Labstream Archivo» con gestión de color a Rec.709.
|
|
proj = None
|
|
try:
|
|
pm.GotoRootFolder()
|
|
proj = pm.LoadProject(PROYECTO) or pm.CreateProject(PROYECTO)
|
|
except Exception: # noqa
|
|
proj = None
|
|
lut_por_clip = None
|
|
plan_b = False
|
|
if proj:
|
|
emitir(msg=f"proyecto «{PROYECTO}» abierto (antes estaba «{nombre_anterior}»)")
|
|
ajustes = {
|
|
"rec709": {
|
|
"colorScienceMode": "davinciYRGBColorManagedv2",
|
|
"rcmPresetMode": "Custom",
|
|
"separateColorSpaceAndGamma": "0",
|
|
"colorSpaceTimeline": "DaVinci WG/Intermediate",
|
|
"colorSpaceOutput": "Rec.709 Gamma 2.4",
|
|
"inputDRT": "DaVinci",
|
|
"outputDRT": "DaVinci",
|
|
},
|
|
"log": {"colorScienceMode": "davinciYRGB"},
|
|
}[color]
|
|
for k, v in ajustes.items():
|
|
try:
|
|
proj.SetSetting(k, v)
|
|
except Exception: # noqa
|
|
pass
|
|
else:
|
|
# Plan B: Resolve no deja cambiar de proyecto (biblioteca colaborativa, etc.):
|
|
# se trabaja dentro del proyecto abierto, en un bin propio, sin tocar sus ajustes.
|
|
proj = anterior
|
|
plan_b = True
|
|
if not proj:
|
|
for t in trabajos:
|
|
emitir(id=t["id"], estado="error", log="no hay ningún proyecto abierto en Resolve y no se pudo crear «Labstream Archivo»")
|
|
return 1
|
|
gestionado = "colormanaged" in (proj.GetSetting("colorScienceMode") or "").lower()
|
|
emitir(msg=f"no se pudo abrir «{PROYECTO}»: se usa el proyecto actual «{nombre_anterior}» (color {'gestionado' if gestionado else 'YRGB'})")
|
|
if color == "rec709" and not gestionado:
|
|
for nombre_lut in ("Blackmagic Gen 5 Film to Extended Video.cube", "Blackmagic Design Film Gen 5 to Extended Video.cube"):
|
|
cand = os.path.join(r"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\LUT\Blackmagic Design", nombre_lut)
|
|
if os.path.exists(cand):
|
|
lut_por_clip = cand
|
|
break
|
|
emitir(msg="a cada clip BRAW se le aplica la LUT Gen 5 Film → Video para que el archivo se vea bien" if lut_por_clip else "aviso: sin LUT Gen 5 disponible, el archivo quedará en log")
|
|
|
|
mp = proj.GetMediaPool()
|
|
raiz = mp.GetRootFolder()
|
|
bin_archivo = None
|
|
for sf in raiz.GetSubFolderList() or []:
|
|
if sf.GetName() == PROYECTO:
|
|
bin_archivo = sf
|
|
break
|
|
if not bin_archivo:
|
|
bin_archivo = mp.AddSubFolder(raiz, PROYECTO)
|
|
lote_bin = mp.AddSubFolder(bin_archivo or raiz, time.strftime("lote %Y-%m-%d %H%M%S"))
|
|
if lote_bin:
|
|
mp.SetCurrentFolder(lote_bin)
|
|
|
|
codecs = proj.GetRenderCodecs("mp4") or {}
|
|
codec = "H265_NVIDIA" if "H265_NVIDIA" in codecs.values() else ("H265" if "H265" in codecs.values() else None)
|
|
if not codec:
|
|
for t in trabajos:
|
|
emitir(id=t["id"], estado="error", log=f"Resolve no ofrece H.265 en MP4 (códecs: {list(codecs.values())})")
|
|
return 1
|
|
|
|
# agrupar por carpeta de salida + formato: cada grupo es una línea de tiempo
|
|
grupos = defaultdict(list)
|
|
for t in trabajos:
|
|
grupos[(os.path.dirname(t["dst"]), t["w"], t["h"], fps_texto(t["fps"]), t["kbps"])].append(t)
|
|
|
|
salida_ok = 0
|
|
for (dst_dir, w, h, fps, kbps), ts in grupos.items():
|
|
ids = [t["id"] for t in ts]
|
|
tmp_dir = os.path.join(dst_dir, f"_render_{int(time.time())}")
|
|
try:
|
|
os.makedirs(tmp_dir, exist_ok=True)
|
|
# ajustes de proyecto (sirven de base para la nueva línea de tiempo)
|
|
# en el proyecto propio se ajusta la base; en el del editor (plan B) NO se toca ningún ajuste
|
|
for k, v in (() if plan_b else (("timelineResolutionWidth", str(w)), ("timelineResolutionHeight", str(h)), ("timelineFrameRate", fps))):
|
|
try:
|
|
proj.SetSetting(k, v)
|
|
except Exception: # noqa
|
|
pass
|
|
items = mp.ImportMedia([t["src"] for t in ts]) or []
|
|
if len(items) != len(ts):
|
|
emitir(msg=f"aviso: se importaron {len(items)} de {len(ts)} clips")
|
|
if not items:
|
|
for i in ids:
|
|
emitir(id=i, estado="error", log="Resolve no pudo importar los clips")
|
|
continue
|
|
tl = mp.CreateEmptyTimeline(f"archivo {w}x{h} {fps} {int(time.time())}")
|
|
if not tl:
|
|
for i in ids:
|
|
emitir(id=i, estado="error", log="no se pudo crear la línea de tiempo")
|
|
continue
|
|
for k, v in (("useCustomSettings", "1"), ("timelineResolutionWidth", str(w)), ("timelineResolutionHeight", str(h)), ("timelineFrameRate", fps)):
|
|
try:
|
|
tl.SetSetting(k, v)
|
|
except Exception: # noqa
|
|
pass
|
|
mp.AppendToTimeline(items)
|
|
proj.SetCurrentTimeline(tl)
|
|
if lut_por_clip:
|
|
try:
|
|
for it in tl.GetItemListInTrack("video", 1) or []:
|
|
it.SetLUT(1, lut_por_clip)
|
|
except Exception as ex: # noqa
|
|
emitir(msg=f"aviso: no se pudo aplicar la LUT: {ex!r}")
|
|
if not plan_b:
|
|
proj.DeleteAllRenderJobs() # solo en el proyecto propio: en el del editor su cola de render es suya
|
|
proj.SetCurrentRenderMode(0) # 0 = clips individuales
|
|
proj.SetCurrentRenderFormatAndCodec("mp4", codec)
|
|
ok = proj.SetRenderSettings({
|
|
"SelectAllFrames": True,
|
|
"TargetDir": tmp_dir,
|
|
"CustomName": "",
|
|
"UniqueFilenameStyle": 1,
|
|
"ExportVideo": True,
|
|
"ExportAudio": True,
|
|
"FormatWidth": int(w),
|
|
"FormatHeight": int(h),
|
|
"FrameRate": float(fps),
|
|
"VideoQuality": int(kbps),
|
|
"EncodingProfile": "Main10",
|
|
"MultiPassEncode": True,
|
|
"AudioCodec": "aac",
|
|
"AudioBitDepth": 24,
|
|
"AudioSampleRate": 48000,
|
|
"NetworkOptimization": True,
|
|
})
|
|
if not ok:
|
|
emitir(msg="aviso: Resolve rechazó parte de los ajustes de render; sigue con los que aceptó")
|
|
job = proj.AddRenderJob()
|
|
if not job:
|
|
for i in ids:
|
|
emitir(id=i, estado="error", log="no se pudo crear el trabajo de render")
|
|
continue
|
|
proj.StartRendering([job], False)
|
|
for i in ids:
|
|
emitir(id=i, estado="corriendo", pct=0)
|
|
while proj.IsRenderingInProgress():
|
|
st = proj.GetRenderJobStatus(job) or {}
|
|
pct = st.get("CompletionPercentage", 0) or 0
|
|
for i in ids:
|
|
emitir(id=i, pct=min(99, pct))
|
|
time.sleep(2)
|
|
st = proj.GetRenderJobStatus(job) or {}
|
|
try:
|
|
proj.DeleteRenderJob(job) # el trabajo ya rindió: no se deja en la cola del editor
|
|
except Exception: # noqa
|
|
pass
|
|
if st.get("JobStatus") not in ("Complete",):
|
|
for i in ids:
|
|
emitir(id=i, estado="error", log=f"Resolve terminó con estado {st.get('JobStatus')}: {st.get('Error', '')}")
|
|
continue
|
|
# mover los renders a su nombre final
|
|
producidos = {os.path.splitext(n)[0].lower(): os.path.join(tmp_dir, n) for n in os.listdir(tmp_dir) if n.lower().endswith(".mp4")}
|
|
for t in ts:
|
|
base = os.path.splitext(os.path.basename(t["src"]))[0].lower()
|
|
src_render = producidos.get(base)
|
|
if not src_render:
|
|
# Resolve puede añadir sufijos: buscar por prefijo
|
|
cands = [p for b, p in producidos.items() if b.startswith(base)]
|
|
src_render = cands[0] if cands else None
|
|
if not src_render or not os.path.exists(src_render):
|
|
emitir(id=t["id"], estado="error", log=f"no apareció el render de {os.path.basename(t['src'])} en {tmp_dir}")
|
|
continue
|
|
os.makedirs(os.path.dirname(t["dst"]), exist_ok=True)
|
|
if os.path.exists(t["dst"]):
|
|
# nunca se pisa un archivo existente (podría ser la salida de otro clip con el mismo nombre)
|
|
emitir(id=t["id"], estado="error", log=f"ya existe {os.path.basename(t['dst'])}; el render queda en {src_render}")
|
|
continue
|
|
shutil.move(src_render, t["dst"])
|
|
try:
|
|
stt = os.stat(t["src"])
|
|
os.utime(t["dst"], (stt.st_atime, stt.st_mtime))
|
|
except OSError:
|
|
pass
|
|
salida_ok += 1
|
|
emitir(id=t["id"], estado="listo", pct=100, salida=t["dst"])
|
|
try:
|
|
if not os.listdir(tmp_dir):
|
|
os.rmdir(tmp_dir)
|
|
except OSError:
|
|
pass
|
|
except Exception as ex: # noqa
|
|
for i in ids:
|
|
emitir(id=i, estado="error", log=f"error en el puente: {ex!r}")
|
|
|
|
if not plan_b:
|
|
try:
|
|
pm.SaveProject()
|
|
except Exception: # noqa
|
|
pass
|
|
if nombre_anterior and nombre_anterior != PROYECTO and proj.GetName() == PROYECTO:
|
|
try:
|
|
pm.LoadProject(nombre_anterior)
|
|
emitir(msg=f"de vuelta en «{nombre_anterior}»")
|
|
except Exception: # noqa
|
|
emitir(msg=f"no pude volver a «{nombre_anterior}»; ábrelo a mano")
|
|
emitir(msg=f"lote terminado: {salida_ok}/{len(trabajos)} clips")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|