Convertir: lista por carpetas con cuánto falta, «comparar» con nota de calidad, y un botón principal según el momento

Munka eligió tres de seis propuestas dibujadas en un lienzo (quedan el horario nocturno, el resumen
al acabar y marcar con criterio):
- La lista va por carpetas (la ruta una sola vez), el clip que se convierte lleva su barra y lo que le
  queda, «primero este» adelanta uno, y los excluidos salen en grupos con su porqué. Antes y después en
  una barra. La cola mide su ritmo y dice cuánto falta y a qué hora acaba (eta en /api/optimizar/cola).
- «Comparar»: el mismo cuadro del original y del convertido en tres instantes, con lupa, y «Medir la
  calidad» (VMAF) que se guarda en el trabajo; braw.py cuadro y braw.py calidad revelan el BRAW con la
  misma curva. Medido: 97,8 en 14 s con un BRAW de 16,5 GB.
- Un solo botón grande según el momento: buscar, convertir (con «reemplazar solo cuando estén
  verificados», optimizar.auto_reemplazar), la tanda con Detener, reanudar, o reemplazar lo verificado.

Pruebas: probar 19, prueba_funcional 31 (nueva: comparar), prueba_ventana 18.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-12 11:43:05 -05:00
padre d92814d16c
commit d38c51bd73
Se han modificado 5 ficheros con 544 adiciones y 40 borrados

110
braw.py
Ver fichero

@@ -609,6 +609,110 @@ def convertir(t):
return 1
def _tubo_a_ffmpeg(clip, cmd, desde, hasta, errores):
"""Le pasa a ffmpeg los cuadros [desde, hasta) por el tubo y espera a que acabe; devuelve el código."""
ff = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errores, creationflags=SIN_VENTANA)
try:
for _, donde, tam in clip.cuadros_en_orden(desde, hasta):
try:
ff.stdin.write((ctypes.c_ubyte * tam).from_address(donde))
except OSError:
break # ffmpeg ya tiene lo que necesitaba (-frames:v) o se cayó
try:
ff.stdin.close()
except OSError:
pass
return ff.wait()
finally:
if ff.poll() is None:
ff.kill()
ff.wait(30)
def cuadro(t):
"""Un solo cuadro del clip, revelado con la misma curva que la conversión y guardado como JPEG: para
poner el original al lado del convertido en «comparar». `t`: src, segundo, salida, ffmpeg, color, ancho."""
clip = None
errores = tempfile.TemporaryFile()
try:
clip = Clip(t["src"], t.get("sdk") or "", t.get("hilos") or 0)
clip.poner_color(t.get("color") or "rec709")
if not clip.cuadros or not clip.fps:
raise OSError("el clip no tiene imagen")
idx = max(0, min(clip.cuadros - 1, int(round(float(t.get("segundo") or 0) * clip.fps))))
# misma matriz que la conversión (Rec.709) y rango completo, que es lo que espera un JPEG
vf = "scale=in_range=full:out_range=full:out_color_matrix=bt709:flags=bicubic+accurate_rnd" + (f",scale={int(t['ancho'])}:-2:flags=bicubic" if t.get("ancho") else "") + ",format=yuvj420p"
cmd = [t["ffmpeg"], "-hide_banner", "-nostdin", "-y", "-loglevel", "error",
"-f", "rawvideo", "-pix_fmt", "rgb48le", "-video_size", f"{clip.ancho}x{clip.alto}", "-framerate", fraccion(clip.fps), "-i", "pipe:0",
"-vf", vf, "-frames:v", "1", "-q:v", "2", "-f", "image2", t["salida"]]
codigo = _tubo_a_ffmpeg(clip, cmd, idx, idx + 1, errores)
if codigo != 0 or not os.path.exists(t["salida"]):
raise OSError(_cola(errores) or f"ffmpeg terminó con el código {codigo}")
emitir(estado="listo", cuadro=idx, segundo=round(idx / clip.fps, 3), **clip.datos())
return 0
except Exception as ex: # noqa
emitir(estado="error", log=str(ex))
return 1
finally:
if clip:
try:
clip.cerrar()
except Exception: # noqa
pass
errores.close()
def calidad(t):
"""Nota de calidad (VMAF, de 0 a 100) del convertido contra el BRAW revelado sin pérdida, sobre un tramo de
cuadros [desde, desde + cuadros). Los dos se miran a la misma anchura (`ancho`, 1920 por defecto: el modelo
de VMAF está pensado para 1080p). `t`: src, conv, desde, cuadros, ffmpeg, color, log (archivo JSON que
escribe libvmaf, en la carpeta de trabajo `cwd`), hilos."""
clip = None
errores = tempfile.TemporaryFile()
try:
clip = Clip(t["src"], t.get("sdk") or "", t.get("hilos") or 0)
clip.poner_color(t.get("color") or "rec709")
if not clip.cuadros or not clip.fps:
raise OSError("el clip no tiene imagen")
desde = max(0, min(clip.cuadros - 1, int(t.get("desde") or 0)))
hasta = min(clip.cuadros, desde + max(1, int(t.get("cuadros") or 1)))
ancho = int(t.get("ancho") or 1920)
hilos = int(t.get("vmaf_hilos") or 8)
# la referencia pasa por la misma conversión de color que la conversión (RGB entero → YCbCr Rec.709 de
# vídeo); así lo único que separa a los dos es lo que perdió el codificador
ref = f"[0:v]scale=in_range=full:out_range=limited:out_color_matrix=bt709:flags=bicubic+accurate_rnd,format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r]"
dis = f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d]"
fc = f"{ref};{dis};[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={t['log']}"
cmd = [t["ffmpeg"], "-hide_banner", "-nostdin", "-y", "-loglevel", "error",
"-f", "rawvideo", "-pix_fmt", "rgb48le", "-video_size", f"{clip.ancho}x{clip.alto}", "-framerate", fraccion(clip.fps), "-i", "pipe:0",
"-ss", f"{desde / clip.fps:.6f}", "-i", t["conv"], "-filter_complex", fc, "-frames:v", str(hasta - desde), "-an", "-f", "null", "-"]
antes = os.getcwd()
os.chdir(t.get("cwd") or antes)
try:
codigo = _tubo_a_ffmpeg(clip, cmd, desde, hasta, errores)
finally:
os.chdir(antes)
log = os.path.join(t.get("cwd") or antes, t["log"])
if codigo != 0 or not os.path.exists(log):
raise OSError(_cola(errores) or f"ffmpeg terminó con el código {codigo}")
with open(log, "r", encoding="utf-8") as fh:
j = json.load(fh)
v = (j.get("pooled_metrics") or {}).get("vmaf") or {}
emitir(estado="listo", vmaf=round(float(v.get("mean") or 0), 1), minimo=round(float(v.get("min") or 0), 1),
medidos=len(j.get("frames") or []), desde=desde, **clip.datos())
return 0
except Exception as ex: # noqa
emitir(estado="error", log=str(ex))
return 1
finally:
if clip:
try:
clip.cerrar()
except Exception: # noqa
pass
errores.close()
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
try:
@@ -632,10 +736,10 @@ def main(argv=None):
for ruta in argv[1:]:
emitir(**info(ruta, carpeta))
return 0
if orden == "convertir" and len(argv) > 1:
if orden in ("convertir", "cuadro", "calidad") 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 {"convertir": convertir, "cuadro": cuadro, "calidad": calidad}[orden](json.load(fh))
emitir(msg="uso: braw.py sonda | info clip.braw [...] | convertir trabajo.json | cuadro ficha.json | calidad ficha.json")
return 2