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