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>
748 líneas
35 KiB
Python
748 líneas
35 KiB
Python
# -*- 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())
|