diff --git a/README.md b/README.md index 5673ae4..cf90fa4 100644 --- a/README.md +++ b/README.md @@ -915,6 +915,31 @@ 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.9.0, la cola más rápida. Munka: «ayúdame a mejorar todo lo que se pueda en la app». Medido en su equipo el +14-oct-2026, con 1.920 clips de Vía Sumapaz en espera (31,8 horas de clip, 1,58 TB, unas 26 horas de cola): el +codificador de la RTX 5080 iba al 46-74 %, la tarjeta entera al 9-18 % y su descompresor al 0 %. + +- **La tarjeta descomprime** los H.264, H.265, AV1, VP9 y MPEG-2 (NVDEC): hasta ahora lo hacía el procesador y la + tarjeta esperaba. Con dos clips 4K de la cola de Sumapaz, un H.264 pasó de 0,54× a 0,86× tiempo real y un H.265 + de 0,70× a 0,85×, con el mismo archivo de salida. Si con ella un clip no sale, se repite con el procesador. +- **Dos clips a la vez** (la RTX 5080 tiene dos codificadores): 1,29× tiempo real entre los dos, frente a unos + 0,77× de uno solo. Juntando las dos cosas, un H.264 de esos va más del doble de rápido. Se cambia en «cambiar» › + «a la vez» (automático: dos con tarjeta NVIDIA, uno sin ella). La ventana, la barra de abajo y la de Windows + enseñan los dos, y el «termina hacia las…» cuenta con ellos. +- **Las conversiones sueltas se cierran al arrancar.** Una de las 10:04, de antes de actualizar a la 3.8.0 (el + lector de BRAW y su ffmpeg), seguía colgada a las 17:00 con una sesión del codificador y memoria de la tarjeta. + Ahora, al abrir, se cierran las que no tienen quien las espere (no las de la pasada de la noche) y se borra su + «.parcial»; y «Detener» cierra también el ffmpeg del lector de BRAW, que quedaba vivo. +- **La cola, entera.** Se pedían los 500 trabajos más recientes de todas las carpetas: con 1.920 en espera en Vía + Sumapaz, los de Línea Gene no salían (ni su «Reemplazar 12 verificados») y el «termina hacia las…» contaba 500 + en espera, no 1.920 (decía 7 horas y eran 26). Ahora se pide la cola de la carpeta, entera, y el cálculo mira todo. +- **Lo que ya no compensa no se convierte**: lo que se encoló con la regla de antes y con la del 30 % ya no se + ofrecería (en Sumapaz, 21 H.265) sale de la cola al llegarle el turno, y se dice cuántos. + +Pruebas: `prueba_orden.py` 46 casos (cinco nuevos: dos a la vez y «Detener», las conversiones sueltas, la tarjeta +descomprime y su vuelta al procesador si falla, lo que ya no compensa); `prueba_funcional.py` 33 de 33 (la +conversión de verdad ya descomprime con la tarjeta), `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». diff --git a/organizador.py b/organizador.py index bc083b7..28d4ca0 100644 --- a/organizador.py +++ b/organizador.py @@ -42,7 +42,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "3.8.1" +VERSION = "3.9.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 @@ -254,6 +254,11 @@ def motores(): out["gpu"]["uhq"] = r.returncode == 0 except Exception: # noqa out["gpu"]["uhq"] = False + try: # ¿descomprime la tarjeta (NVDEC)? ver _decodificar_con_tarjeta + r = subprocess.run([ff, "-hide_banner", "-hwaccels"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA) + out["gpu"]["nvdec"] = bool(out["gpu"]["hevc"] or out["gpu"]["h264"]) and "cuda" in (r.stdout or "").split() + except Exception: # noqa + out["gpu"]["nvdec"] = 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 "" @@ -3747,7 +3752,7 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"): # 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": [], - "esperando": "", "hechos_ids": [], "ultima": None} + "esperando": "", "hechos_ids": [], "ultima": None, "actuales": {}, "saltados": 0} def _opt(k, defecto=None): @@ -4063,6 +4068,22 @@ def _quitar_parcial(tmp): time.sleep(0.25) +# Los códecs que descomprime la tarjeta. ProRes, DNxHD y MJPEG no: esos los sigue leyendo el procesador. +NVDEC = {"h264", "hevc", "av1", "vp9", "mpeg2video"} + + +def _decodificar_con_tarjeta(f, motor): + """["-hwaccel", "cuda"] si el clip lo puede descomprimir la tarjeta, o []. Hasta la 3.8 lo descomprimía el + procesador y la tarjeta esperaba: medido el 14-oct-2026 en la RTX 5080 del estudio, con la cola en marcha, + el codificador iba al 46-74 % y el descompresor de la tarjeta al 0 %. Con dos clips 4K de Vía Sumapaz, un + H.264 pasó de 0,54× a 0,86× tiempo real y un H.265 de 0,70× a 0,85×, con el mismo archivo de salida. Los + cuadros vuelven a la memoria (sin «-hwaccel_output_format cuda»): el formato de píxel y los filtros siguen + igual, y hacerlo todo en la tarjeta no ganó nada medible. Si con ella falla, se repite con el procesador.""" + if motor != "gpu" or not f or (f["codec"] or "") not in NVDEC or _opt("sin_nvdec", False): + return [] + return ["-hwaccel", "cuda"] if motores()["gpu"].get("nvdec") else [] + + def correr_ffmpeg(t): con = db() f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() @@ -4083,41 +4104,44 @@ def correr_ffmpeg(t): tmp = salida + ".parcial" + fm["ext"] 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?"] - cmd += video + ["-map_metadata", "0"] + contenedor + audio - cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp] - set_trabajo(t["id"], log=" ".join(cmd)[:4000]) - try: - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) - except OSError as ex: - set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}") - return - ultimo = 0 - for linea in p.stdout: + con_tarjeta = _decodificar_con_tarjeta(f, motor) + for entrada in ([con_tarjeta, []] if con_tarjeta else [[]]): + cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", *entrada, "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"] + cmd += video + ["-map_metadata", "0"] + contenedor + audio + cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp] + set_trabajo(t["id"], log=" ".join(cmd)[:4000]) + try: + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=SIN_VENTANA) + except OSError as ex: + set_trabajo(t["id"], estado="error", log=f"no se pudo lanzar ffmpeg: {ex}") + return + ultimo = 0 + for linea in p.stdout: + if COLA["cancelar"]: + p.kill() + break + if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="): + try: + us = int(linea.split("=")[1]) # ffmpeg reporta microsegundos en las dos claves + pct = min(99.0, us / 1e6 / dur * 100) if dur else 0 + if time.time() - ultimo > 1: + set_trabajo(t["id"], pct=pct) + ultimo = time.time() + except ValueError: + pass + err = p.stderr.read() + p.wait() if COLA["cancelar"]: - p.kill() + set_trabajo(t["id"], estado="cancelado", pct=0) + _quitar_parcial(tmp) + return + if p.returncode == 0 and os.path.exists(tmp): break - if linea.startswith("out_time_us=") or linea.startswith("out_time_ms="): - try: - us = int(linea.split("=")[1]) - if linea.startswith("out_time_ms="): - us = us # ffmpeg reporta microsegundos en ambas claves - pct = min(99.0, us / 1e6 / dur * 100) if dur else 0 - if time.time() - ultimo > 1: - set_trabajo(t["id"], pct=pct) - ultimo = time.time() - except ValueError: - pass - err = p.stderr.read() - p.wait() - if COLA["cancelar"]: - set_trabajo(t["id"], estado="cancelado", pct=0) - _quitar_parcial(tmp) - return - if p.returncode != 0 or not os.path.exists(tmp): - set_trabajo(t["id"], estado="error", log=(err or "")[-3000:]) _quitar_parcial(tmp) # si no, el .parcial queda y el siguiente escaneo lo inventaría como video - return + if not entrada: + set_trabajo(t["id"], estado="error", log=(err or "")[-3000:]) + return + # con la tarjeta descomprimiendo no salió: se repite con el procesador, como hasta la 3.8 os.replace(tmp, salida) try: st = os.stat(t["ruta"]) @@ -4182,14 +4206,15 @@ def correr_braw(t): 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 + # Detener: se le pide al lector que pare (él cierra ffmpeg y borra lo que llevaba); si no hace caso, se + # corta, y con él su ffmpeg (p.kill() solo cerraba el lector, y su ffmpeg seguía vivo) while p.poll() is None: if COLA["cancelar"]: try: open(bandera, "w").close() p.wait(20) except (OSError, subprocess.TimeoutExpired): - p.kill() + _matar_arbol(p.pid) return time.sleep(0.5) threading.Thread(target=vigia, daemon=True).start() @@ -4342,61 +4367,146 @@ def _clip_presente(t): return None -def bucle_cola(): +def simultaneas(): + """Cuántos clips convierte la cola a la vez: optimizar.simultaneas, o, si es 0, dos con tarjeta NVIDIA y uno + sin ella. Medido el 14-oct-2026 en la RTX 5080 del estudio, que tiene dos codificadores: con un clip a la vez + el codificador iba al 46-74 % y la tarjeta entera al 9-18 %; dos clips 4K a la vez salen a 1,29× tiempo real + entre los dos, frente a unos 0,77× de uno solo.""" try: - while COLA["activa"]: - con = db() - 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()] + n = int(_opt("simultaneas", 0) or 0) + except (TypeError, ValueError): + n = 0 + if n <= 0: + g = motores()["gpu"] + n = 2 if (g.get("hevc") or g.get("h264")) else 1 + return max(1, min(3, n)) + + +_COLA_TOMA = threading.Lock() # para que dos trabajadores no cojan el mismo clip + + +def _fijar_actual(): + """«actual» es el primero de los que van (lo que enseñan la barra de abajo y la de Windows); «actuales», todos.""" + ids = sorted(COLA["actuales"]) + COLA["actual"] = ids[0] if ids else None + COLA["empezo"] = COLA["actuales"].get(ids[0], 0) if ids else 0 + + +def _soltar(*ids): + with _COLA_TOMA: + for i in ids: + COLA["actuales"].pop(i, None) + _fijar_actual() + + +def _tomar(n): + """El siguiente clip para el trabajador n, ya apartado para que no lo coja otro. Los BRAW que van por el guion + de Resolve (un lote de hasta 40) solo los coge el primero, y solo cuando no queda nada más.""" + with _COLA_TOMA: + ocupados = list(COLA["actuales"]) + filtro = f" AND id NOT IN ({','.join('?' * len(ocupados))})" if ocupados else "" + con = db() + try: + t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw')" + filtro + + " ORDER BY orden DESC, id LIMIT 1", ocupados).fetchone() + lote = [] if t or n else [dict(r) for r in con.execute( + "SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve'" + filtro + " ORDER BY id LIMIT 40", ocupados).fetchall()] + finally: con.close() - if t and not _esperar_turno(): - break - if t: - t = _clip_presente(dict(t)) - if not t: - continue - if not t and lote_resolve: - lote_resolve = [x for x in map(_clip_presente, lote_resolve) if x] - if not lote_resolve: - continue - 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)) + for i in ([t["id"]] if t else [r["id"] for r in lote]): + COLA["actuales"][i] = time.time() + _fijar_actual() + return (dict(t) if t else None), lote + + +def _ya_no_compensa(t): + """Una conversión en espera que con la regla de ahorro de ahora no se ofrecería (se encoló con la de antes): no + se convierte. Solo en los formatos que existen para pesar menos. Devuelve el porqué, o «».""" + if FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"])["codec"] != "hevc" or t["motor"] != "ffmpeg": + return "" + con = db() + f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() + con.close() + if not f or not f["dur"] or not f["kbps"]: + return "" + gana = round((1 - estimar_bytes(f, t["perfil"], formato_de(t)) / (f["kbps"] * 1000 / 8 * f["dur"])) * 100) + ahorro = ahorro_minimo() + return f"convertido ahorraría solo un {max(gana, 0)} %, y se convierte desde un {ahorro} %" if gana < ahorro else "" + + +def _trabajador(n): + """Uno de los que convierten a la vez: coge el siguiente clip, lo convierte y vuelve a por otro. Si no queda + nada pero otro aún trabaja, espera un poco por si se encola algo más (así no se pierde el «a la vez»).""" + while COLA["activa"] and not COLA["cancelar"]: + t, lote = _tomar(n) + if not t and not lote: + if any(COLA["actuales"]): + time.sleep(3) + continue + return + ids = [t["id"]] if t else [r["id"] for r in lote] + if not _esperar_turno(): + _soltar(*ids) + return + if t: + t = _clip_presente(t) + pega = _ya_no_compensa(t) if t else "" + if pega: + con = db() + with con: + con.execute("DELETE FROM trabajos WHERE id = ? AND estado = 'pendiente'", (t["id"],)) + con.close() + COLA["saltados"] += 1 + COLA["mensaje"] = f"{COLA['saltados']} sin convertir: con la regla de ahorro de ahora ya no compensaban ({pega})" + if not t or pega: + _soltar(*ids) + continue + COLA["actuales"][t["id"]] = empezo = time.time() # el tiempo cuenta desde que empieza de verdad + _fijar_actual() + set_trabajo(t["id"], estado="corriendo", pct=0) + try: + (correr_braw if t["motor"] == "braw" else correr_ffmpeg)(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() - 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 - 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 - COLA["hechos_ids"].append(t["id"]) - elif lote_resolve: - COLA["actual"] = lote_resolve[0]["id"] - try: - correr_resolve(lote_resolve) - except Exception as ex: # noqa - for r in lote_resolve: - set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}") - _verificar_listos([r["id"] for r in lote_resolve]) - COLA["hechos"] += len(lote_resolve) - COLA["hechos_ids"].extend(r["id"] for r in lote_resolve) - else: - break - COLA["actual"] = None - if COLA["cancelar"]: - COLA["cancelar"] = False - break + COLA["mensaje"] = f"no se pudo reemplazar: {ex}" + hechos = [t["id"]] + else: + lote = [x for x in map(_clip_presente, lote) if x] + try: + if lote: + correr_resolve(lote) + except Exception as ex: # noqa + for r in lote: + set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}") + _verificar_listos([r["id"] for r in lote]) + hechos = [r["id"] for r in lote] + with _COLA_TOMA: + COLA["hechos"] += len(hechos) + COLA["hechos_ids"].extend(hechos) + _soltar(*ids) + + +def bucle_cola(): + """La cola: arranca los trabajadores (ver simultaneas) y espera a que acaben todos.""" + try: + hilos = [threading.Thread(target=_trabajador, args=(i,), daemon=True) for i in range(simultaneas())] + for h in hilos: + h.start() + for h in hilos: + h.join() except Exception as ex: # noqa COLA["mensaje"] = f"la cola se detuvo por un error: {ex}" finally: # pase lo que pase, la cola no queda «activa» para siempre (bloqueaba el cierre del programa) COLA["activa"] = False + COLA["cancelar"] = False + COLA["actuales"] = {} COLA["actual"] = None COLA["hilo"] = None COLA["esperando"] = "" @@ -4420,42 +4530,50 @@ def _anotar_ritmo(id_, segundos): pass -def eta_cola(trabajos): +def eta_cola(trabajos=None): """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")] + hechos en esta tanda; si no hay ninguno, el avance de los que van; si tampoco, a tiempo real los BRAW y a 2,5× + la tarjeta), por cuántos van a la vez. Sin lista, mira toda la cola: hasta la 3.8 contaba solo los 500 últimos + trabajos, y con 1.920 en espera en Vía Sumapaz decía que acababa en 7 horas y no en 26. Devuelve también los + clips que van, con lo que le queda a cada uno.""" + con = db() + if trabajos is None: + pend = [dict(r) for r in con.execute("SELECT t.id, t.estado, t.pct, t.motor, a.dur AS dur FROM trabajos t " + "LEFT JOIN archivos a ON a.ruta = t.ruta WHERE t.estado IN ('pendiente','corriendo')")] + else: + pend = [dict(t) for t in trabajos if t["estado"] in ("pendiente", "corriendo")] + for t in pend: + if t.get("dur") is None: + f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() + t["dur"] = f["dur"] if f else 0 + con.close() 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() + falta, actuales, ahora = 0.0, [], time.time() for t in pend: - d = durs[t["id"]] + d = float(t.get("dur") or 0) 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) + llevado = ahora - (COLA["actuales"].get(t["id"]) or 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} + actuales.append({"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)} + paralelo = max(1, min(simultaneas() if COLA["activa"] else 1, len(pend))) + seg = falta / (vel * paralelo) if falta else 0.0 + for a in actuales: + a["resto_seg"] = a["resto"] / vel + return {"clip_seg": round(falta), "seg": round(seg), "vel": round(vel * paralelo, 2), "vel_clip": round(vel, 2), "paralelo": paralelo, + "medido": medido, "termina": ahora + seg if seg else 0, "actual": actuales[0] if actuales else None, "actuales": actuales, + "en_espera": sum(1 for t in pend if t["estado"] == "pendiente"), "hechos": len(ritmo)} def _ruta_original(t): @@ -4581,6 +4699,78 @@ def calidad_de(id_, segundos=10, ancho=1920): return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)} +def _matar_arbol(pid): + """Cierra un proceso y lo que lanzó (el lector de BRAW lanza su propio ffmpeg).""" + try: + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, timeout=30, creationflags=SIN_VENTANA) + else: + os.kill(pid, 9) + except (OSError, subprocess.SubprocessError): + pass + + +def _conversores_sueltos(procesos, base, yo): + """De una lista de procesos ({ProcessId, ParentProcessId, Name, CommandLine}), los de conversión de esta + instalación (un ffmpeg o un lector de BRAW) que ya no tienen quien los espere: su padre murió, o el número + lo reusó otro proceso. Los de un programa vivo (este, u otra instancia como la pasada de la noche) no.""" + vivos = {p.get("ProcessId"): p for p in procesos} + base = base.lower() + + def conversor(p): + cmd = (p.get("CommandLine") or "").lower() + return base in cmd and ((p.get("Name") or "").lower() == "ffmpeg.exe" or "braw.py" in cmd) + + def programa(p): + cmd = (p.get("CommandLine") or "").lower() + return p.get("ProcessId") == yo or (not conversor(p) and (base in cmd or "organizador" in cmd or "ventana.py" in cmd)) + sueltos = [] + for p in procesos: + if not conversor(p): + continue + padre = vivos.get(p.get("ParentProcessId")) + if padre and (conversor(padre) or programa(padre)): + continue # el ffmpeg de un lector va con su lector; lo demás lo espera un programa vivo + sueltos.append(p) + return sueltos + + +def cerrar_conversores_sueltos(): + """Al arrancar, se cierran las conversiones que quedaron vivas sin el programa que las lanzó, y se borra lo que + dejaron a medio escribir. Medido el 14-oct-2026: el lector de BRAW y el ffmpeg de una conversión de las 10:04, + de antes de actualizar a la 3.8.0, seguían a las 17:00 colgados, con una sesión del codificador de la tarjeta + y su memoria, y su «.parcial.mp4» en la carpeta de Galeano.""" + if os.name != "nt" or os.environ.get("ORGANILAB_ENSAYO"): + return [] + filtro = "Name='ffmpeg.exe' or Name='OrganizadorLabstream.exe' or Name like 'python%'" + try: + r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", + f'Get-CimInstance Win32_Process -Filter "{filtro}" | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress'], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, creationflags=SIN_VENTANA) + procesos = json.loads(r.stdout or "[]") + except (OSError, subprocess.SubprocessError, ValueError): + return [] + if isinstance(procesos, dict): + procesos = [procesos] + sueltos = _conversores_sueltos(procesos, BASE, os.getpid()) + if not sueltos: + return [] + cerrados = {p["ProcessId"] for p in sueltos} + for p in sueltos: + _matar_arbol(p["ProcessId"]) + # lo que dejaron a medias: el último argumento de cada ffmpeg cerrado (el suyo o el de su lector) + parciales = [] + for p in procesos: + if (p.get("Name") or "").lower() == "ffmpeg.exe" and (p.get("ProcessId") in cerrados or p.get("ParentProcessId") in cerrados): + m = re.search(r'(?:"([^"]*\.parcial\.\w+)"|(\S*\.parcial\.\w+))\s*$', p.get("CommandLine") or "") + if m: + parciales.append(m.group(1) or m.group(2)) + for tmp in parciales: + _quitar_parcial(tmp) + bitacora({"op": "conversores_sueltos", "cerrados": sorted(cerrados), "parciales": parciales}) + return sorted(cerrados) + + 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 @@ -4653,6 +4843,8 @@ def iniciar_cola(): COLA["ritmo"] = [] COLA["hechos_ids"] = [] COLA["esperando"] = "" + COLA["actuales"] = {} + COLA["saltados"] = 0 COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True) COLA["hilo"].start() return True @@ -5567,14 +5759,13 @@ def estado_general(): con = db() esc = {r["raiz"]: dict(r) for r in con.execute("SELECT * FROM escaneos").fetchall()} cola_n = {r["estado"]: r["n"] for r in con.execute("SELECT estado, COUNT(*) n FROM trabajos GROUP BY estado").fetchall()} - actual = None - if COLA["actual"]: - t = con.execute("SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() - if t: - actual = dict(t) + ids = sorted(COLA["actuales"]) + actuales = [dict(t) for t in con.execute(f"SELECT id, ruta, raiz, pct, bytes_in, motor, estado FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", + ids)] if ids else [] + actual = next((a for a in actuales if a["id"] == COLA["actual"]), actuales[0] if actuales else None) con.close() - # «clip 3 de 12»: los hechos en esta tanda + el que va + los que esperan - total_tanda = COLA["hechos"] + (1 if actual else 0) + cola_n.get("pendiente", 0) + # «clip 3 de 12»: los hechos en esta tanda + los que van + los que esperan + total_tanda = COLA["hechos"] + len(actuales) + cola_n.get("pendiente", 0) esc_ci = {k.lower(): v for k, v in esc.items()} raices = [] for r in CONFIG["raices"]: @@ -5582,7 +5773,7 @@ def estado_general(): raices.append({**r, "clave": k, "escaneo": esc_ci.get(k.lower()), "progreso": PROGRESO.get(k), "analisis": ANALISIS.get(k)}) return {"config": CONFIG, "raices": raices, "herramientas": herramientas(), "version": VERSION, "actualizacion": actualizador.ESTADO, "congelado": CONGELADO, - "cola": {"activa": COLA["activa"], "actual": actual, "mensaje": COLA["mensaje"], "n": cola_n, + "cola": {"activa": COLA["activa"], "actual": actual, "actuales": actuales, "mensaje": COLA["mensaje"], "n": cola_n, "hechos": COLA["hechos"], "total": total_tanda, "inicio": COLA["inicio"]}, "aplicando": APLICANDO, "asistente": asistente.ESTADO, "respaldo": {k: v for k, v in RESPALDO.items() if k != "errores"} | {"errores_n": len(RESPALDO.get("errores") or [])}, @@ -7464,9 +7655,13 @@ class Manejador(BaseHTTPRequestHandler): return self._json(motores()) if ruta == "/api/optimizar/cola": con = db() - # con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar» + # con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar». Con la + # carpeta, solo los suyos y todos: hasta la 3.8 eran los 500 últimos de todas, y con 1.920 en espera + # en Vía Sumapaz los de Línea Gene no salían (ni su «Reemplazar 12 verificados») + k = (q.get("raiz") or "").strip() + filtro, args = ("WHERE lower(t.raiz) = ? ", [os.path.normpath(k).lower()]) if k else ("", []) 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()] + + filtro + "ORDER BY t.id DESC LIMIT " + ("10000" if k else "500"), args).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) @@ -7475,7 +7670,7 @@ class Manejador(BaseHTTPRequestHandler): f["dur"], f["fps"] = a["dur"], a["fps"] con.close() try: - eta = eta_cola(filas) if COLA["activa"] else None + eta = eta_cola() if COLA["activa"] else None except Exception: # noqa eta = None if COLA.get("ultima") is None: @@ -7627,7 +7822,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", "ahorro_minimo", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar", + permitidas = {"optimizar": {"meses_minimos", "min_mb", "ahorro_minimo", "simultaneas", "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(): @@ -8119,12 +8314,13 @@ def avance(): try: con = db() espera = con.execute("SELECT COUNT(*) FROM trabajos WHERE estado = 'pendiente'").fetchone()[0] - t = con.execute("SELECT pct FROM trabajos WHERE id = ?", (COLA["actual"],)).fetchone() if COLA["actual"] else None + ids = list(COLA["actuales"]) + pcts = [r[0] or 0 for r in con.execute(f"SELECT pct FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)] if ids else [] con.close() except sqlite3.Error: return -1 - total = COLA["hechos"] + (1 if t else 0) + espera - return (COLA["hechos"] + ((t[0] or 0) / 100 if t else 0)) / total if total else -1 + total = COLA["hechos"] + len(pcts) + espera + return (COLA["hechos"] + sum(pcts) / 100) / total if total else -1 if RESPALDO.get("estado") == "corriendo": return RESPALDO["bytes"] / RESPALDO["bytes_total"] if RESPALDO.get("bytes_total") else -1 if TRAER.get("estado") == "copiando": @@ -8350,7 +8546,8 @@ def main(): except OSError: pass huerfanas = recuperar_huerfanas() - threading.Thread(target=lambda: (reubicar_trabajos(), repasar_verificados(), braws_al_lector()), daemon=True).start() + threading.Thread(target=lambda: (cerrar_conversores_sueltos(), reubicar_trabajos(), repasar_verificados(), braws_al_lector()), + daemon=True).start() if huerfanas: print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") try: diff --git a/prueba_orden.py b/prueba_orden.py index 5300be7..d93274d 100644 --- a/prueba_orden.py +++ b/prueba_orden.py @@ -12,6 +12,7 @@ import time import shutil import tempfile import threading +import subprocess import unittest AQUI = os.path.dirname(os.path.abspath(__file__)) @@ -1315,6 +1316,157 @@ class Convertidor(unittest.TestCase): self.assertTrue(r["ok"], r) self.assertTrue(os.path.isfile(os.path.join(nueva, os.path.basename(tb["salida"])))) + def _cola_falsa(self, durante=0.4): + """Sustituye la conversión por una que tarda `durante` segundos y cuenta cuántas van a la vez.""" + cuenta = {"vivos": 0, "maximo": 0, "vistos": []} + candado = threading.Lock() + + def falso(t): + with candado: + cuenta["vivos"] += 1 + cuenta["maximo"] = max(cuenta["maximo"], cuenta["vivos"]) + cuenta["vistos"].append(t["id"]) + fin = time.time() + durante + while time.time() < fin and not O.COLA["cancelar"]: + time.sleep(0.02) + with candado: + cuenta["vivos"] -= 1 + if O.COLA["cancelar"]: + O.set_trabajo(t["id"], estado="cancelado", pct=0) + return + with open(t["salida"], "wb") as fh: + fh.write(b"x" * 1000) + O.set_trabajo(t["id"], estado="listo", pct=100, bytes_out=1000, fin=time.time()) + for k, v in {"correr_ffmpeg": falso, "_verificar_listos": lambda ids: None, "editando": lambda: "", + "_ya_no_compensa": lambda t: ""}.items(): + self.addCleanup(setattr, O, k, getattr(O, k)) + setattr(O, k, v) + self.addCleanup(O.CONFIG["optimizar"].pop, "simultaneas", None) + return cuenta + + def test_dos_a_la_vez(self): + """RTX 5080 del estudio, 14-oct-2026: con un clip a la vez el codificador iba al 46-74 %; dos a la vez salen + a 1,29× tiempo real entre los dos, frente a 0,77×. Nunca van más de los pedidos, ningún clip se convierte + dos veces, y «Detener» para a todos.""" + raiz = cliente("ALaVez") + nr = os.path.normpath(raiz) + g = "2025/11- Noviembre/00_Grabacion/SONY/" + clips = [mk(raiz, g + f"C00{i:02d}.MP4", 2025, 11, 11, tam=300000 + i) for i in range(8)] + O.escanear(raiz) + self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips}) + cuenta = self._cola_falsa() + O.CONFIG["optimizar"]["simultaneas"] = 2 + # lo que otras pruebas dejaran esperando no entra en esta tanda (y se devuelve al acabar) + con = O.db() + otros = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'pendiente'")] + with con: + con.execute("UPDATE trabajos SET estado = 'cancelado' WHERE estado = 'pendiente'") + con.close() + + def devolver(): + c = O.db() + with c: + c.executemany("UPDATE trabajos SET estado = 'pendiente' WHERE id = ?", [(i,) for i in otros]) + c.close() + self.addCleanup(devolver) + ids = O.encolar(clips[:5], "equilibrado", "420", "rec709", nr) + self.assertEqual(len(ids), 5) + self.assertTrue(O.iniciar_cola()) + time.sleep(0.2) + e = O.eta_cola() + self.assertEqual(e["paralelo"], 2) + self.assertEqual(len(e["actuales"]), 2) + O.COLA["hilo"].join(30) + self.assertEqual(cuenta["maximo"], 2) + self.assertEqual(sorted(cuenta["vistos"]), sorted(ids)) + self.assertEqual({self.trabajo(i)["estado"] for i in ids}, {"listo"}) + self.assertEqual((O.COLA["actuales"], O.COLA["activa"], O.COLA["actual"]), ({}, False, None)) + # Detener: los dos que van se cortan, los que esperan se quedan esperando + cuenta.update(maximo=0, vistos=[]) + ids = O.encolar(clips[5:], "equilibrado", "420", "rec709", nr) + self.assertTrue(O.iniciar_cola()) + time.sleep(0.2) + O.COLA["cancelar"], O.COLA["activa"] = True, False # lo que hace «Detener» + O.COLA["hilo"].join(30) + estados = sorted(self.trabajo(i)["estado"] for i in ids) + self.assertEqual(estados, ["cancelado", "cancelado", "pendiente"]) + self.assertFalse(O.COLA["cancelar"]) + con = O.db() + with con: + con.execute(f"DELETE FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids) + con.close() + + def test_conversiones_sueltas(self): + """14-oct-2026: el lector de BRAW y el ffmpeg de una conversión de antes de actualizar seguían colgados siete + horas después. Al arrancar se cierran los que no tienen quien los espere, y no los de un programa vivo.""" + base = r"C:\Programas\OrganiLAB" + P = lambda pid, ppid, nombre, cmd: {"ProcessId": pid, "ParentProcessId": ppid, "Name": nombre, "CommandLine": cmd} + ff = base + r"\ffmpeg\ffmpeg.exe" + procesos = [ + P(100, 50, "OrganizadorLabstream.exe", f'"{base}\\OrganizadorLabstream.exe"'), # este programa + P(101, 100, "OrganizadorLabstream.exe", f"{base}\\OrganizadorLabstream.exe {base}\\braw.py convertir x.json"), # su lector + P(102, 101, "ffmpeg.exe", f'{ff} -i pipe:0 "V:\\G\\a_10bit.mp4.parcial.mp4"'), # el ffmpeg del lector + P(103, 100, "ffmpeg.exe", f"{ff} -i a.mp4 b.parcial.mp4"), # uno suyo + P(200, 65492, "OrganizadorLabstream.exe", f"{base}\\OrganizadorLabstream.exe {base}\\braw.py convertir y.json"), # lector suelto + P(201, 200, "ffmpeg.exe", f"{ff} -i pipe:0 V:\\G\\b.parcial.mp4"), # y su ffmpeg + P(300, 60, "OrganizadorLabstream.exe", f'"{base}\\OrganizadorLabstream.exe" --automatico'), # la pasada de la noche + P(301, 300, "ffmpeg.exe", f"{ff} -i c.mp4 c.parcial.mp4"), # y su ffmpeg + P(401, 4000, "ffmpeg.exe", f"{ff} -i d.mp4 d.parcial.mp4"), # su programa ya no está + P(500, 1, "ffmpeg.exe", r"C:\otro\ffmpeg.exe -i e.mp4 e.mp4"), # de otro programa + ] + self.assertEqual(sorted(p["ProcessId"] for p in O._conversores_sueltos(procesos, base, 100)), [200, 401]) + + def test_la_tarjeta_descomprime(self): + """Con la cola en marcha, el descompresor de la tarjeta estaba al 0 %: un H.264 4K pasa de 0,54× a 0,86× tiempo + real si lo descomprime ella. Lo que ella no lee (ProRes) sigue por el procesador, y si con ella falla, se repite.""" + real = O.motores + self.addCleanup(setattr, O, "motores", real) + O.motores = lambda: {"gpu": {"nvdec": True, "hevc": True}} + self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "gpu"), ["-hwaccel", "cuda"]) + self.assertEqual(O._decodificar_con_tarjeta({"codec": "hevc"}, "gpu"), ["-hwaccel", "cuda"]) + self.assertEqual(O._decodificar_con_tarjeta({"codec": "prores"}, "gpu"), []) + self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "cpu"), []) + O.motores = lambda: {"gpu": {"nvdec": False}} + self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "gpu"), []) + O.motores = real + ff = shutil.which("ffmpeg") + if not ff: + self.skipTest("sin ffmpeg") + raiz = cliente("Descomprime") + nr = os.path.normpath(raiz) + clip = os.path.join(nr, "2025", "11- Noviembre", "00_Grabacion", "SONY", "C0001.MP4") + os.makedirs(os.path.dirname(clip)) + subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=320x180:rate=30:duration=1", + "-c:v", "libx264", "-pix_fmt", "yuv420p", clip], check=True) + O.escanear(raiz) + self.leidos(raiz, {"C0001.MP4": {"codec": "h264", "perfil": "High", "ancho": 320, "alto": 180, "fps": 30, "dur": 1, "kbps": 900}}) + id_ = O.encolar([clip], "equilibrado", "420", "rec709", nr)[0] + # una descompresión que no arranca: se repite con el procesador y sale igual + self.addCleanup(setattr, O, "_decodificar_con_tarjeta", O._decodificar_con_tarjeta) + O._decodificar_con_tarjeta = lambda f, motor: ["-hwaccel", "no_existe"] + O.correr_ffmpeg(self.trabajo(id_)) + t = self.trabajo(id_) + self.assertEqual(t["estado"], "listo", t["log"]) + self.assertTrue(os.path.isfile(t["salida"])) + self.assertNotIn("-hwaccel", t["log"]) + + def test_lo_que_ya_no_compensa_no_se_convierte(self): + """Se encolaron con la regla de antes: un H.265 de cámara que se quedaría en el 93 % no se convierte.""" + raiz = cliente("YaNoCompensa") + nr = os.path.normpath(raiz) + g = "2025/11- Noviembre/00_Grabacion/SONY/" + a, b = mk(raiz, g + "C0001.MP4", 2025, 11, 11, tam=300000), mk(raiz, g + "C0002.MP4", 2025, 11, 11, tam=300001) + O.escanear(raiz) + self.leidos(raiz, {"C0001.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "fps": 59.94, "kbps": 72000}, + "C0002.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000}}) + ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr) + self.assertTrue(O._ya_no_compensa(self.trabajo(ia)).startswith("convertido ahorraría solo un 7 %")) + self.assertEqual(O._ya_no_compensa(self.trabajo(ib)), "") + con = O.db() + with con: + con.execute("DELETE FROM trabajos WHERE id IN (?, ?)", (ia, ib)) + con.close() + def test_el_codificador_que_toca(self): fm = O.FORMATOS["h265_10bit"] t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000} diff --git a/static/index.html b/static/index.html index 99ba4cf..6923b23 100644 --- a/static/index.html +++ b/static/index.html @@ -697,6 +697,7 @@ +
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. 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.
@@ -994,7 +995,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]) => ``).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]) => ``).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 perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => ``).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; $('#optSimult').value = String(ESTADO.config.optimizar.simultaneas ?? 0); $('#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 = ` leyendo clips: ${a.hechos} de ${a.total} `; else if (a && a.error && vistaActual() === 'convertir') $('#anaEstado').innerHTML = `${esc(a.error)}`; @@ -1164,7 +1165,7 @@ function tareasDe(E){ if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); } if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); } }); - if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); } + if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const vs = (E.cola.actuales && E.cola.actuales.length) ? E.cola.actuales : (a ? [a] : []); const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctVan = vs.reduce((s, x) => s + (x.pct || 0), 0); const pctTot = tot ? Math.round((hechos + pctVan / 100) / tot * 100) : null; const numeros = vs.length > 1 ? `${Math.min(hechos + 1, tot)} y ${Math.min(hechos + 2, tot)}` : `${Math.min(hechos + 1, tot)}`; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${numeros} de ${tot}` : 'Convirtiendo', d: `${vs.map(x => (x.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(x.pct || 0) + ' %').join(' · ')}${vs.length ? ' · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); } if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); } else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.unidades_total ? E.aplicando.unidades * 100 / E.aplicando.unidades_total : E.aplicando.hechas * 100 / E.aplicando.total)}); if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); } @@ -1894,6 +1895,7 @@ $('#btnStockPapelera').onclick = () => { const s = STOCK.filter(g => g.sel && g. 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); +$('#optSimult').onchange = () => api('/api/config', {optimizar: {simultaneas: +$('#optSimult').value}}).then(() => aviso(`La cola convertirá ${+$('#optSimult').value || 'automáticamente uno o dos'}${+$('#optSimult').value === 1 ? ' clip' : (+$('#optSimult').value ? ' clips' : '')} a la vez desde la próxima vez que arranque.`, 'ok')).catch(e => aviso(e.message, 'bad')); // 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 = ''; @@ -1959,8 +1961,8 @@ function resumenCand(){ const s = CAND.filter(c => c.sel); const validos = CAND. $('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `${s.length} clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a ${esc(fm.nombre)}, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.
El original no se toca. Cuando terminen y estén verificados, decides si reemplazar.
`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, 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'); const raiz = raizActual(); + const d = await api('/api/optimizar/cola' + (raiz ? '?raiz=' + encodeURIComponent(raiz) : '')); const ts = d.trabajos.filter(t => !raiz || (t.raiz || '').toLowerCase() === raiz.toLowerCase()); 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); @@ -2011,7 +2013,7 @@ async function cargarCola(){ const corre = t && t.estado === 'corriendo', pct = corre ? Math.max(0, Math.min(100, Math.round(t.pct || 0))) : 0; const est = corre ? `${pct} %` : t ? `${EST_TXT[t.estado] || esc(t.estado)}` : `${c.excluido ? 'no' : 'candidato'}`; let sub = c ? `${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${motorT}` : motorT; - if (corre) { const a = eta && eta.actual && eta.actual.id === t.id ? eta.actual : null; sub = `convirtiendo${a && a.dur ? ` · ${tiempoLargo(a.dur - a.resto)} de ${tiempoLargo(a.dur)} · quedan ${tiempoLargo(a.resto_seg)}` : ''}${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''} · ${motorT}`; } + if (corre) { const a = eta && ((eta.actuales || []).find(x => x.id === t.id) || (eta.actual && eta.actual.id === t.id ? eta.actual : null)); sub = `convirtiendo${a && a.dur ? ` · ${tiempoLargo(a.dur - a.resto)} de ${tiempoLargo(a.dur)} · quedan ${tiempoLargo(a.resto_seg)}` : ''}${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''} · ${motorT}`; } else if (t && t.estado === 'error') sub = `${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}`; else if (t && t.estado === 'descartado') sub = `${esc(t.log || '')}`; else if (t && t.estado === 'pendiente' && t.log) sub = `${esc(t.log)}`; @@ -2034,9 +2036,12 @@ async function cargarCola(){ if (d.cola.activa && d.cola.esperando) { mo = `${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'FALLA'} · ${x.motivo}`).join('\n'))}`, null); cargarCola(); });
$('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `${ids.length} originales se apartan a _Para borrar/originales dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.
Cuando estés seguro, una persona vacía _Para borrar a mano.
`, () => accion($('#btnReemplazar'), async () => { const r = await api('/api/optimizar/reemplazar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} reemplazados.`, ok === r.resultados.length ? 'ok' : 'bad', {texto: 'Ver historial', ir: () => irA('historial')}); if (ok < r.resultados.length) modal('Reemplazo', `${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}`, null); cargarCola(); })); $('#modalSi').textContent = 'Reemplazar'; });
$('#btnReintentar').onclick = () => accion($('#btnReintentar'), async () => { const ids = await idsPorEstado('error', 'cancelado'); await api('/api/optimizar/control', {accion: 'reintentar', ids}); await api('/api/optimizar/control', {accion: 'iniciar'}); cargarCola(); });