Convertir: horario y pausa si edito, aligerar solo lo viejo, aviso de Windows, resumen de la tanda con CSV, y marcar con criterio

Las tres propuestas que faltaban del lienzo:
- La cola respeta un horario (espera entre clip y clip) y espera si Resolve, Premiere o After Effects
  están usando la tarjeta (nvidia-smi pmon). La pasada automática puede meter en cola lo de más de N
  meses hasta un tope de GB. Al acabar, un aviso de Windows con la identidad del programa.
- Cada tanda deja un resumen en la bitácora (op tanda): clips, pesos, tiempo, nota, fallos, por
  carpeta. Sale en Convertir con «Exportar CSV» y «Abrir la carpeta», y en Historial cuenta clips.
- Barra de marcar con criterio (tamaño, cámara, mes, antigüedad) y buscador.

Pruebas: probar 19, prueba_orden 33, prueba_funcional 32 (nueva: resumen de la tanda), prueba_ventana 18.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-12 14:29:49 -05:00
padre d38c51bd73
commit ad6efef792
Se han modificado 4 ficheros con 418 adiciones y 20 borrados

Ver fichero

@@ -913,8 +913,7 @@ También del 10 de octubre:
- **El menú lateral se queda fijo al bajar.** Munka lo vio irse con el resto de la página (en la 3.3.0 y la
3.4.0): el cuerpo tenía `height:100%`, el alto de la ventana, así que su rejilla medía una pantalla y el
lateral «sticky» no tenía recorrido. Ahora el cuerpo crece con el contenido y el lateral no se mueve.
- **Convertir, rehecho** (Munka eligió tres de seis propuestas dibujadas en un lienzo; quedan para después
el horario nocturno, el resumen al acabar y la barra de marcar con criterio):
- **Convertir, rehecho** (seis propuestas dibujadas en un lienzo, las seis hechas):
- *Una sola lista, por carpetas.* Antes había dos: los candidatos (con el estado que tenían al pulsar
«Buscar», que se quedaba en «corriendo» en un clip ya acabado) y, 700 filas más abajo, la cola con el
avance. Ahora cada clip sale una vez con su estado de verdad, agrupado por carpeta (la ruta una sola vez,
@@ -934,6 +933,22 @@ También del 10 de octubre:
≈ B» (con «y reemplazar solo cuando estén verificados», ajuste `optimizar.auto_reemplazar`: al acabar cada
clip, si verificó, reemplaza), la tanda con su barra y «Detener», «Reanudar la cola» si está parada, o,
con todo verificado, «Reemplazar los N originales · libera X» en verde con «comparar unos cuantos».
- *Cuándo trabaja la cola y qué mete sola* (un desplegable en Convertir, ajustes `optimizar.horario`,
`pausar_si_edito`, `aligerar`, `avisar`): un horario (de 20:00 a 07:00, puede pasar por medianoche) fuera
del cual la cola espera entre clip y clip, sin perder lo que iba; esperar si DaVinci Resolve, Premiere o
After Effects están usando la tarjeta (lo dice `nvidia-smi pmon`, uso de cómputo o codificador por proceso;
sin nvidia-smi, basta con que estén abiertos), siempre entre clips, nunca a mitad de uno; «aligerar solo lo
viejo», que en la pasada automática mete en cola lo de más de N meses, de más pesado a menos, hasta un tope
de GB por pasada, en todas las carpetas conectadas (convierte y verifica; no reemplaza salvo
`auto_reemplazar`; si la pasada corre sin ventana, la cola va en ese mismo proceso); y «avisar al terminar»,
un aviso de Windows con la identidad del programa cuando acaba la tanda o si falla.
- *Resumen al acabar.* Cada tanda deja en la bitácora (op `tanda`) cuántos clips, pesos antes y después,
tiempo, nota media si se midió, fallos y «no compensan», y el reparto por carpeta. En Convertir sale la
ficha de la última tanda (con «Exportar CSV», `/api/optimizar/tanda.csv`, y «Abrir la carpeta») hasta que
se oculta o empieza otra; en Historial e Inicio cuenta clips, no movimientos, y no se deshace.
- *Marcar con criterio.* Sobre la lista, una barra de criterios con su cuenta: todos los válidos, los de más
de 5 GB, por cámara (BRAW, H.264…), por mes, de más de un año; «ninguno»; y un buscador por nombre o carpeta
que filtra la lista sin perder el foco. Debajo, «87 marcados · 496 GB → ≈ 23 GB».
- *Comparar antes de reemplazar.* En cualquier clip convertido, «comparar» abre el mismo cuadro del original
y del convertido, lado a lado, en tres instantes del clip, con una lupa que amplía el mismo recorte en los
dos; «Medir la calidad» da la nota VMAF (0 a 100) sobre unos 10 s del medio del clip, con el cuadro peor,
@@ -946,9 +961,10 @@ También del 10 de octubre:
cómo queda es un árbol de carpetas, no una lista de rutas completas.
Pruebas: `prueba_funcional.py` comprueba además que el avance de «Aplicar» acaba en «tantas de tantas» con
los movimientos de la respuesta, y una nueva, «Comparar original y convertido», saca los dos cuadros (el
original ya apartado en `_Para borrar/originales`) y mide la nota VMAF; `probar.py` 19, `prueba_orden.py` 33,
`prueba_funcional.py` 31 y `prueba_ventana.py` 18.
los movimientos de la respuesta, y dos nuevas: «Comparar original y convertido» saca los dos cuadros (el
original ya apartado en `_Para borrar/originales`) y mide la nota VMAF, y «Resumen de la tanda» comprueba el
resumen, su CSV, su entrada en Historial y que el horario se guarda; `probar.py` 19, `prueba_orden.py` 33,
`prueba_funcional.py` 32 y `prueba_ventana.py` 18.
3.4.0, el convertidor: los BRAW salen, y lo que ya viene comprimido no se toca. Munka avisó de que había
archivos que no podía reconvertir y que antes sí, y de lo que quiere: 10 bits de color, buena calidad y mucho

Ver fichero

@@ -2850,7 +2850,197 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
# empezo: cuándo arrancó el clip que va; ritmo: (segundos de clip, segundos reales) de cada clip acabado en
# esta tanda, para decir cuánto falta
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0, "empezo": 0, "ritmo": []}
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0, "empezo": 0, "ritmo": [],
"esperando": "", "hechos_ids": [], "ultima": None}
def _opt(k, defecto=None):
v = (CONFIG.get("optimizar") or {}).get(k)
return defecto if v is None else v
def en_horario(ahora=None):
"""Si la cola tiene horario, ¿estamos dentro? (True, "") o (False, "hasta las 20:00"). El horario puede
pasar por medianoche (20:00 a 07:00)."""
h = _opt("horario") or {}
if not h.get("activo"):
return True, ""
try:
d1, m1 = map(int, (h.get("desde") or "20:00").split(":"))
d2, m2 = map(int, (h.get("hasta") or "07:00").split(":"))
except ValueError:
return True, ""
t = time.localtime(ahora or time.time())
x, a, b = t.tm_hour * 60 + t.tm_min, d1 * 60 + m1, d2 * 60 + m2
dentro = (a <= x < b) if a < b else (x >= a or x < b)
return (True, "") if dentro else (False, f"hasta las {h.get('desde') or '20:00'}")
APPS_EDICION = {"resolve.exe": "DaVinci Resolve", "adobe premiere pro.exe": "Premiere Pro", "afterfx.exe": "After Effects"}
def editando():
"""Qué programa de edición está usando la tarjeta ahora mismo, o "". Lo dice nvidia-smi pmon (uso de
cómputo, codificador y decodificador por proceso); si no hay nvidia-smi, basta con que el programa esté
abierto. Solo cuenta si está activado optimizar.pausar_si_edito."""
if not _opt("pausar_si_edito", True):
return ""
try:
r = subprocess.run(["nvidia-smi", "pmon", "-c", "1", "-s", "u"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA)
if r.returncode == 0 and r.stdout:
for linea in r.stdout.splitlines():
partes = linea.split(None, 9)
if linea.startswith("#") or len(partes) < 10:
continue
nombre = partes[9].strip().lower()
uso = [float(u) for u in partes[3:7] if u.replace(".", "", 1).isdigit()]
for exe, bonito in APPS_EDICION.items():
if nombre and exe.startswith(nombre[:10]) and any(u > 5 for u in uso): # pmon recorta el nombre
return bonito
return ""
except (OSError, subprocess.SubprocessError, ValueError):
pass
try:
r = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=15, creationflags=SIN_VENTANA)
bajo = (r.stdout or "").lower()
for exe, bonito in APPS_EDICION.items():
if f'"{exe}"' in bajo:
return bonito
except (OSError, subprocess.SubprocessError):
pass
return ""
def _esperar_turno():
"""Antes de cada clip: si hay horario y estamos fuera, o si un programa de edición está usando la
tarjeta, la cola espera (sigue activa) y lo dice. Nunca corta un clip a medias. False si la detuvieron."""
while COLA["activa"] and not COLA["cancelar"]:
dentro, hasta = en_horario()
motivo = "" if dentro else f"espera {hasta}, por el horario de la cola"
if not motivo:
app = editando()
motivo = f"espera a que {app} deje la tarjeta" if app else ""
COLA["esperando"] = motivo
if not motivo:
return True
COLA["mensaje"] = motivo
time.sleep(20)
COLA["esperando"] = ""
return False
def notificar(titulo, texto):
"""Un aviso de Windows (la burbuja de la esquina), con la identidad del programa. Va en un PowerShell
aparte y sin esperar: si falla, no pasa nada."""
if os.name != "nt" or os.environ.get("ORGANILAB_ENSAYO"):
return False
esc_ = lambda s: str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("'", "&#39;")
ps = ("[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null\n"
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null\n"
"$xml = New-Object Windows.Data.Xml.Dom.XmlDocument\n"
f"$xml.LoadXml('<toast><visual><binding template=\"ToastGeneric\"><text>{esc_(titulo)}</text><text>{esc_(texto)}</text></binding></visual></toast>')\n"
"$t = New-Object Windows.UI.Notifications.ToastNotification $xml\n"
"try { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Labstream.OrganiLAB').Show($t) }\n"
"catch { [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show($t) }\n")
try:
ruta = os.path.join(DATOS, "aviso.ps1")
with open(ruta, "w", encoding="utf-8-sig") as fh:
fh.write(ps.replace("\\n", "\n"))
subprocess.Popen(["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", ruta],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=SIN_VENTANA)
return True
except OSError:
return False
def _resumen_tanda():
"""Al acabar la cola: qué se hizo en esta tanda (clips, pesos, tiempo, nota media, fallos, por carpeta).
Queda en la bitácora (op «tanda»), se recuerda para la interfaz y, si está activado, se avisa."""
ids = list(COLA.get("hechos_ids") or [])
if not ids:
return None
con = db()
ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
f"WHERE t.id IN ({','.join('?' * len(ids))})", ids).fetchall()]
con.close()
bien = [t for t in ts if t["estado"] in ("listo", "verificado", "reemplazado")]
notas = [t["nota"] for t in bien if t.get("nota")]
carpetas = {}
for t in bien:
try:
rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"]))
except ValueError:
rel = os.path.dirname(t["ruta"])
c = carpetas.setdefault(rel, {"carpeta": rel, "clips": 0, "bytes_in": 0, "bytes_out": 0})
c["clips"] += 1
c["bytes_in"] += t["bytes_in"] or 0
c["bytes_out"] += t["bytes_out"] or 0
raices = sorted({t["raiz"] for t in ts if t.get("raiz")})
seg = round(time.time() - (COLA["inicio"] or time.time()))
r = {"lote": "tanda-" + time.strftime("%Y%m%d-%H%M%S"), "inicio": COLA["inicio"], "seg": seg, "clips": len(bien), "pedidos": len(ts),
"bytes_in": sum(t["bytes_in"] or 0 for t in bien), "bytes_out": sum(t["bytes_out"] or 0 for t in bien),
"fallos": sum(1 for t in ts if t["estado"] in ("error", "cancelado")), "descartados": sum(1 for t in ts if t["estado"] == "descartado"),
"reemplazados": sum(1 for t in ts if t["estado"] == "reemplazado"), "verificados": sum(1 for t in ts if t["estado"] == "verificado"),
"nota": round(sum(notas) / len(notas), 1) if notas else None, "nota_min": min(notas) if notas else None,
"raiz": raices[0] if len(raices) == 1 else "", "raices": raices,
"cliente": ", ".join((raiz_cfg(x) or {}).get("cliente") or os.path.basename(x) for x in raices),
"carpetas": sorted(carpetas.values(), key=lambda c: -c["bytes_in"]), "ids": ids}
bitacora({"op": "tanda", **r})
COLA["ultima"] = dict(r, ts=time.time())
if _opt("avisar", True) and (r["clips"] or r["fallos"]):
que = f"{r['clips']} clip{'s' if r['clips'] != 1 else ''} convertido{'s' if r['clips'] != 1 else ''}"
if r["clips"]:
que += f" y verificado{'s' if r['clips'] != 1 else ''} en {_tiempo_llano(seg)}. " + ("Ya reemplazados." if r["reemplazados"] == r["clips"] else "Falta reemplazar.")
if r["fallos"]:
que += f" {r['fallos']} fallaron."
notificar("OrganiLAB terminó" if r["clips"] else "OrganiLAB: la tanda no salió", que)
return r
def _tiempo_llano(seg):
seg = int(seg or 0)
if seg < 60:
return f"{seg} s"
if seg < 3600:
return f"{seg // 60} min"
return f"{seg // 3600} h {round(seg % 3600 / 60)} min"
def _ultima_tanda_guardada():
for e in reversed(leer_bitacora(3000)):
if e.get("op") == "tanda":
return e
return {}
def tanda_csv(lote):
"""Los clips de una tanda, en CSV (para el cliente o para mirar en Excel)."""
import csv, io
e = next((x for x in reversed(leer_bitacora(3000)) if x.get("op") == "tanda" and x.get("lote") == lote), None)
if not e:
return None
ids = e.get("ids") or []
con = db()
ts = [dict(r) for r in con.execute(f"SELECT t.*, a.dur AS dur FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
f"WHERE t.id IN ({','.join('?' * len(ids))}) ORDER BY t.id", ids).fetchall()] if ids else []
con.close()
s = io.StringIO()
w = csv.writer(s, delimiter=";", lineterminator="\n")
w.writerow(["clip", "carpeta", "estado", "antes (MB)", "después (MB)", "ahorro %", "duración (s)", "nota de calidad", "formato", "salida"])
for t in ts:
try:
rel = os.path.dirname(os.path.relpath(t["ruta"], t["raiz"]))
except ValueError:
rel = os.path.dirname(t["ruta"])
bi, bo = t["bytes_in"] or 0, t["bytes_out"] or 0
w.writerow([os.path.basename(t["ruta"]), rel, EST_CSV.get(t["estado"], t["estado"]), round(bi / 1e6, 1), round(bo / 1e6, 1) if bo else "",
round((1 - bo / bi) * 100) if bi and bo else "", round(t["dur"] or 0, 1) if t.get("dur") else "", t.get("nota") or "",
formato_de(t), t["salida"] or ""])
return s.getvalue()
EST_CSV = {"pendiente": "en espera", "corriendo": "convirtiendo", "listo": "convertido", "verificado": "verificado", "reemplazado": "reemplazado",
"error": "falló", "cancelado": "cancelado", "descartado": "no compensa"}
def set_trabajo(id_, **kw):
@@ -3242,6 +3432,8 @@ def bucle_cola():
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY orden DESC, id LIMIT 1").fetchone()
lote_resolve = [dict(r) for r in con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor = 'resolve' ORDER BY id LIMIT 40").fetchall()]
con.close()
if t and not _esperar_turno():
break
if t:
COLA["actual"] = t["id"]
COLA["empezo"] = time.time()
@@ -3258,6 +3450,7 @@ def bucle_cola():
except Exception as ex: # noqa
COLA["mensaje"] = f"no se pudo reemplazar: {ex}"
COLA["hechos"] += 1
COLA["hechos_ids"].append(t["id"])
elif lote_resolve:
COLA["actual"] = lote_resolve[0]["id"]
try:
@@ -3267,6 +3460,7 @@ def bucle_cola():
set_trabajo(r["id"], estado="error", log=f"fallo inesperado del puente: {ex}")
_verificar_listos([r["id"] for r in lote_resolve])
COLA["hechos"] += len(lote_resolve)
COLA["hechos_ids"].extend(r["id"] for r in lote_resolve)
else:
break
COLA["actual"] = None
@@ -3280,6 +3474,12 @@ def bucle_cola():
COLA["activa"] = False
COLA["actual"] = None
COLA["hilo"] = None
COLA["esperando"] = ""
try:
_resumen_tanda()
except Exception as ex: # noqa
COLA["mensaje"] = f"no se pudo apuntar el resumen de la tanda: {ex}"
COLA["hechos_ids"] = []
def _anotar_ritmo(id_, segundos):
@@ -3526,6 +3726,8 @@ def iniciar_cola():
COLA["hechos"] = 0
COLA["inicio"] = time.time()
COLA["ritmo"] = []
COLA["hechos_ids"] = []
COLA["esperando"] = ""
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
COLA["hilo"].start()
return True
@@ -5337,12 +5539,12 @@ def automatico(en_hilo=False, origen="a mano"):
raices = [_clave(r["ruta"]) for r in CONFIG.get("raices", [])]
AUTO.update(activo=True, raiz="", hechas=0, total=len(raices), inicio=time.time())
if en_hilo:
threading.Thread(target=_automatico, args=(raices, origen), daemon=True).start()
threading.Thread(target=_automatico, args=(raices, origen, False), daemon=True).start()
return {"lanzado": True, "carpetas": len(raices)}
return _automatico(raices, origen)
return _automatico(raices, origen, True)
def _automatico(raices, origen):
def _automatico(raices, origen, sincrono=False):
limite = int((CONFIG.get("automatico") or {}).get("limite") or 3000)
resumen = {"ts": time.time(), "origen": origen, "version": VERSION, "carpetas": []}
try:
@@ -5355,6 +5557,10 @@ def _automatico(raices, origen):
"estado": "con errores", "detalle": repr(ex), "movimientos": 0, "lotes": [],
"dudosos": 0, "avisos": [], "errores": [repr(ex)]})
AUTO["hechas"] += 1
try:
aligerar_automatico(resumen, sincrono=sincrono)
except Exception as ex: # noqa
resumen["aligerar"] = {"error": repr(ex)}
resumen["seg"] = round(time.time() - resumen["ts"], 1)
guardar_json(AUTOMATICO_PATH, resumen)
finally:
@@ -5362,6 +5568,42 @@ def _automatico(raices, origen):
return resumen
def aligerar_automatico(resumen, sincrono=False):
"""Con optimizar.aligerar activado, la pasada mete en cola lo de más de N meses, de más pesado a menos,
hasta el tope de GB por noche, en todas las carpetas conectadas. Convierte y verifica; no reemplaza
(salvo que esté puesto auto_reemplazar). Si el programa corre sin ventana (la tarea de Windows), la cola
va aquí mismo, en este proceso; si no, arranca la cola de siempre."""
a = _opt("aligerar") or {}
if not a.get("activo"):
return
o = CONFIG["optimizar"]
meses, tope = int(a.get("meses") or 6), float(a.get("gb_noche") or 100) * 1e9
total_b, por_raiz = 0, {}
for r in CONFIG.get("raices", []):
raiz = _clave(r["ruta"])
if not os.path.isdir(raiz):
continue
AUTO["raiz"] = raiz
analizar(raiz, o["min_mb"]) # lee con ffprobe lo que falte, aquí mismo
for c in sorted(candidatos(raiz, meses, o["perfil"], o["min_mb"], False, o.get("formato", "h265_10bit")), key=lambda c: -c["bytes"]):
if c["excluido"] or c["en_cola"] or total_b + c["bytes"] > tope:
continue
total_b += c["bytes"]
por_raiz.setdefault(raiz, []).append(c["ruta"])
n = 0
for raiz, rutas in por_raiz.items():
n += len(encolar(rutas, o["perfil"], o.get("chroma", "420"), o.get("color", "rec709"), raiz, o.get("formato", "h265_10bit")) or [])
resumen["aligerar"] = {"clips": n, "bytes": total_b, "meses": meses, "tope_gb": a.get("gb_noche") or 100}
if not n:
return
if sincrono:
with _COLA_LOCK:
COLA.update(activa=True, cancelar=False, hechos=0, inicio=time.time(), ritmo=[], hechos_ids=[], esperando="")
bucle_cola()
else:
iniciar_cola()
def estado_automatico():
c = CONFIG.get("automatico") or {}
return {"activo": bool(c.get("activo")), "hora": c.get("hora") or "02:00", "limite": int(c.get("limite") or 3000),
@@ -5835,6 +6077,11 @@ class Manejador(BaseHTTPRequestHandler):
lo["ejemplos"].append({"de": e.get("de"), "a": e.get("a")})
if e.get("op") == "mkdir": # una pieza nueva: cuentan las carpetas creadas
lo["movimientos"] += 1
if e.get("op") == "tanda": # una tanda del convertidor: su resumen
lo["raiz"] = e.get("raiz", "") or ", ".join(e.get("raices") or [])
lo["movimientos"] = e.get("clips", 0)
lo["errores"] = e.get("fallos", 0)
lo["tanda"] = {k: e.get(k) for k in ("clips", "bytes_in", "bytes_out", "seg", "nota", "reemplazados", "descartados", "cliente")}
if e.get("op") == "fin":
lo["raiz"] = e.get("raiz", "")
lo["errores"] = len(e.get("errores") or [])
@@ -5867,7 +6114,22 @@ class Manejador(BaseHTTPRequestHandler):
eta = eta_cola(filas) if COLA["activa"] else None
except Exception: # noqa
eta = None
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta}})
if COLA.get("ultima") is None:
COLA["ultima"] = _ultima_tanda_guardada()
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"], "eta": eta,
"esperando": COLA.get("esperando") or "", "ultima": COLA.get("ultima") or None}})
if ruta == "/api/optimizar/tanda.csv": # la tanda, en CSV
csv_ = tanda_csv(q.get("lote") or "")
if csv_ is None:
return self._json({"error": "no hay una tanda con ese nombre"}, 404)
cuerpo = csv_.encode("utf-8-sig")
self.send_response(200)
self.send_header("Content-Type", "text/csv; charset=utf-8")
self.send_header("Content-Disposition", f"attachment; filename=\"OrganiLAB-{q.get('lote') or 'tanda'}.csv\"")
self.send_header("Content-Length", str(len(cuerpo)))
self.end_headers()
self.wfile.write(cuerpo)
return
if ruta == "/api/optimizar/cuadro": # un cuadro del original o del convertido, para «comparar»
try:
jpg = cuadro_de(int(q.get("id") or 0), q.get("cual") or "convertido", float(q.get("t") or 0))
@@ -5971,7 +6233,8 @@ class Manejador(BaseHTTPRequestHandler):
return self._json({"ok": True})
if ruta == "/api/config":
# solo ajustes de uso; las rutas de herramientas y la URL de actualización se cambian a mano en config.json
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar"},
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar",
"horario", "pausar_si_edito", "aligerar", "avisar"},
"respaldo": {"destino"}, "actualizaciones": {"auto"}}
for k, v in d.items():
if k in permitidas and isinstance(v, dict):

