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:
23
README.md
23
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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
179
organizador.py
179
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:
|
||||
for ruta, info in lote:
|
||||
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"]))
|
||||
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 = ?", (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["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
|
||||
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)
|
||||
|
||||
@@ -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 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;
|
||||
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 = [];
|
||||
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', `<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();
|
||||
}
|
||||
@@ -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 += 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>';
|
||||
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}` : '<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'); });
|
||||
}
|
||||
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'); });
|
||||
|
||||
|
||||
Referencia en una nueva incidencia
Block a user