diff --git a/README.md b/README.md index 77ffcf3..297a66e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/organizador.py b/organizador.py index 736f819..86d7655 100644 --- a/organizador.py +++ b/organizador.py @@ -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("&", "&").replace("<", "<").replace(">", ">").replace("'", "'") + 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('{esc_(titulo)}{esc_(texto)}')\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): diff --git a/prueba_funcional.py b/prueba_funcional.py index 47a6d80..400edd7 100644 --- a/prueba_funcional.py +++ b/prueba_funcional.py @@ -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), diff --git a/static/index.html b/static/index.html index 1ad7456..9eddae4 100644 --- a/static/index.html +++ b/static/index.html @@ -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 @@
+
+
Cuándo trabaja la cola y qué mete sola
Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.
@@ -723,6 +751,9 @@

Convertidor

+
Cuándo trabaja la cola
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. 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.
+
Resumen de la tanda
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.
+
Marcar con criterio
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.
Comparar
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. 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.
Buscar clips
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. 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.
Formato y calidad
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.
@@ -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 => `
${esc(f)}
`).join('') + (d.frases.length > 3 ? `
Más en Revisar y ordenar.
` : ''); } const lotes = CARPETA.lotes || []; - $('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `
${esc(fechaCorta(l.ts))}${esc(queSeHizo(l.lote))} · ${l.movimientos} mov.${l.errores ? ` · ${l.errores} avisos` : ''}${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-')) ? '' : ``}
`).join('') : 'Todavía no se ha movido nada en esta carpeta.'; + $('#inicioLotes').innerHTML = lotes.length ? lotes.map(l => `
${esc(fechaCorta(l.ts))}${esc(queSeHizo(l.lote))} · ${cuentaLote(l, true)}${l.errores ? ` · ${l.errores} avisos` : ''}${l.lote && (l.lote.startsWith('optimizar') || l.lote.startsWith('entrada-') || l.lote.startsWith('tanda-')) ? '' : ``}
`).join('') : 'Todavía no se ha movido nada en esta carpeta.'; $$('#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 = `
La cola está en pausa${esc(d.cola.esperando)} · ${pend} en espera · se cambia en «Cuándo trabaja la cola»
`; + } 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 = `
Convirtiendo ${Math.min(hechosT + 1, total)} de ${total}${eta && eta.seg ? `quedan unas ${tiempoLargo(eta.seg)} · termina hacia las ${hora(eta.termina)}${eta.medido ? ` · va a ${eta.vel.toFixed(1)}× tiempo real` : ' · estimado, hasta que acabe el primero'}` : 'calculando cuánto falta…'}
${corr ? `ahora: ${esc((corr.ruta || '').split(/[\\/]/).pop())} · ${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…'}
`; @@ -1744,6 +1796,16 @@ async function cargarCola(){ mo = `
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.
`; } $('#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 = `
✓Tanda terminada${ul.cliente ? ' · ' + esc(ul.cliente) : ''}${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)}Exportar CSV${ul.raiz ? '' : ''}
+
${ul.clips}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}` : ''}
${gb(ul.bytes_in)} → ${gb(ul.bytes_out)}${ul.bytes_out ? Math.round(ul.bytes_in / ul.bytes_out) + ' veces menos' : ''}
${gb(Math.max(0, ul.bytes_in - ul.bytes_out))}${ul.reemplazados === ul.clips ? 'liberados' : 'se liberan al reemplazar'}
${ul.nota ? ul.nota + ' / 100' : '—'}${ul.nota ? `nota media · la peor, ${ul.nota_min}` : 'sin medir: «comparar» mide'}
${(ul.fallos || 0) + (ul.descartados || 0)}${ul.fallos ? ul.fallos + ' fallaron' : ''}${ul.fallos && ul.descartados ? ' · ' : ''}${ul.descartados ? ul.descartados + ' no compensaban' : ''}${!ul.fallos && !ul.descartados ? 'sin fallos' : ''}
+ ${carps.length ? `
Por carpeta${carps.map(c => `
${esc((c.carpeta || '').split(/[\\/]/).filter(Boolean).join(' › ') || '(raíz)')}
${c.clips} clip${c.clips !== 1 ? 's' : ''} · ${gb(c.bytes_in)} → ${gb(c.bytes_out)}
`).join('')}${(ul.carpetas || []).length > 3 ? `y ${ul.carpetas.length - 3} carpeta${ul.carpetas.length - 3 > 1 ? 's' : ''} más: están en el CSV` : ''}
` : ''}
`; + 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 += `
marcar${cr.map(x => ``).join('')}
`; + if (nSel) html += `
${nSel} marcado${nSel > 1 ? 's' : ''} · ${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))}
`; + } else if (busca || !CAND.length) html += `
`; html += `
${CAND.length ? `` : ''}ver${VER.filter(x => x[2]).map(([k, t, n]) => ``).join('')}ordenar por${ORD.map(([k, t]) => ``).join('')}
`; const tope = lista.slice(0, 800); if (CONV_ORDEN.campo === 'carpeta') { @@ -1778,7 +1846,7 @@ async function cargarCola(){ if (ks.length) html += `
Excluidos · ${cuenta.no || 0}no se ofrecen, y por qué:${ks.map(k => ``).join('')}${CONV_EXC ? '' : ''}
`; } $('#convLista').innerHTML = html || 'Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.'; - $$('#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 = ` +
+
dea· fuera de ese horario la cola espera entre clip y clip, sin perder lo que iba
+ +
0 h12 h24 h
+
+
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.
+
DaVinci ResolvePremiere ProAfter Effects
+
+
en la pasada automática, mete en cola lo de más demeses, hastaGB por pasada
+
Va con el orden automático de la pestaña Carpeta (la tarea de Windows de cada noche). Convierte y verifica; no reemplaza, salvo que marques «y reemplazar solo cuando estén verificados».
+
+
Un aviso de Windows cuando acabe la tanda, o si falla, aunque la ventana esté cerrada.
+
OrganiLAB terminó
212 clips convertidos y verificados en 7 h 12 min. Falta reemplazar.
`; + 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 ? `
Avisos recientes
` + av.slice(0, 20).map(a => `
${hora(a.ts)}${esc(a.texto)}${!solo && a.raiz ? ` · ${esc(a.raiz.split(/[\\/]/).filter(Boolean).pop())}` : ''}${a.detalle ? `
detalle
${esc(a.detalle)}
` : ''}
`).join('') : ''; - $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `
${esc(titulo(k))}
` + dias[k].map(l => `
${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${l.movimientos} movimiento${l.movimientos !== 1 ? 's' : ''}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
ejemplos
${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
')}
` : ''}
${l.deshecho || (l.lote || '').startsWith('entrada-') ? '' : ``}
`).join('')).join('') || '
Todavía no se ha movido nada.
'); + $('#historial').innerHTML = avHtml + (Object.keys(dias).map(k => `
${esc(titulo(k))}
` + dias[k].map(l => `
${hora(l.ts)}${esc(queSeHizo(l.lote))} · ${cuentaLote(l)}${!solo ? ` · ${esc((l.raiz || '').split(/[\\/]/).filter(Boolean).pop() || l.raiz)}` : ''}${l.deshecho ? ' deshecho' : ''}${l.errores ? ` ${l.errores} avisos` : ''}${l.ejemplos.length ? `
ejemplos
${l.ejemplos.map(e => esc(e.de.split(/[\\/]/).slice(-2).join('/')) + ' → ' + esc(e.a.split(/[\\/]/).slice(-3).join('/'))).join('
')}
` : ''}
${l.deshecho || (l.lote || '').startsWith('entrada-') || (l.lote || '').startsWith('tanda-') ? '' : ``}
`).join('')).join('') || '
Todavía no se ha movido nada.
'); $$('#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')); }; }