3.8.1: se convierte todo lo que ahorre al menos un 30 %, con la estimación medida por códec de origen
Munka: «si un archivo se puede optimizar así sea un 30 % se convierta». La regla deja de mirar si el clip pasa de 1 o 2 veces la tasa objetivo y mira el ahorro estimado (ajustable, 30 % de entrada). La estimación sale de las 286 conversiones hechas: H.264 y AV1 de cámara quedan en una fracción, H.265 casi igual. Entran los 11 AV1 de Línea Gene; dejan de ofrecerse los H.265 que no adelgazan y lo apartado en Duplicados. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
36
README.md
36
README.md
@@ -135,12 +135,17 @@ Reglas que hacen que funcione:
|
||||
- **Flujo:** Analizar (ffprobe) → Ver candidatos → Encolar → Verificar (duración, códec, 10 bits) →
|
||||
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`.
|
||||
Una conversión que ahorra menos del mínimo pedido menos 10 puntos (con el 30 % de entrada, menos del 20 %),
|
||||
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 ofrece lo que convertido pesaría al menos un 30 % menos (`optimizar.ahorro_minimo`, en «cambiar»). Lo que
|
||||
sale se estima por el códec de origen, porque la cola codifica a calidad constante: lo que guarda cada cuadro
|
||||
entero (BRAW, ProRes, XAVC-I), por la tasa objetivo; lo comprimido entre cuadros (H.264, AV1, VP9), como mucho
|
||||
a 0,6 veces su tasa y nunca por encima del objetivo; un H.265, a 0,93 veces la suya, hasta el techo de 1,8
|
||||
veces el objetivo.
|
||||
- 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.
|
||||
lo que ahorraría menos del mínimo, lo apartado como repetido (en `Duplicados`), 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.
|
||||
@@ -910,6 +915,27 @@ 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.8.1, convertir todo lo que ahorre un 30 %. Munka: «quiero que si un archivo se puede optimizar así sea un 30 %
|
||||
se convierta, la idea es aprovechar lo máximo el espacio».
|
||||
|
||||
- **La regla, por ahorro**: se ofrece todo lo que convertido pesaría al menos un 30 % menos, y el porcentaje se
|
||||
cambia en «cambiar» (vale también para la pasada automática y para Inicio). Hasta ahora se miraba si el clip
|
||||
pasaba de 1 vez (H.264) o 2 veces (H.265, AV1, VP9) la tasa objetivo, y eso fallaba por los dos lados.
|
||||
- **La estimación, medida**: la cola codifica a calidad constante, así que lo que sale depende de cómo venía el
|
||||
clip. Sobre las 286 conversiones hechas, un H.264 de cámara quedó entre el 9 y el 26 % de su peso, un AV1 de
|
||||
móvil o de cámara entre el 9 y el 11 %, lo que guarda cada cuadro entero entre el 1 y el 6 %, y un H.265 de
|
||||
cámara entre el 85 y el 99 %. La estimación nueva acierta la decisión en los 102 clips que se pudieron cotejar
|
||||
y queda del lado prudente. Con ella entran los 11 AV1 de Línea Gene (21,2 GB, que la estimación deja en unos
|
||||
11 GB; si se portan como los dos AV1 ya convertidos, unos 2 GB) y dejan de ofrecerse los H.265 de cámara: la regla vieja
|
||||
había mandado a convertir 6 que luego no adelgazaron.
|
||||
- **Lo apartado como repetido** (en una carpeta `Duplicados`) ya no se ofrece: es para borrar, no para convertir.
|
||||
- El descarte después de convertir sigue al mínimo: se descarta lo que ahorre menos del mínimo menos 10 puntos
|
||||
(con el 30 %, menos del 20 %, como hasta ahora).
|
||||
|
||||
Pruebas: `prueba_orden.py` 39 casos (el de «lo ya comprimido» pasa a ser el del 30 %: H.265 holgado y justo, H.264
|
||||
por debajo del objetivo, AV1 de móvil, Duplicados, el ajuste al 45 % y el descarte); `prueba_funcional.py` 33 de
|
||||
33, `probar.py` 19 y `prueba_ventana.py` 18.
|
||||
|
||||
3.8.0, lo que faltaba. Munka, después de la 3.7.0: «al hacer un cambio en el plan me debería dar un chequeo
|
||||
para saber que ya se hizo», «construye las mejoras que planteas» y, aplicando el plan de Vía Sumapaz, 753
|
||||
movimientos que no se pudieron y «database is locked» al buscar clips para convertir («lo más importante»). Y
|
||||
|
||||
@@ -42,7 +42,7 @@ import actualizador
|
||||
asistente.O = sys.modules[__name__]
|
||||
actualizador.O = sys.modules[__name__]
|
||||
|
||||
VERSION = "3.8.0"
|
||||
VERSION = "3.8.1"
|
||||
|
||||
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
|
||||
@@ -121,6 +121,7 @@ CONFIG_DEFECTO = {
|
||||
"ffprobe": "ffprobe",
|
||||
"python_resolve": "", # vacío = el mismo intérprete
|
||||
"braw": "", # carpeta del decodificador de Blackmagic RAW; vacío = se busca sola
|
||||
"ahorro_minimo": 30, # % que tiene que ahorrar un clip convertido para que se ofrezca
|
||||
},
|
||||
# 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"]},
|
||||
@@ -3445,21 +3446,44 @@ 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}
|
||||
# Cuánto pesa lo que sale, según de dónde viene. La cola codifica a calidad constante (cq) con un techo de 1,8
|
||||
# veces la tasa objetivo: lo que sale depende de cómo venía el clip, no de la tasa objetivo. Medido el
|
||||
# 14-oct-2026 sobre las 286 conversiones hechas (Galeano, Línea Gene, Diana Méndez), lo que pesó cada una:
|
||||
# - lo que guarda cada cuadro entero (BRAW, XAVC-I): entre el 1 y el 6 % (entre 0,3 y 0,85 veces el objetivo);
|
||||
# - un H.264 de cámara: entre el 9 y el 26 %; el de 4.6K a 33 Mb/s (5-oct), la mitad;
|
||||
# - un AV1 de cámara o de móvil (85-99 Mb/s): entre el 9 y el 11 %, su codificador gasta mucho;
|
||||
# - un H.265 de cámara: entre el 85 y el 99 %, aunque fuera holgado. De ahí no se baja sin quitar calidad.
|
||||
# Así que lo comprimido entre cuadros sale como mucho a 0,6 veces su tasa (MPEG-4 y MPEG-2, menos) y nunca por
|
||||
# encima del objetivo; un H.265, a 0,93 veces la suya, hasta el techo. Hasta la 3.8 se miraba solo si el origen
|
||||
# pasaba de 1 vez (H.264) o 2 veces (H.265, AV1, VP9) el objetivo: once AV1 de Línea Gene que habrían quedado en
|
||||
# la décima parte no se ofrecían, y en cambio salían H.265 que luego no adelgazaban.
|
||||
SALE_A = {"h264": 0.6, "av1": 0.6, "vp9": 0.6, "mpeg4": 0.45, "mpeg2video": 0.35}
|
||||
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 _kbps_salida(f, objetivo):
|
||||
"""La tasa de video (kb/s) con que saldrá un clip a H.265, según su códec y su tasa (ver SALE_A)."""
|
||||
src = f["kbps"] or 0
|
||||
if not src or "intra" in (f["perfil"] or "").lower():
|
||||
return objetivo
|
||||
if f["codec"] == "hevc":
|
||||
return min(src * 0.93, objetivo * 1.8)
|
||||
if f["codec"] in SALE_A:
|
||||
return min(src * SALE_A[f["codec"]], objetivo)
|
||||
return objetivo
|
||||
|
||||
|
||||
def ahorro_minimo():
|
||||
"""Lo que tiene que ahorrar un clip convertido para que se ofrezca, en %. Munka, 14-oct-2026: «si un archivo
|
||||
se puede optimizar así sea un 30 % se convierta, la idea es aprovechar lo máximo el espacio»."""
|
||||
try:
|
||||
return max(0, min(90, int(_opt("ahorro_minimo", 30))))
|
||||
except (TypeError, ValueError):
|
||||
return 30
|
||||
|
||||
|
||||
def _mbps(kbps):
|
||||
return f"{kbps / 1000:.1f}".replace(".", ",") if kbps < 10000 else f"{kbps / 1000:.0f}"
|
||||
|
||||
|
||||
def _salio_del_convertidor(f, salidas):
|
||||
@@ -3482,11 +3506,17 @@ def estimar_bytes(f, perfil, formato):
|
||||
pix = (f["ancho"] or 1920) * (f["alto"] or 1080) * (f["fps"] or 30)
|
||||
return int(fm["mbps_1080p30"] * 1e6 / 8 * dur * pix / (1920 * 1080 * 30))
|
||||
obj = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)
|
||||
if fm["codec"] == "hevc":
|
||||
obj = _kbps_salida(f, obj)
|
||||
return int(obj * 1000 / 8 * dur) + int(256000 / 8 * dur)
|
||||
|
||||
|
||||
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"):
|
||||
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit", ahorro=None):
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
try:
|
||||
ahorro = ahorro_minimo() if ahorro in (None, "") else max(0, min(90, int(ahorro)))
|
||||
except (TypeError, ValueError):
|
||||
ahorro = ahorro_minimo()
|
||||
lee_braw = bool(decodificador_braw().get("ok"))
|
||||
reubicar_trabajos(raiz)
|
||||
con = db()
|
||||
@@ -3514,6 +3544,10 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
|
||||
# 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))
|
||||
est = estimar_bytes(f, perfil, formato)
|
||||
# el ahorro, contra lo que pesa el clip por su tasa (es lo mismo que su peso, y no cuenta lo que no es video)
|
||||
base = f["kbps"] * 1000 / 8 * f["dur"] if f["kbps"] and f["dur"] else f["bytes"]
|
||||
gana = round((1 - est / base) * 100) if base else 100
|
||||
if _salio_del_convertidor(f, salidas):
|
||||
motivo_no = "ya es una conversión hecha con este programa"
|
||||
elif f["es_stock"]:
|
||||
@@ -3522,9 +3556,11 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
|
||||
motivo_no = "tiene canal alfa (HEVC lo perdería)"
|
||||
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 os.sep + R.DUPLICADOS + os.sep in f["ruta"]:
|
||||
motivo_no = "está apartado como repetido (Duplicados): mejor borrarlo que convertirlo"
|
||||
elif fm["codec"] == "hevc" and f["dur"] and gana < ahorro:
|
||||
motivo_no = (f"convertido ahorraría solo un {max(gana, 0)} % ({NOMBRE_CODEC.get(f['codec'], f['codec'])} a {_mbps(f['kbps'] or 0)} Mb/s): "
|
||||
f"se convierte desde un {ahorro} %")
|
||||
elif motor == "resolve" and formato != "h265_10bit":
|
||||
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):
|
||||
@@ -3533,7 +3569,6 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h
|
||||
motivo_no = "es un exportado / master"
|
||||
elif not f["ancho"] or not f["dur"]:
|
||||
motivo_no = "no se pudo leer (ffprobe)"
|
||||
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"],
|
||||
"pix_fmt": f["pix_fmt"], "res": f"{f['ancho']}x{f['alto']}", "fps": f["fps"], "dur": f["dur"], "kbps": f["kbps"],
|
||||
@@ -4525,7 +4560,7 @@ def _no_compensa(t, sale=None):
|
||||
if not entra or not sale:
|
||||
return ""
|
||||
repetida = R.es_conversion(os.path.basename(t["ruta"]))
|
||||
if not repetida and sale <= 0.8 * entra:
|
||||
if not repetida and sale <= (1 - max(5, ahorro_minimo() - 10) / 100) * 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.")
|
||||
@@ -7314,7 +7349,8 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
if ruta == "/api/optimizar/candidatos":
|
||||
o = CONFIG["optimizar"]
|
||||
return self._json({"candidatos": candidatos(os.path.normpath(q["raiz"]), q.get("meses", o["meses_minimos"]), q.get("perfil", o["perfil"]),
|
||||
q.get("min_mb", o["min_mb"]), q.get("exportados") == "1", q.get("formato", o.get("formato", "h265_10bit")))})
|
||||
q.get("min_mb", o["min_mb"]), q.get("exportados") == "1", q.get("formato", o.get("formato", "h265_10bit")),
|
||||
q.get("ahorro"))})
|
||||
if ruta == "/api/optimizar/motores":
|
||||
return self._json(motores())
|
||||
if ruta == "/api/optimizar/cola":
|
||||
@@ -7482,7 +7518,7 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json({"ok": True})
|
||||
if ruta == "/api/config":
|
||||
# solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json
|
||||
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar",
|
||||
permitidas = {"optimizar": {"meses_minimos", "min_mb", "ahorro_minimo", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar",
|
||||
"horario", "pausar_si_edito", "aligerar", "avisar"},
|
||||
"respaldo": {"destino"}, "actualizaciones": {"auto"}, "escaneo": {"profundo"}}
|
||||
for k, v in d.items():
|
||||
|
||||
@@ -397,7 +397,7 @@ def p_analizar():
|
||||
assert clip and not clip[0]['excluido'], (clip or cand)[:1]
|
||||
IDS['ruta'] = clip[0]['ruta']
|
||||
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]
|
||||
assert comprimido and comprimido[0]['excluido'].startswith('convertido ahorraría solo un'), (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'])
|
||||
|
||||
|
||||
@@ -800,38 +800,65 @@ class Convertidor(unittest.TestCase):
|
||||
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."""
|
||||
def test_se_convierte_lo_que_ahorra_un_30(self):
|
||||
"""Munka, 14-oct-2026: «si un archivo se puede optimizar así sea un 30 % se convierta». Medido sobre las 286
|
||||
conversiones hechas: un H.265 de cámara se queda en el 85-99 % (6 se reconvirtieron para nada), y en cambio
|
||||
los AV1 de 85 Mb/s que no se ofrecían quedan en la décima parte."""
|
||||
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"):
|
||||
for n in ("A014_06170738_C019.mp4", "C0001.MP4", "C0002.MP4", "C0003.MP4", "C0004.MP4", "C0005.MP4", "C0006.MP4",
|
||||
"A015_05250930_C004.mp4", "Duplicados/C0003.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},
|
||||
"C0004.MP4": {"codec": "av1", "perfil": "Main", "kbps": 22000},
|
||||
"C0005.MP4": {"codec": "prores", "perfil": "HQ", "pix_fmt": "yuv422p10le", "kbps": 870000},
|
||||
"C0006.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "kbps": 200000},
|
||||
"A015_05250930_C004.mp4": {"codec": "av1", "perfil": "Main", "ancho": 2160, "alto": 3840, "fps": 59.94, "kbps": 83000},
|
||||
})
|
||||
con = O.db()
|
||||
with con:
|
||||
con.execute("UPDATE archivos SET analizado = 1, codec = 'h264', perfil = 'High', pix_fmt = 'yuv420p', ancho = 3840, alto = 2160, "
|
||||
"fps = 30, dur = 60, kbps = 120000 WHERE nombre = 'C0003.MP4'") # el clip y su copia en Duplicados
|
||||
con.close()
|
||||
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
|
||||
cands = {os.path.relpath(c["ruta"], os.path.join(nr, g.replace("/", S))): c for c in O.candidatos(nr, 0, "equilibrado", 0)}
|
||||
cs = {k: c["excluido"] for k, c in cands.items()}
|
||||
# un H.265 de cámara casi no adelgaza: no se ofrece; uno muy holgado (el techo es 1,8 veces el objetivo), sí
|
||||
self.assertTrue(cs["C0002.MP4"].startswith("convertido ahorraría solo un 7 % (H.265 a 72 Mb/s): se convierte desde un 30 %"), cs)
|
||||
self.assertEqual(cs["C0006.MP4"], "")
|
||||
# el H.264 por debajo del objetivo y los AV1 (el de móvil a 83 Mb/s, uno a 22), que antes no se ofrecían, ahora sí
|
||||
self.assertEqual([cs["A014_06170738_C019.mp4"], cs["A015_05250930_C004.mp4"], cs["C0004.MP4"]], ["", "", ""])
|
||||
# lo que guarda cada cuadro entero (XAVC-I, ProRes) y el H.264 muy holgado, como siempre
|
||||
self.assertEqual([cs["C0001.MP4"], cs["C0003.MP4"], cs["C0005.MP4"]], ["", "", ""])
|
||||
# lo apartado como repetido no se convierte: se borra
|
||||
self.assertTrue(cs[S.join(("Duplicados", "C0003.MP4"))].startswith("está apartado como repetido"), cs)
|
||||
# lo que se estima: un AV1 a 83 Mb/s, como mucho el objetivo (49 % menos); un H.264 a 25, como mucho 0,6 veces
|
||||
a = cands["A015_05250930_C004.mp4"]
|
||||
self.assertAlmostEqual(a["estimado"] / (83000 * 1000 / 8 * 60), 1 - 0.49, delta=0.01)
|
||||
# el mínimo se cambia: pidiendo un 45 %, el H.264 a 25 Mb/s (39 %) ya no entra
|
||||
cs45 = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0, ahorro=45)}
|
||||
self.assertTrue(cs45["A014_06170738_C019.mp4"].startswith("convertido ahorraría solo un 39 %"), cs45)
|
||||
self.assertEqual(cs45["A015_05250930_C004.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
|
||||
self.assertEqual(cs["C0002.MP4"], "")
|
||||
# la lista de acciones cuenta 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])
|
||||
self.assertTrue(conv[0]["cifra"].startswith("7 clips"), conv[0])
|
||||
# y después de convertir se descarta lo que ahorre menos del mínimo con 10 puntos de margen (con 30, del 20 %)
|
||||
t = {"formato": "h265_10bit", "ruta": "C0002.MP4", "bytes_in": 100, "bytes_out": 81}
|
||||
self.assertTrue(O._no_compensa(t).startswith("no compensa: pesa el 81 %"))
|
||||
self.assertEqual(O._no_compensa(dict(t, bytes_out=79)), "")
|
||||
O.CONFIG["optimizar"]["ahorro_minimo"] = 50
|
||||
self.assertTrue(O._no_compensa(dict(t, bytes_out=61)).startswith("no compensa"))
|
||||
|
||||
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."""
|
||||
|
||||
@@ -692,12 +692,13 @@
|
||||
<div class="cab"><h2 class="v">Convertidor</h2><span class="sub">clips de cámara <b id="convCriterio">con más de 3 meses y más de 300 MB</b> · <details class="ajc" id="convAjustes"><summary>cambiar</summary><div class="toolbar">
|
||||
<label>con más de <input type="number" id="optMeses" value="3" min="0" style="width:60px"> meses</label>
|
||||
<label>mínimo <input type="number" id="optMin" value="300" min="0" style="width:70px"> MB</label>
|
||||
<label title="Se ofrece lo que convertido pesaría al menos este tanto por ciento menos">que ahorre al menos <input type="number" id="optAhorro" value="30" min="0" max="90" style="width:56px"> %</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 (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 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>
|
||||
<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. Se ofrece todo lo que convertido pesaría al menos un 30 % menos (se cambia en «cambiar»); lo calcula por el códec de cada clip: un H.264 o un AV1 de cámara suelen quedar en la décima parte, y un H.265 de cámara casi no adelgaza. Si al terminar ahorra menos de lo pedido (con 10 puntos de margen), se descarta y se queda el original. 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 id="optCards"></div>
|
||||
<div id="momento"></div>
|
||||
@@ -817,7 +818,7 @@
|
||||
<dt>Comparar</dt><dd>En un clip convertido, enseña el mismo cuadro del original y del convertido, lado a lado, en tres instantes del clip; al pasar el ratón, una lupa amplía el mismo recorte en los dos. «Medir la calidad» da la nota de 0 a 100 (VMAF) sobre unos diez segundos del medio del clip; de 93 arriba no se nota a simple vista. Desde ahí se reemplaza ese original solo. <i>Cómo: un BRAW se revela con la misma curva con que se convirtió; si el original ya se reemplazó, se busca en _Para borrar/originales. La nota se guarda en el trabajo y sale en la lista.</i></dd>
|
||||
<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. Arriba hay un solo botón grande, el que toca en ese momento: buscar, convertir, detener la tanda (con cuánto falta y a qué hora acaba) o reemplazar lo verificado. La lista es una sola, por carpetas: cada clip con su estado de verdad (candidato, en espera, convirtiendo con su barra y lo que le queda, convertido, verificado, falló); «primero este» adelanta un clip; se elige qué ver (por convertir, en cola, terminados, con fallo, excluidos, y los excluidos por grupos con su porqué) y por qué ordenar (carpeta, estado, nombre, fecha, tamaño, ahorro; otra vez invierte el orden). <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>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Arriba hay un solo botón grande, el que toca en ese momento: buscar, convertir, detener la tanda (con cuánto falta y a qué hora acaba) o reemplazar lo verificado. La lista es una sola, por carpetas: cada clip con su estado de verdad (candidato, en espera, convirtiendo con su barra y lo que le queda, convertido, verificado, falló); «primero este» adelanta un clip; se elige qué ver (por convertir, en cola, terminados, con fallo, excluidos, y los excluidos por grupos con su porqué) y por qué ordenar (carpeta, estado, nombre, fecha, tamaño, ahorro; otra vez invierte el orden). <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. Se ofrece lo que convertido ahorraría al menos un 30 % (se cambia en «cambiar»), calculado por el códec de origen; no se ofrece lo que ya convirtió el programa ni lo apartado como repetido.</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>
|
||||
@@ -993,7 +994,7 @@ async function cargarEstado(){
|
||||
sel.value = (antes && ESTADO.raices.some(r => r.clave === antes)) ? antes : '';
|
||||
pintarIndice();
|
||||
const fmt = $('#optFormato'); if (!fmt.options.length && ESTADO.formatos) { fmt.innerHTML = Object.entries(ESTADO.formatos).map(([k, v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); fmt.value = ESTADO.config.optimizar.formato || 'h265_10bit'; pintarFormato(); cargarMotores(); }
|
||||
const perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); perf.value = ESTADO.config.optimizar.perfil; $('#optMeses').value = ESTADO.config.optimizar.meses_minimos; $('#optMin').value = ESTADO.config.optimizar.min_mb; $('#optChroma').value = ESTADO.config.optimizar.chroma; $('#optColor').value = ESTADO.config.optimizar.color; pintarCriterio(); }
|
||||
const perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); perf.value = ESTADO.config.optimizar.perfil; $('#optMeses').value = ESTADO.config.optimizar.meses_minimos; $('#optMin').value = ESTADO.config.optimizar.min_mb; $('#optAhorro').value = ESTADO.config.optimizar.ahorro_minimo ?? 30; $('#optChroma').value = ESTADO.config.optimizar.chroma; $('#optColor').value = ESTADO.config.optimizar.color; pintarCriterio(); }
|
||||
const r = cfgRaiz(); const a = r && r.analisis;
|
||||
if (a && a.estado === 'corriendo') $('#anaEstado').innerHTML = `<span class="spin"></span> leyendo clips: ${a.hechos} de ${a.total} <button class="lnk" id="btnAnaDetener">Detener</button>`;
|
||||
else if (a && a.error && vistaActual() === 'convertir') $('#anaEstado').innerHTML = `<span class="warn">${esc(a.error)}</span>`;
|
||||
@@ -1890,8 +1891,11 @@ function resumenStock(){ const s = STOCK.filter(g => g.sel); bloquear('btnStockP
|
||||
$('#btnStockPapelera').onclick = () => { const s = STOCK.filter(g => g.sel && g.raiz === raizActual()); const rutas = s.flatMap(g => g.rutas); if (!rutas.length) return aviso('La lista es de otra carpeta. Busca de nuevo.', 'bad'); modal('Apartar stock de Envato', `<p><b>${rutas.length}</b> archivos (${gb(s.reduce((a,g)=>a+g.bytes,0))}) se mueven a <span class="path">_Para borrar/stock</span> dentro de la carpeta del cliente. Nada se borra; se deshace desde Historial.</p>`, () => accion($('#btnStockPapelera'), async () => { const r = await api('/api/stock/papelera', {raiz: raizActual(), rutas}); aviso(`${r.movimientos} archivos de stock apartados.`, 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); STOCK = []; pintarStock(); $('#stockCaja').style.display = 'none'; MAPA = null; await cargarEstado(); })); $('#modalSi').textContent = 'Apartar'; };
|
||||
|
||||
// ---------------- convertir ----------------
|
||||
function pintarCriterio(){ $('#convCriterio').textContent = `con más de ${+$('#optMeses').value || 0} meses y más de ${+$('#optMin').value || 0} MB`; }
|
||||
['optMeses','optMin'].forEach(id => $('#' + id).oninput = pintarCriterio);
|
||||
const ahorroPedido = () => { const v = $('#optAhorro').value; return v === '' ? 30 : Math.max(0, Math.min(90, Math.round(+v) || 0)); };
|
||||
function pintarCriterio(){ $('#convCriterio').textContent = `con más de ${+$('#optMeses').value || 0} meses, más de ${+$('#optMin').value || 0} MB y que ahorren al menos un ${ahorroPedido()} %`; }
|
||||
['optMeses','optMin','optAhorro'].forEach(id => $('#' + id).oninput = pintarCriterio);
|
||||
// el ahorro mínimo vale también para la pasada automática y para Inicio: se guarda al cambiarlo
|
||||
$('#optAhorro').onchange = () => api('/api/config', {optimizar: {ahorro_minimo: ahorroPedido()}}).then(() => { if (CAND.length) return cargarCandidatos(); }).catch(e => aviso(e.message, 'bad'));
|
||||
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'carpeta', inverso: false}, CONV_EXC = '', CONV_PLEGADAS = new Set(), CONV_BUSCA = '', CONV_CRITERIO = '';
|
||||
const NOMBRE_CAM = {braw: 'BRAW', h264: 'H.264', hevc: 'H.265', h265: 'H.265', prores: 'ProRes', mpeg4: 'MPEG-4', mpeg2video: 'MPEG-2', dnxhd: 'DNxHD', vp9: 'VP9', av1: 'AV1'};
|
||||
const MES_LARGO = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
|
||||
@@ -1910,7 +1914,8 @@ const tiempoLargo = s => { s = Math.max(0, Math.round(s || 0)); if (s < 60) retu
|
||||
function claveExc(m){
|
||||
m = m || '';
|
||||
if (m.startsWith('ya es una conversión')) return 'ya los convirtió el programa';
|
||||
if (m.startsWith('ya viene comprimido')) return 'ya vienen comprimidos de cámara';
|
||||
if (m.startsWith('convertido ahorraría solo')) return `ahorrarían menos del ${(m.match(/desde un (\d+) %/) || [])[1] || 30} %`;
|
||||
if (m.startsWith('está apartado como repetido')) return 'apartados como repetidos';
|
||||
if (m.startsWith('ya se probó')) return 'ya se probaron con esta calidad';
|
||||
if (m.startsWith('tiene menos de')) return m.replace('tiene menos de', 'de menos de');
|
||||
if (m.startsWith('no se pudo leer')) return 'no se pudieron leer';
|
||||
@@ -1944,14 +1949,14 @@ async function cargarMotores(){
|
||||
$$('#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'); });
|
||||
}
|
||||
async function cargarCandidatos(){
|
||||
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}&formato=${$('#optFormato').value}`;
|
||||
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}&formato=${$('#optFormato').value}&ahorro=${ahorroPedido()}`;
|
||||
const d = await api('/api/optimizar/candidatos?' + q); CAND = d.candidatos.map(c => ({...c, sel: !c.excluido && !c.en_cola}));
|
||||
$('#anaEstado').textContent = '';
|
||||
if (!CAND.length) aviso('No se encontraron clips con esos criterios. Prueba a cambiar los meses o el tamaño mínimo.', '');
|
||||
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 o su salida ya existe (el mismo clip en MP4 y MOV, o una conversión anterior).`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = '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, ahorro_minimo: ahorroPedido(), 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');
|
||||
|
||||
Referencia en una nueva incidencia
Block a user