# -*- coding: utf-8 -*- """ Blackmagic RAW sin DaVinci Resolve abierto. Los .braw se leen con el decodificador oficial de Blackmagic (BlackmagicRawAPI.dll), el mismo que Resolve lleva dentro. Lo instalan DaVinci Resolve —el gratuito o Studio— y Blackmagic RAW Player; no hace falta tener ninguno abierto. Cada cuadro sale ya revelado, a 16 bits por canal, y se le pasa a ffmpeg por un tubo: desde ahí un BRAW se codifica igual que cualquier otro clip. Se ejecuta como proceso aparte, para que un fallo del decodificador no se lleve por delante el programa: python braw.py sonda [carpeta] ¿hay decodificador y es el que se conoce? → una línea JSON python braw.py info clip.braw [...] tamaño real, cuadros, código de tiempo, cámara y curva python braw.py convertir trabajo.json decodifica y codifica; el avance sale por stdout, una línea JSON por evento Por qué existe. Hasta la 3.3 los BRAW iban por el guion externo de Resolve (resolve_bridge.py), que exige Resolve Studio abierto, en un proyecto y sin diálogos, y que además le cambia el proyecto a quien esté editando. Medido el 5-oct-2026 en el equipo de Munka: tres tandas mandadas por esa vía (14 y 16 de septiembre, 5 de octubre) y ni un solo clip convertido. El tamaño de imagen que se le pasaba salía de ffprobe, que en un BRAW lee el cuadro con su relleno y sin girar: 4128×2176 para un clip que es 2160×4096 en vertical. Las interfaces están copiadas de la biblioteca de tipos que trae la propia DLL (Resolve 21.1.1). En COM una interfaz no cambia sin cambiar de identificador: antes de usar cada objeto se le pregunta por el identificador que aquí se conoce, y si no lo tiene se dice y no se sigue. Nunca se llama a ciegas. """ import os import sys import json import time import ctypes import tempfile import threading import subprocess NOMBRE = "BlackmagicRawAPI.dll" SIN_VENTANA = 0x08000000 if os.name == "nt" else 0 P = ctypes.POINTER V = ctypes.c_void_p HR = ctypes.c_long E_NOINTERFACE = ctypes.c_long(0x80004002).value # identificador de cada interfaz y, en cada una, el puesto de los métodos que se usan (los tres primeros # son los de IUnknown: QueryInterface, AddRef, Release) IID = { "unknown": "00000000-0000-0000-C000-000000000046", "fabrica": "78DEEB84-98C9-434A-B7E5-7AACC2988399", # IBlackmagicRawFactory "codec": "C25C15E2-D1D0-4743-9EA8-F21F69751AF3", # IBlackmagicRaw "config": "267E9866-FB40-4BFB-8BF8-96EA3F7DA36E", # IBlackmagicRawConfiguration "clip": "A2910203-787B-4BF2-A374-B1A459E2D351", # IBlackmagicRawClip "atributos": "7E5806E1-40B3-4DAD-8D03-5E294E65AC61", # IBlackmagicRawClipProcessingAttributes "retorno": "E9F98FAC-33DB-4A65-BB94-8A82B027AED0", # IBlackmagicRawCallback "cuadro": "A500B253-1808-4EF2-8692-D23C692404EA", # IBlackmagicRawFrame "imagen": "D87A0F72-A883-42BB-8488-0089411C5035", # IBlackmagicRawProcessedImage } RGB_16 = 0x3136696C # blackmagicRawResourceFormatRGBU16: R, G y B seguidos, 16 bits cada uno EN_MEMORIA = 0x63707562 # blackmagicRawResourceTypeBufferCPU A_GAMMA, A_GAMUT, A_GEN = 0x67616D61, 0x67616D74, 0x6373676E FILM, VIDEO_AMPLIO, GAMUT_BMD = "Blackmagic Design Film", "Blackmagic Design Extended Video", "Blackmagic Design" # curvas que ya son «para ver»: si el clip viene así se respeta. Cualquier otra (Film, Linear, las # logarítmicas de otras marcas) no se puede dejar tal cual en un archivo que dice ser Rec.709. CURVAS_DE_VIDEO = {"Blackmagic Design Video", VIDEO_AMPLIO, "Blackmagic Design Custom", "Rec.709"} MEMORIA_EN_VUELO = 600_000_000 # bytes de cuadros revelados que se dejan esperando turno… CUADROS_EN_VUELO = 8 # …y nunca más de estos class Desconocido(OSError): """El decodificador instalado no ofrece la interfaz que este programa conoce.""" class Cancelado(Exception): pass class GUID(ctypes.Structure): _fields_ = [("a", ctypes.c_uint32), ("b", ctypes.c_uint16), ("c", ctypes.c_uint16), ("d", ctypes.c_ubyte * 8)] def __init__(self, texto=""): super().__init__() if texto: p = texto.split("-") self.a, self.b, self.c = int(p[0], 16), int(p[1], 16), int(p[2], 16) self.d = (ctypes.c_ubyte * 8)(*bytes.fromhex(p[3] + p[4])) class VARIANT(ctypes.Structure): _fields_ = [("vt", ctypes.c_uint16), ("r1", ctypes.c_uint16), ("r2", ctypes.c_uint16), ("r3", ctypes.c_uint16), ("valor", ctypes.c_uint64), ("valor2", V)] # ========================================================================== # Dónde está el decodificador # ========================================================================== def carpetas_posibles(extra=""): """Las carpetas donde Blackmagic deja su decodificador, por orden de preferencia.""" out = [extra or "", os.environ.get("ORGANILAB_BRAW", "")] for var in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"): base = os.environ.get(var) if base: bmd = os.path.join(base, "Blackmagic Design") out += [os.path.join(bmd, "DaVinci Resolve"), os.path.join(bmd, "Blackmagic RAW", "Blackmagic RAW SDK", "Win", "Libraries"), os.path.join(bmd, "Blackmagic RAW", "Blackmagic RAW Player")] limpio = [] for c in out: c = os.path.normpath(c) if c else "" if c and os.path.normcase(c) not in [os.path.normcase(x) for x in limpio]: limpio.append(c) return limpio def buscar(extra=""): """La carpeta del decodificador, o «» si no está instalado. No carga nada: solo mira si el archivo existe.""" for c in carpetas_posibles(extra): if os.path.isfile(os.path.join(c, NOMBRE)): return c return "" # ========================================================================== # COM a mano, con ctypes # ========================================================================== def _ole(): o = ctypes.windll.oleaut32 if not getattr(o, "_listo", False): o.SysAllocString.restype, o.SysAllocString.argtypes = V, [ctypes.c_wchar_p] o.SysFreeString.restype, o.SysFreeString.argtypes = None, [V] o.VariantClear.argtypes = [P(VARIANT)] for f in (o.SafeArrayGetLBound, o.SafeArrayGetUBound): f.argtypes = [V, ctypes.c_uint, P(ctypes.c_long)] o.SafeArrayGetVartype.argtypes = [V, P(ctypes.c_uint16)] o.SafeArrayAccessData.argtypes = [V, P(V)] o.SafeArrayUnaccessData.argtypes = [V] o._listo = True return o def _m(obj, puesto, vuelve, *tipos): """El método número `puesto` de la tabla del objeto, listo para llamarlo.""" tabla = ctypes.cast(obj, P(P(V))).contents f = ctypes.WINFUNCTYPE(vuelve, V, *tipos)(tabla[puesto]) return lambda *a: f(obj, *a) def _bien(hr, que): if hr != 0: raise OSError(f"{que} (código 0x{hr & 0xFFFFFFFF:08X})") def _soltar(obj): if obj: _m(obj, 2, ctypes.c_ulong)() def _como(obj, cual): """El mismo objeto visto por la interfaz `cual`. Si no la ofrece, este decodificador no es el conocido.""" otro = V() if not obj or _m(obj, 0, HR, P(GUID), P(V))(ctypes.byref(GUID(IID[cual])), ctypes.byref(otro)) != 0 or not otro: raise Desconocido("el decodificador de Blackmagic RAW instalado es de una versión que este programa no conoce " f"(no ofrece la interfaz «{cual}»)") return otro def _texto(obj, puesto, *antes): """Un método que devuelve un texto (BSTR) por su último parámetro.""" b = V() tipos = [type(x) for x in antes] if _m(obj, puesto, HR, *tipos, P(V))(*antes, ctypes.byref(b)) != 0 or not b.value: return "" s = ctypes.wstring_at(b.value) _ole().SysFreeString(b) return s def _de_variant(v): """El valor de un VARIANT de los que devuelve el decodificador (texto, número o lista).""" vt, en = v.vt, ctypes.byref(v, 8) if vt == 8: return ctypes.wstring_at(v.valor) if v.valor else "" if vt in (4, 5): return ctypes.cast(en, P(ctypes.c_float if vt == 4 else ctypes.c_double)).contents.value if vt in (2, 3, 22): return ctypes.cast(en, P(ctypes.c_int16 if vt == 2 else ctypes.c_int32)).contents.value if vt in (17, 18, 19, 23): return v.valor & {17: 0xFF, 18: 0xFFFF}.get(vt, 0xFFFFFFFF) if vt == 27 or vt & 0x2000: o, sa = _ole(), V(v.valor) lo, hi, tipo, datos = ctypes.c_long(), ctypes.c_long(), ctypes.c_uint16(), V() o.SafeArrayGetLBound(sa, 1, ctypes.byref(lo)) o.SafeArrayGetUBound(sa, 1, ctypes.byref(hi)) o.SafeArrayGetVartype(sa, ctypes.byref(tipo)) n = hi.value - lo.value + 1 if n <= 0 or o.SafeArrayAccessData(sa, ctypes.byref(datos)) != 0: return [] try: if tipo.value == 12: return [_de_variant(x) for x in (VARIANT * n).from_address(datos.value)] if tipo.value == 8: return [ctypes.wstring_at(x) if x else "" for x in (V * n).from_address(datos.value)] c = {2: ctypes.c_int16, 3: ctypes.c_int32, 4: ctypes.c_float, 5: ctypes.c_double, 17: ctypes.c_ubyte, 18: ctypes.c_uint16, 19: ctypes.c_uint32}.get(tipo.value) return list((c * n).from_address(datos.value)) if c else [] finally: o.SafeArrayUnaccessData(sa) return None class Retorno: """El objeto al que el decodificador avisa cuando termina de leer o de revelar un cuadro. Los avisos llegan desde sus hilos y en el orden que sea: aquí solo se apuntan, y quien escribe los recoge por orden.""" def __init__(self, atributos): self.atributos = atributos self.listos, self.aviso, self.error = {}, threading.Condition(), "" nada = ctypes.WINFUNCTYPE(None, V, V, HR) firmas = [ctypes.WINFUNCTYPE(HR, V, P(GUID), P(V)), ctypes.WINFUNCTYPE(ctypes.c_ulong, V), ctypes.WINFUNCTYPE(ctypes.c_ulong, V), ctypes.WINFUNCTYPE(None, V, V, HR, V), # ReadComplete(trabajo, resultado, cuadro) ctypes.WINFUNCTYPE(None, V, V, HR, V), # ReadAudioComplete nada, # DecodeComplete ctypes.WINFUNCTYPE(None, V, V, HR, V), # ProcessComplete(trabajo, resultado, imagen) ctypes.WINFUNCTYPE(None, V, V, ctypes.c_float), # TrimProgress nada, # TrimComplete ctypes.WINFUNCTYPE(None, V, V, V, ctypes.c_uint, V), # SidecarMetadataParseWarning ctypes.WINFUNCTYPE(None, V, V, V, ctypes.c_uint, V), # SidecarMetadataParseError nada] # PreparePipelineComplete hace = {0: self._interfaz, 1: lambda yo: 2, 2: lambda yo: 1, 3: self._leido, 6: self._revelado} self._funciones = [f(hace.get(i, lambda *a: None)) for i, f in enumerate(firmas)] # vivas mientras viva el objeto self._tabla = (V * len(firmas))(*[ctypes.cast(f, V) for f in self._funciones]) self._objeto = ctypes.pointer(self._tabla) # un objeto COM es un puntero a su tabla de métodos self.puntero = ctypes.cast(ctypes.pointer(self._objeto), V) def _interfaz(self, yo, iid, donde): pedida = bytes(iid.contents) if pedida in (bytes(GUID(IID["unknown"])), bytes(GUID(IID["retorno"]))): donde[0] = yo return 0 donde[0] = None return E_NOINTERFACE def fallo(self, texto): with self.aviso: self.error = self.error or texto self.aviso.notify_all() def _leido(self, yo, trabajo, hr, cuadro): try: if hr != 0: return self.fallo(f"no se pudo leer un cuadro del clip (código 0x{hr & 0xFFFFFFFF:08X})") c = _como(cuadro, "cuadro") try: indice = ctypes.c_uint64() _m(c, 3, HR, P(ctypes.c_uint64))(ctypes.byref(indice)) _bien(_m(c, 11, HR, ctypes.c_uint)(RGB_16), "el decodificador no da 16 bits por canal") revelar = V() _bien(_m(c, 14, HR, V, V, P(V))(self.atributos, None, ctypes.byref(revelar)), "no se pudo preparar el revelado") _m(revelar, 5, HR, V)(V(indice.value + 1)) # el índice viaja con el trabajo (+1: el 0 sería «nada») hr2 = _m(revelar, 3, HR)() _soltar(revelar) _bien(hr2, "no se pudo lanzar el revelado") finally: _soltar(c) except Exception as ex: # noqa self.fallo(str(ex)) def _revelado(self, yo, trabajo, hr, imagen): try: if hr != 0: return self.fallo(f"no se pudo revelar un cuadro (código 0x{hr & 0xFFFFFFFF:08X})") dato = V() _m(trabajo, 6, HR, P(V))(ctypes.byref(dato)) img = _como(imagen, "imagen") # queda retenida hasta que se escriba ancho, alto, tam, tipo, donde = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), V() _m(img, 3, HR, P(ctypes.c_uint))(ctypes.byref(ancho)) _m(img, 4, HR, P(ctypes.c_uint))(ctypes.byref(alto)) _m(img, 8, HR, P(ctypes.c_uint))(ctypes.byref(tam)) _m(img, 6, HR, P(ctypes.c_uint))(ctypes.byref(tipo)) _m(img, 5, HR, P(V))(ctypes.byref(donde)) if tipo.value != EN_MEMORIA or not donde.value or tam.value != ancho.value * alto.value * 6: _soltar(img) return self.fallo("el decodificador devolvió un cuadro en un formato que no se esperaba") with self.aviso: self.listos[(dato.value or 1) - 1] = (img, donde.value, tam.value, ancho.value, alto.value) self.aviso.notify_all() except Exception as ex: # noqa self.fallo(str(ex)) _DLL = {} def _cargar(carpeta): carpeta = carpeta or buscar() if not carpeta or not os.path.isfile(os.path.join(carpeta, NOMBRE)): raise OSError("no está el decodificador de Blackmagic RAW: se instala con DaVinci Resolve (vale el gratuito) " "o con Blackmagic RAW Player") clave = os.path.normcase(carpeta) if clave not in _DLL: os.add_dll_directory(carpeta) # sus compañeras (DecoderCUDA, InstructionSetServices…) viven al lado dll = ctypes.WinDLL(os.path.join(carpeta, NOMBRE)) dll.CreateBlackmagicRawFactoryInstance.restype = V dll.CreateBlackmagicRawFactoryInstance.argtypes = [] _DLL[clave] = dll return _DLL[clave], carpeta class Clip: """Un .braw abierto con el decodificador de Blackmagic. Solo lee: no guarda nada junto al clip.""" def __init__(self, ruta, carpeta="", hilos=0): self.ruta = ruta self.fabrica = self.codec = self.config = self.clip = self.atributos = None self.retorno = None if not os.path.isfile(ruta): raise OSError("el clip ya no está en esa ruta (¿se movió o se desconectó la unidad?)") dll, self.carpeta = _cargar(carpeta) cruda = V(dll.CreateBlackmagicRawFactoryInstance()) if not cruda: raise OSError("el decodificador de Blackmagic RAW no arrancó") try: self.fabrica = _como(cruda, "fabrica") finally: _soltar(cruda) crudo = V() _bien(_m(self.fabrica, 3, HR, P(V))(ctypes.byref(crudo)), "el decodificador no pudo crear su lector") try: self.codec = _como(crudo, "codec") self.config = _como(crudo, "config") finally: _soltar(crudo) if hilos: _m(self.config, 6, HR, ctypes.c_uint)(int(hilos)) nombre = V(_ole().SysAllocString(os.path.abspath(ruta))) crudo = V() hr = _m(self.codec, 3, HR, V, P(V))(nombre, ctypes.byref(crudo)) _ole().SysFreeString(nombre) if hr != 0 or not crudo: raise OSError(f"el decodificador no pudo abrir el clip (código 0x{hr & 0xFFFFFFFF:08X}): puede estar dañado, a medio copiar " "o ser de una cámara más nueva que el decodificador instalado") try: self.clip = _como(crudo, "clip") finally: _soltar(crudo) ancho, alto, fps, n = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_float(), ctypes.c_uint64() _m(self.clip, 3, HR, P(ctypes.c_uint))(ctypes.byref(ancho)) _m(self.clip, 4, HR, P(ctypes.c_uint))(ctypes.byref(alto)) _m(self.clip, 5, HR, P(ctypes.c_float))(ctypes.byref(fps)) _m(self.clip, 6, HR, P(ctypes.c_uint64))(ctypes.byref(n)) self.ancho, self.alto, self.fps, self.cuadros = ancho.value, alto.value, round(fps.value, 3), n.value self.tc = _texto(self.clip, 7, ctypes.c_uint64(0)) self.camara = _texto(self.clip, 11) crudo = V() _bien(_m(self.clip, 12, HR, P(V))(ctypes.byref(crudo)), "no se pudieron leer los ajustes de color del clip") try: self.atributos = _como(crudo, "atributos") finally: _soltar(crudo) self.gamma, self.gamut, self.generacion = self.atributo(A_GAMMA) or "", self.atributo(A_GAMUT) or "", self.atributo(A_GEN) or 0 self.de_video = self.gamma in CURVAS_DE_VIDEO # ---- color ---- def atributo(self, cual): v = VARIANT() if _m(self.atributos, 3, HR, ctypes.c_uint, P(VARIANT))(cual, ctypes.byref(v)) != 0: return None valor = _de_variant(v) _ole().VariantClear(ctypes.byref(v)) return valor def opciones(self, cual): """Los valores que admite un ajuste. Se pregunta primero cuántos son: el decodificador escribe la lista entera en la memoria que se le dé, quepa o no.""" n, fijo = ctypes.c_uint(), ctypes.c_long() f = _m(self.atributos, 6, HR, ctypes.c_uint, V, P(ctypes.c_uint), P(ctypes.c_long)) if f(cual, None, ctypes.byref(n), ctypes.byref(fijo)) != 0 or not n.value: return [] lista = (VARIANT * n.value)() if f(cual, ctypes.cast(lista, V), ctypes.byref(n), ctypes.byref(fijo)) != 0: return [] out = [_de_variant(x) for x in lista[:n.value]] for x in lista: _ole().VariantClear(ctypes.byref(x)) return out def _poner(self, cual, texto): v = VARIANT() v.vt, v.valor = 8, _ole().SysAllocString(texto) hr = _m(self.atributos, 4, HR, ctypes.c_uint, P(VARIANT))(cual, ctypes.byref(v)) _ole().SysFreeString(V(v.valor)) return hr == 0 def poner_color(self, color): """«log»: Blackmagic Film, toda la latitud para quien vaya a etalonar. «rec709»: como se vio al grabar; un clip grabado en Film (plano) pasa a Extended Video, que es su versión para ver. Devuelve una frase que dice lo que se hizo.""" antes = self.gamma quiere = FILM if color == "log" else (antes if antes in CURVAS_DE_VIDEO else VIDEO_AMPLIO) if quiere != antes: if quiere not in self.opciones(A_GAMMA) or not self._poner(A_GAMMA, quiere): raise OSError(f"este clip no admite la curva «{quiere}»") if self.gamut != GAMUT_BMD and GAMUT_BMD in self.opciones(A_GAMUT): self._poner(A_GAMUT, GAMUT_BMD) self.gamma, self.gamut = self.atributo(A_GAMMA) or quiere, self.atributo(A_GAMUT) or self.gamut self.de_video = self.gamma in CURVAS_DE_VIDEO return f"curva {self.gamma}" + (f" (el clip venía en {antes})" if antes and antes != self.gamma else "") # ---- cuadros ---- def cuadros_en_orden(self, desde=0, hasta=None): """Genera (índice, dirección de memoria, bytes) de cada cuadro revelado, por orden. Se piden varios a la vez —el decodificador reparte el trabajo entre los núcleos— pero nunca más de los que caben en MEMORIA_EN_VUELO: un cuadro de 4.6K a 16 bits son 72 MB.""" if self.retorno is None: self.retorno = Retorno(self.atributos) _bien(_m(self.codec, 7, HR, V)(self.retorno.puntero), "el decodificador no aceptó el aviso de fin de cuadro") r = self.retorno hasta = self.cuadros if hasta is None else min(hasta, self.cuadros) ventana = max(2, min(CUADROS_EN_VUELO, MEMORIA_EN_VUELO // max(1, self.ancho * self.alto * 6))) pedido = escrito = desde while escrito < hasta: while pedido < hasta and pedido - escrito < ventana: leer = V() _bien(_m(self.clip, 18, HR, ctypes.c_uint64, P(V))(pedido, ctypes.byref(leer)), "no se pudo pedir un cuadro") hr = _m(leer, 3, HR)() _soltar(leer) _bien(hr, "no se pudo pedir un cuadro") pedido += 1 espera = time.time() with r.aviso: while escrito not in r.listos and not r.error: r.aviso.wait(5) if time.time() - espera > 180: r.error = "el decodificador dejó de responder (tres minutos sin entregar un cuadro)" if r.error: raise OSError(r.error) img, donde, tam, ancho, alto = r.listos.pop(escrito) try: if (ancho, alto) != (self.ancho, self.alto): raise OSError(f"un cuadro salió de {ancho}×{alto} y el clip es de {self.ancho}×{self.alto}") yield escrito, donde, tam finally: _soltar(img) escrito += 1 def cerrar(self): """Espera a lo que esté a medias, suelta los cuadros que quedaran sin escribir y cierra el clip.""" if self.codec: try: _m(self.codec, 10, HR)() # FlushJobs: no vuelve hasta que no queda trabajo en marcha except OSError: pass if self.retorno: with self.retorno.aviso: for img, *_ in self.retorno.listos.values(): _soltar(img) self.retorno.listos.clear() for o in ("atributos", "clip", "config", "codec", "fabrica"): try: _soltar(getattr(self, o)) except OSError: pass setattr(self, o, None) def datos(self): return {"ancho": self.ancho, "alto": self.alto, "fps": self.fps, "cuadros": self.cuadros, "dur": round(self.cuadros / self.fps, 3) if self.fps else 0, "tc": self.tc, "camara": self.camara, "gamma": self.gamma, "gamut": self.gamut, "gen": self.generacion} # ========================================================================== # Lo que hace el proceso aparte # ========================================================================== def emitir(**kw): print(json.dumps(kw, ensure_ascii=False), flush=True) def fraccion(fps): """Los fps como los quiere ffmpeg: 29,97 es 30000/1001, no 29.97.""" for n in (24000, 30000, 48000, 60000, 120000): if abs(fps - n / 1001) < 0.005: return f"{n}/1001" return f"{round(fps, 3):g}" def sonda(carpeta=""): """¿Se puede usar el decodificador? Lo carga y le pide las interfaces que se van a usar.""" try: dll, carpeta = _cargar(carpeta) cruda = V(dll.CreateBlackmagicRawFactoryInstance()) if not cruda: raise OSError("el decodificador de Blackmagic RAW no arrancó") fabrica = codec = config = crudo = None try: fabrica = _como(cruda, "fabrica") crudo = V() _bien(_m(fabrica, 3, HR, P(V))(ctypes.byref(crudo)), "el decodificador no pudo crear su lector") codec, config = _como(crudo, "codec"), _como(crudo, "config") hilos = ctypes.c_uint() _m(config, 7, HR, P(ctypes.c_uint))(ctypes.byref(hilos)) return {"ok": True, "carpeta": carpeta, "camaras": _texto(config, 13), "hilos": hilos.value} finally: for o in (config, codec, crudo, fabrica, cruda): _soltar(o) except Exception as ex: # noqa return {"ok": False, "carpeta": carpeta or "", "motivo": str(ex)} def info(ruta, carpeta=""): clip = None try: clip = Clip(ruta, carpeta) return dict(clip.datos(), ruta=ruta, ok=True) except Exception as ex: # noqa return {"ruta": ruta, "ok": False, "error": str(ex)} finally: if clip: clip.cerrar() def _cola(archivo, n=1500): try: archivo.seek(0) return archivo.read().decode("utf-8", "replace").strip()[-n:] except (OSError, ValueError): return "" def convertir(t): """Un clip: lo abre, le pone la curva, y va pasando los cuadros a ffmpeg. `t` trae la ruta del clip, la de salida, el ffmpeg que hay que usar y los argumentos del codificador, que arma el programa (aquí no se decide ni la calidad ni el formato).""" clip = ff = None errores = tempfile.TemporaryFile() tmp, bandera = t["tmp"], t.get("cancelar") or "" try: clip = Clip(t["src"], t.get("sdk") or "", t.get("hilos") or 0) nota = clip.poner_color(t.get("color") or "rec709") if not clip.cuadros or not clip.ancho or not clip.alto or not clip.fps: raise OSError("el clip no tiene imagen") emitir(msg=f"{clip.ancho}×{clip.alto} · {clip.fps:g} fps · {clip.cuadros} cuadros · {nota}", **clip.datos()) # de RGB entero a YCbCr de vídeo con la matriz de Rec.709. Las marcas de color van en la imagen y # no en el codificador: si no, ffmpeg las pisa con las del tubo, que no trae ninguna. A un archivo # en Film no se le marca curva ni primarios: no son los de Rec.709 y un reproductor los creería. marcas = "colorspace=bt709:range=tv" + (":color_primaries=bt709:color_trc=bt709" if clip.de_video else "") vf = f"scale=in_range=full:out_range=limited:out_color_matrix=bt709:flags=bicubic+accurate_rnd,format={t['pix']},setparams={marcas}" cmd = [t["ffmpeg"], "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb48le", "-video_size", f"{clip.ancho}x{clip.alto}", "-framerate", fraccion(clip.fps), "-i", "pipe:0", "-i", t["src"], "-map", "0:v:0", "-map", "1:a?", "-vf", vf] + list(t["video"]) cmd += ["-map_metadata", "1", "-metadata", f"comment=Blackmagic RAW de {clip.camara or 'cámara Blackmagic'} · {nota} · convertido con OrganiLAB"] if clip.tc: cmd += ["-timecode", clip.tc] cmd += list(t.get("contenedor") or []) + list(t.get("audio") or []) + [tmp] ff = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errores, creationflags=SIN_VENTANA) hechos, inicio, dicho, mirado = 0, time.time(), 0.0, 0.0 for _, donde, tam in clip.cuadros_en_orden(): try: ff.stdin.write((ctypes.c_ubyte * tam).from_address(donde)) except OSError: ff.wait() raise OSError("ffmpeg se cerró a mitad del clip: " + (_cola(errores) or f"código {ff.returncode}")) hechos += 1 ahora = time.time() if bandera and ahora - mirado >= 0.5: mirado = ahora if os.path.exists(bandera): raise Cancelado() if ahora - dicho >= 1: dicho = ahora emitir(pct=round(min(99.0, hechos / clip.cuadros * 100), 1), cuadros=hechos, vel=round(hechos / max(0.1, ahora - inicio), 1)) ff.stdin.close() codigo = ff.wait() if codigo != 0 or not os.path.exists(tmp): raise OSError(_cola(errores) or f"ffmpeg terminó con el código {codigo}") errores.close() emitir(estado="listo", pct=100, segundos=round(time.time() - inicio, 1), **clip.datos()) return 0 except Cancelado: estado, log = "cancelado", "" except Exception as ex: # noqa estado, log = "error", str(ex) finally: if clip: try: clip.cerrar() except Exception: # noqa pass # solo se llega aquí si no acabó bien: ni ffmpeg vivo ni un archivo a medias if ff: try: ff.stdin.close() except OSError: pass try: ff.kill() ff.wait(30) except (OSError, subprocess.TimeoutExpired): pass for _ in range(20): try: if os.path.exists(tmp): os.remove(tmp) break except OSError: time.sleep(0.25) errores.close() emitir(estado=estado, log=log) if log else emitir(estado=estado) return 1 def main(argv=None): argv = list(sys.argv[1:] if argv is None else argv) try: sys.stdout.reconfigure(encoding="utf-8", errors="replace") # el tubo, sin esto, sale en la página de códigos de Windows except Exception: # noqa pass try: import faulthandler faulthandler.enable() # si el decodificador se cae, que quede dicho dónde except Exception: # noqa pass orden = argv[0] if argv else "" if orden == "sonda": emitir(**sonda(argv[1] if len(argv) > 1 else "")) return 0 if orden == "info" and len(argv) > 1: carpeta = "" if "--sdk" in argv: carpeta = argv[argv.index("--sdk") + 1] del argv[argv.index("--sdk"):argv.index("--sdk") + 2] for ruta in argv[1:]: emitir(**info(ruta, carpeta)) return 0 if orden == "convertir" and len(argv) > 1: with open(argv[1], "r", encoding="utf-8") as fh: return convertir(json.load(fh)) emitir(msg="uso: braw.py sonda | info clip.braw [...] | convertir trabajo.json") return 2 if __name__ == "__main__": sys.exit(main())