3.4.0: los BRAW se convierten sin Resolve, y lo que no compensa no se reconvierte

Munka no podía reconvertir archivos que antes sí. Auditado sobre la carpeta de Andrea Galeano:

- Los BRAW nunca habían salido: 87 clips (496 GB), tres tandas por el guion de Resolve y ni un
  clip convertido. Ahora los lee el decodificador de Blackmagic que instala Resolve (braw.py,
  en un proceso aparte), sin abrir Resolve, y ffmpeg los codifica como cualquier otro clip.
  Probado con clips del NAS: 15,6 GB → 394 MB con sus 7.004 cuadros, VMAF 96,0 contra el BRAW
  revelado sin pérdida. Las conversiones de BRAW que fallaron por Resolve pasan solas al lector.
- Lo que «antes podía» eran reconversiones de lo ya convertido: ahorraban un 5 % con pérdida.
  Siguen sin ofrecerse, y lo que ya viene comprimido de cámara solo se ofrece si compensa.
- La tarjeta codifica con «uhq»: entre un 19 y un 55 % menos de peso con la misma calidad
  medida, siempre H.265 de 10 bits.
- Una conversión que no aligera (más del 80 % del clip, o reconversión de una conversión) no
  reemplaza al original: queda «no compensa» y lo que salió va a _Para borrar\descartados.

De paso: los MP4 guardan las etiquetas de la cámara; un intento que no pasó la verificación se
puede reintentar; un H.264 de más de 4.096 px de lado va por CPU; el motivo de un fallo se lee
entero; el guion de Resolve llega con tildes y borra su ficha.

Pruebas: prueba_orden.py 33 (una con un BRAW de verdad), probar.py 19, prueba_funcional.py 30,
prueba_ventana.py 18 desde el código y 17 con el paquete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-05 13:24:34 -05:00
padre 477857db45
commit 0819f2da89
Se han modificado 12 ficheros con 1767 adiciones y 147 borrados

643
braw.py Archivo normal
Ver fichero

@@ -0,0 +1,643 @@
# -*- 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())