diff --git a/README.md b/README.md index 6680d5d..c5e7918 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,29 @@ con LIKE tardan menos de 10 ms; no hacía falta optimizar. carpetas de Windows, sin depender de soltar la carpeta ni de pegar la ruta. `POST /api/elegir-carpeta` lanza el diálogo en un PowerShell aparte para no bloquear el servidor y devuelve la ruta (o nulo si se cancela). +1.7.0, recorrido paso a paso y convertidor: +- Arrastrar una carpeta a la ventana ya trae su ruta real: el puente WebView2 (`v.dom.document.events.drop`) + guarda lo soltado y la interfaz lo pide en `/api/soltado`; la búsqueda por nombre queda solo de reserva + (navegador externo). +- «Qué va a pasar» son ahora tres pasos en frases, cada uno con su botón Aplicar: 1 apartar basura y proxies, + 2 ordenar por año y mes, 3 ordenar lo demás. Cada fila dice «X pasa a Y» y por qué en lenguaje llano; lo + dudoso queda sin marcar. Al aplicar un paso el plan se recalcula solo. Cada fila del plan trae `paso`. +- «Por nombrar» propone qué es cada carpeta mirando su contenido (clips de cámara, proyectos, música, fotos, + documentos, videos) y el parecido del nombre con el diccionario; botón «Sí, es …» por carpeta y «Aceptar las + sugerencias seguras» para todas las de confianza alta (≥ 90 % de un mismo tipo y ≥ 3 archivos). +- «Stock viejo» agrupa por antigüedad (más de 12, 6-12, 3-6, 1-3, menos de 1 mes) con casilla por tramo y + atajos 1/2/3/6/12 meses. +- «Deshacer» sale de la interfaz (confundía). Sigue existiendo por API/CLI para emergencias. +- «Desconectar» pasa a llamarse «Quitar de la lista» con explicación clara. +- «Convertidor» (antes «Convertir a 10 bits»): formatos MP4 H.265 10 bits, H.265 ligero, H.264, ProRes Proxy / + LT / HQ (`FORMATOS`), detección real de GPU NVIDIA (NVENC) y CPU en `/api/optimizar/motores` (se prueba + codificando 3 cuadros), cola tipo Media Encoder («Convirtiendo 2 de 3 · 47 %») y el mismo clip se puede + convertir a varios formatos. ProRes nunca reemplaza el original (es de edición). Columna `formato` en + `trabajos`. +- Respaldar por lotes: «solo lo de más de 3 / 6 / 12 meses» (`meses` en `/api/respaldo/plan`). +- Barra de tareas revisada: la conversión en curso llegaba como un id y la interfaz esperaba un objeto (salía + 0 % sin nombre); ahora `estado.cola` trae `actual` (ruta, pct, raíz), `hechos` y `total`. + Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos: diff --git a/manifiesto.json b/manifiesto.json index 2720b8a..78172af 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "1.6.3", - "builtAt": "2026-09-16T00:50:31.000Z", - "archivo": "OrganizadorLabstream-portatil-1.6.3.zip", - "size": 198631521, - "sha256": "f84af3a46e4284660acac3dd0fd7fdfee9437c680a899dbfcc3cbf85660a9962", + "version": "1.7.0", + "builtAt": "2026-09-16T01:45:56.000Z", + "archivo": "OrganizadorLabstream-portatil-1.7.0.zip", + "size": 198673691, + "sha256": "2dad6903c9484815406c4d0f3e2e3ebf65a916990fc93ee3ade07ac489bd7f9e", "app": { - "archivo": "OrganizadorLabstream-app-1.6.3.zip", - "size": 14811784, - "sha256": "8ec8fcacbbd865c4aca52d233667d4281f5510e3bf14044e8efd5173c2beeeb9", - "url": "/api/herramientas/OrganizadorLabstream-app-1.6.3.zip" + "archivo": "OrganizadorLabstream-app-1.7.0.zip", + "size": 14853941, + "sha256": "faf85782a465f0d1cec062f6d7993e70e4e292adf2095f9864cbfafe4f33c935", + "url": "/api/herramientas/OrganizadorLabstream-app-1.7.0.zip" }, "notes": "", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "58afe4902d9cc98d02ffb98a3376de4dd9da56504c24a88ef1ff3175f8832cac22a20ae9439aca68cba6bbb2126441770b3abe190f5d99d6b313830dd4cc7c4000eede641e97a169f7cebdf541abd618a6f27e032d0c1931aa567849f91a63489b67f3d062a4ea3fd6478d5479a6da9c0cfd56242afbf133ecc2558c95448e443054fd8879a03ccf54c0e0e23057f9923a7ef0fac270ea8f512aab2caf1b1c506f9e8ea749604171a2364d9054901582b455681947d9a26b55459b5b880023d41d532730324daeec88175990a0883c96ad670f79925e35335c68658ccfc70326a7adead5b95c6f3b6d5f309dfc5214734f039397ff9a05b2348643bce27c54fa" + "valor": "bba9a7041615d6b3fc47ecdfee39e6835ed09d8f06bba6629b46e23d8e91a57c84ebf82ad0c9cda49632143a8f0b9baf56698393363c197604687a01f477cda7877cc7f4f6368460a85c08aaa5a17c3e2205ee5f3db598b02592b372f7ea479ca722a1498d2c0c13d6f95c02c1e8a0982ac280bf00b1cbcd71ee2d840a8a8a08038fc45fe7195cbf6ad1f2ab562d619c67060af21028bfab9f8213d9f77a6e974646e7c4c597356b094bfdc379950d2015a75ea131beaf34b29aab710934863e44b7e52055ee2b6d2108c53b2fe1c0249746f00537085a1d2ba051f653091d3b7fa9d523a3ba3d459f4c48ea3985a798d29214a985575e263cdebc4fac4cd493" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index 4f1c040..95e955d 100644 --- a/organizador.py +++ b/organizador.py @@ -33,7 +33,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "1.6.3" +VERSION = "1.7.0" CONGELADO = getattr(sys, "frozen", False) SIN_VENTANA = getattr(subprocess, "CREATE_NO_WINDOW", 0) # ffmpeg/ffprobe/net sin abrir consolas negras # empaquetado como .exe con PyInstaller @@ -101,6 +101,7 @@ CONFIG_DEFECTO = { "meses_minimos": 3, "min_mb": 300, "perfil": "equilibrado", + "formato": "h265_10bit", "chroma": "420", "color": "rec709", "salida": "junto", # junto = al lado del original con sufijo _10bit ; espejo = _Archivo/ @@ -117,6 +118,68 @@ PERFILES = { "maxima": {"bpp": 0.14, "cq": 21, "nombre": "Máxima calidad"}, } +# Formatos de salida del convertidor. gpu/cpu = codificador de ffmpeg que lo hace; se usa la GPU si está +# disponible y si no la CPU. bpp_mult escala el bitrate objetivo del perfil (H.265 10 bits = 1.0). +# ProRes no va por bitrate: mbps_1080p30 es su peso aproximado a 1080p 30 fps para estimar. +FORMATOS = { + "h265_10bit": {"nombre": "MP4 H.265 10 bits · archivo (recomendado)", "ext": ".mp4", "codec": "hevc", "bits": 10, "sufijo": "_10bit", + "gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 1.0, "que": "unas 10 veces más ligero que el original, sin perder calidad visible; el que usa el respaldo"}, + "h265_ligero": {"nombre": "MP4 H.265 8 bits · ligero para compartir", "ext": ".mp4", "codec": "hevc", "bits": 8, "sufijo": "_ligero", + "gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 0.55, "que": "para enviar por WhatsApp o Drive; pierde latitud de color"}, + "h264": {"nombre": "MP4 H.264 8 bits · compatible con todo", "ext": ".mp4", "codec": "h264", "bits": 8, "sufijo": "_h264", + "gpu": "h264_nvenc", "cpu": "libx264", "bpp_mult": 1.5, "que": "se abre en cualquier equipo o televisor; pesa más que H.265"}, + "prores_proxy": {"nombre": "MOV ProRes Proxy · edición ligera", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_proxy", + "cpu": "prores_ks", "prores": 0, "mbps_1080p30": 45, "que": "para editar suave en Resolve o Premiere; luego se reenlaza al original"}, + "prores_lt": {"nombre": "MOV ProRes 422 LT · edición", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_LT", + "cpu": "prores_ks", "prores": 1, "mbps_1080p30": 102, "que": "calidad de edición; pesa bastante"}, + "prores_hq": {"nombre": "MOV ProRes 422 HQ · máster", "ext": ".mov", "codec": "prores", "bits": 10, "sufijo": "_HQ", + "cpu": "prores_ks", "prores": 3, "mbps_1080p30": 220, "que": "entrega o máster; pesa mucho"}, +} +_MOTORES = {"ts": 0, "datos": None} + + +def motores(): + """Qué codificadores hay: GPU NVIDIA (NVENC) y CPU. Se prueba de verdad (un clip de 3 cuadros), no solo + por la lista de ffmpeg, porque la lista incluye nvenc aunque no haya tarjeta NVIDIA.""" + if _MOTORES["datos"] and time.time() - _MOTORES["ts"] < 600: + return _MOTORES["datos"] + ff = ruta_herramienta("ffmpeg") + out = {"gpu": {"nombre": "", "hevc": False, "h264": False}, "cpu": {"libx265": False, "libx264": False, "prores_ks": False}, "formatos": {}, "ffmpeg": bool(ff)} + disponibles = set() + if ff: + try: + r = subprocess.run([ff, "-hide_banner", "-encoders"], capture_output=True, text=True, timeout=20, creationflags=SIN_VENTANA) + for linea in r.stdout.splitlines(): + partes = linea.split() + if len(partes) >= 2 and partes[0][:1] in ("V", "A") and len(partes[0]) == 6: + disponibles.add(partes[1]) + except Exception: # noqa + pass + for k in ("libx265", "libx264", "prores_ks"): + out["cpu"][k] = k in disponibles + for k, enc in (("hevc", "hevc_nvenc"), ("h264", "h264_nvenc")): + if enc in disponibles: + try: + r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=256x144:rate=30", + "-frames:v", "3", "-c:v", enc, "-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA) + out["gpu"][k] = r.returncode == 0 + except Exception: # noqa + pass + 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 "" + except Exception: # noqa + pass + for k, f in FORMATOS.items(): + m = None + if f.get("gpu") and out["gpu"].get(f["codec"]): + m = "gpu" + elif f.get("cpu") and out["cpu"].get(f["cpu"]): + m = "cpu" + out["formatos"][k] = m # None = no se puede con este ffmpeg + _MOTORES.update(ts=time.time(), datos=out) + return out + def cargar_json(ruta, defecto): try: @@ -234,7 +297,7 @@ def init_db(): ); """) con.commit() - for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0",): + for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0", "ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'"): try: con.execute(alter) con.commit() @@ -1350,7 +1413,19 @@ def kbps_objetivo(ancho, alto, fps, perfil): return max(1500, int(ancho * alto * fps * bpp / 1000)) -def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False): +def estimar_bytes(f, perfil, formato): + """peso aproximado de la salida para un archivo del inventario""" + fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) + dur = f["dur"] or 0 + if fm.get("mbps_1080p30"): + 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) + return int(obj * 1000 / 8 * dur) + int(256000 / 8 * dur) + + +def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"): + fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) con = db() limite = time.time() - int(meses) * 30.4 * 86400 filas = con.execute("SELECT * FROM archivos WHERE raiz = ? AND analizado = 1 AND es_proxy = 0 AND bytes >= ? ORDER BY bytes DESC", @@ -1365,16 +1440,18 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False): motivo_no = "stock de Envato: mejor borrarlo (se vuelve a descargar)" elif f["alfa"]: motivo_no = "tiene canal alfa (HEVC lo perdería)" - elif f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * 1.3: + elif fm["codec"] == "hevc" and f["codec"] in ("hevc", "av1") and f["kbps"] and f["kbps"] <= kbps_objetivo(f["ancho"], f["alto"], f["fps"], perfil) * fm.get("bpp_mult", 1.0) * 1.3: motivo_no = "ya está en HEVC/AV1 eficiente" + elif motor == "resolve" and formato != "h265_10bit": + motivo_no = "los BRAW van por Resolve y solo salen a H.265 10 bits" elif f["mtime"] > limite: motivo_no = f"tiene menos de {meses} meses" elif f["contexto"] == "EXPORTADOS" and not incluir_exportados: motivo_no = "es un exportado / master" elif not f["ancho"] or not f["dur"]: motivo_no = "no se pudo leer (ffprobe)" - obj = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) - est = int(obj * 1000 / 8 * (f["dur"] or 0)) + int(256000 / 8 * (f["dur"] or 0)) + obj = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)) + est = estimar_bytes(f, perfil, formato) out.append({ "ruta": f["ruta"], "rel": os.path.relpath(f["ruta"], raiz), "bytes": f["bytes"], "codec": f["codec"], "perfil": f["perfil"], "pix_fmt": f["pix_fmt"], "res": f"{f['ancho']}x{f['alto']}", "fps": f["fps"], "dur": f["dur"], "kbps": f["kbps"], @@ -1384,16 +1461,21 @@ def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False): return out -def salida_para(ruta, raiz): +def salida_para(ruta, raiz, formato="h265_10bit"): o = CONFIG["optimizar"] + fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) base, _ = os.path.splitext(ruta) + sufijo = o.get("sufijo", "_10bit") if formato == "h265_10bit" else fm["sufijo"] if o.get("salida") == "espejo": rel = os.path.relpath(base, raiz) - return os.path.join(raiz, "_Archivo", rel + ".mp4") - return base + o.get("sufijo", "_10bit") + ".mp4" + return os.path.join(raiz, "_Archivo", rel + fm["ext"]) + return base + sufijo + fm["ext"] -def encolar(rutas, perfil, chroma, color, raiz): +def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"): + if formato not in FORMATOS: + formato = "h265_10bit" + fm = FORMATOS[formato] con = db() ids = [] with con: @@ -1401,18 +1483,20 @@ def encolar(rutas, perfil, chroma, color, raiz): f = con.execute("SELECT * FROM archivos WHERE ruta = ?", (r,)).fetchone() if not f: continue - ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r,)).fetchone() + ya = con.execute("SELECT id FROM trabajos WHERE ruta = ? AND formato = ? AND estado IN ('pendiente','corriendo','listo','verificado')", (r, formato)).fetchone() if ya: continue - salida = salida_para(r, raiz) + motor = "resolve" if f["ext"] == ".braw" else "ffmpeg" + if motor == "resolve" and formato != "h265_10bit": + continue # los BRAW solo salen por Resolve a H.265 10 bits + salida = salida_para(r, raiz, formato) # el mismo clip en MP4 y MOV daría la misma salida: el segundo pisaría al primero. No se encola. otro = con.execute("SELECT id FROM trabajos WHERE salida = ? AND ruta != ? AND estado IN ('pendiente','corriendo','listo','verificado')", (salida, r)).fetchone() if otro or os.path.exists(salida): continue - motor = "resolve" if f["ext"] == ".braw" else "ffmpeg" - kb = kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) - cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz) VALUES (?,?,?,?,?,?,?,?,?,?,?)", - (r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz)) + kb = int(kbps_objetivo(f["ancho"] or 1920, f["alto"] or 1080, f["fps"], perfil) * fm.get("bpp_mult", 1.0)) + cur = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (r, salida, motor, perfil, chroma, color, kb, "pendiente", f["bytes"], time.time(), raiz, formato)) ids.append(cur.lastrowid) con.close() return ids @@ -1440,18 +1524,45 @@ def correr_ffmpeg(t): cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"] maxrate = int(t["kbps"] * 1.8) salida = t["salida"] + formato = (t["formato"] if "formato" in t.keys() else None) or "h265_10bit" + fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) + motor = motores()["formatos"].get(formato) + if not motor: + set_trabajo(t["id"], estado="error", log=f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)") + return os.makedirs(os.path.dirname(salida), exist_ok=True) - tmp = salida + ".parcial.mp4" - cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?", - "-c:v", "hevc_nvenc", - "-pix_fmt", "p210le" if chroma == "422" else "p010le", - "-profile:v", "rext" if chroma == "422" else "main10", - "-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0", - "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", - "-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32", - "-bf", "3", "-b_ref_mode", "middle", "-g", str(int(round(fps * 2))), - "-tag:v", "hvc1", "-map_metadata", "0", "-movflags", "+faststart"] - if f and f["acodec"] == "aac": + tmp = salida + ".parcial" + fm["ext"] + gop = str(int(round(fps * 2))) + cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"] + if fm["codec"] == "prores": + cmd += ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0", "-qscale:v", "9"] + elif motor == "gpu": + diez = fm["bits"] == 10 + cmd += ["-c:v", fm["gpu"], + "-pix_fmt", ("p210le" if chroma == "422" else "p010le") if diez else "yuv420p", + "-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", str(cq), "-b:v", "0", + "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", + "-spatial-aq", "1", "-temporal-aq", "1", "-aq-strength", "8", "-rc-lookahead", "32", + "-bf", "3", "-b_ref_mode", "middle", "-g", gop] + if fm["codec"] == "hevc": + cmd += ["-profile:v", ("rext" if chroma == "422" else "main10") if diez else "main", "-tag:v", "hvc1"] + else: + cmd += ["-profile:v", "high"] + else: + diez = fm["bits"] == 10 + cmd += ["-c:v", fm["cpu"], "-preset", "medium", "-crf", str(cq - 2), + "-pix_fmt", ("yuv422p10le" if chroma == "422" else "yuv420p10le") if diez else "yuv420p", + "-maxrate", f"{maxrate}k", "-bufsize", f"{maxrate * 2}k", "-g", gop] + if fm["codec"] == "hevc": + cmd += ["-tag:v", "hvc1"] + if diez: + cmd += ["-profile:v", "main422-10" if chroma == "422" else "main10"] + cmd += ["-map_metadata", "0"] + if fm["ext"] == ".mp4": + cmd += ["-movflags", "+faststart"] + if fm["codec"] == "prores": + cmd += ["-c:a", "pcm_s16le"] + elif f and f["acodec"] == "aac": cmd += ["-c:a", "copy"] else: cmd += ["-c:a", "aac", "-b:a", "320k"] @@ -1677,9 +1788,10 @@ def verificar(ids): problemas = [] if dur_src and abs(info["dur"] - dur_src) > tol: problemas.append(f"duración {info['dur']:.2f}s vs original {dur_src:.2f}s") - if info["codec"] != "hevc": - problemas.append(f"códec {info['codec']}") - if "10" not in info["pix_fmt"]: + fm = FORMATOS.get((t["formato"] if "formato" in t.keys() else None) or "h265_10bit", FORMATOS["h265_10bit"]) + if info["codec"] != fm["codec"]: + problemas.append(f"códec {info['codec']} (se esperaba {fm['codec']})") + if fm["bits"] == 10 and "10" not in info["pix_fmt"]: problemas.append(f"no es 10 bits ({info['pix_fmt']})") if os.path.getsize(t["salida"]) < 100000: problemas.append("archivo demasiado pequeño") @@ -1704,6 +1816,9 @@ def reemplazar(ids): if not t or t["estado"] != "verificado": res.append({"id": id_, "ok": False, "motivo": "solo se reemplazan trabajos verificados"}) continue + if ((t["formato"] if "formato" in t.keys() else "") or "").startswith("prores"): + res.append({"id": id_, "ok": False, "motivo": "ProRes es un formato de edición: queda junto al original, no lo reemplaza"}) + continue if not os.path.exists(t["salida"]) or os.path.getsize(t["salida"]) < 1024: res.append({"id": id_, "ok": False, "motivo": "el MP4 convertido ya no está donde se verificó; el original se queda"}) set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar") @@ -2217,7 +2332,7 @@ def estado_general(): "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 [])}, - "cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "plantillas": PLANTILLAS} + "cubetas": R.CUBETAS, "tipos": R.TIPOS_CLIENTE, "perfiles": PERFILES, "formatos": {k: {"nombre": v["nombre"], "que": v["que"], "ext": v["ext"]} for k, v in FORMATOS.items()}, "plantillas": PLANTILLAS} def estado_carpeta(ruta): @@ -2387,7 +2502,9 @@ 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("min_mb", o["min_mb"]), q.get("exportados") == "1", q.get("formato", o.get("formato", "h265_10bit")))}) + if ruta == "/api/optimizar/motores": + 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()] @@ -2451,7 +2568,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", "chroma", "color", "salida", "sufijo"}, "respaldo": {"destino"}} + permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo"}, "respaldo": {"destino"}} for k, v in d.items(): if k in permitidas and isinstance(v, dict): CONFIG.setdefault(k, {}).update({kk: vv for kk, vv in v.items() if kk in permitidas[k]}) @@ -2527,7 +2644,7 @@ class Manejador(BaseHTTPRequestHandler): return self._json({"lanzado": True}) if ruta == "/api/optimizar/encolar": o = CONFIG["optimizar"] - ids = encolar(d.get("rutas", []), d.get("perfil", o["perfil"]), d.get("chroma", o["chroma"]), d.get("color", o["color"]), os.path.normpath(d["raiz"])) + ids = encolar(d.get("rutas", []), d.get("perfil", o["perfil"]), d.get("chroma", o["chroma"]), d.get("color", o["color"]), os.path.normpath(d["raiz"]), d.get("formato", o.get("formato", "h265_10bit"))) if d.get("iniciar"): iniciar_cola() return self._json({"ids": ids}) diff --git a/static/index.html b/static/index.html index d5e40fc..9810a99 100644 --- a/static/index.html +++ b/static/index.html @@ -164,6 +164,10 @@ .asis-paso h4{margin:0 0 4px;font-size:13.5px} .asis-paso label{display:block;margin:4px 0;cursor:pointer} .asis-frase{color:var(--acc)} + .colabar{display:flex;gap:12px;align-items:center;padding:10px 12px;background:var(--acc2);border-radius:6px;margin-bottom:10px;font-size:13px} + .colabar .b{flex:1;height:8px;background:#fff;border-radius:99px;overflow:hidden} + .colabar .b i{display:block;height:100%;background:var(--acc)} + .colabar b{white-space:nowrap} .est{display:inline-block;padding:1px 8px;border-radius:99px;font-size:11px;font-weight:600;letter-spacing:.02em;white-space:nowrap} .est.pendiente,.est.cancelado{background:var(--panel2);color:var(--mut)} .est.corriendo{background:var(--acc2);color:var(--acc)} @@ -282,7 +286,7 @@