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

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

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

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

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

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

Ver fichero

@@ -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.)

Ver fichero

@@ -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)"),

643
braw.py Archivo normal
Ver fichero

@@ -0,0 +1,643 @@
# -*- coding: utf-8 -*-
"""
Blackmagic RAW sin DaVinci Resolve abierto.
Los .braw se leen con el decodificador oficial de Blackmagic (BlackmagicRawAPI.dll), el mismo que Resolve
lleva dentro. Lo instalan DaVinci Resolve —el gratuito o Studio— y Blackmagic RAW Player; no hace falta
tener ninguno abierto. Cada cuadro sale ya revelado, a 16 bits por canal, y se le pasa a ffmpeg por un
tubo: desde ahí un BRAW se codifica igual que cualquier otro clip.
Se ejecuta como proceso aparte, para que un fallo del decodificador no se lleve por delante el programa:
python braw.py sonda [carpeta] ¿hay decodificador y es el que se conoce? → una línea JSON
python braw.py info clip.braw [...] tamaño real, cuadros, código de tiempo, cámara y curva
python braw.py convertir trabajo.json decodifica y codifica; el avance sale por stdout, una línea
JSON por evento
Por qué existe. Hasta la 3.3 los BRAW iban por el guion externo de Resolve (resolve_bridge.py), que exige
Resolve Studio abierto, en un proyecto y sin diálogos, y que además le cambia el proyecto a quien esté
editando. Medido el 5-oct-2026 en el equipo de Munka: tres tandas mandadas por esa vía (14 y 16 de
septiembre, 5 de octubre) y ni un solo clip convertido. El tamaño de imagen que se le pasaba salía de
ffprobe, que en un BRAW lee el cuadro con su relleno y sin girar: 4128×2176 para un clip que es 2160×4096
en vertical.
Las interfaces están copiadas de la biblioteca de tipos que trae la propia DLL (Resolve 21.1.1). En COM
una interfaz no cambia sin cambiar de identificador: antes de usar cada objeto se le pregunta por el
identificador que aquí se conoce, y si no lo tiene se dice y no se sigue. Nunca se llama a ciegas.
"""
import os
import sys
import json
import time
import ctypes
import tempfile
import threading
import subprocess
NOMBRE = "BlackmagicRawAPI.dll"
SIN_VENTANA = 0x08000000 if os.name == "nt" else 0
P = ctypes.POINTER
V = ctypes.c_void_p
HR = ctypes.c_long
E_NOINTERFACE = ctypes.c_long(0x80004002).value
# identificador de cada interfaz y, en cada una, el puesto de los métodos que se usan (los tres primeros
# son los de IUnknown: QueryInterface, AddRef, Release)
IID = {
"unknown": "00000000-0000-0000-C000-000000000046",
"fabrica": "78DEEB84-98C9-434A-B7E5-7AACC2988399", # IBlackmagicRawFactory
"codec": "C25C15E2-D1D0-4743-9EA8-F21F69751AF3", # IBlackmagicRaw
"config": "267E9866-FB40-4BFB-8BF8-96EA3F7DA36E", # IBlackmagicRawConfiguration
"clip": "A2910203-787B-4BF2-A374-B1A459E2D351", # IBlackmagicRawClip
"atributos": "7E5806E1-40B3-4DAD-8D03-5E294E65AC61", # IBlackmagicRawClipProcessingAttributes
"retorno": "E9F98FAC-33DB-4A65-BB94-8A82B027AED0", # IBlackmagicRawCallback
"cuadro": "A500B253-1808-4EF2-8692-D23C692404EA", # IBlackmagicRawFrame
"imagen": "D87A0F72-A883-42BB-8488-0089411C5035", # IBlackmagicRawProcessedImage
}
RGB_16 = 0x3136696C # blackmagicRawResourceFormatRGBU16: R, G y B seguidos, 16 bits cada uno
EN_MEMORIA = 0x63707562 # blackmagicRawResourceTypeBufferCPU
A_GAMMA, A_GAMUT, A_GEN = 0x67616D61, 0x67616D74, 0x6373676E
FILM, VIDEO_AMPLIO, GAMUT_BMD = "Blackmagic Design Film", "Blackmagic Design Extended Video", "Blackmagic Design"
# curvas que ya son «para ver»: si el clip viene así se respeta. Cualquier otra (Film, Linear, las
# logarítmicas de otras marcas) no se puede dejar tal cual en un archivo que dice ser Rec.709.
CURVAS_DE_VIDEO = {"Blackmagic Design Video", VIDEO_AMPLIO, "Blackmagic Design Custom", "Rec.709"}
MEMORIA_EN_VUELO = 600_000_000 # bytes de cuadros revelados que se dejan esperando turno…
CUADROS_EN_VUELO = 8 # …y nunca más de estos
class Desconocido(OSError):
"""El decodificador instalado no ofrece la interfaz que este programa conoce."""
class Cancelado(Exception):
pass
class GUID(ctypes.Structure):
_fields_ = [("a", ctypes.c_uint32), ("b", ctypes.c_uint16), ("c", ctypes.c_uint16), ("d", ctypes.c_ubyte * 8)]
def __init__(self, texto=""):
super().__init__()
if texto:
p = texto.split("-")
self.a, self.b, self.c = int(p[0], 16), int(p[1], 16), int(p[2], 16)
self.d = (ctypes.c_ubyte * 8)(*bytes.fromhex(p[3] + p[4]))
class VARIANT(ctypes.Structure):
_fields_ = [("vt", ctypes.c_uint16), ("r1", ctypes.c_uint16), ("r2", ctypes.c_uint16), ("r3", ctypes.c_uint16),
("valor", ctypes.c_uint64), ("valor2", V)]
# ==========================================================================
# Dónde está el decodificador
# ==========================================================================
def carpetas_posibles(extra=""):
"""Las carpetas donde Blackmagic deja su decodificador, por orden de preferencia."""
out = [extra or "", os.environ.get("ORGANILAB_BRAW", "")]
for var in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"):
base = os.environ.get(var)
if base:
bmd = os.path.join(base, "Blackmagic Design")
out += [os.path.join(bmd, "DaVinci Resolve"),
os.path.join(bmd, "Blackmagic RAW", "Blackmagic RAW SDK", "Win", "Libraries"),
os.path.join(bmd, "Blackmagic RAW", "Blackmagic RAW Player")]
limpio = []
for c in out:
c = os.path.normpath(c) if c else ""
if c and os.path.normcase(c) not in [os.path.normcase(x) for x in limpio]:
limpio.append(c)
return limpio
def buscar(extra=""):
"""La carpeta del decodificador, o «» si no está instalado. No carga nada: solo mira si el archivo existe."""
for c in carpetas_posibles(extra):
if os.path.isfile(os.path.join(c, NOMBRE)):
return c
return ""
# ==========================================================================
# COM a mano, con ctypes
# ==========================================================================
def _ole():
o = ctypes.windll.oleaut32
if not getattr(o, "_listo", False):
o.SysAllocString.restype, o.SysAllocString.argtypes = V, [ctypes.c_wchar_p]
o.SysFreeString.restype, o.SysFreeString.argtypes = None, [V]
o.VariantClear.argtypes = [P(VARIANT)]
for f in (o.SafeArrayGetLBound, o.SafeArrayGetUBound):
f.argtypes = [V, ctypes.c_uint, P(ctypes.c_long)]
o.SafeArrayGetVartype.argtypes = [V, P(ctypes.c_uint16)]
o.SafeArrayAccessData.argtypes = [V, P(V)]
o.SafeArrayUnaccessData.argtypes = [V]
o._listo = True
return o
def _m(obj, puesto, vuelve, *tipos):
"""El método número `puesto` de la tabla del objeto, listo para llamarlo."""
tabla = ctypes.cast(obj, P(P(V))).contents
f = ctypes.WINFUNCTYPE(vuelve, V, *tipos)(tabla[puesto])
return lambda *a: f(obj, *a)
def _bien(hr, que):
if hr != 0:
raise OSError(f"{que} (código 0x{hr & 0xFFFFFFFF:08X})")
def _soltar(obj):
if obj:
_m(obj, 2, ctypes.c_ulong)()
def _como(obj, cual):
"""El mismo objeto visto por la interfaz `cual`. Si no la ofrece, este decodificador no es el conocido."""
otro = V()
if not obj or _m(obj, 0, HR, P(GUID), P(V))(ctypes.byref(GUID(IID[cual])), ctypes.byref(otro)) != 0 or not otro:
raise Desconocido("el decodificador de Blackmagic RAW instalado es de una versión que este programa no conoce "
f"(no ofrece la interfaz «{cual}»)")
return otro
def _texto(obj, puesto, *antes):
"""Un método que devuelve un texto (BSTR) por su último parámetro."""
b = V()
tipos = [type(x) for x in antes]
if _m(obj, puesto, HR, *tipos, P(V))(*antes, ctypes.byref(b)) != 0 or not b.value:
return ""
s = ctypes.wstring_at(b.value)
_ole().SysFreeString(b)
return s
def _de_variant(v):
"""El valor de un VARIANT de los que devuelve el decodificador (texto, número o lista)."""
vt, en = v.vt, ctypes.byref(v, 8)
if vt == 8:
return ctypes.wstring_at(v.valor) if v.valor else ""
if vt in (4, 5):
return ctypes.cast(en, P(ctypes.c_float if vt == 4 else ctypes.c_double)).contents.value
if vt in (2, 3, 22):
return ctypes.cast(en, P(ctypes.c_int16 if vt == 2 else ctypes.c_int32)).contents.value
if vt in (17, 18, 19, 23):
return v.valor & {17: 0xFF, 18: 0xFFFF}.get(vt, 0xFFFFFFFF)
if vt == 27 or vt & 0x2000:
o, sa = _ole(), V(v.valor)
lo, hi, tipo, datos = ctypes.c_long(), ctypes.c_long(), ctypes.c_uint16(), V()
o.SafeArrayGetLBound(sa, 1, ctypes.byref(lo))
o.SafeArrayGetUBound(sa, 1, ctypes.byref(hi))
o.SafeArrayGetVartype(sa, ctypes.byref(tipo))
n = hi.value - lo.value + 1
if n <= 0 or o.SafeArrayAccessData(sa, ctypes.byref(datos)) != 0:
return []
try:
if tipo.value == 12:
return [_de_variant(x) for x in (VARIANT * n).from_address(datos.value)]
if tipo.value == 8:
return [ctypes.wstring_at(x) if x else "" for x in (V * n).from_address(datos.value)]
c = {2: ctypes.c_int16, 3: ctypes.c_int32, 4: ctypes.c_float, 5: ctypes.c_double, 17: ctypes.c_ubyte,
18: ctypes.c_uint16, 19: ctypes.c_uint32}.get(tipo.value)
return list((c * n).from_address(datos.value)) if c else []
finally:
o.SafeArrayUnaccessData(sa)
return None
class Retorno:
"""El objeto al que el decodificador avisa cuando termina de leer o de revelar un cuadro. Los avisos llegan
desde sus hilos y en el orden que sea: aquí solo se apuntan, y quien escribe los recoge por orden."""
def __init__(self, atributos):
self.atributos = atributos
self.listos, self.aviso, self.error = {}, threading.Condition(), ""
nada = ctypes.WINFUNCTYPE(None, V, V, HR)
firmas = [ctypes.WINFUNCTYPE(HR, V, P(GUID), P(V)), ctypes.WINFUNCTYPE(ctypes.c_ulong, V), ctypes.WINFUNCTYPE(ctypes.c_ulong, V),
ctypes.WINFUNCTYPE(None, V, V, HR, V), # ReadComplete(trabajo, resultado, cuadro)
ctypes.WINFUNCTYPE(None, V, V, HR, V), # ReadAudioComplete
nada, # DecodeComplete
ctypes.WINFUNCTYPE(None, V, V, HR, V), # ProcessComplete(trabajo, resultado, imagen)
ctypes.WINFUNCTYPE(None, V, V, ctypes.c_float), # TrimProgress
nada, # TrimComplete
ctypes.WINFUNCTYPE(None, V, V, V, ctypes.c_uint, V), # SidecarMetadataParseWarning
ctypes.WINFUNCTYPE(None, V, V, V, ctypes.c_uint, V), # SidecarMetadataParseError
nada] # PreparePipelineComplete
hace = {0: self._interfaz, 1: lambda yo: 2, 2: lambda yo: 1, 3: self._leido, 6: self._revelado}
self._funciones = [f(hace.get(i, lambda *a: None)) for i, f in enumerate(firmas)] # vivas mientras viva el objeto
self._tabla = (V * len(firmas))(*[ctypes.cast(f, V) for f in self._funciones])
self._objeto = ctypes.pointer(self._tabla) # un objeto COM es un puntero a su tabla de métodos
self.puntero = ctypes.cast(ctypes.pointer(self._objeto), V)
def _interfaz(self, yo, iid, donde):
pedida = bytes(iid.contents)
if pedida in (bytes(GUID(IID["unknown"])), bytes(GUID(IID["retorno"]))):
donde[0] = yo
return 0
donde[0] = None
return E_NOINTERFACE
def fallo(self, texto):
with self.aviso:
self.error = self.error or texto
self.aviso.notify_all()
def _leido(self, yo, trabajo, hr, cuadro):
try:
if hr != 0:
return self.fallo(f"no se pudo leer un cuadro del clip (código 0x{hr & 0xFFFFFFFF:08X})")
c = _como(cuadro, "cuadro")
try:
indice = ctypes.c_uint64()
_m(c, 3, HR, P(ctypes.c_uint64))(ctypes.byref(indice))
_bien(_m(c, 11, HR, ctypes.c_uint)(RGB_16), "el decodificador no da 16 bits por canal")
revelar = V()
_bien(_m(c, 14, HR, V, V, P(V))(self.atributos, None, ctypes.byref(revelar)), "no se pudo preparar el revelado")
_m(revelar, 5, HR, V)(V(indice.value + 1)) # el índice viaja con el trabajo (+1: el 0 sería «nada»)
hr2 = _m(revelar, 3, HR)()
_soltar(revelar)
_bien(hr2, "no se pudo lanzar el revelado")
finally:
_soltar(c)
except Exception as ex: # noqa
self.fallo(str(ex))
def _revelado(self, yo, trabajo, hr, imagen):
try:
if hr != 0:
return self.fallo(f"no se pudo revelar un cuadro (código 0x{hr & 0xFFFFFFFF:08X})")
dato = V()
_m(trabajo, 6, HR, P(V))(ctypes.byref(dato))
img = _como(imagen, "imagen") # queda retenida hasta que se escriba
ancho, alto, tam, tipo, donde = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), V()
_m(img, 3, HR, P(ctypes.c_uint))(ctypes.byref(ancho))
_m(img, 4, HR, P(ctypes.c_uint))(ctypes.byref(alto))
_m(img, 8, HR, P(ctypes.c_uint))(ctypes.byref(tam))
_m(img, 6, HR, P(ctypes.c_uint))(ctypes.byref(tipo))
_m(img, 5, HR, P(V))(ctypes.byref(donde))
if tipo.value != EN_MEMORIA or not donde.value or tam.value != ancho.value * alto.value * 6:
_soltar(img)
return self.fallo("el decodificador devolvió un cuadro en un formato que no se esperaba")
with self.aviso:
self.listos[(dato.value or 1) - 1] = (img, donde.value, tam.value, ancho.value, alto.value)
self.aviso.notify_all()
except Exception as ex: # noqa
self.fallo(str(ex))
_DLL = {}
def _cargar(carpeta):
carpeta = carpeta or buscar()
if not carpeta or not os.path.isfile(os.path.join(carpeta, NOMBRE)):
raise OSError("no está el decodificador de Blackmagic RAW: se instala con DaVinci Resolve (vale el gratuito) "
"o con Blackmagic RAW Player")
clave = os.path.normcase(carpeta)
if clave not in _DLL:
os.add_dll_directory(carpeta) # sus compañeras (DecoderCUDA, InstructionSetServices…) viven al lado
dll = ctypes.WinDLL(os.path.join(carpeta, NOMBRE))
dll.CreateBlackmagicRawFactoryInstance.restype = V
dll.CreateBlackmagicRawFactoryInstance.argtypes = []
_DLL[clave] = dll
return _DLL[clave], carpeta
class Clip:
"""Un .braw abierto con el decodificador de Blackmagic. Solo lee: no guarda nada junto al clip."""
def __init__(self, ruta, carpeta="", hilos=0):
self.ruta = ruta
self.fabrica = self.codec = self.config = self.clip = self.atributos = None
self.retorno = None
if not os.path.isfile(ruta):
raise OSError("el clip ya no está en esa ruta (¿se movió o se desconectó la unidad?)")
dll, self.carpeta = _cargar(carpeta)
cruda = V(dll.CreateBlackmagicRawFactoryInstance())
if not cruda:
raise OSError("el decodificador de Blackmagic RAW no arrancó")
try:
self.fabrica = _como(cruda, "fabrica")
finally:
_soltar(cruda)
crudo = V()
_bien(_m(self.fabrica, 3, HR, P(V))(ctypes.byref(crudo)), "el decodificador no pudo crear su lector")
try:
self.codec = _como(crudo, "codec")
self.config = _como(crudo, "config")
finally:
_soltar(crudo)
if hilos:
_m(self.config, 6, HR, ctypes.c_uint)(int(hilos))
nombre = V(_ole().SysAllocString(os.path.abspath(ruta)))
crudo = V()
hr = _m(self.codec, 3, HR, V, P(V))(nombre, ctypes.byref(crudo))
_ole().SysFreeString(nombre)
if hr != 0 or not crudo:
raise OSError(f"el decodificador no pudo abrir el clip (código 0x{hr & 0xFFFFFFFF:08X}): puede estar dañado, a medio copiar "
"o ser de una cámara más nueva que el decodificador instalado")
try:
self.clip = _como(crudo, "clip")
finally:
_soltar(crudo)
ancho, alto, fps, n = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_float(), ctypes.c_uint64()
_m(self.clip, 3, HR, P(ctypes.c_uint))(ctypes.byref(ancho))
_m(self.clip, 4, HR, P(ctypes.c_uint))(ctypes.byref(alto))
_m(self.clip, 5, HR, P(ctypes.c_float))(ctypes.byref(fps))
_m(self.clip, 6, HR, P(ctypes.c_uint64))(ctypes.byref(n))
self.ancho, self.alto, self.fps, self.cuadros = ancho.value, alto.value, round(fps.value, 3), n.value
self.tc = _texto(self.clip, 7, ctypes.c_uint64(0))
self.camara = _texto(self.clip, 11)
crudo = V()
_bien(_m(self.clip, 12, HR, P(V))(ctypes.byref(crudo)), "no se pudieron leer los ajustes de color del clip")
try:
self.atributos = _como(crudo, "atributos")
finally:
_soltar(crudo)
self.gamma, self.gamut, self.generacion = self.atributo(A_GAMMA) or "", self.atributo(A_GAMUT) or "", self.atributo(A_GEN) or 0
self.de_video = self.gamma in CURVAS_DE_VIDEO
# ---- color ----
def atributo(self, cual):
v = VARIANT()
if _m(self.atributos, 3, HR, ctypes.c_uint, P(VARIANT))(cual, ctypes.byref(v)) != 0:
return None
valor = _de_variant(v)
_ole().VariantClear(ctypes.byref(v))
return valor
def opciones(self, cual):
"""Los valores que admite un ajuste. Se pregunta primero cuántos son: el decodificador escribe la lista
entera en la memoria que se le dé, quepa o no."""
n, fijo = ctypes.c_uint(), ctypes.c_long()
f = _m(self.atributos, 6, HR, ctypes.c_uint, V, P(ctypes.c_uint), P(ctypes.c_long))
if f(cual, None, ctypes.byref(n), ctypes.byref(fijo)) != 0 or not n.value:
return []
lista = (VARIANT * n.value)()
if f(cual, ctypes.cast(lista, V), ctypes.byref(n), ctypes.byref(fijo)) != 0:
return []
out = [_de_variant(x) for x in lista[:n.value]]
for x in lista:
_ole().VariantClear(ctypes.byref(x))
return out
def _poner(self, cual, texto):
v = VARIANT()
v.vt, v.valor = 8, _ole().SysAllocString(texto)
hr = _m(self.atributos, 4, HR, ctypes.c_uint, P(VARIANT))(cual, ctypes.byref(v))
_ole().SysFreeString(V(v.valor))
return hr == 0
def poner_color(self, color):
"""«log»: Blackmagic Film, toda la latitud para quien vaya a etalonar. «rec709»: como se vio al
grabar; un clip grabado en Film (plano) pasa a Extended Video, que es su versión para ver. Devuelve
una frase que dice lo que se hizo."""
antes = self.gamma
quiere = FILM if color == "log" else (antes if antes in CURVAS_DE_VIDEO else VIDEO_AMPLIO)
if quiere != antes:
if quiere not in self.opciones(A_GAMMA) or not self._poner(A_GAMMA, quiere):
raise OSError(f"este clip no admite la curva «{quiere}»")
if self.gamut != GAMUT_BMD and GAMUT_BMD in self.opciones(A_GAMUT):
self._poner(A_GAMUT, GAMUT_BMD)
self.gamma, self.gamut = self.atributo(A_GAMMA) or quiere, self.atributo(A_GAMUT) or self.gamut
self.de_video = self.gamma in CURVAS_DE_VIDEO
return f"curva {self.gamma}" + (f" (el clip venía en {antes})" if antes and antes != self.gamma else "")
# ---- cuadros ----
def cuadros_en_orden(self, desde=0, hasta=None):
"""Genera (índice, dirección de memoria, bytes) de cada cuadro revelado, por orden. Se piden varios a la
vez —el decodificador reparte el trabajo entre los núcleos— pero nunca más de los que caben en
MEMORIA_EN_VUELO: un cuadro de 4.6K a 16 bits son 72 MB."""
if self.retorno is None:
self.retorno = Retorno(self.atributos)
_bien(_m(self.codec, 7, HR, V)(self.retorno.puntero), "el decodificador no aceptó el aviso de fin de cuadro")
r = self.retorno
hasta = self.cuadros if hasta is None else min(hasta, self.cuadros)
ventana = max(2, min(CUADROS_EN_VUELO, MEMORIA_EN_VUELO // max(1, self.ancho * self.alto * 6)))
pedido = escrito = desde
while escrito < hasta:
while pedido < hasta and pedido - escrito < ventana:
leer = V()
_bien(_m(self.clip, 18, HR, ctypes.c_uint64, P(V))(pedido, ctypes.byref(leer)), "no se pudo pedir un cuadro")
hr = _m(leer, 3, HR)()
_soltar(leer)
_bien(hr, "no se pudo pedir un cuadro")
pedido += 1
espera = time.time()
with r.aviso:
while escrito not in r.listos and not r.error:
r.aviso.wait(5)
if time.time() - espera > 180:
r.error = "el decodificador dejó de responder (tres minutos sin entregar un cuadro)"
if r.error:
raise OSError(r.error)
img, donde, tam, ancho, alto = r.listos.pop(escrito)
try:
if (ancho, alto) != (self.ancho, self.alto):
raise OSError(f"un cuadro salió de {ancho}×{alto} y el clip es de {self.ancho}×{self.alto}")
yield escrito, donde, tam
finally:
_soltar(img)
escrito += 1
def cerrar(self):
"""Espera a lo que esté a medias, suelta los cuadros que quedaran sin escribir y cierra el clip."""
if self.codec:
try:
_m(self.codec, 10, HR)() # FlushJobs: no vuelve hasta que no queda trabajo en marcha
except OSError:
pass
if self.retorno:
with self.retorno.aviso:
for img, *_ in self.retorno.listos.values():
_soltar(img)
self.retorno.listos.clear()
for o in ("atributos", "clip", "config", "codec", "fabrica"):
try:
_soltar(getattr(self, o))
except OSError:
pass
setattr(self, o, None)
def datos(self):
return {"ancho": self.ancho, "alto": self.alto, "fps": self.fps, "cuadros": self.cuadros,
"dur": round(self.cuadros / self.fps, 3) if self.fps else 0, "tc": self.tc, "camara": self.camara,
"gamma": self.gamma, "gamut": self.gamut, "gen": self.generacion}
# ==========================================================================
# Lo que hace el proceso aparte
# ==========================================================================
def emitir(**kw):
print(json.dumps(kw, ensure_ascii=False), flush=True)
def fraccion(fps):
"""Los fps como los quiere ffmpeg: 29,97 es 30000/1001, no 29.97."""
for n in (24000, 30000, 48000, 60000, 120000):
if abs(fps - n / 1001) < 0.005:
return f"{n}/1001"
return f"{round(fps, 3):g}"
def sonda(carpeta=""):
"""¿Se puede usar el decodificador? Lo carga y le pide las interfaces que se van a usar."""
try:
dll, carpeta = _cargar(carpeta)
cruda = V(dll.CreateBlackmagicRawFactoryInstance())
if not cruda:
raise OSError("el decodificador de Blackmagic RAW no arrancó")
fabrica = codec = config = crudo = None
try:
fabrica = _como(cruda, "fabrica")
crudo = V()
_bien(_m(fabrica, 3, HR, P(V))(ctypes.byref(crudo)), "el decodificador no pudo crear su lector")
codec, config = _como(crudo, "codec"), _como(crudo, "config")
hilos = ctypes.c_uint()
_m(config, 7, HR, P(ctypes.c_uint))(ctypes.byref(hilos))
return {"ok": True, "carpeta": carpeta, "camaras": _texto(config, 13), "hilos": hilos.value}
finally:
for o in (config, codec, crudo, fabrica, cruda):
_soltar(o)
except Exception as ex: # noqa
return {"ok": False, "carpeta": carpeta or "", "motivo": str(ex)}
def info(ruta, carpeta=""):
clip = None
try:
clip = Clip(ruta, carpeta)
return dict(clip.datos(), ruta=ruta, ok=True)
except Exception as ex: # noqa
return {"ruta": ruta, "ok": False, "error": str(ex)}
finally:
if clip:
clip.cerrar()
def _cola(archivo, n=1500):
try:
archivo.seek(0)
return archivo.read().decode("utf-8", "replace").strip()[-n:]
except (OSError, ValueError):
return ""
def convertir(t):
"""Un clip: lo abre, le pone la curva, y va pasando los cuadros a ffmpeg. `t` trae la ruta del clip, la de
salida, el ffmpeg que hay que usar y los argumentos del codificador, que arma el programa (aquí no se
decide ni la calidad ni el formato)."""
clip = ff = None
errores = tempfile.TemporaryFile()
tmp, bandera = t["tmp"], t.get("cancelar") or ""
try:
clip = Clip(t["src"], t.get("sdk") or "", t.get("hilos") or 0)
nota = clip.poner_color(t.get("color") or "rec709")
if not clip.cuadros or not clip.ancho or not clip.alto or not clip.fps:
raise OSError("el clip no tiene imagen")
emitir(msg=f"{clip.ancho}×{clip.alto} · {clip.fps:g} fps · {clip.cuadros} cuadros · {nota}", **clip.datos())
# de RGB entero a YCbCr de vídeo con la matriz de Rec.709. Las marcas de color van en la imagen y
# no en el codificador: si no, ffmpeg las pisa con las del tubo, que no trae ninguna. A un archivo
# en Film no se le marca curva ni primarios: no son los de Rec.709 y un reproductor los creería.
marcas = "colorspace=bt709:range=tv" + (":color_primaries=bt709:color_trc=bt709" if clip.de_video else "")
vf = f"scale=in_range=full:out_range=limited:out_color_matrix=bt709:flags=bicubic+accurate_rnd,format={t['pix']},setparams={marcas}"
cmd = [t["ffmpeg"], "-hide_banner", "-nostdin", "-y", "-loglevel", "error",
"-f", "rawvideo", "-pix_fmt", "rgb48le", "-video_size", f"{clip.ancho}x{clip.alto}", "-framerate", fraccion(clip.fps), "-i", "pipe:0",
"-i", t["src"], "-map", "0:v:0", "-map", "1:a?", "-vf", vf] + list(t["video"])
cmd += ["-map_metadata", "1", "-metadata", f"comment=Blackmagic RAW de {clip.camara or 'cámara Blackmagic'} · {nota} · convertido con OrganiLAB"]
if clip.tc:
cmd += ["-timecode", clip.tc]
cmd += list(t.get("contenedor") or []) + list(t.get("audio") or []) + [tmp]
ff = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errores, creationflags=SIN_VENTANA)
hechos, inicio, dicho, mirado = 0, time.time(), 0.0, 0.0
for _, donde, tam in clip.cuadros_en_orden():
try:
ff.stdin.write((ctypes.c_ubyte * tam).from_address(donde))
except OSError:
ff.wait()
raise OSError("ffmpeg se cerró a mitad del clip: " + (_cola(errores) or f"código {ff.returncode}"))
hechos += 1
ahora = time.time()
if bandera and ahora - mirado >= 0.5:
mirado = ahora
if os.path.exists(bandera):
raise Cancelado()
if ahora - dicho >= 1:
dicho = ahora
emitir(pct=round(min(99.0, hechos / clip.cuadros * 100), 1), cuadros=hechos, vel=round(hechos / max(0.1, ahora - inicio), 1))
ff.stdin.close()
codigo = ff.wait()
if codigo != 0 or not os.path.exists(tmp):
raise OSError(_cola(errores) or f"ffmpeg terminó con el código {codigo}")
errores.close()
emitir(estado="listo", pct=100, segundos=round(time.time() - inicio, 1), **clip.datos())
return 0
except Cancelado:
estado, log = "cancelado", ""
except Exception as ex: # noqa
estado, log = "error", str(ex)
finally:
if clip:
try:
clip.cerrar()
except Exception: # noqa
pass
# solo se llega aquí si no acabó bien: ni ffmpeg vivo ni un archivo a medias
if ff:
try:
ff.stdin.close()
except OSError:
pass
try:
ff.kill()
ff.wait(30)
except (OSError, subprocess.TimeoutExpired):
pass
for _ in range(20):
try:
if os.path.exists(tmp):
os.remove(tmp)
break
except OSError:
time.sleep(0.25)
errores.close()
emitir(estado=estado, log=log) if log else emitir(estado=estado)
return 1
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # el tubo, sin esto, sale en la página de códigos de Windows
except Exception: # noqa
pass
try:
import faulthandler
faulthandler.enable() # si el decodificador se cae, que quede dicho dónde
except Exception: # noqa
pass
orden = argv[0] if argv else ""
if orden == "sonda":
emitir(**sonda(argv[1] if len(argv) > 1 else ""))
return 0
if orden == "info" and len(argv) > 1:
carpeta = ""
if "--sdk" in argv:
carpeta = argv[argv.index("--sdk") + 1]
del argv[argv.index("--sdk"):argv.index("--sdk") + 2]
for ruta in argv[1:]:
emitir(**info(ruta, carpeta))
return 0
if orden == "convertir" and len(argv) > 1:
with open(argv[1], "r", encoding="utf-8") as fh:
return convertir(json.load(fh))
emitir(msg="uso: braw.py sonda | info clip.braw [...] | convertir trabajo.json")
return 2
if __name__ == "__main__":
sys.exit(main())

Ver fichero

@@ -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

Ver fichero

@@ -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"
}
}

