1.9.0: convertidor, análisis en paralelo y respaldo

- analizar(): 8 hilos, se puede detener, informa de los ilegibles. ffprobe es
  sobre todo espera de red (0,23 s por clip en el NAS).
- Los formatos pesan de verdad distinto (cq_mas por formato): archivo 14,3 MB,
  ligero 8,2 MB, H.264 18,3 MB sobre el mismo clip.
- ProRes sin -qscale: el perfil vuelve a gobernar la tasa.
- motores(): se prueba el croma 4:2:2 de verdad; si la GPU no puede, va por CPU.
- Audio multipista: -c:a copy solo con una pista AAC (nueva columna pistas_audio).
- Raíces anidadas rechazadas al registrarlas.
- Respaldo: incremental contra el archivo de origen, e índice acumulativo.
- Interfaz: las casillas del plan no repintan la lista ni pierden el foco; el
  buscador de Ajustes conserva lo editado; la lista de stock no salta.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-16 11:08:33 -05:00
padre b492881123
commit 2d7fe55265
Se han modificado 4 ficheros con 208 adiciones y 46 borrados

Ver fichero

@@ -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. 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. 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 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ó 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: con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

Ver fichero

@@ -1,19 +1,19 @@
{ {
"nombre": "OrganiLAB", "nombre": "OrganiLAB",
"version": "1.8.1", "version": "1.9.0",
"builtAt": "2026-09-16T15:47:26.000Z", "builtAt": "2026-09-16T16:06:18.000Z",
"archivo": "OrganizadorLabstream-portatil-1.8.1.zip", "archivo": "OrganizadorLabstream-portatil-1.9.0.zip",
"size": 198686206, "size": 198688972,
"sha256": "f001ba76c553d379f73d7928314d2f187788a8060e3730978b5a67174f2e660f", "sha256": "288861f0e16f6cfd4b081cd7bd65632d65de45453dd1d1a291d9dabdc47624c9",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-1.8.1.zip", "archivo": "OrganizadorLabstream-app-1.9.0.zip",
"size": 14866457, "size": 14869223,
"sha256": "b3ec0d410e06102e928ed459bfa0a0f1c6e7713c6a807b18357fc7ef9fca49c7", "sha256": "bd2786385a7793a54cdc94a13514732b8c4c60f4a5b5f1cfd226e9768e7e5a65",
"url": "/api/herramientas/OrganizadorLabstream-app-1.8.1.zip" "url": "/api/herramientas/OrganizadorLabstream-app-1.9.0.zip"
}, },
"notes": "", "notes": "",
"firma": { "firma": {
"alg": "rsa2048-pkcs1v15-sha256", "alg": "rsa2048-pkcs1v15-sha256",
"valor": "b20e039a9f2a3234ab82a41f69e46583128e671b16ae5ea0bf73356ec9bbc34289e8418737c81d459c20a2cc7c0819c74a066680a83d0f9b88e4a5f5c3db0ff7a3c2d3376ccee53755e0600a063ee951f63048e1cadc5afeaba6e4895a450791e247dc888e3e3a510a36268c7ae4111c20521d8eb41677b0e482de857bbcb165963c0e8d208c076c19e6e75766967eddd62d5a2293217f88e54986c7b4062a75ee3fc2fe153680ca875e9a85d074b2845fe480b9bb32d9f6cfceecbb850bbfc11c81caf81ded6bb7063b88861263ba8d392911355cbeb4f772058754f26823b577885585ae0de6635515a325cfe4404d905ef38a4ff9b0100bd2024195833b92" "valor": "4dfb460c2e17cb978e82089c2206cf273e41fdef2ae0d7b46c128d2bc8c1e65caf6b74cb0e88c13b5ffb01b06a23d3255a747a4f7e2503bfa19a7f80cba25faf3f461349dbbb5e4b987f2f7749e1169fd62ae07083cb726a4494242a7acc81b21469e3b086a73e69c010d9f393c16bfea65b58cf029f5f4b07b5a4267a4f52ac3d65a91e568b16960c926573adfd7c45ab57e0be83054f18261631f18027371ceb48a8a9b7baaef0d8d848cd6099764d3b69248f8e7e03d58f459ff1be92ae9e5a0acb6e30cf17b161cdf34eb47f28b3bffd31c47da661f8c8660898aba6536559436cbf8997fcc575c449495f1993ac2367217fd0e94f60cdfec1bc93000127"
} }
} }

Ver fichero

