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

@@ -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):