Files
labstream-organizador/braw.py
Labstream d38c51bd73 Convertir: lista por carpetas con cuánto falta, «comparar» con nota de calidad, y un botón principal según el momento
Munka eligió tres de seis propuestas dibujadas en un lienzo (quedan el horario nocturno, el resumen
al acabar y marcar con criterio):
- La lista va por carpetas (la ruta una sola vez), el clip que se convierte lleva su barra y lo que le
  queda, «primero este» adelanta uno, y los excluidos salen en grupos con su porqué. Antes y después en
  una barra. La cola mide su ritmo y dice cuánto falta y a qué hora acaba (eta en /api/optimizar/cola).
- «Comparar»: el mismo cuadro del original y del convertido en tres instantes, con lupa, y «Medir la
  calidad» (VMAF) que se guarda en el trabajo; braw.py cuadro y braw.py calidad revelan el BRAW con la
  misma curva. Medido: 97,8 en 14 s con un BRAW de 16,5 GB.
- Un solo botón grande según el momento: buscar, convertir (con «reemplazar solo cuando estén
  verificados», optimizar.auto_reemplazar), la tanda con Detener, reanudar, o reemplazar lo verificado.

Pruebas: probar 19, prueba_funcional 31 (nueva: comparar), prueba_ventana 18.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 11:43:05 -05:00

748 líneas
35 KiB
Python
Original Blame Histórico

Este archivo contiene caracteres Unicode ambiguos

Este archivo contiene caracteres Unicode que pueden confundirse con otros caracteres. Si crees que esto es intencional, puedes ignorar esta advertencia. Usa el botón de Escape para revelarlos.

# -*- 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 _tubo_a_ffmpeg(clip, cmd, desde, hasta, errores):
"""Le pasa a ffmpeg los cuadros [desde, hasta) por el tubo y espera a que acabe; devuelve el código."""
ff = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errores, creationflags=SIN_VENTANA)
try:
for _, donde, tam in clip.cuadros_en_orden(desde, hasta):
try:
ff.stdin.write((ctypes.c_ubyte * tam).from_address(donde))
except OSError:
break # ffmpeg ya tiene lo que necesitaba (-frames:v) o se cayó
try:
ff.stdin.close()
except OSError:
pass
return ff.wait()
finally:
if ff.poll() is None:
ff.kill()
ff.wait(30)
def cuadro(t):
"""Un solo cuadro del clip, revelado con la misma curva que la conversión y guardado como JPEG: para
poner el original al lado del convertido en «comparar». `t`: src, segundo, salida, ffmpeg, color, ancho."""
clip = None
errores = tempfile.TemporaryFile()
try:
clip = Clip(t["src"], t.get("sdk") or "", t.get("hilos") or 0)
clip.poner_color(t.get("color") or "rec709")
if not clip.cuadros or not clip.fps:
raise OSError("el clip no tiene imagen")
idx = max(0, min(clip.cuadros - 1, int(round(float(t.get("segundo") or 0) * clip.fps))))
# misma matriz que la conversión (Rec.709) y rango completo, que es lo que espera un JPEG
vf = "scale=in_range=full:out_range=full:out_color_matrix=bt709:flags=bicubic+accurate_rnd" + (f",scale={int(t['ancho'])}:-2:flags=bicubic" if t.get("ancho") else "") + ",format=yuvj420p"
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",
"-vf", vf, "-frames:v", "1", "-q:v", "2", "-f", "image2", t["salida"]]
codigo = _tubo_a_ffmpeg(clip, cmd, idx, idx + 1, errores)
if codigo != 0 or not os.path.exists(t["salida"]):
raise OSError(_cola(errores) or f"ffmpeg terminó con el código {codigo}")
emitir(estado="listo", cuadro=idx, segundo=round(idx / clip.fps, 3), **clip.datos())
return 0
except Exception as ex: # noqa
emitir(estado="error", log=str(ex))
return 1
finally:
if clip:
try:
clip.cerrar()
except Exception: # noqa
pass
errores.close()
def calidad(t):
"""Nota de calidad (VMAF, de 0 a 100) del convertido contra el BRAW revelado sin pérdida, sobre un tramo de
cuadros [desde, desde + cuadros). Los dos se miran a la misma anchura (`ancho`, 1920 por defecto: el modelo
de VMAF está pensado para 1080p). `t`: src, conv, desde, cuadros, ffmpeg, color, log (archivo JSON que
escribe libvmaf, en la carpeta de trabajo `cwd`), hilos."""
clip = None
errores = tempfile.TemporaryFile()
try:
clip = Clip(t["src"], t.get("sdk") or "", t.get("hilos") or 0)
clip.poner_color(t.get("color") or "rec709")
if not clip.cuadros or not clip.fps:
raise OSError("el clip no tiene imagen")
desde = max(0, min(clip.cuadros - 1, int(t.get("desde") or 0)))
hasta = min(clip.cuadros, desde + max(1, int(t.get("cuadros") or 1)))
ancho = int(t.get("ancho") or 1920)
hilos = int(t.get("vmaf_hilos") or 8)
# la referencia pasa por la misma conversión de color que la conversión (RGB entero → YCbCr Rec.709 de
# vídeo); así lo único que separa a los dos es lo que perdió el codificador
ref = f"[0:v]scale=in_range=full:out_range=limited:out_color_matrix=bt709:flags=bicubic+accurate_rnd,format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r]"
dis = f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d]"
fc = f"{ref};{dis};[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={t['log']}"
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",
"-ss", f"{desde / clip.fps:.6f}", "-i", t["conv"], "-filter_complex", fc, "-frames:v", str(hasta - desde), "-an", "-f", "null", "-"]
antes = os.getcwd()
os.chdir(t.get("cwd") or antes)
try:
codigo = _tubo_a_ffmpeg(clip, cmd, desde, hasta, errores)
finally:
os.chdir(antes)
log = os.path.join(t.get("cwd") or antes, t["log"])
if codigo != 0 or not os.path.exists(log):
raise OSError(_cola(errores) or f"ffmpeg terminó con el código {codigo}")
with open(log, "r", encoding="utf-8") as fh:
j = json.load(fh)
v = (j.get("pooled_metrics") or {}).get("vmaf") or {}
emitir(estado="listo", vmaf=round(float(v.get("mean") or 0), 1), minimo=round(float(v.get("min") or 0), 1),
medidos=len(j.get("frames") or []), desde=desde, **clip.datos())
return 0
except Exception as ex: # noqa
emitir(estado="error", log=str(ex))
return 1
finally:
if clip:
try:
clip.cerrar()
except Exception: # noqa
pass
errores.close()
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 in ("convertir", "cuadro", "calidad") and len(argv) > 1:
with open(argv[1], "r", encoding="utf-8") as fh:
return {"convertir": convertir, "cuadro": cuadro, "calidad": calidad}[orden](json.load(fh))
emitir(msg="uso: braw.py sonda | info clip.braw [...] | convertir trabajo.json | cuadro ficha.json | calidad ficha.json")
return 2
if __name__ == "__main__":
sys.exit(main())