diff --git a/README.md b/README.md index 5d504bf..4c7017b 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,15 @@ ventana.py ← la ventana de Windows del programa (marco Win32 + Web instalar.py ← lo instala en su sitio (carpeta de programas del usuario), con accesos en Inicio y Escritorio y su línea en Aplicaciones arranque.py ← cómo arranca el paquete: su .exe es el Python firmado, y esto le dice qué hacer reglas.py ← cómo reconoce cámaras, carpetas, basura y las plantillas de estructura -resolve_bridge.py ← convierte BRAW a H.265 10 bits usando DaVinci Resolve Studio +braw.py ← lee los BRAW con el decodificador de Blackmagic (el que instala Resolve), sin abrir Resolve +resolve_bridge.py ← la vía antigua para los BRAW, por el guion de Resolve Studio; solo se usa si falta el decodificador actualizador.py ← busca versión nueva en Labstream OS (solo si se le pide) y la instala; firma.py la verifica static/index.html ← la interfaz (Carpeta · Revisar y ordenar · Mapa · Convertidor · Respaldar · Historial · Ajustes · Manual) static/manrope.woff2 ← la tipografía, para no pedirle nada a nadie fuera de este equipo WebView2Loader.dll ← la entrada a WebView2 (Microsoft, firmada); lo único que viaja y no es de Python datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl empaquetar.py ← arma el paquete: dist\OrganizadorLabstream\ con OrganizadorLabstream.exe y los dos zips -probar.py ← 19 pruebas sin abrir nada · prueba_orden.py: el motor de orden, caso a caso · prueba_funcional.py: las 26 funciones · prueba_ventana.py: la ventana de verdad +probar.py ← 19 pruebas sin abrir nada · prueba_orden.py: el motor de orden y el convertidor, caso a caso · prueba_funcional.py: las 30 funciones · prueba_ventana.py: la ventana de verdad Iniciar.bat ← desde el código, con consola (para ver lo que imprime) ``` @@ -115,22 +116,31 @@ Reglas que hacen que funcione: |---|---|---| | Sony XAVC-I 4K 60p 4:2:2 10 bits, 228 Mb/s | ffmpeg + NVENC (RTX 5080) | 671 MB → 24,6 MB (8,4 Mb/s) en 30 s, HEVC Main10 | | Sony XAVC-I 4K 30p, 286 Mb/s | ffmpeg + NVENC | 286 → 9,6 Mb/s, 1,5× tiempo real | -| BRAW URSA 4.6K 30p, 567 Mb/s | DaVinci Resolve Studio (H.265 NVIDIA, Main10) | ≈ 31 Mb/s → unas 18 veces menos | +| BRAW URSA 4.6K 30p, 560 Mb/s | decodificador de Blackmagic + ffmpeg NVENC («uhq», Main10) | 9–14 Mb/s → entre 40 y 60 veces menos, VMAF 96–97,6 | +| BRAW Pocket 4K vertical 30p, 408 Mb/s | lo mismo | ≈ 30 Mb/s → unas 14 veces menos | - **Perfiles:** ahorro / equilibrado / máxima. El bitrate objetivo sale del tamaño del cuadro y los fps (≈ 0,085 bits por píxel y cuadro en «equilibrado»: 4K30 → 21 Mb/s, 4.6K30 → 31 Mb/s). - **Croma:** 4:2:0 10 bits (Main10, se reproduce en todo) o 4:2:2 10 bits (Rext; la RTX 5080 lo codifica y Resolve lo lee, pero muchos reproductores no). -- **BRAW:** pasa por Resolve porque ffmpeg no lo decodifica. El puente abre (o crea) el proyecto - «Labstream Archivo» con gestión de color a Rec.709 Gamma 2.4, importa los clips, monta una línea de - tiempo por resolución y renderiza «clips individuales». Si Resolve no deja cambiar de proyecto, trabaja en - un bin dentro del proyecto abierto y aplica la LUT Gen 5 Film → Video a cada clip. **Resolve debe estar - abierto con un proyecto, no en el Gestor de proyectos, y sin diálogos abiertos.** +- **BRAW:** ffmpeg no los lee: los lee el decodificador oficial de Blackmagic (`BlackmagicRawAPI.dll`), el + que instalan DaVinci Resolve —también el gratuito— y Blackmagic RAW Player. No hace falta abrir Resolve, y + si está abierto no se le toca nada. `braw.py` corre aparte, para que un fallo del decodificador no tumbe el + programa: abre el clip, revela cada cuadro a 16 bits por canal y se lo pasa a ffmpeg por un tubo, y desde + ahí se codifica como cualquier otro clip. «BRAW en Rec.709» respeta la curva con que se grabó si ya es para + ver (Video, Extended Video) y pasa a Extended Video lo grabado en Film; «Log de cámara» saca Blackmagic + Film, para etalonar. El tamaño de imagen lo da el decodificador: ffprobe lee 4128×2176 donde el clip es + 2160×4096 en vertical. Se conservan el código de tiempo, el audio y los metadatos de la cámara. Sin + decodificador se intenta por la vía antigua (`resolve_bridge.py`: Resolve Studio abierto en un proyecto). - **Flujo:** Analizar (ffprobe) → Ver candidatos → Encolar → Verificar (duración, códec, 10 bits) → - Reemplazar (el original y su `.XML` de Sony van a `_Para borrar/originales`). La salida conserva la fecha - de modificación del original y se llama `nombre_10bit.mp4` junto al original. -- Se excluyen solos: lo que ya está en HEVC/AV1 eficiente (los iPhone en HDR, por ejemplo), lo que tiene - canal alfa, los exportados/masters (salvo que se marque «incluir exportados») y lo más reciente que N meses. + Reemplazar (el original y su `.XML` de Sony, o el `.sidecar` de un BRAW, van a `_Para borrar/originales`). + La salida conserva la fecha de modificación del original y se llama `nombre_10bit.mp4` junto al original. + Una conversión que pesa más del 80 % del clip, o que es la reconversión de algo ya convertido, no se + ofrece para reemplazar: queda «no compensa» y lo que salió va a `_Para borrar/descartados`. +- Se excluyen solos: lo que ya convirtió el programa (lleva su sufijo y está en el códec que el sufijo dice), + lo que ya viene comprimido y no compensa tocar (H.265, AV1 o VP9 por debajo del doble de la tasa + objetivo; H.264 por debajo de la tasa objetivo), lo que tiene canal alfa, los exportados/masters (salvo + que se marque «incluir exportados») y lo más reciente que N meses. Estimación con los inventarios actuales: los 1,9 TB de BRAW de Danney y David Reyes quedarían en unos 110 GB; los intermedios ProRes/XAVC de más de 1 GB (≈ 1 TB entre los dos) en menos de 100 GB. @@ -179,8 +189,8 @@ muestra en el mapa para decidirlo. ## 6. Uso (interfaz 1.6) 1. Se abre desde el menú Inicio o el Escritorio (o con doble clic en `OrganizadorLabstream.exe`, en la carpeta - del programa). El paquete trae Python y ffmpeg dentro; para BRAW hace falta DaVinci Resolve Studio abierto - con scripting externo en *Local*. Desde el código: `py -3.12 organizador.py`, con Python 3.10+ y + del programa). El paquete trae Python y ffmpeg dentro; para BRAW hace falta tener instalado DaVinci Resolve + (vale el gratuito, y no hay que abrirlo: el programa usa su decodificador). Desde el código: `py -3.12 organizador.py`, con Python 3.10+ y ffmpeg/ffprobe en el PATH. 2. Suelta la carpeta del cliente en la barra lateral (o pega la ruta). Se registra y se escanea sola. 3. La vista **Carpeta** es el recorrido: cinco pasos con estado (hecho, en curso, listo, pendiente, bloqueado) @@ -870,7 +880,50 @@ Pruebas: `prueba_orden.py` 24 casos (5 nuevos: pieza, bandeja, tarjeta con una c que rechazar, versiones con ffprobe simulado, y la pasada automática con sus frenos); `prueba_funcional.py` 29 de 29 (3 nuevas, por la interfaz de verdad del servidor); `probar.py` 19 y `prueba_ventana.py` 18. +3.4.0, el convertidor: los BRAW salen, y lo que ya viene comprimido no se toca. Munka avisó de que había +archivos que no podía reconvertir y que antes sí, y de lo que quiere: 10 bits de color, buena calidad y mucho +menos peso. La auditoría, sobre la carpeta de Andrea Galeano el 5-oct-2026: + +- **Los BRAW no habían salido nunca.** 87 clips, 496 GB: tres tandas mandadas al guion de Resolve (14 y 16 + de septiembre y 5 de octubre) y ni un clip convertido; las tres con el mismo mensaje («Resolve está en la + ventana del Gestor de proyectos»), y la cola acumulaba 174 filas de error para 87 clips. Ahora los lee el + decodificador de Blackmagic, sin abrir Resolve (sección 4). Probado con clips de la carpeta: una toma de + 15,6 GB y 233 s queda en 394 MB, 40 veces menos, en 263 s, con sus 7.004 cuadros, sin un error al + decodificarla, con su código de tiempo y su audio; contra el BRAW revelado sin pérdida, VMAF 96,0 (el + cuadro peor, 94,6) sobre 100. Otra de 1,4 GB queda en 23 MB, con VMAF 97,6. Las conversiones de BRAW que + quedaron en espera o fallidas pasan solas al lector nuevo al abrir el programa (una por clip; las de clips + que ya no están en esa ruta, porque la carpeta se reordenó, se quitan) y dicen «pulsa Reintentar». +- **Lo que «antes podía» eran reconversiones.** 43 clips ya convertidos (20 GB) que la 3.2 dejó de ofrecer. + Medidas las 188 conversiones hechas contra su original: los nueve H.265 que se reconvirtieron se quedaron en + el 95 % de su peso, y seis de las ocho que esperaban «Reemplazar» eran copias de una copia, al 85–99 %. Se + siguen sin ofrecer, ahora con mejor criterio: cuenta como conversión lo que lleva el sufijo del convertidor + y además está en el códec que ese sufijo produce («Entrevista_HQ.mp4» en H.264 no la hizo el programa). Lo + que ya viene comprimido de cámara se ofrece solo si compensa: H.265, AV1 o VP9 desde el doble de la tasa + objetivo, H.264 desde la tasa objetivo (un H.264 4.6K de 33 Mb/s queda en la mitad, con nota 96,7). +- **Más ligero con la misma calidad.** La tarjeta codifica con su ajuste «uhq» donde lo admite (H.265 de 10 + bits en 4:2:0; se comprueba al arrancar). Medido contra la referencia sin pérdida con tres clips del estudio + (BRAW de la URSA, BRAW vertical de la Pocket y XAVC-I 4K60 de la Sony): pesa entre un 19 y un 55 % menos que + el ajuste anterior con la misma nota (VMAF −0,4 a +0,9). En «equilibrado» un 4.6K 30p queda en unos + 13 Mb/s; en «máxima calidad», en unos 30. +- **No compensa.** Una conversión que pesa más del 80 % del clip, o que reconvierte una conversión, no se + ofrece para reemplazar: queda «no compensa», lo que salió se aparta a `_Para borrar\descartados` (se + deshace desde Historial) y el clip no se vuelve a ofrecer con la misma calidad. Las que venían verificadas + de antes solo cambian de estado al abrir el programa; lo que salió de ellas se aparta con «Limpiar + terminados» o al volver a convertir el clip, nunca sin que nadie lo pida. +- **La lista de la carpeta** cuenta en «Aligerar» solo lo que de verdad adelgaza: en Galeano, los 87 BRAW. + +De paso: los MP4 convertidos guardan las etiquetas de la cámara (ISO, balance de blancos, obturación, óptica, +rollo y toma), sin arrastrar las marcas de formato del original; 48 de las 188 conversiones las habían +perdido. Una conversión que no pasó la verificación se puede reintentar (su propia salida ya no bloquea la +ruta), y nunca se pisa un archivo que no salió de ese mismo trabajo. Un H.264 de más de 4.096 píxeles de lado +va por CPU: la tarjeta no lo codifica («No capable devices found»). El motivo de un fallo se lee entero en la +cola. El guion de Resolve borra su ficha al acabar y sus mensajes llegan con tildes. + +Pruebas: `prueba_orden.py` 33 casos (9 nuevos del convertidor; uno convierte un BRAW de verdad si se le da +uno en `ORGANILAB_BRAW_PRUEBA`); `prueba_funcional.py` 30 de 30 (una nueva: un BRAW por la interfaz del +servidor); `probar.py` 19 y `prueba_ventana.py` 18. + Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: -queda por probar con un proyecto abierto. +queda por probar con un proyecto abierto. (Desde la 3.4.0 los BRAW ya no pasan por ahí: sección 4.) diff --git a/asistente.py b/asistente.py index 5a11433..c4f5052 100644 --- a/asistente.py +++ b/asistente.py @@ -141,8 +141,8 @@ def pasos(diag, plan_filas): if "camara_raw" in g or "camara_video" in g: h = O.herramientas() aviso = "" - if "camara_raw" in g and not (h.get("resolve") or {}).get("ok"): - aviso = " Los BRAW necesitan DaVinci Resolve Studio abierto en un proyecto." + if "camara_raw" in g and not (h.get("braw") or {}).get("ok"): + aviso = " Para los BRAW hace falta el decodificador de Blackmagic, que se instala con DaVinci Resolve (vale el gratuito)." paso("convertir", "Clips de cámara de proyectos ya entregados", f"Convertir a MP4 H.265 de 10 bits los clips con más de {diag['meses_stock']} meses: pesan ~10 veces menos y conservan margen de color.{aviso}", [("no", "Por ahora no (se puede hacer luego desde la pestaña Optimizar)"), diff --git a/braw.py b/braw.py new file mode 100644 index 0000000..623adad --- /dev/null +++ b/braw.py @@ -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()) diff --git a/empaquetar.py b/empaquetar.py index 9139f06..361d88d 100644 --- a/empaquetar.py +++ b/empaquetar.py @@ -38,7 +38,7 @@ os.chdir(AQUI) VERSION = re.search(r'^VERSION = "([^"]+)"', open("organizador.py", encoding="utf-8").read(), re.M).group(1) print("versión", VERSION) -MODULOS = ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", +MODULOS = ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "braw.py", "firma.py", "ventana.py", "instalar.py") PY = sys.base_prefix # el Python con el que se empaqueta MOTOR = f"python{sys.version_info.major}{sys.version_info.minor}" # python312 diff --git a/manifiesto.json b/manifiesto.json index f05bfae..b526297 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "3.3.0", - "builtAt": "2026-10-04T22:05:28.000Z", - "archivo": "OrganizadorLabstream-portatil-3.3.0.zip", - "size": 195421712, - "sha256": "452fea4d67e9e1fc675813ef766a10c1fea4c9e810391faa3cf49d84144738dd", + "version": "3.4.0", + "builtAt": "2026-10-05T18:21:13.000Z", + "archivo": "OrganizadorLabstream-portatil-3.4.0.zip", + "size": 195441581, + "sha256": "89c757b17eb9bb00140bd77f2ed453ef5187966422670a923835df1ec4f6f796", "app": { - "archivo": "OrganizadorLabstream-app-3.3.0.zip", - "size": 11609133, - "sha256": "b321a8c9b2ef166f8d291c31fe66388abf7675f568b71f5f6f0dff36e6bfceb4", - "url": "/api/herramientas/OrganizadorLabstream-app-3.3.0.zip" + "archivo": "OrganizadorLabstream-app-3.4.0.zip", + "size": 11629002, + "sha256": "217c104229470a481da97359d44e50c122c5ab46ff393ff18f4b8e7586ce68b2", + "url": "/api/herramientas/OrganizadorLabstream-app-3.4.0.zip" }, - "notes": "Lo nuevo entra ya ordenado: «Nueva pieza» crea la carpeta con su número y sus subcarpetas, la bandeja de entrada reparte lo que se deje en _Entrada, «Traer material» copia una tarjeta comprobando cada archivo, «Juntar repetidos» reconoce el mismo clip con dos calidades, y el orden automático (apagado de fábrica) pone al día todas las carpetas cada día.", + "notes": "Los BRAW se convierten sin abrir DaVinci Resolve: los lee el decodificador de Blackmagic. La tarjeta comprime con un ajuste más eficiente (entre un 19 y un 55 % menos de peso con la misma calidad, siempre en H.265 de 10 bits). Ya no se ofrece reconvertir lo ya convertido ni lo que viene muy comprimido de cámara, y una conversión que no aligera no reemplaza al original: lo que salió se aparta a _Para borrar.", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "14cf8c856a36db6c1f5a876b1f2418e5a15fe08e6c911593fbb14b49cf4f42af9e8e26f77fac63629212e2a2a71b2a4089dd40bc2cdf703f5263572a07e59b317294b41462543015d51d8db15121fd9c1a944b6f9efaa9c2cd32d7b171468ca9fd18367bfbed5be5b5f9c676d4db50928c33f1808b8eb5097ce59fce2b6077baee7d8ca05989d303b043d7cb48e1c9c56904df1cffd7ab568d6d398ff21fe89de9c62c89190cfaa29ac814a98c534f4d56b8af75a2fa7281075124d564b41b9a8939aa8594234402d5a0bb08633ce43eaddd68b1ff8c12b53d6d0a496d664e7eed5e069661229dfa69bdcdfaaceff89a7b2a727286622c83d3e856bb3724bff8" + "valor": "c49a951e1f6ec9db9aecf548bfb9362c9b063f8ef1af68d3d89252e6187e65f1338c2b33e100e087ebfcdc4cbf9de1192f1ff51ee3d332003dd5fa3ebeefd17c7100ac726e56da5744f779f5e1278e693d1f7c08577f652020d42280a4338a69429b5b351ac0dc2c62ca25f3a7f431adbf51cb897521b725538eeb1fd6d34f04173c402e41e1827a63bd20d67d76c95d8da69a47232308e52c26a08682b54b3ed95b90c9a1a47b47f3e608a3a6bcd232c62630c0ecc035e1646052cb2c7312946f970a8daa18d1985a2c93cda6c1911810483fd534efb2a8ca04f3bb26689996c14e08437a618a369a5940fd7f700372a6c8d3c83cdd4458656aa0edf5ce4e67" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index 662e7a5..d1d204b 100644 --- a/organizador.py +++ b/organizador.py @@ -28,6 +28,7 @@ import hashlib import sqlite3 import threading import subprocess +import tempfile import datetime import webbrowser from collections import Counter, defaultdict @@ -35,12 +36,13 @@ from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse, parse_qs import reglas as R +import braw import asistente import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "3.3.0" +VERSION = "3.4.0" BASE = os.path.dirname(os.path.abspath(sys.executable if getattr(sys, "frozen", False) else __file__)) # Empaquetado: el programa corre con el motor que viaja en su propia carpeta. OrganizadorLabstream.exe es @@ -118,6 +120,7 @@ CONFIG_DEFECTO = { "ffmpeg": "ffmpeg", "ffprobe": "ffprobe", "python_resolve": "", # vacío = el mismo intérprete + "braw": "", # carpeta del decodificador de Blackmagic RAW; vacío = se busca sola }, # las subcarpetas con las que nace una pieza nueva (las que más se repiten en las piezas del estudio) "pieza": {"subcarpetas": ["Guion", "Videos", "Imagenes", "Musica", "Elements", "IA", "Portada", "Exportado"]}, @@ -152,6 +155,54 @@ FORMATOS = { "cpu": "prores_ks", "prores": 3, "mbps_1080p30": 220, "que": "entrega o máster; pesa mucho"}, } _MOTORES = {"ts": 0, "datos": None} +_BRAW = {"ts": 0, "datos": None} + + +def _lector_braw(*args, espera=120): + """Lanza braw.py —proceso aparte: ahí se carga el decodificador de Blackmagic, no aquí— y devuelve lo que + dijo, una línea JSON por evento.""" + carpeta = CONFIG["optimizar"].get("braw") or "" + r = subprocess.run([sys.executable, os.path.join(BASE, "braw.py"), *args], capture_output=True, text=True, encoding="utf-8", + errors="replace", timeout=espera, creationflags=SIN_VENTANA, + env=dict(os.environ, ORGANILAB_BRAW=carpeta) if carpeta else None) + out = [] + for linea in (r.stdout or "").splitlines(): + try: + out.append(json.loads(linea)) + except ValueError: + pass + return out + + +def decodificador_braw(): + """¿Se pueden leer los BRAW sin Resolve? Lo dice braw.py después de cargar el decodificador de Blackmagic + y pedirle las interfaces que se usan. La respuesta se recuerda diez minutos.""" + if _BRAW["datos"] and time.time() - _BRAW["ts"] < 600: + return _BRAW["datos"] + carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "") + datos = {"ok": False, "carpeta": "", "motivo": "no está el decodificador de Blackmagic RAW: se instala con DaVinci Resolve (vale el gratuito)"} + if carpeta: + try: + dicho = _lector_braw("sonda", carpeta, espera=60) + datos = dicho[-1] if dicho else {"ok": False, "carpeta": carpeta, "motivo": "el decodificador de Blackmagic RAW no respondió"} + except Exception as ex: # noqa + datos = {"ok": False, "carpeta": carpeta, "motivo": f"el decodificador de Blackmagic RAW no respondió: {ex}"} + _BRAW.update(ts=time.time(), datos=datos) + return datos + + +def medir_braw(ruta): + """Lo que el decodificador dice de un BRAW: su tamaño de verdad (ffprobe lo lee con relleno y sin girar: + 4128×2176 para un clip que es 2160×4096 en vertical), sus cuadros, su código de tiempo y su curva.""" + try: + dicho = _lector_braw("info", ruta) + except Exception as ex: # noqa + return {"ok": False, "error": f"el lector de BRAW no respondió: {ex}"} + return dicho[-1] if dicho else {"ok": False, "error": "el lector de BRAW no dijo nada"} + + +def _curva(m): + return (m.get("gamma") or "BRAW").replace("Blackmagic Design ", "") def motores(): @@ -193,6 +244,15 @@ def motores(): except Exception: # noqa out["gpu"]["chroma422"] = False out["chroma422"] = bool(out["gpu"].get("chroma422") or out["cpu"].get("libx265")) + if out["gpu"]["hevc"]: + try: # ¿la tarjeta y su controlador admiten el ajuste «uhq»? (ver _args_video) + r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", + "-i", "testsrc=size=256x144:rate=30", "-frames:v", "3", + "-c:v", "hevc_nvenc", "-pix_fmt", "p010le", "-tune", "uhq", "-profile:v", "main10", + "-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA) + out["gpu"]["uhq"] = r.returncode == 0 + except Exception: # noqa + out["gpu"]["uhq"] = False try: r = subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True, text=True, timeout=10, creationflags=SIN_VENTANA) out["gpu"]["nombre"] = (r.stdout or "").strip().splitlines()[0] if r.returncode == 0 and r.stdout.strip() else "" @@ -205,6 +265,7 @@ def motores(): elif f.get("cpu") and out["cpu"].get(f["cpu"]): m = "cpu" out["formatos"][k] = m # None = no se puede con este ffmpeg + out["braw"] = decodificador_braw() _MOTORES.update(ts=time.time(), datos=out) return out @@ -265,13 +326,16 @@ def ruta_herramienta(h): def herramientas(): - """qué hay disponible: ffmpeg/ffprobe y Resolve""" + """qué hay disponible: ffmpeg/ffprobe, el decodificador de Blackmagic RAW y Resolve""" out = {} for h in ("ffmpeg", "ffprobe"): ruta = ruta_herramienta(h) ok = os.path.exists(ruta) or shutil.which(ruta) is not None out[h] = {"ruta": ruta, "ok": ok, "empaquetado": ruta.lower().startswith(BASE.lower())} out["resolve"] = {"ok": os.path.exists(r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll"), "ruta": r"C:\Program Files\Blackmagic Design\DaVinci Resolve"} + # solo se mira si el archivo está: cargarlo de verdad lo hace decodificador_braw(), en un proceso aparte + carpeta = braw.buscar(CONFIG["optimizar"].get("braw") or "") + out["braw"] = {"ok": bool(carpeta), "ruta": carpeta} out["congelado"] = CONGELADO return out @@ -2550,7 +2614,11 @@ def analizar(raiz, min_mb, reintentar=False): "cancelar": False, "ilegibles": 0} con = db() estados = "(0, 2)" if reintentar else "(0)" - pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado IN {estados} AND es_proxy = 0 " + lee_braw = bool(decodificador_braw().get("ok")) + # los BRAW que solo leyó ffprobe se miden otra vez con el decodificador: se les nota en que no tienen + # «perfil», que es donde queda apuntada su curva + remedir = " OR (ext = '.braw' AND analizado = 1 AND COALESCE(perfil, '') = '')" if lee_braw else "" + pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND (analizado IN {estados}{remedir}) AND es_proxy = 0 " "AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock') " "ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall() prog["total"] = len(pend) @@ -2574,6 +2642,9 @@ def analizar(raiz, min_mb, reintentar=False): info = ffprobe(f["ruta"]) if info and f["ext"] == ".braw": info["codec"] = "braw" # ffprobe lee el BRAW pero no le pone nombre al códec + m = medir_braw(f["ruta"]) if lee_braw else {} + if m.get("ok"): + info.update(ancho=m["ancho"], alto=m["alto"], fps=m["fps"] or info["fps"], dur=m["dur"] or info["dur"], perfil=_curva(m)) return f["ruta"], info with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool: @@ -2604,6 +2675,35 @@ def kbps_objetivo(ancho, alto, fps, perfil): return max(1500, int(ancho * alto * fps * bpp / 1000)) +# Desde cuántas veces la tasa objetivo compensa recomprimir un clip que ya viene comprimido entre cuadros. +# H.265, AV1 y VP9 rinden como la salida: de ahí solo se baja quitando calidad, y hace falta que el origen +# vaya muy holgado (el doble). Un H.264 necesita unas 1,6 veces los bits de H.265 para verse igual: basta +# con que no esté ya por debajo del objetivo. Medido el 5-oct-2026 con clips de V:\Galeano contra su original: +# un H.264 4.6K de 33 Mb/s (1,08 veces el objetivo) queda en la mitad con nota 96,7 sobre 100; uno de +# 121 Mb/s, en la quinta parte. Lo que guarda cada cuadro entero (XAVC-I, ProRes, BRAW) no pasa por aquí. +RECOMPRIMIR_DESDE = {"hevc": 2.0, "av1": 2.0, "vp9": 2.0, "h264": 1.0, "mpeg4": 0.7, "mpeg2video": 0.5} +NOMBRE_CODEC = {"hevc": "H.265", "av1": "AV1", "vp9": "VP9", "h264": "H.264", "mpeg4": "MPEG-4", "mpeg2video": "MPEG-2"} + + +def poco_que_ganar(f, objetivo): + """True si el clip ya viene comprimido y tan cerca de lo que pesaría convertido que no compensa tocarlo.""" + if not f["kbps"] or f["codec"] not in RECOMPRIMIR_DESDE or "intra" in (f["perfil"] or "").lower(): + return False + return f["kbps"] <= RECOMPRIMIR_DESDE[f["codec"]] * objetivo + + +def _salio_del_convertidor(f, salidas): + """Lleva el sufijo del convertidor y además está en el códec que ese sufijo produce: un «Entrevista_HQ.mp4» + en H.264, o un «clip_proxy.mp4» de la cámara, no los hizo este programa.""" + if os.path.normcase(os.path.normpath(f["ruta"])) in salidas: + return True + if not R.es_conversion(f["nombre"]): + return False + base = os.path.splitext(f["nombre"])[0].lower() + sufijo = next((s for s in R.SUFIJOS_CONVERSION if base.endswith(s)), "") + return f["codec"] == {"_h264": "h264", "_proxy": "prores", "_lt": "prores", "_hq": "prores"}.get(sufijo, "hevc") + + def estimar_bytes(f, perfil, formato): """peso aproximado de la salida para un archivo del inventario""" fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) @@ -2617,6 +2717,7 @@ def estimar_bytes(f, perfil, formato): def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"): fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) + lee_braw = bool(decodificador_braw().get("ok")) con = db() tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad # es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales @@ -2632,28 +2733,35 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h # pérdida y sus horas, porque una conversión con buen bitrate no pasaba por «ya está en HEVC eficiente». salidas = {os.path.normcase(os.path.normpath(r["salida"])) for r in con.execute( "SELECT salida FROM trabajos WHERE estado IN ('listo','verificado','reemplazado') AND salida IS NOT NULL").fetchall()} + # y lo que ya se probó con esta misma calidad y no compensó, tampoco + probados = {r["ruta"]: r["log"] or "" for r in con.execute( + "SELECT ruta, log FROM trabajos WHERE formato = ? AND perfil = ? AND estado = 'descartado'", (formato, perfil)).fetchall()} con.close() out = [] for f in filas: motivo_no = "" - motor = "resolve" if f["ext"] == ".braw" else "ffmpeg" - if R.es_conversion(f["nombre"]) or os.path.normcase(os.path.normpath(f["ruta"])) in salidas: + # los BRAW los lee el decodificador de Blackmagic; solo si falta se intenta por el guion de Resolve + motor = ("braw" if lee_braw else "resolve") if f["ext"] == ".braw" else "ffmpeg" + obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)) + if _salio_del_convertidor(f, salidas): motivo_no = "ya es una conversión hecha con este programa" elif f["es_stock"]: motivo_no = "stock de Envato: mejor borrarlo (se vuelve a descargar)" elif f["alfa"]: motivo_no = "tiene canal alfa (HEVC lo perdería)" - elif fm["codec"] == "hevc" and f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * fm.get("bpp_mult", 1.0) * 1.3: - motivo_no = "ya está en HEVC/AV1 eficiente" + elif f["ruta"] in probados: + motivo_no = "ya se probó con esta calidad: " + probados[f["ruta"]].replace("no compensa: ", "").split(". ")[0] + elif fm["codec"] == "hevc" and poco_que_ganar(f, obj): + motivo_no = (f"ya viene comprimido ({NOMBRE_CODEC.get(f['codec'], f['codec'])} a {f['kbps'] / 1000:.0f} Mb/s): " + "recomprimirlo ahorraría poco y le quitaría calidad") elif motor == "resolve" and formato != "h265_10bit": - motivo_no = "los BRAW van por Resolve y solo salen a H.265 10 bits" + motivo_no = "sin el decodificador de Blackmagic los BRAW van por Resolve, y solo salen a H.265 10 bits" elif tope and not es_viejo(f, tope): motivo_no = f"tiene menos de {meses} meses" elif f["contexto"] == "EXPORTADOS" and not incluir_exportados: motivo_no = "es un exportado / master" elif not f["ancho"] or not f["dur"]: motivo_no = "no se pudo leer (ffprobe)" - obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)) est = estimar_bytes(f, perfil, formato) out.append({ "ruta": f["ruta"], "rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"], "codec": f["codec"], "perfil": f["perfil"], @@ -2680,8 +2788,10 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"): if formato not in FORMATOS: formato = "h265_10bit" fm = FORMATOS[formato] + lee_braw = bool(decodificador_braw().get("ok")) con = db() ids = [] + lote, movidos, errores, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "" with con: for r in rutas: f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (r,)).fetchone() @@ -2690,19 +2800,32 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"): ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r, formato)).fetchone() if ya: continue - motor = "resolve" if f["ext"] == ".braw" else "ffmpeg" + motor = ("braw" if lee_braw else "resolve") if f["ext"] == ".braw" else "ffmpeg" if motor == "resolve" and formato != "h265_10bit": - continue # los BRAW solo salen por Resolve a H.265 10 bits + continue # por el guion de Resolve los BRAW solo salen a H.265 10 bits salida = salida_para(r, raiz, formato) + if os.path.exists(salida): + previo = con.execute("SELECT * FROM trabajos WHERE ruta = ? AND salida = ? AND estado = 'descartado' ORDER BY id DESC", + (r, salida)).fetchone() + if previo: + try: + if _apartar_convertido(dict(previo), lote, movidos): + raiz_desc = raiz_desc or previo["raiz"] + except OSError as ex: + errores.append(f"{os.path.basename(salida)}: {ex}") # el mismo clip en MP4 y MOV daría la misma salida: el segundo pisaría al primero. No se encola. otro = con.execute("SELECT id FROM trabajos WHERE salida = ? AND ruta != ? AND estado IN ('pendiente','corriendo','listo','verificado')", (salida, r)).fetchone() if otro or os.path.exists(salida): continue kb = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)) + # los intentos fallidos de este mismo clip se sustituyen por el nuevo (el 5-oct-2026 había 174 + # filas de error para 87 clips: cada tanda dejaba las suyas) + con.execute("DELETE FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('error','cancelado','descartado')", (r, formato)) cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz, formato)) ids.append(cur.lastrowid) con.close() + _cerrar_lote(lote, raiz_desc or raiz, movidos, errores) return ids @@ -2726,72 +2849,135 @@ def formato_de(t): return f or "h265_10bit" +def _motor_de(formato, chroma, ancho=0, alto=0): + """Con qué se codifica un formato en este equipo, «gpu» o «cpu». Si no se puede: None y el porqué.""" + fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) + _m = motores() + motor = _m["formatos"].get(formato) + lado = 4096 if fm["codec"] == "h264" else 8192 + if motor == "gpu" and max(ancho or 0, alto or 0) > lado: + # la tarjeta no codifica imágenes de más de 4096 píxeles de lado en H.264 (un clip de 4.6K ya se pasa: + # «No capable devices found») ni de más de 8192 en H.265 (un BRAW de 12K): esos van por CPU + motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None + if motor is None: + return None, f"la tarjeta gráfica no codifica {fm['codec'].upper()} de más de {lado} píxeles de lado y no hay codificador por CPU" + if motor == "gpu" and chroma == "422" and fm["bits"] == 10 and not _m["gpu"].get("chroma422"): + # la tarjeta no admite 4:2:2 de 10 bits (solo desde Blackwell): en vez de fallar, va por CPU + motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None + if motor is None: + return None, "esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0" + if not motor: + return None, f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)" + return motor, "" + + +def _args_video(t, fm, motor, fps): + """Los argumentos de ffmpeg que codifican la imagen —los mismos venga el clip de un archivo o del lector + de BRAW— y el formato de píxel en que la quiere el codificador.""" + chroma = t["chroma"] or "420" + cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"] + # el formato también mueve la calidad, no solo el techo de tasa: sin esto «ligero para compartir» + # salía prácticamente del mismo tamaño que el de archivo (mismo cq, y el techo casi nunca se toca) + cq = max(14, min(38, round(cq + fm.get("cq_mas", 0)))) + maxrate = int(t["kbps"] * 1.8) + gop = str(int(round((fps or 30) * 2))) + diez = fm["bits"] == 10 + if fm["codec"] == "prores": + # sin -qscale: con cuantizador fijo, prores_ks se salta la tasa del perfil y un «máster HQ» no + # salía a la tasa de un HQ (que es justo lo que promete el formato) + return ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"], "yuv422p10le" + if motor == "gpu": + pix = ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p" + # «uhq» es el ajuste de más rendimiento de las tarjetas recientes. Medido el 5-oct-2026 con tres clips + # del estudio (BRAW de la URSA, BRAW vertical de la Pocket y XAVC-I 4K60 de la Sony), cada uno contra + # su referencia sin pérdida y a la misma calidad pedida: pesa entre un 19 y un 55 % menos que «hq» con + # la misma nota (VMAF −0,4 a +0,9 sobre 100), y en el cuadro peor sale igual o mejor en dos de los + # tres. No deja fijar los cuadros B, y solo se usa donde se midió: H.265 de 10 bits en 4:2:0. + uhq = bool(fm["codec"] == "hevc" and diez and chroma != "422" and motores()["gpu"].get("uhq")) + args = ["-c:v", fm["gpu"], "-pix_fmt", pix, + "-preset", "p6", "-tune", "uhq" if uhq else "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0", + "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", + "-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32"] + args += ([] if uhq else ["-bf", "3", "-b_ref_mode", "middle"]) + ["-g", gop] + if fm["codec"] == "hevc": + args += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"] + else: + args += ["-profile:v", "high"] + return args, pix + pix = ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p" + args = ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2), "-pix_fmt", pix, + "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop] + if fm["codec"] == "hevc": + args += ["-tag:v", "hvc1"] + if diez: + args += ["-profile:v", "main422-10" if chroma == "422" else "main10"] + return args, pix + + +def _args_salida(fm, f): + """Lo que no es imagen: el contenedor y el audio.""" + # use_metadata_tags: sin ella el MP4 solo guarda las etiquetas que conoce y se pierden las de la cámara + # (ISO, balance de blancos, obturación, óptica, rollo y toma): medido el 5-oct-2026, 48 de 188 + # conversiones de clips de Blackmagic habían perdido entre 24 y 36 etiquetas cada una. + contenedor = ["-movflags", "+faststart+use_metadata_tags" if fm["ext"] == ".mp4" else "use_metadata_tags"] + # …pero con ella también viajarían, como si fueran etiquetas, las marcas de formato del original + # («major_brand = XAVC» dentro de un archivo que ya no es XAVC): esas tres se quitan + contenedor += ["-metadata", "major_brand=", "-metadata", "minor_version=", "-metadata", "compatible_brands="] + if fm["codec"] == "prores": + audio = ["-c:a", "pcm_s16le"] + elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1: + audio = ["-c:a", "copy"] + else: + # con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4 + audio = ["-c:a", "aac", "-b:a", "320k"] + return contenedor, audio + + +def _salida_ajena(t): + """True si en la ruta de salida hay un archivo que no dejó este mismo trabajo: ese no se pisa. El que dejó + él (un intento que no pasó la verificación, y que «Reintentar» rehace) sí.""" + salida = t["salida"] + if not os.path.exists(salida): + return False + try: + return not (t["bytes_out"] and os.path.getsize(salida) == t["bytes_out"]) + except OSError: + return True + + +def _quitar_parcial(tmp): + """Borra el archivo a medio escribir. Si ffmpeg aún lo tiene cogido (lo acaban de cortar), espera.""" + for _ in range(40): + try: + if os.path.exists(tmp): + os.remove(tmp) + return + except OSError: + time.sleep(0.25) + + def correr_ffmpeg(t): - o = CONFIG["optimizar"] con = db() f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() con.close() dur = (f["dur"] if f else 0) or 0 fps = (f["fps"] if f else 30) or 30 - chroma = t["chroma"] or "420" - cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"] - # el formato también mueve la calidad, no solo el techo de tasa: sin esto «ligero para compartir» - # salía prácticamente del mismo tamaño que el de archivo (mismo cq, y el techo casi nunca se toca) - cq = max(14, min(38, round(cq + FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"]).get("cq_mas", 0)))) - maxrate = int(t["kbps"] * 1.8) - salida = t["salida"] formato = formato_de(t) fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) - _m = motores() - motor = _m["formatos"].get(formato) - if motor == "gpu" and chroma == "422" and fm["bits"] == 10 and not _m["gpu"].get("chroma422"): - # la tarjeta no admite 4:2:2 de 10 bits (solo desde Blackwell): en vez de fallar, va por CPU - motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None - if motor is None: - set_trabajo(t["id"], estado="error", log="esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0") - return + motor, pega = _motor_de(formato, t["chroma"] or "420", f["ancho"] if f else 0, f["alto"] if f else 0) if not motor: - set_trabajo(t["id"], estado="error", log=f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)") + set_trabajo(t["id"], estado="error", log=pega) + return + salida = t["salida"] + if _salida_ajena(t): + set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola") return os.makedirs(os.path.dirname(salida), exist_ok=True) tmp = salida + ".parcial" + fm["ext"] - gop = str(int(round(fps * 2))) + video, _ = _args_video(t, fm, motor, fps) + contenedor, audio = _args_salida(fm, f) cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"] - if fm["codec"] == "prores": - # sin -qscale: con cuantizador fijo, prores_ks se salta la tasa del perfil y un «máster HQ» no - # salía a la tasa de un HQ (que es justo lo que promete el formato) - cmd += ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"] - elif motor == "gpu": - diez = fm["bits"] == 10 - cmd += ["-c:v", fm["gpu"], - "-pix_fmt", ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p", - "-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0", - "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", - "-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32", - "-bf", "3", "-b_ref_mode", "middle", "-g", gop] - if fm["codec"] == "hevc": - cmd += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"] - else: - cmd += ["-profile:v", "high"] - else: - diez = fm["bits"] == 10 - cmd += ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2), - "-pix_fmt", ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p", - "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop] - if fm["codec"] == "hevc": - cmd += ["-tag:v", "hvc1"] - if diez: - cmd += ["-profile:v", "main422-10" if chroma == "422" else "main10"] - cmd += ["-map_metadata", "0"] - if fm["ext"] == ".mp4": - cmd += ["-movflags", "+faststart"] - if fm["codec"] == "prores": - cmd += ["-c:a", "pcm_s16le"] - elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1: - cmd += ["-c:a", "copy"] - else: - # con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4 - cmd += ["-c:a", "aac", "-b:a", "320k"] + cmd += video + ["-map_metadata", "0"] + contenedor + audio cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp] set_trabajo(t["id"], log=" ".join(cmd)[:4000]) try: @@ -2819,17 +3005,11 @@ def correr_ffmpeg(t): p.wait() if COLA["cancelar"]: set_trabajo(t["id"], estado="cancelado", pct=0) - try: - os.remove(tmp) - except OSError: - pass + _quitar_parcial(tmp) return if p.returncode != 0 or not os.path.exists(tmp): set_trabajo(t["id"], estado="error", log=(err or "")[-3000:]) - try: - os.remove(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video - except OSError: - pass + _quitar_parcial(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video return os.replace(tmp, salida) try: @@ -2840,6 +3020,117 @@ def correr_ffmpeg(t): set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time()) +def correr_braw(t): + """Un BRAW. Lo lee el decodificador de Blackmagic en un proceso aparte (braw.py), que le va pasando los + cuadros ya revelados a ffmpeg; el codificador y la calidad son los de cualquier otro clip. No hace + falta tener Resolve abierto, y si lo está no se le toca nada.""" + formato = formato_de(t) + fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) + dec = decodificador_braw() + if not dec.get("ok"): + set_trabajo(t["id"], estado="error", log=dec.get("motivo") or "no está el decodificador de Blackmagic RAW") + return + salida = t["salida"] + if _salida_ajena(t): + set_trabajo(t["id"], estado="error", log=f"ya existe «{os.path.basename(salida)}» junto al clip y no se pisa: quita este trabajo de la cola") + return + m = medir_braw(t["ruta"]) + if not m.get("ok"): + set_trabajo(t["id"], estado="error", log=m.get("error") or "el lector de BRAW no pudo abrir el clip") + return + motor, pega = _motor_de(formato, t["chroma"] or "420", m["ancho"], m["alto"]) + if not motor: + set_trabajo(t["id"], estado="error", log=pega) + return + # la tasa se calcula con el tamaño de verdad del clip, que es el que dice el decodificador + kbps = int(kbps_objetivo(m["ancho"], m["alto"], m["fps"], t["perfil"]) * fm.get("bpp_mult", 1.0)) + t = dict(t, kbps=kbps) + con = db() + with con: + con.execute("UPDATE archivos SET ancho = ?, alto = ?, fps = ?, dur = ?, perfil = ? WHERE ruta = ?", + (m["ancho"], m["alto"], m["fps"], m["dur"], _curva(m), t["ruta"])) + con.execute("UPDATE trabajos SET kbps = ? WHERE id = ?", (kbps, t["id"])) + f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() + con.close() + os.makedirs(os.path.dirname(salida), exist_ok=True) + tmp = salida + ".parcial" + fm["ext"] + video, pix = _args_video(t, fm, motor, m["fps"]) + contenedor, audio = _args_salida(fm, f) + ficha = os.path.join(DATOS, f"braw_{t['id']}.json") + bandera = ficha + ".cancelar" + guardar_json(ficha, {"src": t["ruta"], "tmp": tmp, "ffmpeg": ruta_herramienta("ffmpeg"), "pix": pix, "video": video, + "contenedor": contenedor, "audio": audio, "color": t["color"] or "rec709", "cancelar": bandera, + "sdk": dec.get("carpeta") or ""}) + try: + os.remove(bandera) + except OSError: + pass + errores = tempfile.TemporaryFile() + try: + p = subprocess.Popen([sys.executable, os.path.join(BASE, "braw.py"), "convertir", ficha], stdout=subprocess.PIPE, stderr=errores, + text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) + except OSError as ex: + errores.close() + set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar el lector de BRAW: {ex}") + return + + def vigia(): + # Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se corta + while p.poll() is None: + if COLA["cancelar"]: + try: + open(bandera, "w").close() + p.wait(20) + except (OSError, subprocess.TimeoutExpired): + p.kill() + return + time.sleep(0.5) + threading.Thread(target=vigia, daemon=True).start() + fin, dicho = {}, "" + for linea in p.stdout: + try: + ev = json.loads(linea) + except ValueError: + continue + if ev.get("estado"): + fin = ev + elif "pct" in ev: + set_trabajo(t["id"], pct=ev["pct"]) + elif ev.get("msg"): + dicho = ev["msg"] + set_trabajo(t["id"], log=dicho) + p.wait() + p.stdout.close() + try: + errores.seek(0) + cola_err = errores.read().decode("utf-8", "replace").strip()[-2000:] + except (OSError, ValueError): + cola_err = "" + errores.close() + for resto in (ficha, bandera): + try: + os.remove(resto) + except OSError: + pass + if COLA["cancelar"] or fin.get("estado") == "cancelado": + set_trabajo(t["id"], estado="cancelado", pct=0) + _quitar_parcial(tmp) + return + if fin.get("estado") != "listo" or not os.path.exists(tmp): + set_trabajo(t["id"], estado="error", + log=(fin.get("log") or cola_err or f"el lector de BRAW se cerró sin acabar el clip (código {p.returncode})")[-3000:]) + _quitar_parcial(tmp) + return + os.replace(tmp, salida) + try: + st = os.stat(t["ruta"]) + os.utime(salida, (st.st_atime, st.st_mtime)) + except OSError: + pass + set_trabajo(t["id"], estado="listo", pct=100, bytes_out=os.path.getsize(salida), fin=time.time(), + log=(f"{dicho} · {fin.get('segundos', 0)} s · " + " ".join(video))[:4000]) + + def correr_resolve(trabajos): """Manda un lote de BRAW a DaVinci Resolve por el puente (proceso aparte).""" o = CONFIG["optimizar"] @@ -2898,6 +3189,10 @@ def correr_resolve(trabajos): p.kill() break p.wait() + try: + os.remove(lote) + except OSError: + pass con = db() for t in trabajos: r = con.execute("SELECT estado FROM trabajos WHERE id = ?", (t["id"],)).fetchone() @@ -2923,14 +3218,14 @@ def bucle_cola(): try: while COLA["activa"]: con = db() - t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'ffmpeg' ORDER BY id LIMIT 1").fetchone() + t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY id LIMIT 1").fetchone() lote_resolve = [dict(r) for r in con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve' ORDER BY id LIMIT 40").fetchall()] con.close() if t: COLA["actual"] = t["id"] set_trabajo(t["id"], estado="corriendo", pct=0) try: - correr_ffmpeg(dict(t)) + (correr_braw if t["motor"] == "braw" else correr_ffmpeg)(dict(t)) except Exception as ex: # noqa set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}") _verificar_listos([t["id"]]) @@ -2976,6 +3271,33 @@ def recuperar_huerfanas(): return len(filas) +def braws_al_lector(): + """Las conversiones de BRAW que quedaron en espera o fallidas por la vía de Resolve pasan al lector + directo. De cada clip se queda una sola, la última; las que apuntan a un clip que ya no está en esa ruta + —la carpeta se reordenó después— se quitan, porque reintentarlas no podía salir bien.""" + if not decodificador_braw().get("ok"): + return 0 + con = db() + n = 0 + with con: + vistos = set() + for t in con.execute("SELECT id, ruta, raiz, formato FROM trabajos WHERE motor = 'resolve' " + "AND estado IN ('pendiente','error','cancelado') ORDER BY id DESC").fetchall(): + if not os.path.isdir(t["raiz"] or ""): + continue # unidad desconectada: no se sabe si el clip sigue ahí + clave = (os.path.normcase(t["ruta"]), t["formato"] or "h265_10bit") + if clave in vistos or not os.path.isfile(t["ruta"]): + con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],)) + else: + vistos.add(clave) + con.execute("UPDATE trabajos SET motor = 'braw' WHERE id = ?", (t["id"],)) + con.execute("UPDATE trabajos SET log = ? WHERE id = ? AND estado = 'error'", + ("no salió por la vía de Resolve; ahora se lee sin Resolve: pulsa «Reintentar los que fallaron»", t["id"])) + n += 1 + con.close() + return n + + class ErrorCola(ValueError): """Un ValueError que además lleva datos para la interfaz (qué trabajos estorban), no solo un texto.""" def __init__(self, mensaje, datos): @@ -2993,6 +3315,10 @@ def iniciar_cola(): if COLA["hilo"] and COLA["hilo"].is_alive(): return False recuperar_huerfanas() + try: + braws_al_lector() + except Exception: # noqa + pass COLA["activa"] = True COLA["cancelar"] = False COLA["hechos"] = 0 @@ -3002,15 +3328,103 @@ def iniciar_cola(): return True +def _no_compensa(t, sale=None): + """Por qué no merece la pena dar por buena esta conversión, o «» si sí. Solo cuenta para los formatos que + existen para pesar menos (H.265). El 5-oct-2026 había 8 conversiones verificadas esperando en la cola de + V:\\Galeano con el botón «Reemplazar 8 verificados»: seis eran reconversiones de un clip ya convertido + y pesaban entre el 85 y el 99 % de él. Reemplazar habría cambiado cada uno por una copia de su copia.""" + if FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])["codec"] != "hevc": + return "" + entra, sale = t["bytes_in"] or 0, sale or t["bytes_out"] or 0 + if not entra or not sale: + return "" + repetida = R.es_conversion(os.path.basename(t["ruta"])) + if not repetida and sale <= 0.8 * entra: + return "" + return (f"no compensa: pesa el {round(sale / entra * 100)} % del clip del que salió" + + (", que ya era una conversión de este programa" if repetida else "") + ". Se queda el que estaba.") + + +DESCARTADOS = "descartados" + + +def _apartar_convertido(t, lote, movidos): + """Lo que salió de una conversión que no se usa va a _Para borrar\\descartados, con su ruta: no se borra, y + «Deshacer» lo devuelve. Solo si sigue siendo el archivo que dejó este trabajo. Devuelve dónde quedó.""" + salida, raiz = t["salida"] or "", t["raiz"] or "" + if not os.path.isfile(salida) or not raiz or not dentro_de(raiz, salida): + return "" + if t["bytes_out"] and os.path.getsize(salida) != t["bytes_out"]: + return "" + antes = len(movidos) + mover(salida, os.path.join(raiz, R.PAPELERA, DESCARTADOS, os.path.relpath(salida, raiz)), lote, movidos) + return movidos[-1][1] if len(movidos) > antes else "" + + +def _cerrar_lote(lote, raiz, movidos, errores): + if movidos or errores: + actualizar_inventario(movidos) + bitacora({"lote": lote, "op": "fin", "raiz": raiz, "filas": len(movidos), "movimientos": len(movidos), "errores": errores}) + + +def _descartar(t, pega, lote, movidos, errores, **kw): + """Marca el trabajo como «no compensa» y aparta lo que salió de él.""" + nota = "" + try: + if _apartar_convertido(t, lote, movidos): + nota = f" Lo convertido quedó en «{R.PAPELERA}\\{DESCARTADOS}»." + except OSError as ex: + errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}") + nota = " Lo convertido sigue junto al original (no se pudo apartar: ¿está abierto?)." + set_trabajo(t["id"], estado="descartado", log=pega + nota, **kw) + return pega + nota + + +def apartar_descartados(ids): + """«Limpiar terminados»: lo que salió de las conversiones que no compensaron y sigue junto a su original se + aparta antes de quitarlas de la cola. Devuelve también las que no se pudieron apartar: esas se quedan.""" + con = db() + filas = [dict(r) for r in con.execute(f"SELECT * FROM trabajos WHERE estado = 'descartado' AND id IN ({','.join('?' * len(ids))})", + list(ids)).fetchall()] if ids else [] + con.close() + lote, movidos, errores, raiz, quedan = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "", [] + for t in filas: + try: + if _apartar_convertido(t, lote, movidos): + raiz = raiz or t["raiz"] + except OSError as ex: + errores.append(f"{os.path.basename(t['salida'] or '')}: {ex}") + quedan.append(t["id"]) + _cerrar_lote(lote, raiz or (filas[0]["raiz"] if filas else ""), movidos, errores) + return {"movidos": len(movidos), "errores": errores, "quedan": quedan} + + +def repasar_verificados(): + """Las conversiones verificadas con una versión anterior que no compensan dejan de ofrecerse para reemplazar. + Al arrancar solo cambia su estado: lo que salió de ellas se aparta cuando alguien limpia la cola o vuelve a + convertir el clip, no sin que nadie lo pida.""" + con = db() + n = 0 + for t in con.execute("SELECT * FROM trabajos WHERE estado = 'verificado'").fetchall(): + pega = _no_compensa(t) + if pega: + with con: + con.execute("UPDATE trabajos SET estado = 'descartado', log = ? WHERE id = ?", (pega, t["id"])) + n += 1 + con.close() + return n + + def verificar(ids): con = db() res = [] + lote, movidos, errores, raiz_lote = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "" for id_ in ids: t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() if not t or not os.path.exists(t["salida"]): res.append({"id": id_, "ok": False, "motivo": "no existe la salida"}) continue - f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() + f = con.execute("SELECT dur, ancho, alto FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() info = ffprobe(t["salida"]) if not info: res.append({"id": id_, "ok": False, "motivo": "ffprobe no pudo leer la salida"}) @@ -3026,15 +3440,24 @@ def verificar(ids): problemas.append(f"códec {info['codec']} (se esperaba {fm['codec']})") if fm["bits"] == 10 and "10" not in info["pix_fmt"]: problemas.append(f"no es 10 bits ({info['pix_fmt']})") + if t["motor"] == "braw" and f and f["ancho"] and (info["ancho"], info["alto"]) != (f["ancho"], f["alto"]): + problemas.append(f"imagen de {info['ancho']}×{info['alto']} (el clip es de {f['ancho']}×{f['alto']})") if os.path.getsize(t["salida"]) < 100000: problemas.append("archivo demasiado pequeño") if problemas: set_trabajo(id_, estado="error", log="verificación: " + "; ".join(problemas)) res.append({"id": id_, "ok": False, "motivo": "; ".join(problemas)}) else: - set_trabajo(id_, estado="verificado", bytes_out=os.path.getsize(t["salida"])) - res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"}) + sale = os.path.getsize(t["salida"]) + pega = _no_compensa(t, sale) + if pega: # está bien hecha, pero no merece ocupar el sitio del original + raiz_lote = raiz_lote or t["raiz"] + res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t, bytes_out=sale), pega, lote, movidos, errores, bytes_out=sale)}) + else: + set_trabajo(id_, estado="verificado", bytes_out=sale) + res.append({"id": id_, "ok": True, "motivo": f"{info['codec']} {info['pix_fmt']} {info['dur']:.2f}s"}) con.close() + _cerrar_lote(lote, raiz_lote, movidos, errores) return res @@ -3044,6 +3467,8 @@ def reemplazar(ids): res = [] lote = "optimizar-" + time.strftime("%Y%m%d-%H%M%S") # un lote por reemplazo: se deshace solo este, no toda la historia movimientos, raiz_lote = 0, "" + # lo que no compensa va en su propio lote: deshacer el reemplazo no debe devolver también eso + lote_desc, movidos_desc, errores_desc, raiz_desc = "descartados-" + time.strftime("%Y%m%d-%H%M%S"), [], [], "" for id_ in ids: t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() if not t or t["estado"] != "verificado": @@ -3052,6 +3477,11 @@ def reemplazar(ids): if ((t["formato"] if "formato" in t.keys() else "") or "").startswith("prores"): res.append({"id": id_, "ok": False, "motivo": "ProRes es un formato de edición: queda junto al original, no lo reemplaza"}) continue + pega = _no_compensa(t) + if pega: + raiz_desc = raiz_desc or t["raiz"] + res.append({"id": id_, "ok": False, "motivo": _descartar(dict(t), pega, lote_desc, movidos_desc, errores_desc)}) + continue if not os.path.exists(t["salida"]) or os.path.getsize(t["salida"]) < 1024: res.append({"id": id_, "ok": False, "motivo": "el MP4 convertido ya no está donde se verificó; el original se queda"}) set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar") @@ -3066,9 +3496,9 @@ def reemplazar(ids): os.rename(t["ruta"], destino) movimientos += 1 bitacora({"lote": lote, "op": "mover", "de": t["ruta"], "a": destino, "trabajo": id_}) - # el sidecar .xml de Sony y el .mp4 proxy de Blackmagic se van con el original + # el sidecar .xml de Sony y el .sidecar de un BRAW (los ajustes que le dejó Resolve) se van con el original base, ext = os.path.splitext(t["ruta"]) - for extra in (base + "M01.xml", base + "M01.XML"): + for extra in (base + "M01.xml", base + "M01.XML", base + ".sidecar"): if os.path.exists(extra): d2 = os.path.join(os.path.dirname(destino), os.path.basename(extra)) os.rename(extra, d2) @@ -3083,7 +3513,8 @@ def reemplazar(ids): con.close() if movimientos: bitacora({"lote": lote, "op": "fin", "raiz": raiz_lote, "filas": len(ids), "movimientos": movimientos, - "errores": [f"#{r['id']}: {r['motivo']}" for r in res if not r["ok"]]}) + "errores": [f"#{r['id']}: {r['motivo']}" for r in res if not r["ok"] and not r["motivo"].startswith("no compensa")]}) + _cerrar_lote(lote_desc, raiz_desc, movidos_desc, errores_desc) return res @@ -4988,11 +5419,23 @@ def acciones(raiz): detalle="Primero hay que leerlos para saber cuáles conviene convertir. Va en segundo plano.", ir="convertir") else: - añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Ver clips", - que="Pasan a un formato unas 10 veces más ligero. El original no se toca hasta que reemplaces.", - cifra=f"{_plural(cam['n'], 'clip', 'clips')} · {_gb(cam['b'])}", - detalle=f"{cam['ana']} ya están leídos" + (f" y {hechos} reemplazados." if hechos else "."), - ir="convertir") + # ya leídos: cuentan solo los que de verdad adelgazan (ni conversiones, ni lo que ya viene comprimido) + cands = [c for c in candidatos(raiz, o["meses_minimos"], o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit")) + if not c["excluido"] and not c["en_cola"] and c["rol"] in ("camara_raw", "camara_video")] + sin_leer = cam["n"] - cam["ana"] + if cands: + añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Ver clips", + que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.", + cifra=f"{_plural(len(cands), 'clip', 'clips')} · {_gb(sum(c['bytes'] for c in cands))} → unos {_gb(sum(c['estimado'] for c in cands))}", + detalle=(f"{sum(1 for c in cands if c['motor'] != 'ffmpeg')} son BRAW. " if any(c["motor"] != "ffmpeg" for c in cands) else "") + + (f"{hechos} ya reemplazados." if hechos else "El resto ya viene comprimido o es reciente."), + ir="convertir") + elif sin_leer > 0: + añadir(clave="convertir", titulo="Aligerar los clips de cámara", boton="Leer los clips", + que="Pasan a H.265 de 10 bits, mucho más ligero. El original no se toca hasta que reemplaces.", + cifra=f"{_plural(sin_leer, 'clip', 'clips')} por leer", + detalle="Hay clips nuevos sin leer: hasta entonces no se sabe cuáles conviene convertir.", + ir="convertir") # --- 8. respaldo, al final --- dest = CONFIG.get("respaldo", {}).get("destino", "") @@ -5412,11 +5855,13 @@ class Manejador(BaseHTTPRequestHandler): COLA["activa"] = False return self._json({"ok": True}) if acc == "quitar": + ap = apartar_descartados(d.get("ids", [])) + ids = [i for i in d.get("ids", []) if i not in ap["quedan"]] con = db() with con: - con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", [])) + con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado','descartado') AND id IN ({','.join('?' * len(ids))})", ids) con.close() - return self._json({"ok": True}) + return self._json({"ok": True, "apartados": ap}) if acc == "reintentar": con = db() with con: @@ -5989,8 +6434,16 @@ def main(): return h = herramientas() print("ffmpeg:", "OK" if h["ffmpeg"]["ok"] else "NO ENCONTRADO", h["ffmpeg"]["ruta"]) - print("Resolve:", "OK" if h["resolve"]["ok"] else "no instalado (solo hace falta para BRAW)") + print("BRAW:", "se leen con el decodificador de Blackmagic" if h["braw"]["ok"] + else "falta el decodificador de Blackmagic RAW (se instala con DaVinci Resolve; solo hace falta para BRAW)") + for resto in os.listdir(DATOS): # fichas de trabajo que dejó una conversión cortada (o el guion de Resolve, que no las borraba) + if resto.endswith((".json", ".cancelar")) and resto.startswith(("resolve_lote_", "braw_")): + try: + os.remove(os.path.join(DATOS, resto)) + except OSError: + pass huerfanas = recuperar_huerfanas() + threading.Thread(target=lambda: (repasar_verificados(), braws_al_lector()), daemon=True).start() if huerfanas: print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") try: diff --git a/probar.py b/probar.py index 3bd222f..13e4541 100644 --- a/probar.py +++ b/probar.py @@ -199,7 +199,7 @@ class Windows(unittest.TestCase): tapon = http.server.ThreadingHTTPServer(("127.0.0.1", 0), http.server.BaseHTTPRequestHandler) ocupado = tapon.server_address[1] try: - for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", "ventana.py", "instalar.py"): + for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "braw.py", "firma.py", "ventana.py", "instalar.py"): shutil.copy(os.path.join(AQUI, f), tmp) shutil.copytree(os.path.join(AQUI, "static"), os.path.join(tmp, "static")) guion = ("import sys; sys.argv = ['organizador.py']; import organizador as O; " @@ -376,7 +376,7 @@ class Servidor(unittest.TestCase): cls.tmp = tempfile.mkdtemp(prefix="organilab-e2e-") cls.app = os.path.join(cls.tmp, "app") os.makedirs(cls.app) - for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", "ventana.py", "instalar.py"): + for f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "braw.py", "firma.py", "ventana.py", "instalar.py"): shutil.copy(os.path.join(AQUI, f), cls.app) shutil.copytree(os.path.join(AQUI, "static"), os.path.join(cls.app, "static")) # carpeta de ensayo: cámara Sony, basura y un proxy diff --git a/prueba_funcional.py b/prueba_funcional.py index 6dc4d69..de4fe15 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -19,7 +19,7 @@ AQUI = os.path.dirname(os.path.abspath(__file__)) TMP = tempfile.mkdtemp(prefix='organilab-smoke-') APP = os.path.join(TMP, 'app') os.makedirs(APP) -for f in ('organizador.py', 'reglas.py', 'asistente.py', 'actualizador.py', 'resolve_bridge.py', 'firma.py', 'ventana.py', 'instalar.py'): +for f in ('organizador.py', 'reglas.py', 'asistente.py', 'actualizador.py', 'resolve_bridge.py', 'braw.py', 'firma.py', 'ventana.py', 'instalar.py'): shutil.copy(os.path.join(AQUI, f), APP) shutil.copytree(os.path.join(AQUI, 'static'), os.path.join(APP, 'static')) RAIZ = os.path.join(TMP, 'Cliente Smoke') @@ -64,15 +64,25 @@ shutil.copy2(os.path.join(RAIZ, '2026', '03- Marzo', '01- Botox', 'Reel botox.mp mk('Raro/algo.xyz', 2026, 2, 2, 10) os.makedirs(os.path.join(RAIZ, 'Vacia', 'dentro'), exist_ok=True) -# un video de verdad para el convertidor (6 s, 1280x720, H.264) +# dos videos de verdad para el convertidor (6 s, 1280x720, H.264): uno con tanto grano y tanta tasa que sí +# adelgaza, y otro ya comprimido como sale de una cámara, que el convertidor no debe ofrecer FF = shutil.which('ffmpeg') CLIP = os.path.join(RAIZ, '2026', '03- Marzo', 'Grabacion', 'C0020.MP4') +CLIP2 = os.path.join(RAIZ, '2026', '03- Marzo', 'Grabacion', 'C0021.MP4') if FF: - subprocess.run([FF, '-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=duration=6:size=1280x720:rate=25', - '-f', 'lavfi', '-i', 'sine=frequency=440:duration=6', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '18', - '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', CLIP], check=True) - t = time.mktime(time.struct_time((2025, 6, 10, 12, 0, 0, 0, 0, -1))) - os.utime(CLIP, (t, t)) + for destino, imagen, crf in ((CLIP, 'testsrc=duration=6:size=1280x720:rate=25,noise=alls=40:allf=t', '8'), + (CLIP2, 'testsrc=duration=6:size=1280x720:rate=25', '30')): + subprocess.run([FF, '-hide_banner', '-loglevel', 'error', '-y', '-f', 'lavfi', '-i', imagen, + '-f', 'lavfi', '-i', 'sine=frequency=440:duration=6', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', crf, + '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', destino], check=True) + t = time.mktime(time.struct_time((2025, 6, 10, 12, 0, 0, 0, 0, -1))) + os.utime(destino, (t, t)) +# y, si se da uno, un BRAW de verdad (variable ORGANILAB_BRAW_PRUEBA): los de arriba son bytes al azar +BRAW = os.environ.get('ORGANILAB_BRAW_PRUEBA') or '' +if BRAW: + shutil.copy(BRAW, os.path.join(RAIZ, '2026', '03- Marzo', 'Grabacion', 'A001_03051200_C003.braw')) + t = time.mktime(time.struct_time((2026, 3, 5, 12, 0, 0, 0, 0, -1))) + os.utime(os.path.join(RAIZ, '2026', '03- Marzo', 'Grabacion', 'A001_03051200_C003.braw'), (t, t)) # ---------------------------------------------------------------- el servidor with socket.socket() as s_: @@ -320,7 +330,10 @@ def p_motores(): assert m.get('ffmpeg'), 'sin ffmpeg' disp = [k for k, v in m['formatos'].items() if v] assert 'h265_10bit' in disp or 'h264' in disp, disp - return 'formatos disponibles: ' + ', '.join(disp) + ' · GPU: ' + (m['gpu'].get('nombre') or 'no') + assert 'ok' in (m.get('braw') or {}), m.get('braw') + assert get('/api/estado')['herramientas']['braw']['ok'] in (True, False) + return 'formatos disponibles: ' + ', '.join(disp) + ' · GPU: ' + (m['gpu'].get('nombre') or 'no') + \ + ' · lector de BRAW: ' + ('sí' if m['braw']['ok'] else 'no (' + (m['braw'].get('motivo') or '')[:40] + ')') def p_analizar(): @@ -332,7 +345,10 @@ def p_analizar(): clip = [x for x in cand if x['ruta'].lower().endswith('c0020.mp4')] assert clip and not clip[0]['excluido'], (clip or cand)[:1] IDS['ruta'] = clip[0]['ruta'] - return 'leyó %d clip(s) · C0020.MP4 %s %s' % (len(cand), clip[0]['codec'], clip[0]['res']) + comprimido = [x for x in cand if x['ruta'].lower().endswith('c0021.mp4')] + assert comprimido and comprimido[0]['excluido'].startswith('ya viene comprimido (H.264'), (comprimido or cand)[:1] + return 'leyó %d clip(s) · C0020.MP4 %s %s a %d kb/s: se ofrece · C0021.MP4 a %d kb/s: no' % ( + len(cand), clip[0]['codec'], clip[0]['res'], clip[0]['kbps'], comprimido[0]['kbps']) def p_convertir(): @@ -353,6 +369,26 @@ def p_convertir(): return '%s: convertido (%s), verificado y reemplazado; original apartado' % (fmt, t['motor']) +def p_braw(): + m = get('/api/optimizar/motores')['braw'] + if not BRAW: + return 'sin clip de prueba (ORGANILAB_BRAW_PRUEBA): solo se comprueba que el lector responde (%s)' % ('sí' if m['ok'] else 'no') + assert m['ok'], m + cand = get('/api/optimizar/candidatos' + q(raiz=RAIZ, meses=0, min_mb=0, formato='h265_10bit'))['candidatos'] + clip = [x for x in cand if x['ruta'].lower().endswith('a001_03051200_c003.braw')] + assert clip and clip[0]['motor'] == 'braw' and not clip[0]['excluido'], (clip or cand)[:1] + c, r = post('/api/optimizar/encolar', {'raiz': RAIZ, 'rutas': [clip[0]['ruta']], 'perfil': 'equilibrado', 'formato': 'h265_10bit', 'chroma': '420', 'color': 'rec709', 'iniciar': True}) + assert c == 200 and r['ids'], r + assert esperar(lambda: not get('/api/estado')['cola']['activa'], 240), 'la cola no terminó' + t = next(x for x in get('/api/optimizar/cola' + q(raiz=RAIZ))['trabajos'] if x['id'] == r['ids'][0]) + assert t['estado'] == 'verificado' and t['motor'] == 'braw', (t['estado'], t['motor'], (t.get('log') or '')[-300:]) + assert os.path.isfile(t['salida']) and os.path.isfile(clip[0]['ruta']) # el original sigue en su sitio + c, rr = post('/api/optimizar/reemplazar', {'ids': r['ids']}) + assert rr['resultados'][0]['ok'], rr + assert os.path.isfile(t['salida']) and not os.path.isfile(clip[0]['ruta']) + return 'BRAW %s leído sin Resolve, convertido (%s), verificado y reemplazado' % (clip[0]['res'], (t.get('log') or '').split(' · ')[3][:40]) + + def p_cola_control(): c, r = post('/api/optimizar/control', {'accion': 'quitar', 'ids': [IDS['trabajo']]}) assert c == 200 @@ -502,7 +538,7 @@ for nombre, fn in (('Interfaz sin nada externo', p_estatico), ('Registrar carpet ('Historial y papelera', p_historial), ('Repetidos', p_repetidos), ('Stock viejo', p_stock), ('Carpetas vacías', p_vacias), ('Mapa interactivo', p_mapa), ('Colores en Windows', p_colores), ('Motores de conversión', p_motores), ('Leer clips (ffprobe)', p_analizar), - ('Convertir, verificar, reemplazar', p_convertir), ('Control de la cola', p_cola_control), + ('Convertir, verificar, reemplazar', p_convertir), ('Convertir un BRAW', p_braw), ('Control de la cola', p_cola_control), ('Respaldar', p_respaldo), ('Asistente / hacer de una vez', p_asistente), ('Pieza nueva', p_pieza), ('Bandeja de entrada y tarjeta', p_entrada), ('Orden automático', p_automatico), ('Arrastrar y buscar carpeta', p_soltar_y_buscar), ('Actualizaciones', p_actualizacion_offline), diff --git a/prueba_orden.py b/prueba_orden.py index 51b867e..8220e98 100644 --- a/prueba_orden.py +++ b/prueba_orden.py @@ -11,6 +11,7 @@ import json import time import shutil import tempfile +import threading import unittest AQUI = os.path.dirname(os.path.abspath(__file__)) @@ -18,7 +19,7 @@ TMP = tempfile.mkdtemp(prefix="organilab-motor-") APP = os.path.join(TMP, "app") os.makedirs(os.path.join(APP, "datos")) for _f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "firma.py", "instalar.py", "ventana.py", - "resolve_bridge.py"): + "resolve_bridge.py", "braw.py"): shutil.copy(os.path.join(AQUI, _f), APP) # un diccionario como el que dejaba la 3.1.1: con una carpeta de la propia app aprendida como grabación with open(os.path.join(APP, "datos", "diccionario.json"), "w", encoding="utf-8") as _fh: @@ -601,6 +602,432 @@ class Nuevo(unittest.TestCase): O.CONFIG["automatico"] = ajuste +# el lector de BRAW de mentira: habla como braw.py (una línea JSON por evento) sin decodificar nada +LECTOR_FALSO = """# -*- coding: utf-8 -*- +import os, sys, json, time +t = json.load(open(sys.argv[2], encoding="utf-8")) +modo = os.environ.get("BRAW_FALSO", "bien") +def di(**kw): + print(json.dumps(kw), flush=True) +di(msg="2160x4096 · 30 fps · 60 cuadros · curva de prueba") +if modo == "falla": + di(estado="error", log="el decodificador no pudo abrir el clip (prueba)") + sys.exit(1) +with open(t["tmp"], "wb") as fh: + fh.write(b"x" * 200000) +if modo == "lento": + for i in range(300): + di(pct=i / 3.0) + if os.path.exists(t["cancelar"]): + os.remove(t["tmp"]) + di(estado="cancelado") + sys.exit(1) + time.sleep(0.1) +di(pct=50.0) +di(estado="listo", pct=100, segundos=0.1) +""" + + +class Convertidor(unittest.TestCase): + """El convertidor, medido el 5-oct-2026 sobre la carpeta de Andrea Galeano: los BRAW no habían salido + nunca (87 clips, 462 GB, tres tandas fallidas por la vía de Resolve) y lo que ya venía bien comprimido + se reconvertía para ahorrar un 5 %, con una generación de pérdida encima.""" + + MOTORES = {"ffmpeg": True, "gpu": {"nombre": "", "hevc": False, "h264": False, "chroma422": False, "uhq": False}, + "cpu": {"libx265": True, "libx264": True, "prores_ks": True}, "formatos": {k: "cpu" for k in O.FORMATOS}, "chroma422": True} + + def setUp(self): + antes = (O.decodificador_braw, O.motores, O.medir_braw, O.ffprobe) + + def devolver(): + O.decodificador_braw, O.motores, O.medir_braw, O.ffprobe = antes + O.COLA["cancelar"] = False + os.environ.pop("BRAW_FALSO", None) + self.de_verdad = antes + self.addCleanup(devolver) + O.decodificador_braw = lambda: {"ok": True, "carpeta": "X:\\decodificador"} + O.motores = lambda: self.MOTORES + + def leidos(self, raiz, datos): + """Apunta en el inventario lo que ffprobe habría leído de cada clip.""" + con = O.db() + with con: + for nombre, d in datos.items(): + d = dict({"perfil": "", "pix_fmt": "yuv420p", "ancho": 3840, "alto": 2160, "fps": 30, "dur": 60, "acodec": "aac"}, **d) + n = con.execute("UPDATE archivos SET analizado = 1, codec = ?, perfil = ?, pix_fmt = ?, ancho = ?, alto = ?, fps = ?, dur = ?, " + "kbps = ?, acodec = ? WHERE raiz = ? AND nombre = ?", + (d["codec"], d["perfil"], d["pix_fmt"], d["ancho"], d["alto"], d["fps"], d["dur"], d["kbps"], d["acodec"], + os.path.normpath(raiz), nombre)).rowcount + assert n == 1, nombre + con.close() + + def trabajo(self, id_): + con = O.db() + t = dict(con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()) + con.close() + return t + + def test_lo_ya_comprimido_no_se_reconvierte(self): + """9 clips que ya eran H.265 se reconvirtieron para quedarse en el 95 % de su peso.""" + raiz = cliente("Comprimidos") + g = "2025/06- Junio/00_Grabacion/" + for n in ("A014_06170738_C019.mp4", "C0001.MP4", "C0002.MP4", "C0003.MP4", "C0004.MP4", "C0005.MP4"): + mk(raiz, g + n, 2025, 6, 17) + O.escanear(raiz) + self.leidos(raiz, { + "A014_06170738_C019.mp4": {"codec": "h264", "perfil": "Main", "ancho": 4608, "alto": 2592, "kbps": 25000}, + "C0001.MP4": {"codec": "h264", "perfil": "High 4:2:2 Intra", "pix_fmt": "yuv422p10le", "fps": 59.94, "kbps": 596000}, + "C0002.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "fps": 59.94, "kbps": 72000}, + "C0003.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000}, + "C0004.MP4": {"codec": "av1", "perfil": "Main", "kbps": 30000}, + "C0005.MP4": {"codec": "prores", "perfil": "HQ", "pix_fmt": "yuv422p10le", "kbps": 870000}, + }) + nr = os.path.normpath(raiz) + cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0)} + self.assertTrue(cs["A014_06170738_C019.mp4"].startswith("ya viene comprimido (H.264 a 25 Mb/s)"), cs) # por debajo del objetivo (30) + self.assertTrue(cs["C0002.MP4"].startswith("ya viene comprimido (H.265 a 72 Mb/s)"), cs) + self.assertTrue(cs["C0004.MP4"].startswith("ya viene comprimido (AV1 a 30 Mb/s)"), cs) + # lo que guarda cada cuadro entero (XAVC-I, ProRes) y el H.264 muy holgado sí adelgazan de verdad + self.assertEqual([cs["C0001.MP4"], cs["C0003.MP4"], cs["C0005.MP4"]], ["", "", ""]) + # a un formato de edición no se le aplica: ahí no se busca que pese menos + cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0, False, "prores_lt")} + self.assertEqual(cs["A014_06170738_C019.mp4"], "") + # y la lista de acciones cuenta solo lo que se puede aligerar + ajustes = dict(O.CONFIG["optimizar"]) + self.addCleanup(lambda: O.CONFIG["optimizar"].update(ajustes)) + O.CONFIG["optimizar"].update(min_mb=0, meses_minimos=0) + conv = [a for a in O.acciones(raiz)["acciones"] if a["clave"] == "convertir"] + self.assertEqual(len(conv), 1) + self.assertTrue(conv[0]["cifra"].startswith("3 clips"), conv[0]) + + def test_un_nombre_parecido_no_es_una_conversion(self): + """El sufijo solo no basta: «Entrevista_HQ.mp4» en H.264 no lo hizo este programa.""" + raiz = cliente("Parecidos") + g = "2025/06- Junio/00_Grabacion/" + for n in ("Entrevista_HQ.mp4", "C0001_10bit.mp4", "C0002_HQ.mov", "C0003_h264.mp4"): + mk(raiz, g + n, 2025, 6, 17) + O.escanear(raiz) + self.leidos(raiz, {"Entrevista_HQ.mp4": {"codec": "h264", "perfil": "High", "kbps": 400000}, + "C0001_10bit.mp4": {"codec": "hevc", "perfil": "Main 10", "kbps": 400000}, + "C0002_HQ.mov": {"codec": "prores", "perfil": "HQ", "kbps": 870000}, + "C0003_h264.mp4": {"codec": "hevc", "perfil": "Main", "kbps": 400000}}) + cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(os.path.normpath(raiz), 0, "equilibrado", 0, True)} + self.assertEqual(cs["Entrevista_HQ.mp4"], "") + self.assertEqual(cs["C0003_h264.mp4"], "") # dice _h264 y es H.265: tampoco es nuestro + self.assertIn("ya es una conversión", cs["C0001_10bit.mp4"]) + self.assertIn("ya es una conversión", cs["C0002_HQ.mov"]) + + def test_los_braw_van_por_el_lector(self): + raiz = cliente("Braws") + g = "2025/12- Diciembre/00_Grabacion/URSA_BRAW/" + a = mk(raiz, g + "A014_12091814_C001.braw", 2025, 12, 9) + b = mk(raiz, g + "A014_12091819_C002.braw", 2025, 12, 9) + O.escanear(raiz) + como_ffprobe = {"codec": "braw", "pix_fmt": "", "ancho": 4640, "alto": 2624, "kbps": 560000, "acodec": "pcm_s24le"} + self.leidos(raiz, {"A014_12091814_C001.braw": como_ffprobe, "A014_12091819_C002.braw": como_ffprobe}) + nr = os.path.normpath(raiz) + cs = O.candidatos(nr, 0, "equilibrado", 0) + self.assertEqual([(c["motor"], c["excluido"]) for c in cs], [("braw", ""), ("braw", "")]) + # con el lector, un BRAW sale a cualquier formato + self.assertEqual([c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0, False, "prores_lt")], ["", ""]) + # sin decodificador se intenta por Resolve, y por ahí solo a H.265 de 10 bits + O.decodificador_braw = lambda: {"ok": False, "motivo": "no está"} + self.assertEqual({c["motor"] for c in O.candidatos(nr, 0, "equilibrado", 0)}, {"resolve"}) + self.assertIn("Resolve", O.candidatos(nr, 0, "equilibrado", 0, False, "prores_lt")[0]["excluido"]) + self.assertEqual(O.encolar([a], "equilibrado", "420", "rec709", nr, "prores_lt"), []) + viejo = O.encolar([a], "equilibrado", "420", "rec709", nr) + self.assertEqual(self.trabajo(viejo[0])["motor"], "resolve") + # así quedó la cola de verdad: cada clip fallido dos veces, y uno apuntando a donde ya no está + con = O.db() + with con: + con.execute("UPDATE trabajos SET estado = 'error', log = 'Resolve está en la ventana del Gestor de proyectos' WHERE id = ?", (viejo[0],)) + for ruta in (a, os.path.join(nr, "Orden", "A014_12091814_C001.braw")): + con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (ruta, ruta[:-5] + "_10bit.mp4", "resolve", "equilibrado", "420", "rec709", 31047, + "error", 1, time.time(), nr, "h265_10bit")) + con.close() + self.assertEqual(O.braws_al_lector(), 0) # sin decodificador no se toca nada + O.decodificador_braw = lambda: {"ok": True, "carpeta": "X:\\decodificador"} + self.assertEqual(O.braws_al_lector(), 1) + con = O.db() + filas = [tuple(r) for r in con.execute("SELECT ruta, motor, estado FROM trabajos WHERE raiz = ?", (nr,))] + con.close() + self.assertEqual(filas, [(a, "braw", "error")]) + # volver a encolar el clip sustituye el intento fallido en vez de dejar otro al lado + nuevos = O.encolar([a, b], "equilibrado", "420", "rec709", nr) + self.assertEqual(len(nuevos), 2) + con = O.db() + filas = sorted(tuple(r) for r in con.execute("SELECT ruta, motor, estado FROM trabajos WHERE raiz = ?", (nr,))) + con.execute("DELETE FROM trabajos WHERE raiz = ?", (nr,)) + con.commit() + con.close() + self.assertEqual(filas, [(a, "braw", "pendiente"), (b, "braw", "pendiente")]) + + def test_un_braw_pasa_por_la_cola(self): + """La cola con un lector de mentira: lo que importa aquí es lo que hace el programa con lo que el lector dice.""" + raiz = cliente("ColaBraw") + g = "2025/03- Marzo/00_Grabacion/URSA_BRAW/" + clips = [mk(raiz, g + f"A002_0311042{i}_C04{i}.braw", 2025, 3, 11, tam=1_000_000) for i in (4, 5, 6)] + mk(raiz, g + "A002_03110424_C044.sidecar", 2025, 3, 11) + O.escanear(raiz) + self.leidos(raiz, {os.path.basename(c): {"codec": "braw", "pix_fmt": "", "ancho": 4128, "alto": 2176, "kbps": 407000, "dur": 2.0, + "acodec": "pcm_s24le"} for c in clips}) + nr = os.path.normpath(raiz) + # el decodificador dice el tamaño de verdad: el clip es vertical, y ffprobe lo leyó tumbado y con relleno + O.medir_braw = lambda ruta: {"ok": True, "ancho": 2160, "alto": 4096, "fps": 30.0, "cuadros": 60, "dur": 2.0, "tc": "04:27:26:19", + "camara": "Blackmagic Pocket Cinema Camera 4K", "gamma": "Blackmagic Design Film", "gamut": "Blackmagic Design", "gen": 5} + lector = os.path.join(APP, "braw.py") + shutil.copy(lector, lector + ".de-verdad") + self.addCleanup(lambda: os.replace(lector + ".de-verdad", lector)) + with open(lector, "w", encoding="utf-8") as fh: + fh.write(LECTOR_FALSO) + ids = O.encolar(clips, "equilibrado", "420", "rec709", nr) + self.assertEqual(len(ids), 3) + + # 1) sale bien + O.correr_braw(self.trabajo(ids[0])) + t = self.trabajo(ids[0]) + self.assertEqual(t["estado"], "listo", t["log"]) + self.assertEqual(os.path.getsize(t["salida"]), 200000) + self.assertIn("curva de prueba", t["log"]) + self.assertEqual(t["kbps"], O.kbps_objetivo(2160, 4096, 30.0, "equilibrado")) # la tasa, con el tamaño de verdad + con = O.db() + f = con.execute("SELECT ancho, alto, perfil FROM archivos WHERE ruta = ?", (clips[0],)).fetchone() + con.close() + self.assertEqual((f["ancho"], f["alto"], f["perfil"]), (2160, 4096, "Film")) + self.assertEqual([x for x in os.listdir(O.DATOS) if x.startswith("braw_")], []) + # la verificación compara con ese tamaño: una salida tumbada no pasa + bien = {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "ancho": 2160, "alto": 4096, "fps": 30.0, "dur": 2.0, "kbps": 20000, + "alfa": 0, "acodec": "aac", "pistas_audio": 1} + O.ffprobe = lambda ruta: dict(bien, ancho=4128, alto=2176) + v = O.verificar([ids[0]])[0] + self.assertFalse(v["ok"]) + self.assertIn("2160×4096", v["motivo"]) + O.set_trabajo(ids[0], estado="listo") + O.ffprobe = lambda ruta: dict(bien) + self.assertTrue(O.verificar([ids[0]])[0]["ok"]) + # al reemplazar, el .sidecar del BRAW se va con él + self.assertTrue(O.reemplazar([ids[0]])[0]["ok"]) + apartados = os.path.join(nr, R.PAPELERA, "originales", "2025", "03- Marzo", "00_Grabacion", "URSA_BRAW") + self.assertEqual(sorted(os.listdir(apartados)), ["A002_03110424_C044.braw", "A002_03110424_C044.sidecar"]) + self.assertTrue(os.path.isfile(t["salida"])) + + # 2) el lector dice que no puede: el motivo llega tal cual y no queda nada a medias + os.environ["BRAW_FALSO"] = "falla" + O.correr_braw(self.trabajo(ids[1])) + t = self.trabajo(ids[1]) + self.assertEqual((t["estado"], t["log"]), ("error", "el decodificador no pudo abrir el clip (prueba)")) + self.assertFalse(os.path.exists(t["salida"]) or os.path.exists(t["salida"] + ".parcial.mp4")) + + # 3) Detener a mitad de un clip + os.environ["BRAW_FALSO"] = "lento" + hilo = threading.Thread(target=O.correr_braw, args=(self.trabajo(ids[2]),)) + hilo.start() + fin = time.time() + 20 + while time.time() < fin and not (self.trabajo(ids[2])["pct"] or 0) > 0: + time.sleep(0.1) + O.COLA["cancelar"] = True + hilo.join(40) + self.assertFalse(hilo.is_alive()) + t = self.trabajo(ids[2]) + self.assertEqual(t["estado"], "cancelado") + self.assertFalse(os.path.exists(t["salida"]) or os.path.exists(t["salida"] + ".parcial.mp4")) + self.assertEqual([x for x in os.listdir(O.DATOS) if x.startswith("braw_")], []) + + # 4) si ya hay un archivo con el nombre de la salida, no se pisa + O.COLA["cancelar"] = False + os.environ["BRAW_FALSO"] = "bien" + O.set_trabajo(ids[1], estado="pendiente") + with open(self.trabajo(ids[1])["salida"], "wb") as fh: + fh.write(b"de otro") + O.correr_braw(self.trabajo(ids[1])) + t = self.trabajo(ids[1]) + self.assertEqual(t["estado"], "error") + self.assertIn("no se pisa", t["log"]) + with open(t["salida"], "rb") as fh: + self.assertEqual(fh.read(), b"de otro") + + def test_lo_que_no_compensa_no_reemplaza(self): + """8 conversiones esperaban con «Reemplazar 8 verificados»: seis eran copias de una copia, al 85-99 % de su peso.""" + raiz = cliente("NoCompensa") + g = "2025/11- Noviembre/00_Grabacion/SONY/" + clips = [mk(raiz, g + n, 2025, 11, 11, tam=300000) for n in ("C0001_10bit.mp4", "C0002.MP4", "C0003.MP4")] + O.escanear(raiz) + nr = os.path.normpath(raiz) + self.leidos(raiz, {"C0001_10bit.mp4": {"codec": "hevc", "perfil": "Main 10", "kbps": 72000}, + "C0002.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000}, + "C0003.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000}}) + ids = [] + con = O.db() + with con: + # una reconversión (93 %), un clip que apenas aligera (95 %) y uno que sí (40 %) + for ruta, pesa in zip(clips, (280000, 285000, 120000)): + salida = os.path.splitext(ruta)[0] + "_10bit.mp4" + with open(salida, "wb") as fh: + fh.write(b"x" * pesa) + ids.append(con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (ruta, salida, "ffmpeg", "equilibrado", "420", "rec709", 21150, "listo", 300000, + time.time(), nr, "h265_10bit")).lastrowid) + con.close() + O.ffprobe = lambda ruta: {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "ancho": 3840, "alto": 2160, "fps": 30.0, "dur": 60.0, + "kbps": 20000, "alfa": 0, "acodec": "aac", "pistas_audio": 1} + v = O.verificar(ids) + self.assertEqual([x["ok"] for x in v], [False, False, True]) + self.assertIn("93 %", v[0]["motivo"]) + self.assertIn("ya era una conversión", v[0]["motivo"]) + self.assertIn("95 %", v[1]["motivo"]) + self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["descartado", "descartado", "verificado"]) + # lo descartado ni se reemplaza… + r = O.reemplazar(ids) + self.assertEqual([x["ok"] for x in r], [False, False, True]) + self.assertEqual([os.path.isfile(c) for c in clips], [True, True, False]) + # …ni aunque viniera dado por bueno de una versión anterior: se repasa al arrancar y al reemplazar + O.set_trabajo(ids[0], estado="verificado") + O.set_trabajo(ids[1], estado="verificado") + r = O.reemplazar([ids[1]])[0] + self.assertFalse(r["ok"]) + self.assertTrue(r["motivo"].startswith("no compensa"), r) + self.assertEqual(O.repasar_verificados(), 1) + self.assertEqual([self.trabajo(i)["estado"] for i in ids[:2]], ["descartado", "descartado"]) + self.assertTrue(os.path.isfile(clips[0]) and os.path.isfile(clips[1])) + # y el clip no se vuelve a ofrecer con la misma calidad; con otra, sí + cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0)} + self.assertTrue(cs["C0002.MP4"].startswith("ya se probó con esta calidad: pesa el 95 %"), cs) + cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "ahorro", 0)} + self.assertEqual(cs["C0002.MP4"], "") + # a un formato de edición no se le pide que pese menos + self.assertEqual(O._no_compensa({"formato": "prores_lt", "ruta": clips[1], "bytes_in": 100, "bytes_out": 900}), "") + + def test_lo_que_no_compensa_se_aparta(self): + """Lo que salió de una conversión que no compensa no se queda junto al original ocupando su nombre, y un + intento que no pasó la verificación se puede rehacer.""" + raiz = cliente("Apartar") + g = "2025/11- Noviembre/00_Grabacion/SONY/" + a, b, c = (mk(raiz, g + n, 2025, 11, 11, tam=300000) for n in ("C0001.MP4", "C0002.MP4", "C0003.MP4")) + O.escanear(raiz) + nr = os.path.normpath(raiz) + self.leidos(raiz, {n: {"codec": "h264", "perfil": "High", "kbps": 120000} for n in ("C0001.MP4", "C0002.MP4", "C0003.MP4")}) + O.ffprobe = lambda ruta: {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "ancho": 3840, "alto": 2160, "fps": 30.0, "dur": 60.0, + "kbps": 20000, "alfa": 0, "acodec": "aac", "pistas_audio": 1} + ids = O.encolar([a, b, c], "equilibrado", "420", "rec709", nr) + self.assertEqual(len(ids), 3) + for i in ids: # tres conversiones que salieron casi del mismo peso + with open(self.trabajo(i)["salida"], "wb") as fh: + fh.write(b"x" * 290000) + O.set_trabajo(i, estado="listo", bytes_out=290000) + apartado = lambda t: os.path.join(nr, R.PAPELERA, "descartados", os.path.relpath(t["salida"], nr)) + + # 1) recién convertida y no compensa: lo que salió se aparta en el acto, con su lote en la bitácora + v = O.verificar([ids[0]])[0] + self.assertFalse(v["ok"]) + self.assertIn("descartados", v["motivo"]) + t0 = self.trabajo(ids[0]) + self.assertFalse(os.path.exists(t0["salida"])) + self.assertTrue(os.path.isfile(apartado(t0))) + self.assertTrue(os.path.isfile(a)) # el original, intacto + lotes = [e["lote"] for e in O.leer_bitacora() if e.get("op") == "fin" and e.get("raiz") == nr and e["lote"].startswith("descartados-")] + self.assertEqual(len(lotes), 1) + O.deshacer(lotes[0]) # y «Deshacer» lo devuelve + self.assertTrue(os.path.isfile(t0["salida"])) + + # 2) las que venían «verificadas» de antes: al arrancar solo cambian de estado, sin mover nada… + O.set_trabajo(ids[1], estado="verificado") + O.set_trabajo(ids[2], estado="verificado") + self.assertEqual(O.repasar_verificados(), 2) + t1, t2 = self.trabajo(ids[1]), self.trabajo(ids[2]) + self.assertEqual((t1["estado"], t2["estado"]), ("descartado", "descartado")) + self.assertTrue(os.path.isfile(t1["salida"]) and os.path.isfile(t2["salida"])) + # …volver a convertir el clip con otra calidad aparta lo que salió de aquel y deja libre la ruta… + nuevos = O.encolar([b], "ahorro", "420", "rec709", nr) + self.assertEqual(len(nuevos), 1) + self.assertFalse(os.path.exists(t1["salida"])) + self.assertTrue(os.path.isfile(apartado(t1))) + # …y «Limpiar terminados» aparta lo de los que quedaran + r = O.apartar_descartados([ids[2]]) + self.assertEqual((r["movidos"], r["errores"], r["quedan"]), (1, [], [])) + self.assertFalse(os.path.exists(t2["salida"])) + self.assertTrue(os.path.isfile(apartado(t2))) + + # 3) un intento que no pasó la verificación se rehace: su propia salida no bloquea la ruta; otra sí + tn = self.trabajo(nuevos[0]) + with open(tn["salida"], "wb") as fh: + fh.write(b"y" * 1000) + O.set_trabajo(nuevos[0], estado="error", bytes_out=1000, log="verificación: duración 3.00s vs original 60.00s") + self.assertFalse(O._salida_ajena(self.trabajo(nuevos[0]))) + with open(tn["salida"], "wb") as fh: + fh.write(b"z" * 999) # alguien dejó otro archivo con ese nombre + self.assertTrue(O._salida_ajena(self.trabajo(nuevos[0]))) + + def test_el_codificador_que_toca(self): + fm = O.FORMATOS["h265_10bit"] + t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000} + con_uhq = dict(self.MOTORES, gpu=dict(self.MOTORES["gpu"], hevc=True, h264=True, uhq=True), formatos={k: "gpu" for k in O.FORMATOS}) + O.motores = lambda: con_uhq + args, pix = O._args_video(t, fm, "gpu", 30) + self.assertEqual((pix, args[args.index("-tune") + 1], args[args.index("-profile:v") + 1]), ("p010le", "uhq", "main10")) + self.assertNotIn("-bf", args) # «uhq» no admite que se le fijen los cuadros B + self.assertEqual(O._args_video(dict(t, chroma="422"), fm, "gpu", 30)[1], "p210le") + self.assertIn("hq", O._args_video(dict(t, chroma="422"), fm, "gpu", 30)[0]) # solo donde se midió: 4:2:0 + self.assertIn("hq", O._args_video(t, O.FORMATOS["h264"], "gpu", 30)[0]) + O.motores = lambda: dict(con_uhq, gpu=dict(con_uhq["gpu"], uhq=False)) + args, _ = O._args_video(t, fm, "gpu", 30) + self.assertEqual(args[args.index("-tune") + 1], "hq") + self.assertIn("-bf", args) + # la tarjeta tiene un tamaño máximo de imagen por códec: un 4.6K en H.264 falla («No capable devices found») + self.assertEqual(O._motor_de("h264", "420", 4608, 2592)[0], "cpu") + self.assertEqual(O._motor_de("h264", "420", 3840, 2160)[0], "gpu") + self.assertEqual(O._motor_de("h265_10bit", "420", 4608, 2592)[0], "gpu") + self.assertEqual(O._motor_de("h265_10bit", "420", 12288, 6480)[0], "cpu") + # el contenedor guarda las etiquetas de la cámara, sin arrastrar las marcas de formato del original + cont, audio = O._args_salida(fm, None) + self.assertIn("+faststart+use_metadata_tags", cont) + self.assertIn("major_brand=", cont) + self.assertEqual(audio, ["-c:a", "aac", "-b:a", "320k"]) + + def test_el_lector_sin_decodificador(self): + """Lo que braw.py hace sin tocar el decodificador, y cómo responde si lo que encuentra no sirve.""" + import braw + self.assertEqual([braw.fraccion(x) for x in (23.976, 24.0, 25.0, 29.97, 30.0, 59.94, 60.0)], + ["24000/1001", "24", "25", "30000/1001", "30", "60000/1001", "60"]) + falso = os.path.join(TMP, "decodificador-falso") + os.makedirs(falso) + self.assertNotEqual(braw.buscar(falso), falso) + with open(os.path.join(falso, braw.NOMBRE), "wb") as fh: + fh.write(b"esto no es una DLL") + self.assertEqual(braw.buscar(falso), falso) + r = braw.sonda(falso) + self.assertFalse(r["ok"]) + self.assertTrue(r["motivo"]) + r = braw.info(os.path.join(TMP, "no-existe.braw"), falso) + self.assertFalse(r["ok"]) + self.assertTrue(r["error"].startswith("el clip ya no está en esa ruta"), r) + + @unittest.skipUnless(os.environ.get("ORGANILAB_BRAW_PRUEBA"), "sin un .braw de prueba (variable ORGANILAB_BRAW_PRUEBA)") + def test_un_braw_de_verdad(self): + """Con el decodificador instalado y un clip real: se mide, se convierte, se verifica.""" + O.decodificador_braw, O.motores, O.medir_braw, O.ffprobe = self.de_verdad + self.assertTrue(O.decodificador_braw()["ok"], O.decodificador_braw()) + raiz = cliente("BrawReal") + destino = os.path.join(raiz, "2025", "03- Marzo", "00_Grabacion", "URSA_BRAW", "A002_03110347_C038.braw") + os.makedirs(os.path.dirname(destino)) + shutil.copy(os.environ["ORGANILAB_BRAW_PRUEBA"], destino) + O.escanear(raiz) + nr = os.path.normpath(raiz) + O.analizar(nr, 0) + real = O.medir_braw(destino) + self.assertTrue(real["ok"], real) + c = O.candidatos(nr, 0, "equilibrado", 0)[0] + self.assertEqual((c["motor"], c["excluido"], c["res"]), ("braw", "", f"{real['ancho']}x{real['alto']}")) + ids = O.encolar([destino], "equilibrado", "420", "rec709", nr) + O.correr_braw(self.trabajo(ids[0])) + t = self.trabajo(ids[0]) + self.assertEqual(t["estado"], "listo", t["log"]) + v = O.verificar(ids)[0] + self.assertTrue(v["ok"], v) + i = O.ffprobe(t["salida"]) + self.assertEqual((i["codec"], i["pix_fmt"], i["ancho"], i["alto"]), ("hevc", "yuv420p10le", real["ancho"], real["alto"])) + + if __name__ == "__main__": try: unittest.main(verbosity=2, exit=False) diff --git a/prueba_ventana.py b/prueba_ventana.py index c65b08b..fe7211d 100644 --- a/prueba_ventana.py +++ b/prueba_ventana.py @@ -27,7 +27,7 @@ import urllib.request from ctypes import wintypes as W AQUI = os.path.dirname(os.path.abspath(__file__)) -MODULOS = ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "firma.py", +MODULOS = ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "resolve_bridge.py", "braw.py", "firma.py", "ventana.py", "instalar.py", "WebView2Loader.dll") RES = [] # (nombre, pasó, detalle) U = ctypes.WinDLL("user32") diff --git a/resolve_bridge.py b/resolve_bridge.py index ef9eec2..a20118f 100644 --- a/resolve_bridge.py +++ b/resolve_bridge.py @@ -40,6 +40,10 @@ def fps_texto(fps): def main(ruta_lote=None): + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") # el programa lo lee en UTF-8; sin esto las tildes llegaban rotas + except Exception: # noqa + pass ruta_lote = ruta_lote or (sys.argv[1] if len(sys.argv) > 1 else None) if not ruta_lote: emitir(msg="uso: resolve_bridge.py lote.json") diff --git a/static/index.html b/static/index.html index 8d31696..4ff1f1c 100644 --- a/static/index.html +++ b/static/index.html @@ -218,7 +218,7 @@ .colabar .b i{display:block;height:100%;background:var(--acc)} .colabar b{white-space:nowrap} .est{display:inline-block;padding:1px 8px;border-radius:99px;font-size:11px;font-weight:600;letter-spacing:.02em;white-space:nowrap} - .est.pendiente,.est.cancelado{background:var(--panel2);color:var(--mut)} + .est.pendiente,.est.cancelado,.est.descartado{background:var(--panel2);color:var(--mut)} .est.corriendo{background:var(--acc2);color:var(--acc)} .est.listo,.est.verificado,.est.reemplazado{background:var(--ok2);color:var(--ok)} .est.error{background:var(--bad2);color:var(--bad)} @@ -280,6 +280,7 @@ .cfila .nom{min-width:0} .cfila .nom .clip{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600} .cfila .nom .sub2{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;color:var(--mut);margin-top:1px} + .cfila .nom .sub2.err{white-space:normal} .cfila .tam{color:var(--mut);font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap} .cfila .ah{font-variant-numeric:tabular-nums;text-align:right;font-weight:600;color:var(--ok);white-space:nowrap} .cfila .bar{margin-top:4px;height:5px} @@ -493,10 +494,10 @@ - + -
Convierte los clips de proyectos ya entregados a un formato más ligero. El original no se toca hasta que reemplaces. ?Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a _Para borrar/originales y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW van por DaVinci Resolve Studio (debe estar abierto) y solo salen a H.265 10 bits; el resto por ffmpeg, con la GPU cuando la hay. Nada se borra.
+Convierte los clips de proyectos ya entregados a un formato más ligero. El original no se toca hasta que reemplaces. ?Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a _Para borrar/originales y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW se leen con el decodificador de Blackmagic que instala DaVinci Resolve, sin tener que abrirlo; todo se codifica con ffmpeg, con la tarjeta gráfica cuando la hay. Lo que ya viene comprimido de cámara (H.264 o H.265 de pocos megabits) no se ofrece: ahorraría poco y perdería calidad. Nada se borra.