diff --git a/README.md b/README.md index c8c9da4..4c2cab9 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,29 @@ diálogo en un PowerShell aparte para no bloquear el servidor y devuelve la ruta recalcula el plan sin volver a escanear, los pasos siguientes salían con «0 archivos · 0 B» y sin fecha. Comprobado: tras aplicar el paso de fechas, la fila del paso 3 conserva sus 3 archivos y su peso. +1.9.0, convertidor y respaldo: +- **El análisis de clips va en paralelo** (`HILOS_ANALISIS = 8`), se puede detener y dice cuántos no se + pudieron leer. Cada lectura con ffprobe es sobre todo espera de red: 0,23 s por clip medidos en el NAS, así + que los 15.423 clips pendientes pasaban de la hora solo para poder empezar a convertir. +- **Los formatos ahora pesan de verdad distinto.** «Ligero para compartir» usaba el mismo perfil de calidad + que el de archivo y solo bajaba un techo de tasa que casi nunca se toca. Medido sobre el mismo clip: + archivo 14,3 MB, ligero 8,2 MB, H.264 18,3 MB. +- **ProRes ya no lleva cuantizador fijo:** con `-qscale` se saltaba la tasa del perfil y un «máster HQ» no + salía a tasa de HQ, que es justo lo que promete. +- **El croma 4:2:2 se prueba de verdad** al arrancar (`motores()["chroma422"]`). NVENC no lo admite antes de + Blackwell; si la tarjeta no puede, el trabajo va por CPU en vez de fallar, y la interfaz lo dice. +- **El audio multipista ya no rompe la conversión:** `-c:a copy` solo cuando hay una sola pista AAC. Con dos + pistas (cámaras de audio multipista) ffmpeg abortaba. Se guarda `pistas_audio` al analizar. +- **Raíces anidadas rechazadas:** registrar `N:\` y `N:\Camilo` dejaba a la interna sin inventario y su + respaldo copiaba cero archivos diciendo que había ido bien. +- **Respaldo incremental corregido:** compara con el archivo de origen tal como está ahora, no con lo que + decía el último escaneo. Antes, algo editado después de escanear no se volvía a copiar nunca. +- **El índice del respaldo se acumula** en vez de reescribirse con la última tanda, que es lo que hacía + inservible el respaldo por lotes. +- Interfaz: marcar una casilla del plan actualiza solo las cifras (antes repintaba la lista entera y se + perdía el foco); el buscador de Ajustes ya no borra lo que estabas editando; la lista de stock no salta al + principio en cada clic. + 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 601f0e5..177edad 100644 --- a/manifiesto.json +++ b/manifiesto.json @@ -1,19 +1,19 @@ { "nombre": "OrganiLAB", - "version": "1.8.1", - "builtAt": "2026-09-16T15:47:26.000Z", - "archivo": "OrganizadorLabstream-portatil-1.8.1.zip", - "size": 198686206, - "sha256": "f001ba76c553d379f73d7928314d2f187788a8060e3730978b5a67174f2e660f", + "version": "1.9.0", + "builtAt": "2026-09-16T16:06:18.000Z", + "archivo": "OrganizadorLabstream-portatil-1.9.0.zip", + "size": 198688972, + "sha256": "288861f0e16f6cfd4b081cd7bd65632d65de45453dd1d1a291d9dabdc47624c9", "app": { - "archivo": "OrganizadorLabstream-app-1.8.1.zip", - "size": 14866457, - "sha256": "b3ec0d410e06102e928ed459bfa0a0f1c6e7713c6a807b18357fc7ef9fca49c7", - "url": "/api/herramientas/OrganizadorLabstream-app-1.8.1.zip" + "archivo": "OrganizadorLabstream-app-1.9.0.zip", + "size": 14869223, + "sha256": "bd2786385a7793a54cdc94a13514732b8c4c60f4a5b5f1cfd226e9768e7e5a65", + "url": "/api/herramientas/OrganizadorLabstream-app-1.9.0.zip" }, "notes": "", "firma": { "alg": "rsa2048-pkcs1v15-sha256", - "valor": "b20e039a9f2a3234ab82a41f69e46583128e671b16ae5ea0bf73356ec9bbc34289e8418737c81d459c20a2cc7c0819c74a066680a83d0f9b88e4a5f5c3db0ff7a3c2d3376ccee53755e0600a063ee951f63048e1cadc5afeaba6e4895a450791e247dc888e3e3a510a36268c7ae4111c20521d8eb41677b0e482de857bbcb165963c0e8d208c076c19e6e75766967eddd62d5a2293217f88e54986c7b4062a75ee3fc2fe153680ca875e9a85d074b2845fe480b9bb32d9f6cfceecbb850bbfc11c81caf81ded6bb7063b88861263ba8d392911355cbeb4f772058754f26823b577885585ae0de6635515a325cfe4404d905ef38a4ff9b0100bd2024195833b92" + "valor": "4dfb460c2e17cb978e82089c2206cf273e41fdef2ae0d7b46c128d2bc8c1e65caf6b74cb0e88c13b5ffb01b06a23d3255a747a4f7e2503bfa19a7f80cba25faf3f461349dbbb5e4b987f2f7749e1169fd62ae07083cb726a4494242a7acc81b21469e3b086a73e69c010d9f393c16bfea65b58cf029f5f4b07b5a4267a4f52ac3d65a91e568b16960c926573adfd7c45ab57e0be83054f18261631f18027371ceb48a8a9b7baaef0d8d848cd6099764d3b69248f8e7e03d58f459ff1be92ae9e5a0acb6e30cf17b161cdf34eb47f28b3bffd31c47da661f8c8660898aba6536559436cbf8997fcc575c449495f1993ac2367217fd0e94f60cdfec1bc93000127" } } \ No newline at end of file diff --git a/organizador.py b/organizador.py index d3d6245..d3af3b6 100644 --- a/organizador.py +++ b/organizador.py @@ -34,7 +34,7 @@ import actualizador asistente.O = sys.modules[__name__] actualizador.O = sys.modules[__name__] -VERSION = "1.8.1" +VERSION = "1.9.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 @@ -126,9 +126,11 @@ 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"}, + "gpu": "hevc_nvenc", "cpu": "libx265", "bpp_mult": 0.55, "cq_mas": 5, + "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"}, + "gpu": "h264_nvenc", "cpu": "libx264", "bpp_mult": 1.5, "cq_mas": -2, + "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", @@ -145,7 +147,9 @@ def motores(): 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)} + out = {"gpu": {"nombre": "", "hevc": False, "h264": False, "chroma422": False}, + "cpu": {"libx265": False, "libx264": False, "prores_ks": False}, + "formatos": {}, "chroma422": False, "ffmpeg": bool(ff)} disponibles = set() if ff: try: @@ -166,6 +170,16 @@ def motores(): out["gpu"][k] = r.returncode == 0 except Exception: # noqa pass + if out["gpu"]["hevc"]: + try: + r = subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", + "-i", "testsrc=size=256x144:rate=30", "-frames:v", "3", + "-c:v", "hevc_nvenc", "-pix_fmt", "p210le", "-profile:v", "rext", + "-f", "null", "-"], capture_output=True, text=True, timeout=40, creationflags=SIN_VENTANA) + out["gpu"]["chroma422"] = r.returncode == 0 + except Exception: # noqa + out["gpu"]["chroma422"] = False + out["chroma422"] = bool(out["gpu"].get("chroma422") or out["cpu"].get("libx265")) 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 "" @@ -303,7 +317,9 @@ def init_db(): ); """) con.commit() - for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0", "ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'"): + for alter in ("ALTER TABLE archivos ADD COLUMN es_stock INTEGER DEFAULT 0", + "ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'", + "ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1"): try: con.execute(alter) con.commit() @@ -487,8 +503,8 @@ def escanear(ruta): with con: # conservar el análisis de ffprobe de los archivos que no cambiaron previo = {r["ruta"]: r for r in con.execute( - "SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, analizado " - "FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()} + "SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, " + "pistas_audio, analizado FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()} con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,)) con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,)) con.executemany( @@ -498,8 +514,8 @@ def escanear(ruta): for f in filas: pv = previo.get(f[0]) if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2: - con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, analizado=1 WHERE ruta=?", - (pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], f[0])) + con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?", + (pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0])) cfilas = [] for d, info in dirs.items(): n, b, fechas, p, bs, nombre = info @@ -1616,28 +1632,65 @@ def ffprobe(ruta): "ancho": int(v.get("width") or 0), "alto": int(v.get("height") or 0), "fps": round(fps, 3), "dur": dur, "kbps": kbps, "alfa": 1 if ("a" in pix and pix not in ("yuva", "") and ("rgba" in pix or "argb" in pix or "bgra" in pix or "yuva" in pix)) else 0, "acodec": (a or {}).get("codec_name") or "", + "pistas_audio": sum(1 for x in j.get("streams", []) if x.get("codec_type") == "audio"), } -def analizar(raiz, min_mb): - prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time()} +HILOS_ANALISIS = 8 # ffprobe pasa casi todo el tiempo esperando al disco de red + + +def analizar(raiz, min_mb, reintentar=False): + """Lee con ffprobe los clips que faltan (códec, duración, resolución). Va en paralelo porque cada + lectura es sobre todo espera de red: medido, 0,23 s por clip; 15.423 clips en serie pasan de la hora + y con ocho a la vez bajan a unos ocho minutos. + + Con `reintentar` también vuelve sobre los que no se pudieron leer (analizado = 2), por si el problema + era un ffprobe viejo o una unidad que estaba desconectada.""" + prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time(), + "cancelar": False, "ilegibles": 0} con = db() - pend = con.execute("SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado = 0 AND es_proxy = 0 AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock')", - (raiz, int(min_mb) * 1024 * 1024)).fetchall() + estados = "(0, 2)" if reintentar else "(0)" + pend = con.execute(f"SELECT ruta, ext FROM archivos WHERE raiz = ? AND analizado IN {estados} AND es_proxy = 0 " + "AND es_basura = 0 AND bytes >= ? AND rol IN ('camara_raw','camara_video','video','celular_video','stock') " + "ORDER BY bytes DESC", (raiz, int(min_mb) * 1024 * 1024)).fetchall() prog["total"] = len(pend) try: - for f in pend: - info = ffprobe(f["ruta"]) + import concurrent.futures as cf + lote = [] + + def guardar(lote): with con: - if info: - if f["ext"] == ".braw": - info["codec"] = "braw" - con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, analizado=1 WHERE ruta=?", - (info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"], f["ruta"])) - else: - con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (f["ruta"],)) - prog["hechos"] += 1 - prog["estado"] = "listo" + for ruta, info in lote: + if info: + con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, " + "kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?", + (info["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], + info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"], + info.get("pistas_audio", 1), ruta)) + else: + con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (ruta,)) + + def leer(f): + info = ffprobe(f["ruta"]) + if info and f["ext"] == ".braw": + info["codec"] = "braw" # ffprobe lee el BRAW pero no le pone nombre al códec + return f["ruta"], info + + with cf.ThreadPoolExecutor(max_workers=HILOS_ANALISIS) as pool: + for ruta, info in pool.map(leer, pend): + if prog["cancelar"]: + break + if info is None: + prog["ilegibles"] += 1 + lote.append((ruta, info)) + prog["hechos"] += 1 + if len(lote) >= 50: # se guarda por tandas: una transacción por clip es mucho más lento + guardar(lote) + lote = [] + guardar(lote) + prog["estado"] = "cancelado" if prog["cancelar"] else "listo" + if prog["ilegibles"]: + prog["error"] = f"{prog['ilegibles']} clips no se pudieron leer con ffprobe (no saldrán como convertibles)" except Exception as ex: # noqa prog["estado"] = "error" prog["error"] = str(ex) @@ -1757,6 +1810,15 @@ def set_trabajo(id_, **kw): con.close() +def formato_de(t): + """el formato de un trabajo, tolerando filas viejas sin columna""" + try: + f = t["formato"] if "formato" in t.keys() else None + except AttributeError: + f = t.get("formato") + return f or "h265_10bit" + + def correr_ffmpeg(t): o = CONFIG["optimizar"] con = db() @@ -1766,11 +1828,21 @@ def correr_ffmpeg(t): fps = (f["fps"] if f else 30) or 30 chroma = t["chroma"] or "420" cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"] + # el formato también mueve la calidad, no solo el techo de tasa: sin esto «ligero para compartir» + # salía prácticamente del mismo tamaño que el de archivo (mismo cq, y el techo casi nunca se toca) + cq = max(14, min(38, round(cq + FORMATOS.get(formato_de(t), FORMATOS["h265_10bit"]).get("cq_mas", 0)))) maxrate = int(t["kbps"] * 1.8) salida = t["salida"] - formato = (t["formato"] if "formato" in t.keys() else None) or "h265_10bit" + formato = formato_de(t) fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) - motor = motores()["formatos"].get(formato) + _m = motores() + motor = _m["formatos"].get(formato) + if motor == "gpu" and chroma == "422" and fm["bits"] == 10 and not _m["gpu"].get("chroma422"): + # la tarjeta no admite 4:2:2 de 10 bits (solo desde Blackwell): en vez de fallar, va por CPU + motor = "cpu" if _m["cpu"].get(fm.get("cpu") or "") else None + if motor is None: + set_trabajo(t["id"], estado="error", log="esta tarjeta no puede con 4:2:2 de 10 bits y no hay codificador por CPU; elige croma 4:2:0") + return 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 @@ -1779,7 +1851,9 @@ def correr_ffmpeg(t): 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"] + # sin -qscale: con cuantizador fijo, prores_ks se salta la tasa del perfil y un «máster HQ» no + # salía a la tasa de un HQ (que es justo lo que promete el formato) + cmd += ["-c:v", "prores_ks", "-profile:v", str(fm["prores"]), "-pix_fmt", "yuv422p10le", "-vendor", "apl0"] elif motor == "gpu": diez = fm["bits"] == 10 cmd += ["-c:v", fm["gpu"], @@ -1806,9 +1880,10 @@ def correr_ffmpeg(t): cmd += ["-movflags", "+faststart"] if fm["codec"] == "prores": cmd += ["-c:a", "pcm_s16le"] - elif f and f["acodec"] == "aac": + elif f and f["acodec"] == "aac" and (f["pistas_audio"] if "pistas_audio" in f.keys() else 1) <= 1: cmd += ["-c:a", "copy"] else: + # con varias pistas (cámaras de audio multipista) copiar falla: un PCM no cabe en un MP4 cmd += ["-c:a", "aac", "-b:a", "320k"] cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp] set_trabajo(t["id"], log=" ".join(cmd)[:4000]) @@ -2427,6 +2502,18 @@ def copiar_respaldo(raiz, plan): RESPALDO.update(estado="corriendo", hechos=0, total=plan["n"], bytes=0, bytes_total=plan["bytes"], actual="", errores=[], destino=plan["base"], cancelar=False, inicio=time.time(), copiados=0, saltados=0) indice = [] + # un respaldo por lotes (solo lo de más de 6 meses, luego el resto) reescribía el índice con lo de la + # última tanda y dejaba fuera todo lo anterior: se arranca de lo que ya había. + previo = {} + try: + import csv as _csv + with open(os.path.join(plan["base"], "INDICE.csv"), newline="", encoding="utf-8-sig") as fh: + filas = list(_csv.reader(fh, delimiter=";")) + for fila in filas[1:]: + if len(fila) >= 5: + previo[fila[0]] = tuple(fila) + except (OSError, ValueError): + pass for it in plan["items"]: if RESPALDO["cancelar"]: RESPALDO["estado"] = "cancelado" @@ -2438,13 +2525,20 @@ def copiar_respaldo(raiz, plan): ya = False if os.path.exists(d): st = os.stat(d) - ya = st.st_size == it["bytes"] and abs(st.st_mtime - it["mtime"]) < 2 + # contra el archivo de ORIGEN tal como está ahora, no contra lo que decía el último escaneo: + # si se editó después de escanear, la copia vieja «coincidía» y no se volvía a copiar nunca + try: + so = os.stat(it["origen"]) + tam_o, mt_o = so.st_size, so.st_mtime + except OSError: + tam_o, mt_o = it["bytes"], it["mtime"] + ya = st.st_size == tam_o and abs(st.st_mtime - mt_o) < 2 if ya: RESPALDO["saltados"] += 1 else: tmp = d + ".parcial" shutil.copy2(it["origen"], tmp) - if os.path.getsize(tmp) != it["bytes"]: + if os.path.getsize(tmp) != os.path.getsize(it["origen"]): raise OSError("tamaño distinto tras copiar") os.replace(tmp, d) RESPALDO["copiados"] += 1 @@ -2456,15 +2550,19 @@ def copiar_respaldo(raiz, plan): try: os.makedirs(plan["base"], exist_ok=True) import csv + # lo de esta tanda manda sobre lo que ya había; lo anterior se conserva + for fila in indice: + previo[fila[0]] = fila + todo = sorted(previo.values(), key=lambda x: str(x[0]).lower()) with open(os.path.join(plan["base"], "INDICE.csv"), "w", newline="", encoding="utf-8-sig") as fh: w = csv.writer(fh, delimiter=";") w.writerow(["ruta en el respaldo", "ruta original", "tipo", "bytes", "fecha"]) - w.writerows(indice) + w.writerows(todo) resumen = defaultdict(lambda: defaultdict(lambda: [0, 0])) - for rel_d, rel_o, tipo, b, _ in indice: + for rel_d, rel_o, tipo, b, _ in todo: carpeta = os.path.dirname(os.path.dirname(rel_d)) if os.sep in rel_d else "" resumen[carpeta][tipo][0] += 1 - resumen[carpeta][tipo][1] += b + resumen[carpeta][tipo][1] += int(b or 0) with open(os.path.join(plan["base"], "_MAPA.md"), "w", encoding="utf-8") as fh: fh.write(f"# Respaldo de {plan['cliente']}\n\nOrigen: `{raiz}` \nFecha: {time.strftime('%Y-%m-%d %H:%M')} \n" f"Archivos: {len(indice)} · {sum(i[3] for i in indice)/1e9:.1f} GB\n\n") @@ -2517,6 +2615,18 @@ def elegir_carpeta(): _ELIGIENDO.release() +def _anidadas(raices): + """(externa, interna) si alguna carpeta de la lista está dentro de otra. El inventario va por raíz y + `archivos.ruta` es única: al escanear la externa, las filas de la interna cambian de dueño y la interna + se queda vacía sin decir nada (su respaldo copiaría cero archivos y terminaría «bien»).""" + rutas = [os.path.normpath(r["ruta"]) for r in raices if r.get("ruta")] + for a in rutas: + for b in rutas: + if a is not b and a.lower() != b.lower() and dentro_de(a, b): + return a, b + return None + + def quitar_raiz(ruta, borrar_datos=False, descartar_cola=False): """Desconecta una carpeta de la lista. Con borrar_datos también olvida su inventario (se puede volver a escanear). Con descartar_cola se quitan de la cola sus conversiones EN ESPERA (no toca archivos); las que se están @@ -2814,6 +2924,10 @@ class Manejador(BaseHTTPRequestHandler): except ValueError as e: return self._json({"error": str(e)}, 409) if ruta == "/api/raices": + choque = _anidadas(d.get("raices") or []) + if choque: + return self._json({"error": f"«{choque[1]}» está dentro de «{choque[0]}», que ya está en la lista. " + "Dos carpetas anidadas se pisan el inventario: deja solo una de las dos."}, 400) CONFIG["raices"] = d["raices"] guardar_json(CONFIG_PATH, CONFIG) return self._json({"ok": True}) @@ -2891,6 +3005,11 @@ class Manejador(BaseHTTPRequestHandler): return self._json({"lanzado": False, "error": "ya hay un asistente en marcha"}) asistente.ejecutar(raiz, d.get("decisiones", {}), asistente.PLAN_CACHE.pop(raiz, None), en_hilo=True) return self._json({"lanzado": True}) + if ruta == "/api/optimizar/analizar/detener": + a = ANALISIS.get(os.path.normpath(d["raiz"])) + if a: + a["cancelar"] = True + return self._json({"ok": bool(a)}) if ruta == "/api/optimizar/analizar": raiz = os.path.normpath(d["raiz"]) p = ANALISIS.get(raiz) diff --git a/static/index.html b/static/index.html index 1af0e68..e7a094a 100644 --- a/static/index.html +++ b/static/index.html @@ -550,7 +550,9 @@ async function cargarEstado(){ 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; $('#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}`; + 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)}`; + const bad = $('#btnAnaDetener'); if (bad) bad.onclick = () => accion(bad, async () => { await api('/api/optimizar/analizar/detener', {raiz: raizActual()}); }); const h = ESTADO.herramientas || {}; const faltas = []; if (h.ffmpeg && !h.ffmpeg.ok) faltas.push('ffmpeg no encontrado: sin él no se puede leer ni convertir (pon ffmpeg.exe y ffprobe.exe en la carpeta ffmpeg\\ junto al programa)'); if (h.resolve && !h.resolve.ok) faltas.push('DaVinci Resolve no está instalado: los BRAW no se podrán convertir'); @@ -889,6 +891,20 @@ function fraseDe(r){ if (r.archivos) return `${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''} de ${nom(r.origen_rel)} van a «${esc(r.destino_rel)}»`; return `${nom(r.origen_rel)} se mueve a «${esc(r.destino_rel)}»`; } +// actualiza solo las cifras de cada paso y los botones, sin tocar las filas: así el foco se queda +// donde está y se puede recorrer la lista con el teclado +function actualizarContadores(){ + if (!PLAN) return; + PASOS_PLAN.forEach(p => { + const caja = $(`#planPasos .paso[data-k="${p.k}"]`); if (!caja) return; + const rs = PLAN.filas.filter(r => (r.paso || 'resto') === p.k), marc = rs.filter(r => r.marcado); + const res = caja.querySelector('.res'); + if (res) res.textContent = `${rs.length} cambio${rs.length > 1 ? 's' : ''} · ${gb(rs.reduce((a, r) => a + r.bytes, 0))}${marc.length < rs.length ? ` · ${marc.length} marcado${marc.length !== 1 ? 's' : ''}` : ''}`; + const bt = caja.querySelector('[data-aplicar]'); + if (bt) { bt.disabled = !marc.length; bt.textContent = 'Aplicar' + (marc.length ? ' ' + marc.length : ''); } + }); + estadoPlan(); +} function pintarPlan(){ if (!PLAN) { estadoPlan(); return; } const f = ($('#filtroPlan').value || '').toLowerCase(); @@ -912,7 +928,7 @@ function pintarPlan(){ }).join(''); $$('#planPasos [data-ver]').forEach(b => b.onclick = () => { const k = b.dataset.ver; if (PLAN_ABIERTOS.has(k)) PLAN_ABIERTOS.delete(k); else PLAN_ABIERTOS.add(k); pintarPlan(); }); $$('#planPasos [data-aplicar]').forEach(b => b.onclick = () => aplicarPaso(b.dataset.aplicar, b)); - $$('#planPasos .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; pintarPlan(); }; }); + $$('#planPasos .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; actualizarContadores(); }; }); $$('#planPasos [data-ed]').forEach(b => b.onclick = () => { const r = PLAN.filas.find(x => x.id === +b.dataset.ed); modal('Cambiar destino', `