Ver fichero

@@ -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"]))
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,10 +5419,22 @@ def acciones(raiz):
detalle="Primero hay que leerlos para saber cuáles conviene convertir. Va en segundo plano.",
ir="convertir")
else:
# 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 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 "."),
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 ---
@@ -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:

Ver fichero

@@ -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

Ver fichero

@@ -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)
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(CLIP, (t, t))
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),

Ver fichero

@@ -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)

Ver fichero

@@ -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")

Ver fichero

@@ -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")

Ver fichero

@@ -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 @@
<label>mínimo <input type="number" id="optMin" value="300" min="0" style="width:70px"> MB</label>
<label>calidad <select id="optPerfil"></select></label>
<label>croma <select id="optChroma"><option value="420">4:2:0 10 bits (compatible)</option><option value="422">4:2:2 10 bits (solo ffmpeg, pesa más)</option></select></label>
<label>BRAW en <select id="optColor"><option value="rec709">Rec.709 (se ve bien)</option><option value="log">Log de cámara (plano, más latitud)</option></select></label>
<label>BRAW en <select id="optColor"><option value="rec709">Rec.709 (como se vio al grabar)</option><option value="log">Log de cámara (plano, más latitud)</option></select></label>
<label><input type="checkbox" id="optExport"> incluir exportados</label>
</div></details></span></div>
<p class="ayuda">Convierte los clips de proyectos ya entregados a un formato más ligero. <b>El original no se toca</b> hasta que reemplaces. <span class="q">?</span><span class="mas">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 <span class="path">_Para borrar/originales</span> 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.</span></p>
<p class="ayuda">Convierte los clips de proyectos ya entregados a un formato más ligero. <b>El original no se toca</b> hasta que reemplaces. <span class="q">?</span><span class="mas">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 <span class="path">_Para borrar/originales</span> 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.</span></p>
<div class="toolbar" style="margin-bottom:10px"><label>formato de salida <select id="optFormato"></select></label><span id="fmtQue" class="mut" style="font-size:12px"></span><span class="sep"></span><span id="motores" class="mut" style="font-size:12px"><span class="spin"></span> detectando GPU y CPU…</span></div>
<div class="cards c4" id="optCards"></div>
<div class="toolbar">
@@ -601,7 +602,7 @@
<dl>
<dt>Buscar clips</dt><dd>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. <i>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.</i></dd>
<dt>Formato y calidad</dt><dd>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.</dd>
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. <i>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.</i></dd>
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. <i>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.</i></dd>
<dt>Verificar</dt><dd>Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.</dd>
<dt>Reemplazar</dt><dd>El convertido ocupa el sitio del original, con su misma fecha, y el original se aparta a <span class="path">_Para borrar\originales</span>.</dd>
</dl>
@@ -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 ? `<span class="warn" title="${esc(faltas.join('\n'))}">⚠ ${faltas.length} aviso${faltas.length > 1 ? 's' : ''}</span> · ` : '') + `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}` : '<span class="bad">ffmpeg no encontrado: no se puede convertir</span>';
const lb = MOTORES.braw || {};
const brawT = lb.ok ? 'BRAW: se leen sin abrir Resolve' : `<span class="bad" title="${esc(lb.motivo || '')}">BRAW: falta el decodificador de Blackmagic</span>`;
$('#motores').innerHTML = MOTORES.ffmpeg ? `${gpuT} · ${cpuT} · ${brawT}` : '<span class="bad">ffmpeg no encontrado: no se puede convertir</span>';
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', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a <b>${esc(fm.nombre)}</b>, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => 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', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a <b>${esc(fm.nombre)}</b>, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => 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 += `<div class="cabsec"><label style="display:flex;gap:6px;align-items:center;text-transform:none;letter-spacing:0;font-weight:500"><input type="checkbox" id="candTodos" ${validos.length && CAND.filter(c => c.sel).length === validos.filter(c => !c.en_cola).length ? 'checked' : ''}> marcar todos los válidos</label><span class="sep"></span>${CAND.length} candidatos</div>`;
html += CAND.slice(0, 800).map((c, i) => `<div class="cfila ${c.excluido ? 'exc' : ''}"><input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} ${c.excluido || c.en_cola ? 'disabled' : ''} aria-label="marcar"><span class="est candidato">${c.en_cola ? 'en cola' : c.excluido ? 'no' : 'candidato'}</span><span class="nom" title="${esc(c.codec)} ${esc(c.perfil)} ${esc(c.pix_fmt)} · ${esc(c.res)} · ${esc(c.fps)} fps · ${c.kbps} kb/s → ≤ ${c.kbps_objetivo} kb/s"><span class="clip">${esc(c.rel)}</span><span class="sub2">${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) : ''}</span></span><span class="tam">${gb(c.bytes)} → ≈ ${gb(c.estimado)}</span><span class="ah">${c.bytes ? (c.estimado <= c.bytes ? '−' + Math.round((1-c.estimado/c.bytes)*100) : '+' + Math.round((c.estimado/c.bytes-1)*100)) : '0'}%</span></div>`).join('');
html += CAND.slice(0, 800).map((c, i) => `<div class="cfila ${c.excluido ? 'exc' : ''}"><input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} ${c.excluido || c.en_cola ? 'disabled' : ''} aria-label="marcar"><span class="est candidato">${c.en_cola ? 'en cola' : c.excluido ? 'no' : 'candidato'}</span><span class="nom" title="${esc(c.codec)} ${esc(c.perfil)} ${esc(c.pix_fmt)} · ${esc(c.res)} · ${esc(c.fps)} fps · ${c.kbps} kb/s → ≤ ${c.kbps_objetivo} kb/s"><span class="clip">${esc(c.rel)}</span><span class="sub2">${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) : ''}</span></span><span class="tam">${gb(c.bytes)} → ≈ ${gb(c.estimado)}</span><span class="ah">${c.bytes ? (c.estimado <= c.bytes ? '−' + Math.round((1-c.estimado/c.bytes)*100) : '+' + Math.round((c.estimado/c.bytes-1)*100)) : '0'}%</span></div>`).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 += `<div class="colabar"><b>Convirtiendo ${Math.min((E.hechos || 0) + 1, E.total)} de ${E.total}</b><span class="b"><i style="width:${pctTot}%"></i></span><b>${pctTot} %</b>${corr ? `<span class="mut">${esc((corr.ruta || '').split(/[\\/]/).pop())} · ${Math.round(corr.pct || 0)} %</span>` : ''}</div>`; }
else if (pend) html += `<div class="colabar" style="background:var(--panel2)"><b>${pend} en espera</b><span class="mut">la cola está parada: pulsa Reanudar</span></div>`;
html += `<div class="cabsec">Cola de conversión<span class="sep"></span>${ts.length}</div>`;
html += ts.slice(0, 120).map(t => `<div class="cfila"><span></span><span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}</span><span class="nom" title="${esc(t.ruta)}"><span class="clip">${esc((t.ruta || '').split(/[\\/]/).pop())} <span class="mut" style="font-weight:500;font-size:11px">· ${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]}</span></span><span class="sub2">${t.estado === 'error' ? `<span class="bad">${esc((t.log || '').slice(-110))}</span>` : t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió') ? `<span class="mut">${esc(t.log)}</span>` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}</span>${t.estado === 'corriendo' ? `<div class="bar"><i style="width:${Math.round(t.pct || 0)}%"></i></div>` : ''}</span><span class="tam">${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}</span><span class="ah">${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)) + '%' : ''}</span></div>`).join('') + (ts.length > 120 ? `<div class="mut" style="padding:8px 0">… y ${ts.length - 120} más</div>` : '');
html += ts.slice(0, 120).map(t => `<div class="cfila"><span></span><span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}${t.estado === 'corriendo' ? ` ${Math.round(t.pct || 0)} %` : ''}</span><span class="nom" title="${esc(t.ruta)}"><span class="clip">${esc((t.ruta || '').split(/[\\/]/).pop())} <span class="mut" style="font-weight:500;font-size:11px">· ${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]}</span></span><span class="sub2${t.estado === 'error' || t.estado === 'descartado' ? ' err' : ''}">${t.estado === 'descartado' ? `<span class="mut">${esc(t.log || '')}</span>` : t.estado === 'error' ? `<span class="bad" title="${esc(t.log || '')}">${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}</span>` : t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió') ? `<span class="mut">${esc(t.log)}</span>` : esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))}</span>${t.estado === 'corriendo' ? `<div class="bar"><i style="width:${Math.round(t.pct || 0)}%"></i></div>` : ''}</span><span class="tam">${gb(t.bytes_in)}${t.bytes_out ? ' → ' + gb(t.bytes_out) : ''}</span><span class="ah">${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)) + '%' : ''}</span></div>`).join('') + (ts.length > 120 ? `<div class="mut" style="padding:8px 0">… y ${ts.length - 120} más</div>` : '');
}
$('#convLista').innerHTML = html || '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>';
$$('#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', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'FALLA'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); });
$('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `<p><b>${ids.length}</b> originales se apartan a <span class="path">_Para borrar/originales</span> dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.</p><p>Cuando estés seguro, una persona vacía _Para borrar a mano.</p>`, () => 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', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}</pre>`, 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', `<p><b>${ids.length}</b> 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».</p>`, () => accion($('#btnQuitarEspera'), async () => { await api('/api/optimizar/control', {accion: 'quitar', ids}); await cargarEstado(); cargarCola(); })); $('#modalSi').textContent = 'Quitar'; });