diff --git a/README.md b/README.md index 73e815b..77ffcf3 100644 --- a/README.md +++ b/README.md @@ -913,19 +913,42 @@ También del 10 de octubre: - **El menú lateral se queda fijo al bajar.** Munka lo vio irse con el resto de la página (en la 3.3.0 y la 3.4.0): el cuerpo tenía `height:100%`, el alto de la ventana, así que su rejilla medía una pantalla y el lateral «sticky» no tenía recorrido. Ahora el cuerpo crece con el contenido y el lateral no se mueve. -- **Convertir, una sola lista.** Antes había dos: los candidatos (con el estado que tenían al pulsar «Buscar», - que se quedaba en «corriendo» en un clip ya acabado) y, 700 filas más abajo, la cola con el avance. Ahora - cada clip sale una vez con su estado de verdad; el que se está convirtiendo lleva la barra de avance en su - propia casilla de estado («62 %», la fila resaltada) y debajo qué hace el lector. Arriba: «ver» (todos, por - convertir, en cola, terminados, con fallo, excluidos, con su cuenta) y «ordenar por» (estado, nombre, fecha, - tamaño, ahorro; otra vez invierte). Lo convertido muestra el peso real (16,5 GB → 214 MB, −99 %) en vez del - estimado. +- **Convertir, rehecho** (Munka eligió tres de seis propuestas dibujadas en un lienzo; quedan para después + el horario nocturno, el resumen al acabar y la barra de marcar con criterio): + - *Una sola lista, por carpetas.* Antes había dos: los candidatos (con el estado que tenían al pulsar + «Buscar», que se quedaba en «corriendo» en un clip ya acabado) y, 700 filas más abajo, la cola con el + avance. Ahora cada clip sale una vez con su estado de verdad, agrupado por carpeta (la ruta una sola vez, + con casilla y plegable; las carpetas con algo que hacer van primero) y con solo el nombre del clip en la + fila. El que se está convirtiendo lleva la barra en su casilla de estado («62 %», la fila resaltada) y + «2 min 26 s de 3 min 56 s · quedan 1 min 40 s»; los que esperan tienen «primero este». Arriba, «ver» y + «ordenar por» (carpeta, estado, nombre, fecha, tamaño, ahorro). Abajo, los excluidos en grupos con su + porqué (en Diana Méndez: 473 son stock de Envato, 26 exportados, 2 ya comprimidos, 1 con alfa), y cada + grupo se abre con un clic. + - *Antes y después, en una barra* en vez de cuatro cifras: 676 GB hoy → 40 GB, lo que se libera, y lo ya + ganado en los convertidos. + - *Tiempo que falta.* La cola mide su ritmo con los clips ya acabados en la tanda (segundos de clip por + segundo real; si aún no acabó ninguno, con el avance del que va) y dice «quedan unas 7 h 10 min · termina + hacia las 19:40 · va a 0,9× tiempo real», también en la barra de tareas. Sale de `/api/optimizar/cola` + (`eta`). + - *Un botón principal según el momento*: «Buscar clips que se puedan aligerar», «Convertir N clips · A → + ≈ B» (con «y reemplazar solo cuando estén verificados», ajuste `optimizar.auto_reemplazar`: al acabar cada + clip, si verificó, reemplaza), la tanda con su barra y «Detener», «Reanudar la cola» si está parada, o, + con todo verificado, «Reemplazar los N originales · libera X» en verde con «comparar unos cuantos». + - *Comparar antes de reemplazar.* En cualquier clip convertido, «comparar» abre el mismo cuadro del original + y del convertido, lado a lado, en tres instantes del clip, con una lupa que amplía el mismo recorte en los + dos; «Medir la calidad» da la nota VMAF (0 a 100) sobre unos 10 s del medio del clip, con el cuadro peor, + y la guarda en el trabajo (`nota`, `nota_min`), que luego sale en la fila y como media en el panel. Un + BRAW se revela con la misma curva que la conversión (`braw.py cuadro` y `braw.py calidad`, en el proceso + aparte); el original ya reemplazado se busca en `_Para borrar/originales`. Medido con el clip de 16,5 GB + de Diana Méndez: nota 97,8 (peor cuadro 97) en 14 s. Desde ahí se reemplaza ese original solo. - En «Añadir carpeta», la clase de trabajo se elige entre cuatro tarjetas (título, cómo se ordena y «como David Reyes, Danney Gómez y 3 más», los clientes que ya van así) en vez de un desplegable, y el ejemplo de cómo queda es un árbol de carpetas, no una lista de rutas completas. -Pruebas: `prueba_funcional.py` comprueba además que el avance acaba en «tantas de tantas» con los movimientos -de la respuesta; `probar.py` 19, `prueba_orden.py` 33, `prueba_funcional.py` 30 y `prueba_ventana.py` 18. +Pruebas: `prueba_funcional.py` comprueba además que el avance de «Aplicar» acaba en «tantas de tantas» con +los movimientos de la respuesta, y una nueva, «Comparar original y convertido», saca los dos cuadros (el +original ya apartado en `_Para borrar/originales`) y mide la nota VMAF; `probar.py` 19, `prueba_orden.py` 33, +`prueba_funcional.py` 31 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 diff --git a/braw.py b/braw.py index 623adad..22d162c 100644 --- a/braw.py +++ b/braw.py @@ -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 diff --git a/organizador.py b/organizador.py index 83208da..736f819 100644 --- a/organizador.py +++ b/organizador.py @@ -436,7 +436,11 @@ def init_db(): "ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'", "ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1", # con qué versión de las reglas se hizo el inventario (R.VERSION_REGLAS) - "ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0"): + "ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0", + # «primero este»: la cola va por orden descendente y luego por id + "ALTER TABLE trabajos ADD COLUMN orden INTEGER DEFAULT 0", + # la nota de calidad (VMAF) medida en «comparar»: media y cuadro peor + "ALTER TABLE trabajos ADD COLUMN nota REAL", "ALTER TABLE trabajos ADD COLUMN nota_min REAL"): try: con.execute(alter) con.commit() @@ -2844,7 +2848,9 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"): return ids -COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0} +# empezo: cuándo arrancó el clip que va; ritmo: (segundos de clip, segundos reales) de cada clip acabado en +# esta tanda, para decir cuánto falta +COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0, "empezo": 0, "ritmo": []} def set_trabajo(id_, **kw): @@ -3233,17 +3239,24 @@ def bucle_cola(): try: while COLA["activa"]: con = db() - t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY id LIMIT 1").fetchone() + t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY orden DESC, 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"] + COLA["empezo"] = time.time() set_trabajo(t["id"], estado="corriendo", pct=0) try: (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"]]) + _anotar_ritmo(t["id"], time.time() - COLA["empezo"]) + if CONFIG["optimizar"].get("auto_reemplazar"): + try: # solo reemplaza lo verificado; lo demás lo deja como está + reemplazar([t["id"]]) + except Exception as ex: # noqa + COLA["mensaje"] = f"no se pudo reemplazar: {ex}" COLA["hechos"] += 1 elif lote_resolve: COLA["actual"] = lote_resolve[0]["id"] @@ -3269,6 +3282,180 @@ def bucle_cola(): COLA["hilo"] = None +def _anotar_ritmo(id_, segundos): + """Cuánto clip salió por segundo real en este trabajo, si acabó bien: con eso se calcula cuánto falta.""" + try: + con = db() + t = con.execute("SELECT estado, ruta FROM trabajos WHERE id = ?", (id_,)).fetchone() + f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None + con.close() + if t and t["estado"] in ("listo", "verificado", "reemplazado", "descartado") and f and (f["dur"] or 0) > 0 and segundos > 0: + COLA["ritmo"].append((float(f["dur"]), float(segundos))) + except Exception: # noqa + pass + + +def eta_cola(trabajos): + """Cuánto falta para que acabe la cola: segundos de clip pendientes partidos por el ritmo medido (clips ya + hechos en esta tanda; si no hay ninguno, el avance del que va; si tampoco, a tiempo real los BRAW y a 2,5× + la tarjeta). Devuelve también el clip que va, con lo que le queda.""" + pend = [t for t in trabajos if t["estado"] in ("pendiente", "corriendo")] + if not pend: + return None + con = db() + durs = {} + for t in pend: + f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() + durs[t["id"]] = float((f["dur"] if f else 0) or 0) + con.close() + ritmo = COLA.get("ritmo") or [] + clip_s, real_s = sum(d for d, _ in ritmo), sum(s for _, s in ritmo) + vel, medido = (clip_s / real_s if clip_s > 0 and real_s > 0 else 0.0), "tanda" if ritmo else "" + falta, actual, ahora = 0.0, None, time.time() + for t in pend: + d = durs[t["id"]] + if t["estado"] == "corriendo": + pct = float(t["pct"] or 0) + resto = d * max(0.0, 1 - pct / 100) + falta += resto + llevado = ahora - (COLA.get("empezo") or ahora) + if not vel and pct >= 3 and llevado > 5: + vel, medido = (d * pct / 100) / llevado, "clip" + actual = {"id": t["id"], "dur": d, "resto": resto, "llevado": llevado} + else: + falta += d + if not vel: + vel = 1.0 if any(t["motor"] == "braw" for t in pend) else 2.5 + seg = falta / vel if falta else 0.0 + if actual: + actual["resto_seg"] = actual["resto"] / vel + return {"clip_seg": round(falta), "seg": round(seg), "vel": round(vel, 2), "medido": medido, "termina": ahora + seg if seg else 0, + "actual": actual, "en_espera": sum(1 for t in pend if t["estado"] == "pendiente"), "hechos": len(ritmo)} + + +def _ruta_original(t): + """Dónde está hoy el original de un trabajo: en su sitio o, si ya se reemplazó, en _Para borrar/originales.""" + if os.path.isfile(t["ruta"]): + return t["ruta"] + try: + otra = os.path.join(t["raiz"], R.PAPELERA, "originales", os.path.relpath(t["ruta"], t["raiz"])) + except ValueError: + return "" + return otra if os.path.isfile(otra) else "" + + +def cuadro_de(id_, cual, segundo, ancho=1600): + """Un cuadro (JPEG) del original o del convertido de un trabajo, en el segundo pedido; se guarda en + datos/cuadros para no sacarlo dos veces. Devuelve la ruta del JPEG o lanza ValueError.""" + con = db() + t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() + f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None + con.close() + if not t: + raise ValueError("no existe ese trabajo") + segundo = max(0.0, float(segundo or 0)) + carpeta = os.path.join(DATOS, "cuadros") + os.makedirs(carpeta, exist_ok=True) + salida = os.path.join(carpeta, f"{id_}_{'o' if cual == 'original' else 'c'}_{int(round(segundo * 10))}.jpg") + if os.path.isfile(salida) and os.path.getsize(salida) > 0: + return salida + if cual == "original": + src = _ruta_original(dict(t)) + if not src: + raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)") + else: + src = t["salida"] + if not os.path.isfile(src): + raise ValueError("el convertido ya no está donde se hizo") + if cual == "original" and t["motor"] == "braw": + dec = decodificador_braw() + if not dec.get("ok"): + raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW") + ficha = os.path.join(DATOS, f"cuadro_{id_}.json") + guardar_json(ficha, {"src": src, "segundo": segundo, "salida": salida, "ffmpeg": ruta_herramienta("ffmpeg"), + "color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho}) + try: + ev = [e for e in _lector_braw("cuadro", ficha, espera=180) if e.get("estado")] + finally: + try: + os.remove(ficha) + except OSError: + pass + if not ev or ev[-1].get("estado") != "listo": + raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo sacar el cuadro") + else: + cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{segundo:.3f}", "-i", src, + "-frames:v", "1", "-vf", f"scale={ancho}:-2:flags=bicubic,format=yuvj420p", "-q:v", "2", "-f", "image2", salida] + r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, creationflags=SIN_VENTANA) + if r.returncode != 0 or not os.path.isfile(salida): + raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo sacar el cuadro") + return salida + + +def calidad_de(id_, segundos=10, ancho=1920): + """Nota de calidad (VMAF, 0 a 100) del convertido de un trabajo contra su original, sobre un tramo del medio + del clip. Se guarda en el trabajo (nota, nota_min). Lanza ValueError si no se puede.""" + con = db() + t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone() + f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None + con.close() + if not t: + raise ValueError("no existe ese trabajo") + src = _ruta_original(dict(t)) + if not src: + raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)") + if not os.path.isfile(t["salida"]): + raise ValueError("el convertido ya no está donde se hizo") + fps = float((f["fps"] if f else 0) or 0) or 25.0 + dur = float((f["dur"] if f else 0) or 0) + total = int(dur * fps) if dur else 0 + n = max(24, min(int(segundos * fps), 300, total or 300)) + desde = max(0, (total - n) // 2) if total else 0 + hilos = max(2, min(16, (os.cpu_count() or 8) - 2)) + carpeta = os.path.join(DATOS, "cuadros") + os.makedirs(carpeta, exist_ok=True) + log = f"calidad_{id_}.json" + t0 = time.time() + if t["motor"] == "braw": + dec = decodificador_braw() + if not dec.get("ok"): + raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW") + ficha = os.path.join(DATOS, f"calidad_{id_}.ficha.json") + guardar_json(ficha, {"src": src, "conv": t["salida"], "desde": desde, "cuadros": n, "ffmpeg": ruta_herramienta("ffmpeg"), + "color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho, "log": log, "cwd": carpeta, "vmaf_hilos": hilos}) + try: + ev = [e for e in _lector_braw("calidad", ficha, espera=900) if e.get("estado")] + finally: + try: + os.remove(ficha) + except OSError: + pass + if not ev or ev[-1].get("estado") != "listo": + raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo medir") + nota, minimo, cuadros = ev[-1]["vmaf"], ev[-1]["minimo"], ev[-1]["medidos"] + else: + fc = (f"[0:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r];" + f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d];" + f"[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={log}") + ss = f"{desde / fps:.6f}" + cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", ss, "-i", src, "-ss", ss, "-i", t["salida"], + "-filter_complex", fc, "-frames:v", str(n), "-an", "-f", "null", "-"] + r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900, cwd=carpeta, creationflags=SIN_VENTANA) + ruta_log = os.path.join(carpeta, log) + if r.returncode != 0 or not os.path.isfile(ruta_log): + raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo medir (¿este ffmpeg trae libvmaf?)") + with open(ruta_log, "r", encoding="utf-8") as fh: + j = json.load(fh) + v = (j.get("pooled_metrics") or {}).get("vmaf") or {} + nota, minimo, cuadros = round(float(v.get("mean") or 0), 1), round(float(v.get("min") or 0), 1), len(j.get("frames") or []) + try: + os.remove(os.path.join(carpeta, log)) + except OSError: + pass + set_trabajo(id_, nota=nota, nota_min=minimo) + return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)} + + def recuperar_huerfanas(): """Trabajos que quedaron «corriendo» sin una cola viva: el programa se cerró (o se actualizó) a mitad de una conversión y nadie volvía a tocarlos. Bloqueaban «Desconectar», no salían en «En curso» y el paso @@ -3338,6 +3525,7 @@ def iniciar_cola(): COLA["cancelar"] = False COLA["hechos"] = 0 COLA["inicio"] = time.time() + COLA["ritmo"] = [] COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True) COLA["hilo"].start() return True @@ -5665,9 +5853,35 @@ class Manejador(BaseHTTPRequestHandler): return self._json(motores()) if ruta == "/api/optimizar/cola": con = db() - filas = [dict(r) for r in con.execute("SELECT * FROM trabajos ORDER BY id DESC LIMIT 500").fetchall()] + # con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar» + filas = [dict(r) for r in con.execute("SELECT t.*, a.dur AS dur, a.fps AS fps FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta " + "ORDER BY t.id DESC LIMIT 500").fetchall()] + for f in filas: # un original ya reemplazado vive en _Para borrar/originales: su ficha está ahí + if f.get("dur") is None and f["estado"] == "reemplazado": + otra = _ruta_original(f) + a = con.execute("SELECT dur, fps FROM archivos WHERE ruta = ?", (otra,)).fetchone() if otra else None + if a: + f["dur"], f["fps"] = a["dur"], a["fps"] con.close() - return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"]}}) + try: + eta = eta_cola(filas) if COLA["activa"] else None + except Exception: # noqa + eta = None + return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta}}) + if ruta == "/api/optimizar/cuadro": # un cuadro del original o del convertido, para «comparar» + try: + jpg = cuadro_de(int(q.get("id") or 0), q.get("cual") or "convertido", float(q.get("t") or 0)) + except (ValueError, TypeError) as ex: + return self._json({"error": str(ex)}, 400) + with open(jpg, "rb") as fh: + cuerpo = fh.read() + self.send_response(200) + self.send_header("Content-Type", "image/jpeg") + self.send_header("Content-Length", str(len(cuerpo))) + self.send_header("Cache-Control", "private, max-age=86400") + self.end_headers() + self.wfile.write(cuerpo) + return if ruta == "/api/stock": tipos = [t for t in (q.get("tipos") or "Video").split(",") if t] return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos)) @@ -5757,7 +5971,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"}, + permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar"}, "respaldo": {"destino"}, "actualizaciones": {"auto"}} for k, v in d.items(): if k in permitidas and isinstance(v, dict): @@ -5879,6 +6093,12 @@ class Manejador(BaseHTTPRequestHandler): 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, "apartados": ap}) + if acc == "primero": # «primero este»: pasa delante de los que esperan + con = db() + with con: + con.execute("UPDATE trabajos SET orden = (SELECT COALESCE(MAX(orden), 0) + 1 FROM trabajos) WHERE id = ? AND estado = 'pendiente'", (int(d.get("id") or 0),)) + con.close() + return self._json({"ok": True}) if acc == "reintentar": con = db() with con: @@ -5887,6 +6107,11 @@ class Manejador(BaseHTTPRequestHandler): return self._json({"ok": True}) if ruta == "/api/optimizar/verificar": return self._json({"resultados": verificar(d.get("ids", []))}) + if ruta == "/api/optimizar/calidad": # la nota VMAF de un convertido: tarda unos segundos + try: + return self._json(calidad_de(int(d.get("id") or 0))) + except (ValueError, TypeError, subprocess.TimeoutExpired) as ex: + return self._json({"error": str(ex)}, 400) if ruta == "/api/optimizar/reemplazar": return self._json({"resultados": reemplazar(d.get("ids", []))}) if ruta == "/api/stock/papelera": diff --git a/prueba_funcional.py b/prueba_funcional.py index 214601b..47a6d80 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -371,6 +371,18 @@ def p_convertir(): return '%s: convertido (%s), verificado y reemplazado; original apartado' % (fmt, t['motor']) +def p_comparar(): + # el original ya se reemplazó (está en _Para borrar/originales): el cuadro tiene que salir de ahí + for cual in ('original', 'convertido'): + r = urllib.request.urlopen(BASE + '/api/optimizar/cuadro' + q(id=IDS['trabajo'], cual=cual, t=1.5), timeout=120) + assert r.headers.get('Content-Type') == 'image/jpeg' and len(r.read()) > 2000, 'no salió el cuadro %s' % cual + c, r = post('/api/optimizar/calidad', {'id': IDS['trabajo']}) + assert c == 200 and 30 < r['vmaf'] <= 100 and r['cuadros'] > 10, r + t = next(x for x in get('/api/optimizar/cola')['trabajos'] if x['id'] == IDS['trabajo']) + assert t['nota'] == r['vmaf'] and t.get('dur'), t + return 'dos cuadros JPEG y nota VMAF %s sobre %d cuadros (%.0f s)' % (r['vmaf'], r['cuadros'], r['seg']) + + def p_braw(): m = get('/api/optimizar/motores')['braw'] if not BRAW: @@ -540,7 +552,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), ('Convertir un BRAW', p_braw), ('Control de la cola', p_cola_control), + ('Convertir, verificar, reemplazar', p_convertir), ('Comparar original y convertido', p_comparar), ('Convertir un BRAW', p_braw), ('Control de la cola', p_cola_control), ('Respaldar', p_respaldo), ('Asistente / hacer de una vez', p_asistente), ('Pieza nueva', p_pieza), ('Bandeja de entrada y tarjeta', p_entrada), ('Orden automático', p_automatico), ('Arrastrar y buscar carpeta', p_soltar_y_buscar), ('Actualizaciones', p_actualizacion_offline), diff --git a/static/index.html b/static/index.html index 2e49f03..1ad7456 100644 --- a/static/index.html +++ b/static/index.html @@ -345,7 +345,47 @@ .nombrar li .r{font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} details.mas>summary{cursor:pointer;color:var(--mut);font-size:12.5px;margin:8px 0} /* convertir */ - .cfila{display:grid;grid-template-columns:24px 110px 1fr 150px 80px;gap:10px;padding:7px 0;border-bottom:1px solid var(--line2);font-size:12.5px;align-items:center} + .cfila{display:grid;grid-template-columns:24px 110px minmax(0,1fr) 150px 70px 110px;gap:10px;padding:7px 0;border-bottom:1px solid var(--line2);font-size:12.5px;align-items:center} + .cfila .acts{text-align:right;white-space:nowrap} + .cfila .acts button{background:none;border:0;color:var(--acc);font-size:12px;font-weight:600;padding:0 4px;cursor:pointer} + .cfila .nom .sub2 .carp{font-family:ui-monospace,Consolas,monospace} + .cgrupo{display:flex;gap:10px;align-items:center;padding:12px 0 6px;font-size:12.5px;border-top:1px solid var(--line);margin-top:6px} + .cgrupo.primero{border-top:0;margin-top:0} + .cgrupo .pl{background:none;border:0;padding:0;color:var(--mut);width:14px;cursor:pointer;display:inline-flex} + .cgrupo .cab{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:700;color:var(--mut);white-space:nowrap} + .cgrupo .cab.corre{color:var(--acc)} + .excl{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:12px 0 4px;border-top:1px dashed var(--line);margin-top:6px;font-size:12.5px;color:var(--mut)} + .excl button{background:var(--panel2);border:1px solid var(--line);border-radius:99px;padding:1px 9px;font-size:12px;color:var(--tx);cursor:pointer} + .excl button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)} + .excl button.ojo{background:var(--warn2);color:var(--warn)} + .momento{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:8px} + .momento.corre{border-color:var(--acc)} .momento.listo{border-color:var(--ok)} + .momento .l1{display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13.5px} + .momento .l3{font-size:12.5px} + .momento .bar.grande{height:10px} + .momento button.grande{font-size:14px;padding:9px 16px;border-radius:6px;font-weight:700} + .momento .ico{width:22px;height:22px;border-radius:50%;background:var(--ok2);color:var(--ok);display:inline-flex;align-items:center;justify-content:center;font-weight:700;font-size:13px} + .antes{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:6px} + .antes .l1,.antes .l3{display:flex;gap:16px;align-items:center;flex-wrap:wrap;font-size:12.5px} + .antes .l2{display:flex;gap:12px;align-items:center} + .antes .l2 b{font-size:22px;white-space:nowrap}.antes .l2 b.acc{color:var(--acc)} + .antes .pista{flex:1;height:18px;background:var(--panel2);border-radius:99px;overflow:hidden;display:flex} + .antes .pista i.conv{display:block;height:100%;background:var(--acc);border-radius:99px 0 0 99px} + .antes .pista i.libre{display:block;height:100%;background:var(--ok2);border-right:2px solid var(--ok)} + .antes .sw{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:-1px;margin-right:4px}.antes .sw.conv{background:var(--acc)}.antes .sw.libre{background:var(--ok2);border:1px solid var(--ok)} + .modal.ancho .box{max-width:1200px} + .cmp{display:grid;grid-template-columns:1fr 1fr;gap:14px} + .cmp .lado{display:flex;flex-direction:column;gap:6px;min-width:0} + .cmp .marco{position:relative;background:#d9dcdf;border-radius:8px;overflow:hidden;aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#4d5157;font-size:12.5px} + .cmp .marco img{width:100%;height:100%;object-fit:contain;display:block;cursor:crosshair} + .cmp .lupa{position:absolute;width:220px;height:220px;border:2px solid var(--acc);border-radius:8px;background-repeat:no-repeat;background-color:#000;pointer-events:none;display:none;box-shadow:0 4px 14px rgba(0,0,0,.3)} + .cmp .marco.con .lupa{display:block} + .cmpnota{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:12px} + .cmpnota>div{border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-size:12.5px;display:flex;flex-direction:column;gap:4px} + .cmpnota b.n{font-size:26px;color:var(--ok)} .cmpnota b.n.baja{color:var(--warn)} + .cmpt{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px;color:var(--mut);margin-top:10px} + .cmpt button{font-size:12px;padding:2px 8px} + .cmpt button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)} .cfila:last-child{border-bottom:0} .cfila .nom{min-width:0} .cfila .nom .clip{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600} @@ -581,7 +621,8 @@
Convierte los clips de proyectos ya entregados a un formato más ligero. El original no se toca hasta que reemplaces. ?Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a _Para borrar/originales y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW se leen con el decodificador de Blackmagic que instala DaVinci Resolve, sin tener que abrirlo; todo se codifica con ffmpeg, con la tarjeta gráfica cuando la hay. Lo que ya viene comprimido de cámara (H.264 o H.265 de pocos megabits) no se ofrece: ahorraría poco y perdería calidad. Nada se borra.
- + +