@@ -34,7 +34,7 @@ import actualizador
asistente.O = sys.modules[__name__] asistente.O = sys.modules[__name__]
actualizador.O = sys.modules[__name__] actualizador.O = sys.modules[__name__]
VERSION = "1.8.1" VERSION = "1.9.0"
CONGELADO = getattr(sys, "frozen", False) 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 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", "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"}, "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", "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", "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", "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"}, "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", "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: if _MOTORES["datos"] and time.time() - _MOTORES["ts"] < 600:
return _MOTORES["datos"] return _MOTORES["datos"]
ff = ruta_herramienta("ffmpeg") 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() disponibles = set()
if ff: if ff:
try: try:
@@ -166,6 +170,16 @@ def motores():
out["gpu"][k] = r.returncode == 0 out["gpu"][k] = r.returncode == 0
except Exception: # noqa except Exception: # noqa
pass 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: try:
r = subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"], capture_output=True, text=True, timeout=10, creationflags=SIN_VENTANA) 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 "" 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() 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: try:
con.execute(alter) con.execute(alter)
con.commit() con.commit()
@@ -487,8 +503,8 @@ def escanear(ruta):
with con: with con:
# conservar el análisis de ffprobe de los archivos que no cambiaron # conservar el análisis de ffprobe de los archivos que no cambiaron
previo = {r["ruta"]: r for r in con.execute( 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 " "SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, "
"FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()} "pistas_audio, analizado FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()}
con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,)) con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,))
con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,)) con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,))
con.executemany( con.executemany(
@@ -498,8 +514,8 @@ def escanear(ruta):
for f in filas: for f in filas:
pv = previo.get(f[0]) pv = previo.get(f[0])
if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2: 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=?", 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"], f[0])) (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 = [] cfilas = []
for d, info in dirs.items(): for d, info in dirs.items():
n, b, fechas, p, bs, nombre = info 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, "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, "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 "", "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): HILOS_ANALISIS = 8 # ffprobe pasa casi todo el tiempo esperando al disco de red
prog = ANALISIS[raiz] = {"estado": "corriendo", "hechos": 0, "total": 0, "inicio": time.time()}
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() 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')", estados = "(0, 2)" if reintentar else "(0)"
(raiz, int(min_mb) * 1024 * 1024)).fetchall() 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) prog["total"] = len(pend)
try: try:
for f in pend: import concurrent.futures as cf
info = ffprobe(f["ruta"]) lote = []
def guardar(lote):
with con: with con:
for ruta, info in lote:
if info: if info:
if f["ext"] == ".braw": con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, "
info["codec"] = "braw" "kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
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["codec"], info["perfil"], info["pix_fmt"], info["ancho"], info["alto"], info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"], f["ruta"])) info["fps"], info["dur"], info["kbps"], info["alfa"], info["acodec"],
info.get("pistas_audio", 1), ruta))
else: else:
con.execute("UPDATE archivos SET analizado = 2 WHERE ruta = ?", (f["ruta"],)) 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 prog["hechos"] += 1
prog["estado"] = "listo" 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 except Exception as ex: # noqa
prog["estado"] = "error" prog["estado"] = "error"
prog["error"] = str(ex) prog["error"] = str(ex)
@@ -1757,6 +1810,15 @@ def set_trabajo(id_, **kw):
con.close() 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): def correr_ffmpeg(t):
o = CONFIG["optimizar"] o = CONFIG["optimizar"]
con = db() con = db()
@@ -1766,11 +1828,21 @@ def correr_ffmpeg(t):
fps = (f["fps"] if f else 30) or 30 fps = (f["fps"] if f else 30) or 30
chroma = t["chroma"] or "420" chroma = t["chroma"] or "420"
cq = PERFILES.get(t["perfil"], PERFILES["equilibrado"])["cq"] 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) maxrate = int(t["kbps"] * 1.8)
salida = t["salida"] 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"]) 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: if not motor:
set_trabajo(t["id"], estado="error", log=f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)") set_trabajo(t["id"], estado="error", log=f"este ffmpeg no puede producir {fm['nombre']} (ni por GPU ni por CPU)")
return return
@@ -1779,7 +1851,9 @@ def correr_ffmpeg(t):
gop = str(int(round(fps * 2))) gop = str(int(round(fps * 2)))
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"] cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-i", t["ruta"], "-map", "0:v:0", "-map", "0:a?"]
if fm["codec"] == "prores": 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": elif motor == "gpu":
diez = fm["bits"] == 10 diez = fm["bits"] == 10
cmd += ["-c:v", fm["gpu"], cmd += ["-c:v", fm["gpu"],
@@ -1806,9 +1880,10 @@ def correr_ffmpeg(t):
cmd += ["-movflags", "+faststart"] cmd += ["-movflags", "+faststart"]
if fm["codec"] == "prores": if fm["codec"] == "prores":
cmd += ["-c:a", "pcm_s16le"] 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"] cmd += ["-c:a", "copy"]
else: 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 += ["-c:a", "aac", "-b:a", "320k"]
cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp] cmd += ["-progress", "pipe:1", "-nostats", "-loglevel", "error", tmp]
set_trabajo(t["id"], log=" ".join(cmd)[:4000]) 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=[], 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) destino=plan["base"], cancelar=False, inicio=time.time(), copiados=0, saltados=0)
indice = [] 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"]: for it in plan["items"]:
if RESPALDO["cancelar"]: if RESPALDO["cancelar"]:
RESPALDO["estado"] = "cancelado" RESPALDO["estado"] = "cancelado"
@@ -2438,13 +2525,20 @@ def copiar_respaldo(raiz, plan):
ya = False ya = False
if os.path.exists(d): if os.path.exists(d):
st = os.stat(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: if ya:
RESPALDO["saltados"] += 1 RESPALDO["saltados"] += 1
else: else:
tmp = d + ".parcial" tmp = d + ".parcial"
shutil.copy2(it["origen"], tmp) 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") raise OSError("tamaño distinto tras copiar")
os.replace(tmp, d) os.replace(tmp, d)
RESPALDO["copiados"] += 1 RESPALDO["copiados"] += 1
@@ -2456,15 +2550,19 @@ def copiar_respaldo(raiz, plan):
try: try:
os.makedirs(plan["base"], exist_ok=True) os.makedirs(plan["base"], exist_ok=True)
import csv 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: with open(os.path.join(plan["base"], "INDICE.csv"), "w", newline="", encoding="utf-8-sig") as fh:
w = csv.writer(fh, delimiter=";") w = csv.writer(fh, delimiter=";")
w.writerow(["ruta en el respaldo", "ruta original", "tipo", "bytes", "fecha"]) w.writerow(["ruta en el respaldo", "ruta original", "tipo", "bytes", "fecha"])
w.writerows(indice) w.writerows(todo)
resumen = defaultdict(lambda: defaultdict(lambda: [0, 0])) 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 "" carpeta = os.path.dirname(os.path.dirname(rel_d)) if os.sep in rel_d else ""
resumen[carpeta][tipo][0] += 1 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: 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" 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") 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() _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): 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). """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 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: except ValueError as e:
return self._json({"error": str(e)}, 409) return self._json({"error": str(e)}, 409)
if ruta == "/api/raices": 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"] CONFIG["raices"] = d["raices"]
guardar_json(CONFIG_PATH, CONFIG) guardar_json(CONFIG_PATH, CONFIG)
return self._json({"ok": True}) return self._json({"ok": True})
@@ -2891,6 +3005,11 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"lanzado": False, "error": "ya hay un asistente en marcha"}) 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) asistente.ejecutar(raiz, d.get("decisiones", {}), asistente.PLAN_CACHE.pop(raiz, None), en_hilo=True)
return self._json({"lanzado": 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": if ruta == "/api/optimizar/analizar":
raiz = os.path.normpath(d["raiz"]) raiz = os.path.normpath(d["raiz"])
p = ANALISIS.get(raiz) p = ANALISIS.get(raiz)

Ver fichero

@@ -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]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); fmt.value = ESTADO.config.optimizar.formato || 'h265_10bit'; pintarFormato(); cargarMotores(); } const fmt = $('#optFormato'); if (!fmt.options.length && ESTADO.formatos) { fmt.innerHTML = Object.entries(ESTADO.formatos).map(([k, v]) => `<option value="${k}">${esc(v.nombre)}</option>`).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]) => `<option value="${k}">${esc(v.nombre)}</option>`).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 perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).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; const r = cfgRaiz(); const a = r && r.analisis;
if (a && a.estado === 'corriendo') $('#anaEstado').innerHTML = `<span class="spin"></span> leyendo clips: ${a.hechos} de ${a.total}`; if (a && a.estado === 'corriendo') $('#anaEstado').innerHTML = `<span class="spin"></span> leyendo clips: ${a.hechos} de ${a.total} <button class="lnk" id="btnAnaDetener">Detener</button>`;
else if (a && a.error && vistaActual() === 'convertir') $('#anaEstado').innerHTML = `<span class="warn">${esc(a.error)}</span>`;
const bad = $('#btnAnaDetener'); if (bad) bad.onclick = () => accion(bad, async () => { await api('/api/optimizar/analizar/detener', {raiz: raizActual()}); });
const h = ESTADO.herramientas || {}; const faltas = []; 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.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'); 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)}»`; 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)}»`; 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(){ function pintarPlan(){
if (!PLAN) { estadoPlan(); return; } if (!PLAN) { estadoPlan(); return; }
const f = ($('#filtroPlan').value || '').toLowerCase(); const f = ($('#filtroPlan').value || '').toLowerCase();
@@ -912,7 +928,7 @@ function pintarPlan(){
}).join(''); }).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-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 [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', `<p>${fraseDe(r)}</p><p class="mut">Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.</p><input type="text" id="mDest" style="width:100%" value="${esc(r.destino_rel)}">`, 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(); })); }); $$('#planPasos [data-ed]').forEach(b => b.onclick = () => { const r = PLAN.filas.find(x => x.id === +b.dataset.ed); modal('Cambiar destino', `<p>${fraseDe(r)}</p><p class="mut">Escribe la ruta dentro de la carpeta del cliente a la que debe ir. La app se lo aprende para la próxima vez.</p><input type="text" id="mDest" style="width:100%" value="${esc(r.destino_rel)}">`, 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(); estadoPlan();
} }
@@ -956,7 +972,9 @@ function pintarStock(){
html += `<tr class="stk-grp"><td><input type="checkbox" data-t="${t[0]}" ${todos ? 'checked' : ''} aria-label="marcar tramo"></td><td colspan="2">${t[1]}</td><td class="num">${num(gs.reduce((a, x) => a + x.g.n, 0))}</td><td class="num">${gb(gs.reduce((a, x) => a + x.g.bytes, 0))}</td><td class="mut" style="font-weight:500">${gs.length} carpeta${gs.length > 1 ? 's' : ''}</td></tr>`; html += `<tr class="stk-grp"><td><input type="checkbox" data-t="${t[0]}" ${todos ? 'checked' : ''} aria-label="marcar tramo"></td><td colspan="2">${t[1]}</td><td class="num">${num(gs.reduce((a, x) => a + x.g.n, 0))}</td><td class="num">${gb(gs.reduce((a, x) => a + x.g.bytes, 0))}</td><td class="mut" style="font-weight:500">${gs.length} carpeta${gs.length > 1 ? 's' : ''}</td></tr>`;
html += gs.map(({g, i}) => `<tr><td><input type="checkbox" data-i="${i}" ${g.sel ? 'checked' : ''} aria-label="marcar"></td><td class="path">${esc(g.carpeta)}</td><td>${esc(g.mes)}</td><td class="num">${g.n}</td><td class="num">${gb(g.bytes)}</td><td class="cell-small">${g.ejemplos.map(esc).join('<br>')}</td></tr>`).join(''); html += gs.map(({g, i}) => `<tr><td><input type="checkbox" data-i="${i}" ${g.sel ? 'checked' : ''} aria-label="marcar"></td><td class="path">${esc(g.carpeta)}</td><td>${esc(g.mes)}</td><td class="num">${g.n}</td><td class="num">${gb(g.bytes)}</td><td class="cell-small">${g.ejemplos.map(esc).join('<br>')}</td></tr>`).join('');
}); });
const caja = $('#stockCaja'), y = caja ? caja.scrollTop : 0;
$('#tablaStock tbody').innerHTML = html || '<tr><td colspan="6" class="mut">No hay stock de Envato en esta carpeta.</td></tr>'; $('#tablaStock tbody').innerHTML = html || '<tr><td colspan="6" class="mut">No hay stock de Envato en esta carpeta.</td></tr>';
if (caja) caja.scrollTop = y;
$$('#tablaStock input[data-i]').forEach(i => i.onchange = () => { STOCK[+i.dataset.i].sel = i.checked; pintarStock(); }); $$('#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(); }); $$('#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(); 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 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'}`; 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}` : '<span class="bad">ffmpeg no encontrado: no se puede convertir</span>'; $('#motores').innerHTML = MOTORES.ffmpeg ? `${gpuT} · ${cpuT}` : '<span class="bad">ffmpeg no encontrado: no se puede convertir</span>';
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'); }); $$('#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(){ async function cargarCandidatos(){
@@ -1117,7 +1137,7 @@ async function guardarClientes(){
await api('/api/clientes', {clientes}); await api('/api/raices', {raices}); await api('/api/clientes', {clientes}); await api('/api/raices', {raices});
AJ = null; await cargarEstado(); pintarAjustes(); aviso('Guardado.', 'ok'); 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(); }; $('#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'); }); $('#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'); });