From 0819f2da896195441c12e917f520f5262101ece1 Mon Sep 17 00:00:00 2001 From: Labstream Date: Mon, 5 Oct 2026 13:24:34 -0500 Subject: [PATCH] 3.4.0: los BRAW se convierten sin Resolve, y lo que no compensa no se reconvierte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Munka no podía reconvertir archivos que antes sí. Auditado sobre la carpeta de Andrea Galeano: - Los BRAW nunca habían salido: 87 clips (496 GB), tres tandas por el guion de Resolve y ni un clip convertido. Ahora los lee el decodificador de Blackmagic que instala Resolve (braw.py, en un proceso aparte), sin abrir Resolve, y ffmpeg los codifica como cualquier otro clip. Probado con clips del NAS: 15,6 GB → 394 MB con sus 7.004 cuadros, VMAF 96,0 contra el BRAW revelado sin pérdida. Las conversiones de BRAW que fallaron por Resolve pasan solas al lector. - Lo que «antes podía» eran reconversiones de lo ya convertido: ahorraban un 5 % con pérdida. Siguen sin ofrecerse, y lo que ya viene comprimido de cámara solo se ofrece si compensa. - La tarjeta codifica con «uhq»: entre un 19 y un 55 % menos de peso con la misma calidad medida, siempre H.265 de 10 bits. - Una conversión que no aligera (más del 80 % del clip, o reconversión de una conversión) no reemplaza al original: queda «no compensa» y lo que salió va a _Para borrar\descartados. De paso: los MP4 guardan las etiquetas de la cámara; un intento que no pasó la verificación se puede reintentar; un H.264 de más de 4.096 px de lado va por CPU; el motivo de un fallo se lee entero; el guion de Resolve llega con tildes y borra su ficha. Pruebas: prueba_orden.py 33 (una con un BRAW de verdad), probar.py 19, prueba_funcional.py 30, prueba_ventana.py 18 desde el código y 17 con el paquete. Co-Authored-By: Claude Opus 5.5 --- README.md | 83 ++++-- asistente.py | 4 +- braw.py | 643 ++++++++++++++++++++++++++++++++++++++++++++ empaquetar.py | 2 +- manifiesto.json | 22 +- organizador.py | 629 +++++++++++++++++++++++++++++++++++++------ probar.py | 4 +- prueba_funcional.py | 56 +++- prueba_orden.py | 429 ++++++++++++++++++++++++++++- prueba_ventana.py | 2 +- resolve_bridge.py | 4 + static/index.html | 36 +-- 12 files changed, 1767 insertions(+), 147 deletions(-) create mode 100644 braw.py 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.

