Files
labstream-organizador/resolve_bridge.py
Labstream 8c5153e8ed OrganiLAB 1.6.1: organizador, actualizador, interfaz 1.6 y publicación
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>
2026-09-15 14:26:02 -05:00

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())