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>
Este commit está contenido en:
285
resolve_bridge.py
Archivo normal
285
resolve_bridge.py
Archivo normal
@@ -0,0 +1,285 @@
|
||||
# -*- 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())
|
||||
Referencia en una nueva incidencia
Block a user