detectando GPU y CPU…
@@ -601,7 +602,7 @@
Buscar clips
Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero, los exportados y lo que ya convirtió este programa, para no pasar un clip dos veces.
Formato y calidad
H.265 10 bits (el recomendado, unas diez veces más ligero), H.265 ligero, H.264 o ProRes proxy, LT y HQ; calidad de ahorro, equilibrada o máxima. «Motores» te dice qué acelera tu tarjeta gráfica.
-
Convertir
Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW pasan por DaVinci Resolve Studio, que tiene que estar abierto en un proyecto.
+
Convertir
Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW los lee el decodificador de Blackmagic que viene con DaVinci Resolve, sin abrirlo. No se ofrece lo que ya viene comprimido de cámara ni lo que ya convirtió el programa: reconvertirlo ahorra poco y pierde calidad.
Verificar
Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.
Reemplazar
El convertido ocupa el sitio del original, con su misma fecha, y el original se aparta a _Para borrar\originales.
@@ -773,7 +774,7 @@ async function cargarEstado(){ const bad = $('#btnAnaDetener'); if (bad) bad.onclick = () => accion(bad, async () => { await api('/api/optimizar/analizar/detener', {raiz: raizActual()}); }); const h = ESTADO.herramientas || {}; const faltas = []; if (h.ffmpeg && !h.ffmpeg.ok) faltas.push('ffmpeg no encontrado: sin él no se puede leer ni convertir (pon ffmpeg.exe y ffprobe.exe en la carpeta ffmpeg\\ junto al programa)'); - if (h.resolve && !h.resolve.ok) faltas.push('DaVinci Resolve no está instalado: los BRAW no se podrán convertir'); + if (h.braw && !h.braw.ok) faltas.push('Falta el decodificador de Blackmagic RAW (se instala con DaVinci Resolve, vale el gratuito): los BRAW no se podrán convertir'); $('#reloj').innerHTML = (faltas.length ? `⚠ ${faltas.length} aviso${faltas.length > 1 ? 's' : ''} · ` : '') + `v${esc(ESTADO.version || '')}`; pintarTareas(); pintarActualizacion(); @@ -1070,6 +1071,7 @@ function queSeHizo(lote){ if (!lote) return 'Cambio'; if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por la versión convertida'; if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo'; + if (lote.startsWith('descartados-')) return 'Apartó conversiones que no compensaban'; if (lote.startsWith('dup-')) return 'Juntó material repetido'; if (lote.startsWith('vacias-')) return 'Borró carpetas vacías'; if (lote.startsWith('pieza-')) return 'Creó una pieza nueva'; @@ -1084,7 +1086,7 @@ function pasosDe(C, r){ : esc ? {e:'hecho', t:'Escanear', d:`${fecha(C.escaneo.ts)} · ${num(C.escaneo.n)} archivos`, corto: fechaCorta(C.escaneo.ts)} : {e:'pend', t:'Escanear', d:'Lee nombres y tamaños. Solo lectura.', corto: ''}); const sn = C.sin_nombre_n || 0; P.push(!esc ? {e:'bloq', t:'Revisar', d:'Después de escanear'} : sn ? {e:'listo', t:'Revisar', d:`${sn} carpeta${sn > 1 ? 's' : ''} por nombrar`, corto: `${sn} por nombrar`} : {e:'hecho', t:'Revisar', d:'Todas las carpetas reconocidas', corto: 'ok'}); - const lotes = (C.lotes || []).filter(l => !l.lote.startsWith('stock-') && !l.lote.startsWith('optimizar')); + const lotes = (C.lotes || []).filter(l => !l.lote.startsWith('stock-') && !l.lote.startsWith('optimizar') && !l.lote.startsWith('descartados-')); const asis = C.asistente && C.asistente.activo; P.push(!esc ? {e:'bloq', t:'Ordenar', d:'Después de escanear'} : asis ? {e:'curso', t:'Ordenar', d: (NOMBRE_PASO[C.asistente.paso] || '') + '…', corto: 'en marcha'} : C.plan ? {e:'listo', t:'Ordenar', d:`Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`, corto: `${C.plan.marcadas} mov.`} @@ -1122,7 +1124,7 @@ function pintarSiguiente(P){ else if (P[2].e === 'pend') { s.t = 'Siguiente: ordenar la carpeta'; s.p = 'Calcula qué va a pasar (basura y proxies a _Para borrar, grabaciones por mes y cámara, piezas numeradas) y aplícalo con un clic. O deja que el asistente te pregunte paso a paso.'; b('Calcular qué va a pasar', () => { irA('revisar'); subRev('plan'); $('#btnPlan').click(); }, true); } else if (P[3].e === 'listo') { const d = P[3].d; s.t = 'Siguiente: ' + (d.includes('verificar') ? 'verificar los clips convertidos' : d.includes('reemplazar') ? 'reemplazar los originales' : 'retomar la conversión'); s.p = d.includes('verificar') ? 'Verificar comprueba códec, 10 bits y duración. Después puedes reemplazar los originales (quedan en _Para borrar/originales).' : d.includes('reemplazar') ? 'Los originales se apartan a _Para borrar/originales y el MP4 10 bits queda en su lugar. Nada se borra.' : 'Hay clips en cola con la conversión parada.'; b('Ir a Convertir', () => irA('convertir'), true); } else if (P[3].e === 'curso') { s.t = 'Convirtiendo clips'; s.p = 'Sigue en segundo plano. El avance está en la barra de abajo; cuando termine, toca reemplazar.'; b('Ver la cola', () => irA('convertir')); } - else if (P[3].e === 'pend') { const cv = C.convertir; s.t = 'Siguiente: aligerar el material de cámara'; s.p = `Hay ${gb(cv.camara_bytes)} de clips de cámara. Los de proyectos ya entregados pueden pasar a MP4 10 bits, unas 10 veces más ligeros. ${cv.raw_bytes ? 'Los BRAW necesitan DaVinci Resolve Studio abierto. ' : ''}El original no se toca hasta que reemplaces.`; b('Buscar clips a convertir', () => { irA('convertir'); $('#btnBuscarClips').click(); }, true); b('Saltar a respaldar', () => irA('respaldar')); } + else if (P[3].e === 'pend') { const cv = C.convertir; s.t = 'Siguiente: aligerar el material de cámara'; s.p = `Hay ${gb(cv.camara_bytes)} de clips de cámara. Los de proyectos ya entregados pueden pasar a MP4 10 bits, unas 10 veces más ligeros. El original no se toca hasta que reemplaces.`; b('Buscar clips a convertir', () => { irA('convertir'); $('#btnBuscarClips').click(); }, true); b('Saltar a respaldar', () => irA('respaldar')); } else if (P[4].e === 'listo') { s.t = 'Siguiente: copiar el respaldo'; s.p = P[4].d + '. Es incremental: lo que ya esté igual se salta.'; b('Ir a Respaldar', () => irA('respaldar'), true); } else if (P[4].e === 'curso') { s.t = 'Copiando el respaldo'; s.p = 'Sigue en segundo plano. Puedes detenerlo desde la barra de abajo y retomarlo luego.'; } else { s.t = 'Siguiente: respaldo compacto en otro disco'; s.p = 'Copia organizada por carpeta y tipo, con los clips ya convertidos en vez de los originales y un índice. ' + (C.respaldo.raw_pendientes ? `${C.respaldo.raw_pendientes} originales de cámara sin convertir no se copiarían.` : ''); b('Calcular el respaldo', () => irA('respaldar'), true); } @@ -1390,7 +1392,9 @@ async function cargarMotores(){ const g = MOTORES.gpu, c = MOTORES.cpu; const gpuT = g.hevc || g.h264 ? `GPU ${esc(g.nombre || 'NVIDIA')}: ${[g.hevc && 'H.265', g.h264 && 'H.264'].filter(Boolean).join(' y ')} acelerados` : 'Sin GPU NVIDIA: todo por CPU (más lento)'; const cpuT = `CPU: ${[c.prores_ks && 'ProRes', c.libx265 && 'H.265', c.libx264 && 'H.264'].filter(Boolean).join(', ') || 'nada'}`; - $('#motores').innerHTML = MOTORES.ffmpeg ? `${gpuT} · ${cpuT}` : 'ffmpeg no encontrado: no se puede convertir'; + const lb = MOTORES.braw || {}; + const brawT = lb.ok ? 'BRAW: se leen sin abrir Resolve' : `BRAW: falta el decodificador de Blackmagic`; + $('#motores').innerHTML = MOTORES.ffmpeg ? `${gpuT} · ${cpuT} · ${brawT}` : 'ffmpeg no encontrado: no se puede convertir'; const ch = $('#optChroma'); const op422 = ch && ch.querySelector('option[value="422"]'); if (op422) { const hay = MOTORES.chroma422 !== false; op422.disabled = !hay; op422.textContent = '4:2:2 10 bits' + (hay ? (MOTORES.gpu.chroma422 ? ' (pesa más)' : ' (por CPU, más lento)') : ' (este equipo no puede)'); if (!hay && ch.value === '422') ch.value = '420'; } $$('#optFormato option').forEach(o => { const m = MOTORES.formatos[o.value]; o.disabled = !m; o.textContent = (ESTADO.formatos[o.value] || {}).nombre + (m === 'gpu' ? ' · GPU' : m === 'cpu' ? ' · CPU' : ' · no disponible'); }); @@ -1403,20 +1407,20 @@ async function cargarCandidatos(){ await cargarCola(); } function resumenCand(){ const s = CAND.filter(c => c.sel); const validos = CAND.filter(c => !c.excluido && !c.en_cola); bloquear('btnEncolar', validos.length ? (s.length ? '' : 'x') : 'x'); $('#btnEncolar').textContent = s.length ? `Convertir ${s.length} marcado${s.length > 1 ? 's' : ''}` : 'Convertir'; } -$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `

${s.length} clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a ${esc(fm.nombre)}, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.

El original no se toca. Cuando terminen y estén verificados, decides si reemplazar.

`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola, su salida ya existe (mismo clip en MP4 y MOV) o son BRAW con un formato que no sea H.265 10 bits.`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = 'Convertir'; }; -const EST_TXT = {pendiente:'en espera', corriendo:'convirtiendo', listo:'convertido', verificado:'verificado', reemplazado:'reemplazado', error:'falló', cancelado:'cancelado'}; +$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `

${s.length} clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a ${esc(fm.nombre)}, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.

El original no se toca. Cuando terminen y estén verificados, decides si reemplazar.

`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola o su salida ya existe (el mismo clip en MP4 y MOV, o una conversión anterior).`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = 'Convertir'; }; +const EST_TXT = {pendiente:'en espera', corriendo:'convirtiendo', listo:'convertido', verificado:'verificado', reemplazado:'reemplazado', error:'falló', cancelado:'cancelado', descartado:'no compensa'}; async function cargarCola(){ const d = await api('/api/optimizar/cola'); const raiz = raizActual(); const ts = d.trabajos.filter(t => !raiz || (t.raiz || '').toLowerCase() === raiz.toLowerCase()); - const orden = {corriendo:0, pendiente:1, listo:2, verificado:2, error:3, reemplazado:4, cancelado:5}; + const orden = {corriendo:0, pendiente:1, listo:2, verificado:2, error:3, descartado:4, reemplazado:5, cancelado:6}; ts.sort((x, y) => (orden[x.estado] ?? 9) - (orden[y.estado] ?? 9) || y.id - x.id); COLA_TS = ts; const n = k => ts.filter(t => t.estado === k).length; const pend = n('pendiente'), listos = n('listo'), verif = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores')).length, err = n('error') + n('cancelado'); - const est = $('#colaEstado'); est.className = 'est ' + (d.cola.activa ? 'corriendo' : err ? 'error' : verif ? 'verificado' : 'pendiente'); est.textContent = d.cola.activa ? `convirtiendo${d.cola.mensaje ? ' · ' + d.cola.mensaje : ''}` : pend ? `${pend} en espera, parada` : ts.length ? `${verif} verificados · ${n('reemplazado')} reemplazados${err ? ` · ${err} con error` : ''}` : 'sin actividad'; + const est = $('#colaEstado'); est.className = 'est ' + (d.cola.activa ? 'corriendo' : err ? 'error' : verif ? 'verificado' : 'pendiente'); est.textContent = d.cola.activa ? `convirtiendo${d.cola.mensaje ? ' · ' + d.cola.mensaje : ''}` : pend ? `${pend} en espera, parada` : ts.length ? `${verif} verificados · ${n('reemplazado')} reemplazados${err ? ` · ${err} con error` : ''}${n('descartado') ? ` · ${n('descartado')} que no compensan` : ''}` : 'sin actividad'; const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Reanudar'; - $('#btnReintentar').style.display = err ? '' : 'none'; $('#btnQuitar').style.display = ts.some(t => ['error','cancelado','reemplazado'].includes(t.estado)) ? '' : 'none'; $('#btnVerificar').style.display = listos ? '' : 'none'; + $('#btnReintentar').style.display = err ? '' : 'none'; $('#btnQuitar').style.display = ts.some(t => ['error','cancelado','reemplazado','descartado'].includes(t.estado)) ? '' : 'none'; $('#btnVerificar').style.display = listos ? '' : 'none'; $('#btnQuitarEspera').style.display = (!d.cola.activa && pend) ? '' : 'none'; bloquear('btnReemplazar', verif ? '' : (listos ? 'hay convertidos sin verificar' : 'x')); $('#btnReemplazar').textContent = verif ? `Reemplazar ${verif} verificado${verif > 1 ? 's' : ''}` : 'Reemplazar'; const validos = CAND.filter(c => !c.excluido), ahora = validos.reduce((s,c)=>s+c.bytes,0), despues = validos.reduce((s,c)=>s+c.estimado,0); @@ -1426,14 +1430,14 @@ async function cargarCola(){ let html = ''; if (CAND.length) { html += `
${CAND.length} candidatos
`; - html += CAND.slice(0, 800).map((c, i) => `
${c.en_cola ? 'en cola' : c.excluido ? 'no' : 'candidato'}${esc(c.rel)}${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${c.motor === 'resolve' ? 'Resolve' : 'ffmpeg'}${c.excluido || c.en_cola ? ' · ' + esc(c.excluido || c.en_cola) : ''}${gb(c.bytes)} → ≈ ${gb(c.estimado)}${c.bytes ? (c.estimado <= c.bytes ? '−' + Math.round((1-c.estimado/c.bytes)*100) : '+' + Math.round((c.estimado/c.bytes-1)*100)) : '0'}%
`).join(''); + html += CAND.slice(0, 800).map((c, i) => `
${c.en_cola ? 'en cola' : c.excluido ? 'no' : 'candidato'}${esc(c.rel)}${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${c.motor === 'braw' ? 'lector de BRAW' : c.motor === 'resolve' ? 'Resolve' : 'ffmpeg'}${c.excluido || c.en_cola ? ' · ' + esc(c.excluido || c.en_cola) : ''}${gb(c.bytes)} → ≈ ${gb(c.estimado)}${c.bytes ? (c.estimado <= c.bytes ? '−' + Math.round((1-c.estimado/c.bytes)*100) : '+' + Math.round((c.estimado/c.bytes-1)*100)) : '0'}%
`).join(''); } if (ts.length) { const E = ESTADO && ESTADO.cola; const corr = ts.find(t => t.estado === 'corriendo'); if (E && E.activa && E.total) { const pctTot = Math.round(((E.hechos || 0) + ((E.actual && E.actual.pct) || 0) / 100) / E.total * 100); html += `
Convirtiendo ${Math.min((E.hechos || 0) + 1, E.total)} de ${E.total}${pctTot} %${corr ? `${esc((corr.ruta || '').split(/[\\/]/).pop())} · ${Math.round(corr.pct || 0)} %` : ''}
`; } else if (pend) html += `
${pend} en esperala cola está parada: pulsa Reanudar
`; html += `
Cola de conversión${ts.length}
`; - html += ts.slice(0, 120).map(t => `
${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}${esc((t.ruta || '').split(/[\\/]/).pop())} · ${t.motor === 'resolve' ? 'Resolve' : (MOTORES && MOTORES.formatos[t.formato || 'h265_10bit'] === 'gpu' ? 'GPU' : 'CPU')} · ${esc(((ESTADO.formatos || {})[t.formato || 'h265_10bit'] || {}).nombre || t.formato || '').split(' · ')[0]}${t.estado === 'error' ? `${esc((t.log || '').slice(-110))}` : t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió') ? `${esc(t.log)}` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}${t.estado === 'corriendo' ? `
` : ''}
${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}${t.bytes_out ? (t.bytes_out <= t.bytes_in ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 100) : '+' + Math.round((t.bytes_out / t.bytes_in - 1) * 100)) + '%' : ''}
`).join('') + (ts.length > 120 ? `
… y ${ts.length - 120} más
` : ''); + html += ts.slice(0, 120).map(t => `
${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}${esc((t.ruta || '').split(/[\\/]/).pop())} · ${t.motor === 'resolve' ? 'Resolve' : (t.motor === 'braw' ? 'BRAW · ' : '') + (MOTORES && MOTORES.formatos[t.formato || 'h265_10bit'] === 'gpu' ? 'GPU' : 'CPU')} · ${esc(((ESTADO.formatos || {})[t.formato || 'h265_10bit'] || {}).nombre || t.formato || '').split(' · ')[0]}${t.estado === 'descartado' ? `${esc(t.log || '')}` : t.estado === 'error' ? `${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}` : t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió') ? `${esc(t.log)}` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}${t.estado === 'corriendo' ? `
` : ''}
${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}${t.bytes_out ? (t.bytes_out <= t.bytes_in ? '−' + Math.round((1 - t.bytes_out / t.bytes_in) * 100) : '+' + Math.round((t.bytes_out / t.bytes_in - 1) * 100)) + '%' : ''}
`).join('') + (ts.length > 120 ? `
… y ${ts.length - 120} más
` : ''); } $('#convLista').innerHTML = html || 'Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.'; $$('#convLista input[data-i]').forEach(i => i.onchange = () => { CAND[+i.dataset.i].sel = i.checked; resumenCand(); }); @@ -1444,7 +1448,7 @@ const idsPorEstado = async (...est) => { const d = await api('/api/optimizar/col $('#btnVerificar').onclick = () => accion($('#btnVerificar'), async () => { const ids = await idsPorEstado('listo'); const r = await api('/api/optimizar/verificar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} verificados.`, ok === r.resultados.length ? 'ok' : 'bad'); if (ok < r.resultados.length) modal('Verificación', `
${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'FALLA'} · ${x.motivo}`).join('\n'))}
`, null); cargarCola(); }); $('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `

${ids.length} originales se apartan a _Para borrar/originales dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.

Cuando estés seguro, una persona vacía _Para borrar a mano.

`, () => accion($('#btnReemplazar'), async () => { const r = await api('/api/optimizar/reemplazar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} reemplazados.`, ok === r.resultados.length ? 'ok' : 'bad', {texto: 'Ver historial', ir: () => irA('historial')}); if (ok < r.resultados.length) modal('Reemplazo', `
${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}
`, null); cargarCola(); })); $('#modalSi').textContent = 'Reemplazar'; }); $('#btnReintentar').onclick = () => accion($('#btnReintentar'), async () => { const ids = await idsPorEstado('error', 'cancelado'); await api('/api/optimizar/control', {accion: 'reintentar', ids}); await api('/api/optimizar/control', {accion: 'iniciar'}); cargarCola(); }); -$('#btnQuitar').onclick = () => accion($('#btnQuitar'), async () => { const ids = await idsPorEstado('error', 'cancelado', 'reemplazado'); await api('/api/optimizar/control', {accion: 'quitar', ids}); cargarCola(); }); +$('#btnQuitar').onclick = () => accion($('#btnQuitar'), async () => { const ids = await idsPorEstado('error', 'cancelado', 'reemplazado', 'descartado'); const r = await api('/api/optimizar/control', {accion: 'quitar', ids}); const ap = r.apartados || {}; if (ap.movidos) aviso(`${ap.movidos} archivo${ap.movidos > 1 ? 's' : ''} de conversiones que no compensaban, apartado${ap.movidos > 1 ? 's' : ''} a _Para borrar\\descartados. Se deshace desde Historial.`, 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); if ((ap.errores || []).length) aviso(`No se pudieron apartar ${ap.errores.length}: ${ap.errores[0]}`, 'bad'); cargarCola(); }); // Con la cola parada, las que esperan también se pueden quitar (antes no había forma desde la interfaz, y // bloqueaban Desconectar). No toca archivos: solo las saca de la cola. $('#btnQuitarEspera').onclick = () => accion($('#btnQuitarEspera'), async () => { const ids = await idsPorEstado('pendiente'); if (!ids.length) throw new ApiError('No hay conversiones en espera de esta carpeta.'); modal('Quitar las conversiones en espera', `

${ids.length} conversión${ids.length > 1 ? 'es' : ''} en espera se quita${ids.length > 1 ? 'n' : ''} de la cola. No se toca ningún archivo: los originales siguen donde están y puedes volver a encolarlas desde «Buscar clips».

`, () => accion($('#btnQuitarEspera'), async () => { await api('/api/optimizar/control', {accion: 'quitar', ids}); await cargarEstado(); cargarCola(); })); $('#modalSi').textContent = 'Quitar'; });