${fraseDe(r)}

Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.

`, seguro(async () => { const v = $('#mDest').value.trim(); if (!v) return; r.destino_rel = v; r.editado = true; r.marcado = true; if (!r.archivos) await api('/api/regla', {cliente: PLAN.cliente, origen: r.origen_rel, destino: v, cubeta: r.cubeta}); pintarPlan(); })); }); estadoPlan(); } @@ -956,7 +972,9 @@ function pintarStock(){ html += `${t[1]}${num(gs.reduce((a, x) => a + x.g.n, 0))}${gb(gs.reduce((a, x) => a + x.g.bytes, 0))}${gs.length} carpeta${gs.length > 1 ? 's' : ''}`; html += gs.map(({g, i}) => `${esc(g.carpeta)}${esc(g.mes)}${g.n}${gb(g.bytes)}${g.ejemplos.map(esc).join('
')}`).join(''); }); + const caja = $('#stockCaja'), y = caja ? caja.scrollTop : 0; $('#tablaStock tbody').innerHTML = html || 'No hay stock de Envato en esta carpeta.'; + if (caja) caja.scrollTop = y; $$('#tablaStock input[data-i]').forEach(i => i.onchange = () => { STOCK[+i.dataset.i].sel = i.checked; pintarStock(); }); $$('#tablaStock input[data-t]').forEach(i => i.onchange = () => { const t = TRAMOS.find(x => x[0] === +i.dataset.t); STOCK.forEach(g => { if (tramoDe(g) === t) g.sel = i.checked; }); pintarStock(); }); resumenStock(); @@ -986,6 +1004,8 @@ async function cargarMotores(){ const gpuT = g.hevc || g.h264 ? `GPU ${esc(g.nombre || 'NVIDIA')}: ${[g.hevc && 'H.265', g.h264 && 'H.264'].filter(Boolean).join(' y ')} acelerados` : 'Sin GPU NVIDIA: todo por CPU (más lento)'; const cpuT = `CPU: ${[c.prores_ks && 'ProRes', c.libx265 && 'H.265', c.libx264 && 'H.264'].filter(Boolean).join(', ') || 'nada'}`; $('#motores').innerHTML = MOTORES.ffmpeg ? `${gpuT} · ${cpuT}` : 'ffmpeg no encontrado: no se puede convertir'; + const ch = $('#optChroma'); const op422 = ch && ch.querySelector('option[value="422"]'); + if (op422) { const hay = MOTORES.chroma422 !== false; op422.disabled = !hay; op422.textContent = '4:2:2 10 bits' + (hay ? (MOTORES.gpu.chroma422 ? ' (pesa más)' : ' (por CPU, más lento)') : ' (este equipo no puede)'); if (!hay && ch.value === '422') ch.value = '420'; } $$('#optFormato option').forEach(o => { const m = MOTORES.formatos[o.value]; o.disabled = !m; o.textContent = (ESTADO.formatos[o.value] || {}).nombre + (m === 'gpu' ? ' · GPU' : m === 'cpu' ? ' · CPU' : ' · no disponible'); }); } async function cargarCandidatos(){ @@ -1117,7 +1137,7 @@ async function guardarClientes(){ await api('/api/clientes', {clientes}); await api('/api/raices', {raices}); AJ = null; await cargarEstado(); pintarAjustes(); aviso('Guardado.', 'ok'); } -$('#filtroCli').oninput = pintarAjustes; +$('#filtroCli').oninput = () => { leerAjustes(); pintarAjustes(); }; // sin leer antes, el filtro borraba lo que estabas editando $('#btnAddCliente').onclick = () => { leerAjustes(); AJ.clientes.unshift({nombre: '', orig: '', tipo: 'contenido', alias: '', rutas: []}); CLI_ABIERTO = 0; $('#filtroCli').value = ''; pintarAjustes(); const el = $('#clientes .cli.abierto [data-k=nombre]'); if (el) el.focus(); }; $('#btnGuardarPlantillas').onclick = () => accion($('#btnGuardarPlantillas'), async () => { const plantillas = {}; $$('#plantillas input').forEach(i => { plantillas[i.dataset.t] = plantillas[i.dataset.t] || {}; plantillas[i.dataset.t][i.dataset.b] = i.value.trim(); }); await api('/api/plantillas', {plantillas}); await cargarEstado(); PLAN = null; estadoPlan(); aviso('Plantillas guardadas', 'ok'); });