Ver fichero

@@ -383,6 +383,25 @@ def p_comparar():
return 'dos cuadros JPEG y nota VMAF %s sobre %d cuadros (%.0f s)' % (r['vmaf'], r['cuadros'], r['seg'])
def p_tanda():
# al acabar la cola queda el resumen de la tanda: clips, pesos, tiempo; y se exporta en CSV
u = get('/api/optimizar/cola')['cola']['ultima']
assert u and u['clips'] >= 1 and u['bytes_in'] > u['bytes_out'] > 0 and u['lote'].startswith('tanda-'), u
assert IDS['trabajo'] in u['ids'] and u['raiz'].lower() == RAIZ.lower(), u
r = urllib.request.urlopen(BASE + '/api/optimizar/tanda.csv' + q(lote=u['lote']), timeout=60)
csv_ = r.read().decode('utf-8-sig')
assert r.headers.get('Content-Type', '').startswith('text/csv') and csv_.startswith('clip;carpeta;estado') and 'reemplazado' in csv_, csv_[:200]
lotes = get('/api/bitacora')['lotes']
l = next((x for x in lotes if x['lote'] == u['lote']), None)
assert l and l['tanda']['clips'] == u['clips'], l
# el horario: fuera de él la cola espera (se comprueba la regla, no esperando de verdad)
c, r2 = post('/api/config', {'optimizar': {'horario': {'activo': True, 'desde': '20:00', 'hasta': '07:00'}, 'pausar_si_edito': False, 'avisar': False}})
assert c == 200 and r2['config']['optimizar']['horario']['desde'] == '20:00'
c, r3 = post('/api/config', {'optimizar': {'horario': {'activo': False, 'desde': '20:00', 'hasta': '07:00'}}})
assert c == 200 and not r3['config']['optimizar']['horario']['activo']
return 'resumen de %d clip(s), %s, CSV con %d líneas, y en Historial' % (u['clips'], u['lote'], csv_.count('\n'))
def p_braw():
m = get('/api/optimizar/motores')['braw']
if not BRAW:
@@ -552,7 +571,7 @@ for nombre, fn in (('Interfaz sin nada externo', p_estatico), ('Registrar carpet
('Historial y papelera', p_historial), ('Repetidos', p_repetidos), ('Stock viejo', p_stock),
('Carpetas vacías', p_vacias), ('Mapa interactivo', p_mapa), ('Colores en Windows', p_colores),
('Motores de conversión', p_motores), ('Leer clips (ffprobe)', p_analizar),
('Convertir, verificar, reemplazar', p_convertir), ('Comparar original y convertido', p_comparar), ('Convertir un BRAW', p_braw), ('Control de la cola', p_cola_control),
('Convertir, verificar, reemplazar', p_convertir), ('Comparar original y convertido', p_comparar), ('Resumen de la tanda', p_tanda), ('Convertir un BRAW', p_braw), ('Control de la cola', p_cola_control),
('Respaldar', p_respaldo), ('Asistente / hacer de una vez', p_asistente),
('Pieza nueva', p_pieza), ('Bandeja de entrada y tarjeta', p_entrada), ('Orden automático', p_automatico),
('Arrastrar y buscar carpeta', p_soltar_y_buscar), ('Actualizaciones', p_actualizacion_offline),

Ver fichero

@@ -374,6 +374,32 @@
.antes .pista i.libre{display:block;height:100%;background:var(--ok2);border-right:2px solid var(--ok)}
.antes .sw{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:-1px;margin-right:4px}.antes .sw.conv{background:var(--acc)}.antes .sw.libre{background:var(--ok2);border:1px solid var(--ok)}
.modal.ancho .box{max-width:1200px}
.ajcola{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:10px}
@media(max-width:900px){.ajcola{grid-template-columns:1fr}}
.ajcola .caja{border:1px solid var(--line);border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:8px;background:var(--panel)}
.ajcola .caja .t{display:flex;gap:8px;align-items:center;font-weight:700;font-size:14px}
.ajcola .caja .d{font-size:12.5px;color:var(--mut)}
.ajcola .caja .f{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px;color:var(--mut)}
.ajcola input[type=time],.ajcola input[type=number]{font:inherit;padding:4px 6px;border:1px solid var(--line);border-radius:5px}
.ajcola .dia{display:flex;gap:2px;height:12px;border-radius:4px;overflow:hidden}
.ajcola .dia i{display:block;height:100%}
.ajcola .apps span{background:var(--acc2);color:var(--acc);border-radius:99px;padding:1px 9px;font-weight:600;font-size:12px;margin-right:4px}
.tanda{background:var(--panel);border:1px solid var(--ok);border-radius:8px;padding:14px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:12px}
.tanda .l1{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:13.5px}
.tanda .cifras{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px}
@media(max-width:900px){.tanda .cifras{grid-template-columns:repeat(2,minmax(0,1fr))}}
.tanda .cifras div{border-bottom:1px solid var(--line);padding:2px 0 8px}
.tanda .cifras b{font-size:20px;display:block;white-space:nowrap}
.tanda .cifras small{font-size:12px;color:var(--mut)}
.tanda .carp{display:flex;gap:10px;align-items:center;font-size:12.5px}
.tanda .carp .n{width:260px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tanda .carp .b{flex:1;height:8px;background:var(--panel2);border-radius:99px;overflow:hidden}.tanda .carp .b i{display:block;height:100%;background:var(--acc)}
.tanda .carp .c{width:190px;text-align:right;color:var(--mut);white-space:nowrap}
.marcar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:8px 0;border-bottom:1px solid var(--line);font-size:12.5px}
.marcar>span{color:var(--mut);margin-right:2px}
.marcar button{background:#fff;border:1px solid var(--line);border-radius:99px;padding:2px 10px;font-size:12px;font-weight:600;color:var(--tx);cursor:pointer}
.marcar button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)}
.marcar input[type=search]{font:inherit;padding:4px 8px;border:1px solid var(--line);border-radius:5px;min-width:220px;margin-left:auto}
.cmp{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.cmp .lado{display:flex;flex-direction:column;gap:6px;min-width:0}
.cmp .marco{position:relative;background:#d9dcdf;border-radius:8px;overflow:hidden;aspect-ratio:16/9;display:flex;align-items:center;justify-content:center;color:#4d5157;font-size:12.5px}
@@ -636,6 +662,8 @@
<button class="lnk" id="btnQuitarEspera" style="display:none">Quitar los que esperan</button>
</div>
<div id="anaEstado" class="mut" style="margin:-6px 0 10px"></div>
<div id="tanda"></div>
<details class="mas" id="colaAjustes" style="margin-bottom:12px"><summary>Cuándo trabaja la cola y qué mete sola</summary><div class="ajcola" id="colaAjustesCuerpo"></div></details>
<div class="box" id="convCaja"><div id="convLista"><span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span></div></div>
</section>
@@ -723,6 +751,9 @@
<h3>Convertidor</h3>
<dl>
<dt>Cuándo trabaja la cola</dt><dd>Un desplegable en Convertir: un horario fuera del cual la cola espera entre clip y clip; esperar si Resolve, Premiere o After Effects están usando la tarjeta (nunca corta un clip a medias); «aligerar solo lo viejo», que en la pasada automática de cada noche mete en cola lo de más de N meses hasta un tope de GB (convierte y verifica, no reemplaza); y un aviso de Windows al terminar. <i>Cómo: la cola mira el horario y los programas de edición antes de cada clip; si tiene que esperar, lo dice en el panel de arriba.</i></dd>
<dt>Resumen de la tanda</dt><dd>Al acabar la cola queda una ficha: clips, peso antes y después, lo que se libera, la nota media si se midió, los fallos, y el reparto por carpeta, con «Exportar CSV» y «Abrir la carpeta». Sale en Convertir hasta que la ocultes o empiece otra, y queda en Historial.</dd>
<dt>Marcar con criterio</dt><dd>Sobre la lista del convertidor: todos los válidos, los de más de 5 GB, por cámara, por mes, de más de un año, ninguno; y un buscador por nombre o carpeta.</dd>
<dt>Comparar</dt><dd>En un clip convertido, enseña el mismo cuadro del original y del convertido, lado a lado, en tres instantes del clip; al pasar el ratón, una lupa amplía el mismo recorte en los dos. «Medir la calidad» da la nota de 0 a 100 (VMAF) sobre unos diez segundos del medio del clip; de 93 arriba no se nota a simple vista. Desde ahí se reemplaza ese original solo. <i>Cómo: un BRAW se revela con la misma curva con que se convirtió; si el original ya se reemplazó, se busca en _Para borrar/originales. La nota se guarda en el trabajo y sale en la lista.</i></dd>
<dt>Buscar clips</dt><dd>Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. <i>Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero, los exportados y lo que ya convirtió este programa, para no pasar un clip dos veces.</i></dd>
<dt>Formato y calidad</dt><dd>H.265 10 bits (el recomendado, unas diez veces más ligero), H.265 ligero, H.264 o ProRes proxy, LT y HQ; calidad de ahorro, equilibrada o máxima. «Motores» te dice qué acelera tu tarjeta gráfica.</dd>
@@ -874,7 +905,7 @@ const cargarVista = seguro(async t => {
if (t === 'carpeta') await cargarCarpeta();
if (t === 'revisar') { await cargarMapa(); await cargarVoc(); if (PLAN) pintarPlan(); if (STOCK.length && STOCK[0].raiz !== raizActual()) { STOCK = []; $('#stockCaja').style.display = 'none'; } if (VACIAS && VACIAS.raiz !== raizActual()) { VACIAS = null; pintarVacias(); } }
if (t === 'mapa') await cargarMapaEst();
if (t === 'convertir') { await cargarCola(); }
if (t === 'convertir') { pintarAjustesCola(); await cargarCola(); }
if (t === 'respaldar') { if (!$('#respDestino').value) $('#respDestino').value = (ESTADO.config.respaldo || {}).destino || ''; pintarRespaldo(); }
if (t === 'historial') { await cargarBit(); }
if (t === 'ajustes') pintarAjustes();
@@ -1140,7 +1171,7 @@ async function cargarCarpeta(){
$('#inicioDiag').innerHTML = d.frases.slice(0, 3).map(f => `<div>${esc(f)}</div>`).join('') + (d.frases.length > 3 ? `<div class="mut">Más en Revisar y ordenar.</div>` : '');
}
const lotes = CARPETA.lotes || [];
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} mov.${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-')) ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
$('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `<div class="ev"><span class="h">${esc(fechaCorta(l.ts))}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${cuentaLote(l, true)}${l.errores ? ` <span class="bad">· ${l.errores} avisos</span>` : ''}</span>${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-') || l.lote.startsWith('tanda-')) ? '' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('') : '<span class="mut">Todavía no se ha movido nada en esta carpeta.</span>';
$$('#inicioLotes button[data-l]').forEach(bt => bt.onclick = () => deshacer(bt.dataset.l));
}
// ---------------- lo que se puede hacer en esta carpeta ----------------
@@ -1191,11 +1222,17 @@ async function pintarAcciones(){
await cargarEstado(); await pintarAcciones();
}));
}
// «212 clips · 676 GB → 24 GB · nota 96» para una tanda del convertidor; «N movimientos» para lo demás
function cuentaLote(l, corto){
if (l.tanda) return `${l.tanda.clips} clip${l.tanda.clips !== 1 ? 's' : ''} · ${gb(l.tanda.bytes_in)} → ${gb(l.tanda.bytes_out)}${l.tanda.nota ? ` · nota ${l.tanda.nota}` : ''}${l.tanda.reemplazados ? ` · ${l.tanda.reemplazados} reemplazado${l.tanda.reemplazados !== 1 ? 's' : ''}` : ''}`;
return corto ? `${l.movimientos} mov.` : `${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}`;
}
function queSeHizo(lote){
if (!lote) return 'Cambio';
if (lote === 'optimizar' || lote.startsWith('optimizar-')) return 'Reemplazó originales por la versión convertida';
if (lote.startsWith('stock-')) return 'Apartó stock de Envato viejo';
if (lote.startsWith('descartados-')) return 'Apartó conversiones que no compensaban';
if (lote.startsWith('tanda-')) return 'Convirtió una tanda de clips';
if (lote.startsWith('dup-')) return 'Juntó material repetido';
if (lote.startsWith('vacias-')) return 'Borró carpetas vacías';
if (lote.startsWith('pieza-')) return 'Creó una pieza nueva';
@@ -1210,7 +1247,7 @@ function pasosDe(C, r){
: esc ? {e:'hecho', t:'Escanear', d:`${fecha(C.escaneo.ts)} · ${num(C.escaneo.n)} archivos`, corto: fechaCorta(C.escaneo.ts)} : {e:'pend', t:'Escanear', d:'Lee nombres y tamaños. Solo lectura.', corto: ''});
const sn = C.sin_nombre_n || 0;
P.push(!esc ? {e:'bloq', t:'Revisar', d:'Después de escanear'} : sn ? {e:'listo', t:'Revisar', d:`${sn} carpeta${sn > 1 ? 's' : ''} por nombrar`, corto: `${sn} por nombrar`} : {e:'hecho', t:'Revisar', d:'Todas las carpetas reconocidas', corto: 'ok'});
const lotes = (C.lotes || []).filter(l => !l.lote.startsWith('stock-') && !l.lote.startsWith('optimizar') && !l.lote.startsWith('descartados-'));
const lotes = (C.lotes || []).filter(l => !l.lote.startsWith('stock-') && !l.lote.startsWith('optimizar') && !l.lote.startsWith('descartados-') && !l.lote.startsWith('tanda-'));
const asis = C.asistente && C.asistente.activo;
P.push(!esc ? {e:'bloq', t:'Ordenar', d:'Después de escanear'} : asis ? {e:'curso', t:'Ordenar', d: (NOMBRE_PASO[C.asistente.paso] || '') + '…', corto: 'en marcha'}
: C.plan ? {e:'listo', t:'Ordenar', d:`Plan listo: ${C.plan.marcadas} de ${C.plan.filas} movimientos marcados`, corto: `${C.plan.marcadas} mov.`}
@@ -1605,7 +1642,19 @@ $('#btnStockPapelera').onclick = () => { const s = STOCK.filter(g => g.sel && g.
// ---------------- convertir ----------------
function pintarCriterio(){ $('#convCriterio').textContent = `con más de ${+$('#optMeses').value || 0} meses y más de ${+$('#optMin').value || 0} MB`; }
['optMeses','optMin'].forEach(id => $('#' + id).oninput = pintarCriterio);
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'carpeta', inverso: false}, CONV_EXC = '', CONV_PLEGADAS = new Set();
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'carpeta', inverso: false}, CONV_EXC = '', CONV_PLEGADAS = new Set(), CONV_BUSCA = '', CONV_CRITERIO = '';
const NOMBRE_CAM = {braw: 'BRAW', h264: 'H.264', hevc: 'H.265', h265: 'H.265', prores: 'ProRes', mpeg4: 'MPEG-4', mpeg2video: 'MPEG-2', dnxhd: 'DNxHD', vp9: 'VP9', av1: 'AV1'};
const MES_LARGO = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
// los criterios de «marcar»: cada uno sabe decir cuántos clips coge
function criteriosDe(marcables){
const hoy = Date.now(), anio = 365 * 86400000, cods = {}, meses = {};
marcables.forEach(c => { const k = NOMBRE_CAM[(c.codec || '').toLowerCase()] || (c.codec || '').toUpperCase(); cods[k] = (cods[k] || 0) + 1; const m = (c.fecha || '').slice(0, 7); if (m) meses[m] = (meses[m] || 0) + 1; });
const cr = [['todos', 'todos los válidos', c => true], ['grandes', 'los de más de 5 GB', c => c.bytes > 5e9]];
Object.keys(cods).sort((a, b) => cods[b] - cods[a]).forEach(k => cr.push(['cam:' + k, 'solo ' + k, c => (NOMBRE_CAM[(c.codec || '').toLowerCase()] || (c.codec || '').toUpperCase()) === k]));
Object.keys(meses).sort().slice(-6).forEach(m => cr.push(['mes:' + m, `${MES_LARGO[+m.slice(5, 7) - 1] || m} ${m.slice(0, 4)}`, c => (c.fecha || '').slice(0, 7) === m]));
cr.push(['viejos', 'de más de un año', c => c.fecha && hoy - new Date(c.fecha).getTime() > anio]);
return cr.map(([k, t, p]) => ({k, t, p, n: marcables.filter(p).length})).filter(x => x.n);
}
const tiempoLargo = s => { s = Math.max(0, Math.round(s || 0)); if (s < 60) return `${s} s`; if (s < 3600) return `${Math.floor(s / 60)} min${s % 60 ? ' ' + (s % 60) + ' s' : ''}`; return `${Math.floor(s / 3600)} h${Math.round(s % 3600 / 60) ? ' ' + Math.round(s % 3600 / 60) + ' min' : ''}`; };
// por qué no se ofrece un clip, en grupos: 502 filas grises con un «no» no explican nada
function claveExc(m){
@@ -1694,7 +1743,8 @@ async function cargarCola(){
const rango = f => f.t ? ({corriendo: 0, pendiente: 1, listo: 3, verificado: 3, error: 4, descartado: 5, reemplazado: 6, cancelado: 6}[f.t.estado] ?? 7) : f.c.excluido ? 8 : 2;
const grupo = f => f.t && ['pendiente', 'corriendo'].includes(f.t.estado) ? 'cola' : f.t && ['listo', 'verificado', 'reemplazado'].includes(f.t.estado) ? 'hechos' : f.t && ['error', 'cancelado', 'descartado'].includes(f.t.estado) ? 'fallos' : f.c && f.c.excluido ? 'no' : 'libres';
const CLAVES = {carpeta: (a, b) => carpeta(a).localeCompare(carpeta(b), 'es') || rango(a) - rango(b) || bytes(b) - bytes(a), estado: (a, b) => rango(a) - rango(b) || bytes(b) - bytes(a), nombre: (a, b) => nombre(a).localeCompare(nombre(b), 'es'), fecha: (a, b) => ((b.c || {}).fecha || '').localeCompare((a.c || {}).fecha || '') || bytes(b) - bytes(a), tamano: (a, b) => bytes(b) - bytes(a), ahorro: (a, b) => ahorro(b) - ahorro(a)};
const lista = filas.filter(f => (CONV_VER === 'todos' || grupo(f) === CONV_VER) && (!CONV_EXC || (f.c && claveExc(f.c.excluido) === CONV_EXC))).sort(CLAVES[CONV_ORDEN.campo] || CLAVES.carpeta);
const busca = CONV_BUSCA.trim().toLowerCase();
const lista = filas.filter(f => (CONV_VER === 'todos' || grupo(f) === CONV_VER) && (!CONV_EXC || (f.c && claveExc(f.c.excluido) === CONV_EXC)) && (!busca || relDe(f).toLowerCase().includes(busca))).sort(CLAVES[CONV_ORDEN.campo] || CLAVES.carpeta);
if (CONV_ORDEN.inverso) lista.reverse();
const cuenta = {}; filas.forEach(f => cuenta[grupo(f)] = (cuenta[grupo(f)] || 0) + 1);
const E = ESTADO && ESTADO.cola, corr = ts.find(t => t.estado === 'corriendo'), eta = d.cola.eta;
@@ -1726,7 +1776,9 @@ async function cargarCola(){
const vers = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores'));
const notas = vers.filter(t => t.nota), notaMedia = notas.length ? (notas.reduce((s, t) => s + t.nota, 0) / notas.length).toFixed(0) : '';
let mo = '';
if (d.cola.activa) {
if (d.cola.activa && d.cola.esperando) {
mo = `<div class="momento"><div class="l1"><span class="spin"></span><b>La cola está en pausa</b><span class="mut">${esc(d.cola.esperando)} · ${pend} en espera · se cambia en «Cuándo trabaja la cola»</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div></div>`;
} else if (d.cola.activa) {
const total = E && E.total ? E.total : (pend + (corr ? 1 : 0)), hechosT = E ? (E.hechos || 0) : 0;
const pctTot = total ? Math.round((hechosT + (corr ? (corr.pct || 0) / 100 : 0)) / total * 100) : 0;
mo = `<div class="momento corre"><div class="l1"><span class="spin"></span><b>Convirtiendo ${Math.min(hechosT + 1, total)} de ${total}</b><span class="mut">${eta && eta.seg ? `quedan unas <b>${tiempoLargo(eta.seg)}</b> · termina hacia las <b>${hora(eta.termina)}</b>${eta.medido ? ` · va a ${eta.vel.toFixed(1)}× tiempo real` : ' · estimado, hasta que acabe el primero'}` : 'calculando cuánto falta…'}</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div><div class="bar grande"><i style="width:${pctTot}%"></i></div><div class="l3 mut">${corr ? `ahora: <b>${esc((corr.ruta || '').split(/[\\/]/).pop())}</b> · ${Math.round(corr.pct || 0)} %${eta && eta.actual ? ` · quedan ${tiempoLargo(eta.actual.resto_seg)} de este clip` : ''} · ${eta ? eta.en_espera : pend} en espera` : 'empezando…'}</div></div>`;
@@ -1744,6 +1796,16 @@ async function cargarCola(){
mo = `<div class="momento"><div class="l1"><button class="p grande" id="moBuscar">Buscar clips que se puedan aligerar</button><span class="mut">lee los clips de más de ${+$('#optMeses').value || 0} meses y ${+$('#optMin').value || 0} MB, y calcula cuánto pesarían. No convierte nada todavía.</span></div></div>`;
}
$('#momento').innerHTML = mo;
// la última tanda, con su resumen, hasta que se oculte o empiece otra
const ul = d.cola.ultima, vista = (() => { try { return localStorage.getItem('organilab.tanda.vista'); } catch (e) { return ''; } })();
if (!d.cola.activa && ul && ul.lote && ul.clips && ul.lote !== vista && (!raizActual() || !ul.raices || !ul.raices.length || ul.raices.some(x => x.toLowerCase() === raizActual().toLowerCase()))) {
const carps = (ul.carpetas || []).slice(0, 3), maxB = Math.max(1, ...carps.map(c => c.bytes_in));
$('#tanda').innerHTML = `<div class="tanda"><div class="l1"><span class="ico" style="width:22px;height:22px;border-radius:50%;background:var(--ok2);color:var(--ok);display:inline-flex;align-items:center;justify-content:center;font-weight:700">✓</span><b>Tanda terminada${ul.cliente ? ' · ' + esc(ul.cliente) : ''}</b><span class="mut">${ul.inicio ? `empezó el ${new Date(ul.inicio * 1000).toLocaleDateString('es-CO', {day: 'numeric', month: 'long'})} a las ${hora(ul.inicio)} · ` : ''}acabó a las ${hora(ul.ts || (ul.inicio + ul.seg))} · ${tiempoLargo(ul.seg)}</span><span class="sep"></span><a class="lnk" href="/api/optimizar/tanda.csv?lote=${encodeURIComponent(ul.lote)}" download style="padding:5px 6px;font-weight:600">Exportar CSV</a>${ul.raiz ? '<button id="taAbrir">Abrir la carpeta</button>' : ''}<button class="lnk" id="taOcultar">ocultar</button></div>
<div class="cifras"><div><b>${ul.clips}</b><small>clip${ul.clips !== 1 ? 's' : ''} convertido${ul.clips !== 1 ? 's' : ''}${ul.verificados || ul.reemplazados ? ' y verificado' + (ul.clips !== 1 ? 's' : '') : ''}${ul.pedidos > ul.clips ? ` de ${ul.pedidos}` : ''}</small></div><div><b>${gb(ul.bytes_in)} → ${gb(ul.bytes_out)}</b><small>${ul.bytes_out ? Math.round(ul.bytes_in / ul.bytes_out) + ' veces menos' : ''}</small></div><div><b class="ok">${gb(Math.max(0, ul.bytes_in - ul.bytes_out))}</b><small>${ul.reemplazados === ul.clips ? 'liberados' : 'se liberan al reemplazar'}</small></div><div><b>${ul.nota ? ul.nota + ' / 100' : '—'}</b><small>${ul.nota ? `nota media · la peor, ${ul.nota_min}` : 'sin medir: «comparar» mide'}</small></div><div><b class="${ul.fallos || ul.descartados ? 'warn' : ''}">${(ul.fallos || 0) + (ul.descartados || 0)}</b><small>${ul.fallos ? ul.fallos + ' fallaron' : ''}${ul.fallos && ul.descartados ? ' · ' : ''}${ul.descartados ? ul.descartados + ' no compensaban' : ''}${!ul.fallos && !ul.descartados ? 'sin fallos' : ''}</small></div></div>
${carps.length ? `<div style="display:flex;flex-direction:column;gap:4px"><span class="cabsec" style="margin:0">Por carpeta</span>${carps.map(c => `<div class="carp"><span class="n" title="${esc(c.carpeta)}">${esc((c.carpeta || '').split(/[\\/]/).filter(Boolean).join(' › ') || '(raíz)')}</span><div class="b"><i style="width:${Math.round(c.bytes_in / maxB * 100)}%"></i></div><span class="c">${c.clips} clip${c.clips !== 1 ? 's' : ''} · ${gb(c.bytes_in)} → ${gb(c.bytes_out)}</span></div>`).join('')}${(ul.carpetas || []).length > 3 ? `<span class="mut" style="font-size:12px">y ${ul.carpetas.length - 3} carpeta${ul.carpetas.length - 3 > 1 ? 's' : ''} más: están en el CSV</span>` : ''}</div>` : ''}</div>`;
const ta = $('#taAbrir'); if (ta) ta.onclick = () => api('/api/abrir', {ruta: ul.raiz}).catch(e => aviso(e.message, 'bad'));
$('#taOcultar').onclick = () => { try { localStorage.setItem('organilab.tanda.vista', ul.lote); } catch (e) {} $('#tanda').innerHTML = ''; };
} else $('#tanda').innerHTML = '';
const liga = (id, a) => { const b = $('#' + id); if (b) b.onclick = a; };
liga('moDetener', () => $('#btnColaToggle').click()); liga('moReanudar', () => $('#btnColaToggle').click());
liga('moReemplazar', () => $('#btnReemplazar').click()); liga('moConvertir', () => $('#btnEncolar').click()); liga('moBuscar', () => $('#btnBuscarClips').click());
@@ -1755,6 +1817,12 @@ async function cargarCola(){
const marcables = CAND.filter(c => !c.excluido && !c.en_cola);
const VER = [['todos', 'todos', filas.length], ['libres', 'por convertir', cuenta.libres], ['cola', 'en cola', cuenta.cola], ['hechos', 'terminados', cuenta.hechos], ['fallos', 'con fallo', cuenta.fallos], ['no', 'excluidos', cuenta.no]];
const ORD = [['carpeta', 'carpeta'], ['estado', 'estado'], ['nombre', 'nombre'], ['fecha', 'fecha'], ['tamano', 'tamaño'], ['ahorro', 'ahorro']];
if (marcables.length) {
// marcar con criterio: por tamaño, por cámara, por mes, por antigüedad; y un buscador
const cr = criteriosDe(marcables), nSel = marcables.filter(c => c.sel).length;
html += `<div class="marcar"><span>marcar</span>${cr.map(x => `<button data-criterio="${esc(x.k)}" class="${CONV_CRITERIO === x.k ? 'on' : ''}">${esc(x.t)} · ${x.n}</button>`).join('')}<button class="lnk" data-criterio="ninguno" style="border:0;background:none">ninguno</button><input type="search" id="convBusca" placeholder="buscar por nombre o carpeta…" value="${esc(CONV_BUSCA)}" aria-label="buscar clip"></div>`;
if (nSel) html += `<div class="mut" style="padding:6px 0 2px;font-size:12.5px"><b style="color:var(--tx)">${nSel} marcado${nSel > 1 ? 's' : ''}</b> · ${gb(marcables.filter(c => c.sel).reduce((s, c) => s + c.bytes, 0))} → ≈ ${gb(marcables.filter(c => c.sel).reduce((s, c) => s + c.estimado, 0))}</div>`;
} else if (busca || !CAND.length) html += `<div class="marcar"><input type="search" id="convBusca" placeholder="buscar por nombre o carpeta…" value="${esc(CONV_BUSCA)}" aria-label="buscar clip"></div>`;
html += `<div class="convbar">${CAND.length ? `<label style="display:flex;gap:6px;align-items:center;font-weight:500"><input type="checkbox" id="candTodos" ${marcables.length && CAND.filter(c => c.sel).length === marcables.length ? 'checked' : ''} ${marcables.length ? '' : 'disabled'}> marcar todos los válidos</label>` : ''}<span class="sep"></span><span class="mut">ver</span>${VER.filter(x => x[2]).map(([k, t, n]) => `<button class="lnk ${CONV_VER === k ? 'on' : ''}" data-ver="${k}">${t} · ${n}</button>`).join('')}<span class="mut">ordenar por</span>${ORD.map(([k, t]) => `<button class="lnk ${CONV_ORDEN.campo === k ? 'on' : ''}" data-orden="${k}" title="${CONV_ORDEN.campo === k ? 'otra vez para invertir el orden' : ''}">${t}${CONV_ORDEN.campo === k ? (CONV_ORDEN.inverso ? ' ↑' : ' ↓') : ''}</button>`).join('')}</div>`;
const tope = lista.slice(0, 800);
if (CONV_ORDEN.campo === 'carpeta') {
@@ -1778,7 +1846,7 @@ async function cargarCola(){
if (ks.length) html += `<div class="excl"><b style="color:var(--tx)">Excluidos · ${cuenta.no || 0}</b><span>no se ofrecen, y por qué:</span>${ks.map(k => `<button data-exc="${esc(k)}" class="${CONV_EXC === k ? 'on' : ''} ${k === 'no se pudieron leer' ? 'ojo' : ''}">${exc[k]} ${esc(k)}</button>`).join('')}${CONV_EXC ? '<button class="lnk" data-exc="" style="background:none;border:0">ver todos</button>' : ''}</div>`;
}
$('#convLista').innerHTML = html || '<span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span>';
$$('#convLista input[data-i]').forEach(i => i.onchange = () => { CAND[+i.dataset.i].sel = i.checked; resumenCand(); });
$$('#convLista input[data-i]').forEach(i => i.onchange = () => { CAND[+i.dataset.i].sel = i.checked; CONV_CRITERIO = ''; resumenCand(); });
const ct = $('#candTodos'); if (ct) ct.onchange = e => { CAND.forEach(c => c.sel = e.target.checked && !c.excluido && !c.en_cola); cargarCola(); };
$$('#convLista [data-grupo]').forEach(g => g.onchange = e => { CAND.forEach(c => { if (padreDe(c.rel) === g.dataset.grupo && !c.excluido && !c.en_cola) c.sel = e.target.checked; }); cargarCola(); });
$$('#convLista [data-plegar]').forEach(b => b.onclick = () => { const k = b.dataset.plegar; if (CONV_PLEGADAS.has(k)) CONV_PLEGADAS.delete(k); else CONV_PLEGADAS.add(k); cargarCola(); });
@@ -1787,6 +1855,38 @@ async function cargarCola(){
$$('#convLista [data-orden]').forEach(b => b.onclick = () => { if (CONV_ORDEN.campo === b.dataset.orden) CONV_ORDEN.inverso = !CONV_ORDEN.inverso; else CONV_ORDEN = {campo: b.dataset.orden, inverso: false}; cargarCola(); });
$$('#convLista [data-primero]').forEach(b => b.onclick = () => accion(b, async () => { await api('/api/optimizar/control', {accion: 'primero', id: +b.dataset.primero}); await cargarCola(); }));
$$('#convLista [data-comparar]').forEach(b => b.onclick = () => comparar(+b.dataset.comparar));
$$('#convLista [data-criterio]').forEach(b => b.onclick = () => { const k = b.dataset.criterio, marc = CAND.filter(c => !c.excluido && !c.en_cola), cr = criteriosDe(marc).find(x => x.k === k); CONV_CRITERIO = k === 'ninguno' ? '' : k; marc.forEach(c => c.sel = cr ? cr.p(c) : false); cargarCola(); });
const bq = $('#convBusca'); if (bq) { bq.oninput = () => { CONV_BUSCA = bq.value; clearTimeout(bq._t); bq._t = setTimeout(() => { const pos = bq.selectionStart; cargarCola().then(() => { const b2 = $('#convBusca'); if (b2) { b2.focus(); b2.setSelectionRange(pos, pos); } }); }, 350); }; }
}
// ---- cuándo trabaja la cola y qué mete sola (se guarda en optimizar.*)
function pintarAjustesCola(){
const o = (ESTADO.config && ESTADO.config.optimizar) || {}, h = o.horario || {}, a = o.aligerar || {};
const desde = h.desde || '20:00', hasta = h.hasta || '07:00';
const min = s => { const [hh, mm] = s.split(':').map(Number); return hh * 60 + mm; };
const d1 = min(desde), d2 = min(hasta), seg = d1 < d2 ? [[0, d1, 0], [d1, d2, 1], [d2, 1440, 0]] : [[0, d2, 1], [d2, d1, 0], [d1, 1440, 1]];
$('#colaAjustesCuerpo').innerHTML = `
<div class="caja"><label class="t"><input type="checkbox" id="ajHorario" ${h.activo ? 'checked' : ''}> Convertir solo en un horario</label>
<div class="f"><span>de</span><input type="time" id="ajDesde" value="${esc(desde)}" aria-label="desde"><span>a</span><input type="time" id="ajHasta" value="${esc(hasta)}" aria-label="hasta"><span>· fuera de ese horario la cola espera entre clip y clip, sin perder lo que iba</span></div>
<div class="dia" aria-hidden="true">${seg.map(([x, y, on]) => `<i style="flex:${Math.max(1, y - x)};background:${on ? 'var(--acc)' : 'var(--panel2)'}"></i>`).join('')}</div>
<div class="f" style="justify-content:space-between"><span>0 h</span><span>12 h</span><span>24 h</span></div></div>
<div class="caja"><label class="t"><input type="checkbox" id="ajPausa" ${o.pausar_si_edito === false ? '' : 'checked'}> Esperar si estoy editando</label>
<div class="d">Si DaVinci Resolve, Premiere o After Effects están usando la tarjeta, la cola no empieza el siguiente clip hasta que la dejen. El que va se termina.</div>
<div class="apps"><span>DaVinci Resolve</span><span>Premiere Pro</span><span>After Effects</span></div></div>
<div class="caja"><label class="t"><input type="checkbox" id="ajAligerar" ${a.activo ? 'checked' : ''}> Aligerar solo lo viejo</label>
<div class="f"><span>en la pasada automática, mete en cola lo de más de</span><input type="number" id="ajMeses" value="${+a.meses || 6}" min="1" style="width:56px" aria-label="meses"><span>meses, hasta</span><input type="number" id="ajGb" value="${+a.gb_noche || 100}" min="1" style="width:70px" aria-label="GB por pasada"><span>GB por pasada</span></div>
<div class="d">Va con el orden automático de la pestaña Carpeta (la tarea de Windows de cada noche). Convierte y verifica; <b style="color:var(--tx)">no reemplaza</b>, salvo que marques «y reemplazar solo cuando estén verificados».</div></div>
<div class="caja"><label class="t"><input type="checkbox" id="ajAvisar" ${o.avisar === false ? '' : 'checked'}> Avisar al terminar</label>
<div class="d">Un aviso de Windows cuando acabe la tanda, o si falla, aunque la ventana esté cerrada.</div>
<div style="border:1px solid var(--line);border-radius:8px;padding:8px 12px;display:flex;gap:10px;align-items:center;background:var(--panel2);max-width:420px;font-size:12.5px"><span style="width:26px;height:26px;border-radius:7px;background:var(--acc);flex:none"></span><span><b>OrganiLAB terminó</b><br>212 clips convertidos y verificados en 7 h 12 min. Falta reemplazar.</span></div></div>`;
const guardar = seguro(async () => {
const parcial = {horario: {activo: $('#ajHorario').checked, desde: $('#ajDesde').value || '20:00', hasta: $('#ajHasta').value || '07:00'},
pausar_si_edito: $('#ajPausa').checked, aligerar: {activo: $('#ajAligerar').checked, meses: +$('#ajMeses').value || 6, gb_noche: +$('#ajGb').value || 100},
avisar: $('#ajAvisar').checked};
await api('/api/config', {optimizar: parcial});
Object.assign(ESTADO.config.optimizar, parcial);
pintarAjustesCola();
});
$$('#colaAjustesCuerpo input').forEach(i => i.onchange = guardar);
}
// ---- comparar: el mismo cuadro en el original y en el convertido, con lupa, y la nota de calidad
function comparar(id){
@@ -1866,7 +1966,7 @@ async function cargarBit(){
// Avisos de error recientes (los globos rojos), con su detalle: para saber qué pasó después de que desaparecieron.
const av = avisosGuardados().filter(a => !solo || !raiz || (a.raiz || '').toLowerCase() === raiz);
const avHtml = av.length ? `<div class="dia">Avisos recientes <button class="lnk" id="btnBorrarAvisos" style="margin-left:8px;font-size:12px">borrar</button></div>` + av.slice(0, 20).map(a => `<div class="ev"><span class="h">${hora(a.ts)}</span><span class="q"><b>${esc(a.texto)}</b>${!solo && a.raiz ? ` · <span class="mut">${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}</span>` : ''}${a.detalle ? `<details><summary>detalle</summary><pre class="cell-small" style="white-space:pre-wrap">${esc(a.detalle)}</pre></details>` : ''}</span><span></span></div>`).join('') : '';
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho || (l.lote || '').startsWith('entrada-') ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `<div class="dia">${esc(titulo(k))}</div>` + dias[k].map(l => `<div class="ev"><span class="h">${hora(l.ts)}</span><span class="q"><b>${esc(queSeHizo(l.lote))}</b> · ${cuentaLote(l)}${!solo ? ` · <span class="mut">${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}</span>` : ''}${l.deshecho ? ' <span class="tag">deshecho</span>' : ''}${l.errores ? ` <span class="tag baja">${l.errores} avisos</span>` : ''}${l.ejemplos.length ? `<details><summary>ejemplos</summary><div class="cell-small">${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('<br>')}</div></details>` : ''}</span>${l.deshecho || (l.lote || '').startsWith('entrada-') || (l.lote || '').startsWith('tanda-') ? '<span></span>' : `<button data-l="${esc(l.lote)}">Deshacer</button>`}</div>`).join('')).join('') || '<div class="mut">Todavía no se ha movido nada.</div>');
$$('#historial button[data-l]').forEach(b => b.onclick = () => deshacer(b.dataset.l));
const ba = $('#btnBorrarAvisos'); if (ba) ba.onclick = () => { try { localStorage.removeItem(AVISOS_CLAVE); } catch (e) {} cargarBit().catch(e => aviso(e.message, 'bad')); };
}