Convertir: lista por carpetas con cuánto falta, «comparar» con nota de calidad, y un botón principal según el momento
Munka eligió tres de seis propuestas dibujadas en un lienzo (quedan el horario nocturno, el resumen al acabar y marcar con criterio): - La lista va por carpetas (la ruta una sola vez), el clip que se convierte lleva su barra y lo que le queda, «primero este» adelanta uno, y los excluidos salen en grupos con su porqué. Antes y después en una barra. La cola mide su ritmo y dice cuánto falta y a qué hora acaba (eta en /api/optimizar/cola). - «Comparar»: el mismo cuadro del original y del convertido en tres instantes, con lupa, y «Medir la calidad» (VMAF) que se guarda en el trabajo; braw.py cuadro y braw.py calidad revelan el BRAW con la misma curva. Medido: 97,8 en 14 s con un BRAW de 16,5 GB. - Un solo botón grande según el momento: buscar, convertir (con «reemplazar solo cuando estén verificados», optimizar.auto_reemplazar), la tanda con Detener, reanudar, o reemplazar lo verificado. Pruebas: probar 19, prueba_funcional 31 (nueva: comparar), prueba_ventana 18. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
41
README.md
41
README.md
@@ -913,19 +913,42 @@ 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, una sola lista.** 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; el que se está convirtiendo lleva la barra de avance en su
|
||||
propia casilla de estado («62 %», la fila resaltada) y debajo qué hace el lector. Arriba: «ver» (todos, por
|
||||
convertir, en cola, terminados, con fallo, excluidos, con su cuenta) y «ordenar por» (estado, nombre, fecha,
|
||||
tamaño, ahorro; otra vez invierte). Lo convertido muestra el peso real (16,5 GB → 214 MB, −99 %) en vez del
|
||||
estimado.
|
||||
- **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):
|
||||
- *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,
|
||||
con casilla y plegable; las carpetas con algo que hacer van primero) y con solo el nombre del clip en la
|
||||
fila. El que se está convirtiendo lleva la barra en su casilla de estado («62 %», la fila resaltada) y
|
||||
«2 min 26 s de 3 min 56 s · quedan 1 min 40 s»; los que esperan tienen «primero este». Arriba, «ver» y
|
||||
«ordenar por» (carpeta, estado, nombre, fecha, tamaño, ahorro). Abajo, los excluidos en grupos con su
|
||||
porqué (en Diana Méndez: 473 son stock de Envato, 26 exportados, 2 ya comprimidos, 1 con alfa), y cada
|
||||
grupo se abre con un clic.
|
||||
- *Antes y después, en una barra* en vez de cuatro cifras: 676 GB hoy → 40 GB, lo que se libera, y lo ya
|
||||
ganado en los convertidos.
|
||||
- *Tiempo que falta.* La cola mide su ritmo con los clips ya acabados en la tanda (segundos de clip por
|
||||
segundo real; si aún no acabó ninguno, con el avance del que va) y dice «quedan unas 7 h 10 min · termina
|
||||
hacia las 19:40 · va a 0,9× tiempo real», también en la barra de tareas. Sale de `/api/optimizar/cola`
|
||||
(`eta`).
|
||||
- *Un botón principal según el momento*: «Buscar clips que se puedan aligerar», «Convertir N clips · A →
|
||||
≈ 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».
|
||||
- *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,
|
||||
y la guarda en el trabajo (`nota`, `nota_min`), que luego sale en la fila y como media en el panel. Un
|
||||
BRAW se revela con la misma curva que la conversión (`braw.py cuadro` y `braw.py calidad`, en el proceso
|
||||
aparte); el original ya reemplazado se busca en `_Para borrar/originales`. Medido con el clip de 16,5 GB
|
||||
de Diana Méndez: nota 97,8 (peor cuadro 97) en 14 s. Desde ahí se reemplaza ese original solo.
|
||||
- En «Añadir carpeta», la clase de trabajo se elige entre cuatro tarjetas (título, cómo se ordena y «como
|
||||
David Reyes, Danney Gómez y 3 más», los clientes que ya van así) en vez de un desplegable, y el ejemplo de
|
||||
cómo queda es un árbol de carpetas, no una lista de rutas completas.
|
||||
|
||||
Pruebas: `prueba_funcional.py` comprueba además que el avance acaba en «tantas de tantas» con los movimientos
|
||||
de la respuesta; `probar.py` 19, `prueba_orden.py` 33, `prueba_funcional.py` 30 y `prueba_ventana.py` 18.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
110
braw.py
110
braw.py
@@ -609,6 +609,110 @@ def convertir(t):
|
||||
return 1
|
||||
|
||||
|
||||
def _tubo_a_ffmpeg(clip, cmd, desde, hasta, errores):
|
||||
"""Le pasa a ffmpeg los cuadros [desde, hasta) por el tubo y espera a que acabe; devuelve el código."""
|
||||
ff = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=errores, creationflags=SIN_VENTANA)
|
||||
try:
|
||||
for _, donde, tam in clip.cuadros_en_orden(desde, hasta):
|
||||
try:
|
||||
ff.stdin.write((ctypes.c_ubyte * tam).from_address(donde))
|
||||
except OSError:
|
||||
break # ffmpeg ya tiene lo que necesitaba (-frames:v) o se cayó
|
||||
try:
|
||||
ff.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
return ff.wait()
|
||||
finally:
|
||||
if ff.poll() is None:
|
||||
ff.kill()
|
||||
ff.wait(30)
|
||||
|
||||
|
||||
def cuadro(t):
|
||||
"""Un solo cuadro del clip, revelado con la misma curva que la conversión y guardado como JPEG: para
|
||||
poner el original al lado del convertido en «comparar». `t`: src, segundo, salida, ffmpeg, color, ancho."""
|
||||
clip = None
|
||||
errores = tempfile.TemporaryFile()
|
||||
try:
|
||||
clip = Clip(t["src"], t.get("sdk") or "", t.get("hilos") or 0)
|
||||
clip.poner_color(t.get("color") or "rec709")
|
||||
if not clip.cuadros or not clip.fps:
|
||||
raise OSError("el clip no tiene imagen")
|
||||
idx = max(0, min(clip.cuadros - 1, int(round(float(t.get("segundo") or 0) * clip.fps))))
|
||||
# misma matriz que la conversión (Rec.709) y rango completo, que es lo que espera un JPEG
|
||||
vf = "scale=in_range=full:out_range=full:out_color_matrix=bt709:flags=bicubic+accurate_rnd" + (f",scale={int(t['ancho'])}:-2:flags=bicubic" if t.get("ancho") else "") + ",format=yuvj420p"
|
||||
cmd = [t["ffmpeg"], "-hide_banner", "-nostdin", "-y", "-loglevel", "error",
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb48le", "-video_size", f"{clip.ancho}x{clip.alto}", "-framerate", fraccion(clip.fps), "-i", "pipe:0",
|
||||
"-vf", vf, "-frames:v", "1", "-q:v", "2", "-f", "image2", t["salida"]]
|
||||
codigo = _tubo_a_ffmpeg(clip, cmd, idx, idx + 1, errores)
|
||||
if codigo != 0 or not os.path.exists(t["salida"]):
|
||||
raise OSError(_cola(errores) or f"ffmpeg terminó con el código {codigo}")
|
||||
emitir(estado="listo", cuadro=idx, segundo=round(idx / clip.fps, 3), **clip.datos())
|
||||
return 0
|
||||
except Exception as ex: # noqa
|
||||
emitir(estado="error", log=str(ex))
|
||||
return 1
|
||||
finally:
|
||||
if clip:
|
||||
try:
|
||||
clip.cerrar()
|
||||
except Exception: # noqa
|
||||
pass
|
||||
errores.close()
|
||||
|
||||
|
||||
def calidad(t):
|
||||
"""Nota de calidad (VMAF, de 0 a 100) del convertido contra el BRAW revelado sin pérdida, sobre un tramo de
|
||||
cuadros [desde, desde + cuadros). Los dos se miran a la misma anchura (`ancho`, 1920 por defecto: el modelo
|
||||
de VMAF está pensado para 1080p). `t`: src, conv, desde, cuadros, ffmpeg, color, log (archivo JSON que
|
||||
escribe libvmaf, en la carpeta de trabajo `cwd`), hilos."""
|
||||
clip = None
|
||||
errores = tempfile.TemporaryFile()
|
||||
try:
|
||||
clip = Clip(t["src"], t.get("sdk") or "", t.get("hilos") or 0)
|
||||
clip.poner_color(t.get("color") or "rec709")
|
||||
if not clip.cuadros or not clip.fps:
|
||||
raise OSError("el clip no tiene imagen")
|
||||
desde = max(0, min(clip.cuadros - 1, int(t.get("desde") or 0)))
|
||||
hasta = min(clip.cuadros, desde + max(1, int(t.get("cuadros") or 1)))
|
||||
ancho = int(t.get("ancho") or 1920)
|
||||
hilos = int(t.get("vmaf_hilos") or 8)
|
||||
# la referencia pasa por la misma conversión de color que la conversión (RGB entero → YCbCr Rec.709 de
|
||||
# vídeo); así lo único que separa a los dos es lo que perdió el codificador
|
||||
ref = f"[0:v]scale=in_range=full:out_range=limited:out_color_matrix=bt709:flags=bicubic+accurate_rnd,format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r]"
|
||||
dis = f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d]"
|
||||
fc = f"{ref};{dis};[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={t['log']}"
|
||||
cmd = [t["ffmpeg"], "-hide_banner", "-nostdin", "-y", "-loglevel", "error",
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb48le", "-video_size", f"{clip.ancho}x{clip.alto}", "-framerate", fraccion(clip.fps), "-i", "pipe:0",
|
||||
"-ss", f"{desde / clip.fps:.6f}", "-i", t["conv"], "-filter_complex", fc, "-frames:v", str(hasta - desde), "-an", "-f", "null", "-"]
|
||||
antes = os.getcwd()
|
||||
os.chdir(t.get("cwd") or antes)
|
||||
try:
|
||||
codigo = _tubo_a_ffmpeg(clip, cmd, desde, hasta, errores)
|
||||
finally:
|
||||
os.chdir(antes)
|
||||
log = os.path.join(t.get("cwd") or antes, t["log"])
|
||||
if codigo != 0 or not os.path.exists(log):
|
||||
raise OSError(_cola(errores) or f"ffmpeg terminó con el código {codigo}")
|
||||
with open(log, "r", encoding="utf-8") as fh:
|
||||
j = json.load(fh)
|
||||
v = (j.get("pooled_metrics") or {}).get("vmaf") or {}
|
||||
emitir(estado="listo", vmaf=round(float(v.get("mean") or 0), 1), minimo=round(float(v.get("min") or 0), 1),
|
||||
medidos=len(j.get("frames") or []), desde=desde, **clip.datos())
|
||||
return 0
|
||||
except Exception as ex: # noqa
|
||||
emitir(estado="error", log=str(ex))
|
||||
return 1
|
||||
finally:
|
||||
if clip:
|
||||
try:
|
||||
clip.cerrar()
|
||||
except Exception: # noqa
|
||||
pass
|
||||
errores.close()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
try:
|
||||
@@ -632,10 +736,10 @@ def main(argv=None):
|
||||
for ruta in argv[1:]:
|
||||
emitir(**info(ruta, carpeta))
|
||||
return 0
|
||||
if orden == "convertir" and len(argv) > 1:
|
||||
if orden in ("convertir", "cuadro", "calidad") and len(argv) > 1:
|
||||
with open(argv[1], "r", encoding="utf-8") as fh:
|
||||
return convertir(json.load(fh))
|
||||
emitir(msg="uso: braw.py sonda | info clip.braw [...] | convertir trabajo.json")
|
||||
return {"convertir": convertir, "cuadro": cuadro, "calidad": calidad}[orden](json.load(fh))
|
||||
emitir(msg="uso: braw.py sonda | info clip.braw [...] | convertir trabajo.json | cuadro ficha.json | calidad ficha.json")
|
||||
return 2
|
||||
|
||||
|
||||
|
||||
237
organizador.py
237
organizador.py
@@ -436,7 +436,11 @@ def init_db():
|
||||
"ALTER TABLE trabajos ADD COLUMN formato TEXT DEFAULT 'h265_10bit'",
|
||||
"ALTER TABLE archivos ADD COLUMN pistas_audio INTEGER DEFAULT 1",
|
||||
# con qué versión de las reglas se hizo el inventario (R.VERSION_REGLAS)
|
||||
"ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0"):
|
||||
"ALTER TABLE escaneos ADD COLUMN reglas INTEGER DEFAULT 0",
|
||||
# «primero este»: la cola va por orden descendente y luego por id
|
||||
"ALTER TABLE trabajos ADD COLUMN orden INTEGER DEFAULT 0",
|
||||
# la nota de calidad (VMAF) medida en «comparar»: media y cuadro peor
|
||||
"ALTER TABLE trabajos ADD COLUMN nota REAL", "ALTER TABLE trabajos ADD COLUMN nota_min REAL"):
|
||||
try:
|
||||
con.execute(alter)
|
||||
con.commit()
|
||||
@@ -2844,7 +2848,9 @@ def encolar(rutas, perfil, chroma, color, raiz, formato="h265_10bit"):
|
||||
return ids
|
||||
|
||||
|
||||
COLA = {"activa": False, "actual": None, "cancelar": False, "hilo": None, "mensaje": "", "hechos": 0, "inicio": 0}
|
||||
# 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": []}
|
||||
|
||||
|
||||
def set_trabajo(id_, **kw):
|
||||
@@ -3233,17 +3239,24 @@ def bucle_cola():
|
||||
try:
|
||||
while COLA["activa"]:
|
||||
con = db()
|
||||
t = con.execute("SELECT * FROM trabajos WHERE estado = 'pendiente' AND motor IN ('ffmpeg', 'braw') ORDER BY id LIMIT 1").fetchone()
|
||||
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:
|
||||
COLA["actual"] = t["id"]
|
||||
COLA["empezo"] = time.time()
|
||||
set_trabajo(t["id"], estado="corriendo", pct=0)
|
||||
try:
|
||||
(correr_braw if t["motor"] == "braw" else correr_ffmpeg)(dict(t))
|
||||
except Exception as ex: # noqa
|
||||
set_trabajo(t["id"], estado="error", log=f"fallo inesperado: {ex}")
|
||||
_verificar_listos([t["id"]])
|
||||
_anotar_ritmo(t["id"], time.time() - COLA["empezo"])
|
||||
if CONFIG["optimizar"].get("auto_reemplazar"):
|
||||
try: # solo reemplaza lo verificado; lo demás lo deja como está
|
||||
reemplazar([t["id"]])
|
||||
except Exception as ex: # noqa
|
||||
COLA["mensaje"] = f"no se pudo reemplazar: {ex}"
|
||||
COLA["hechos"] += 1
|
||||
elif lote_resolve:
|
||||
COLA["actual"] = lote_resolve[0]["id"]
|
||||
@@ -3269,6 +3282,180 @@ def bucle_cola():
|
||||
COLA["hilo"] = None
|
||||
|
||||
|
||||
def _anotar_ritmo(id_, segundos):
|
||||
"""Cuánto clip salió por segundo real en este trabajo, si acabó bien: con eso se calcula cuánto falta."""
|
||||
try:
|
||||
con = db()
|
||||
t = con.execute("SELECT estado, ruta FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||||
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||||
con.close()
|
||||
if t and t["estado"] in ("listo", "verificado", "reemplazado", "descartado") and f and (f["dur"] or 0) > 0 and segundos > 0:
|
||||
COLA["ritmo"].append((float(f["dur"]), float(segundos)))
|
||||
except Exception: # noqa
|
||||
pass
|
||||
|
||||
|
||||
def eta_cola(trabajos):
|
||||
"""Cuánto falta para que acabe la cola: segundos de clip pendientes partidos por el ritmo medido (clips ya
|
||||
hechos en esta tanda; si no hay ninguno, el avance del que va; si tampoco, a tiempo real los BRAW y a 2,5×
|
||||
la tarjeta). Devuelve también el clip que va, con lo que le queda."""
|
||||
pend = [t for t in trabajos if t["estado"] in ("pendiente", "corriendo")]
|
||||
if not pend:
|
||||
return None
|
||||
con = db()
|
||||
durs = {}
|
||||
for t in pend:
|
||||
f = con.execute("SELECT dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone()
|
||||
durs[t["id"]] = float((f["dur"] if f else 0) or 0)
|
||||
con.close()
|
||||
ritmo = COLA.get("ritmo") or []
|
||||
clip_s, real_s = sum(d for d, _ in ritmo), sum(s for _, s in ritmo)
|
||||
vel, medido = (clip_s / real_s if clip_s > 0 and real_s > 0 else 0.0), "tanda" if ritmo else ""
|
||||
falta, actual, ahora = 0.0, None, time.time()
|
||||
for t in pend:
|
||||
d = durs[t["id"]]
|
||||
if t["estado"] == "corriendo":
|
||||
pct = float(t["pct"] or 0)
|
||||
resto = d * max(0.0, 1 - pct / 100)
|
||||
falta += resto
|
||||
llevado = ahora - (COLA.get("empezo") or ahora)
|
||||
if not vel and pct >= 3 and llevado > 5:
|
||||
vel, medido = (d * pct / 100) / llevado, "clip"
|
||||
actual = {"id": t["id"], "dur": d, "resto": resto, "llevado": llevado}
|
||||
else:
|
||||
falta += d
|
||||
if not vel:
|
||||
vel = 1.0 if any(t["motor"] == "braw" for t in pend) else 2.5
|
||||
seg = falta / vel if falta else 0.0
|
||||
if actual:
|
||||
actual["resto_seg"] = actual["resto"] / vel
|
||||
return {"clip_seg": round(falta), "seg": round(seg), "vel": round(vel, 2), "medido": medido, "termina": ahora + seg if seg else 0,
|
||||
"actual": actual, "en_espera": sum(1 for t in pend if t["estado"] == "pendiente"), "hechos": len(ritmo)}
|
||||
|
||||
|
||||
def _ruta_original(t):
|
||||
"""Dónde está hoy el original de un trabajo: en su sitio o, si ya se reemplazó, en _Para borrar/originales."""
|
||||
if os.path.isfile(t["ruta"]):
|
||||
return t["ruta"]
|
||||
try:
|
||||
otra = os.path.join(t["raiz"], R.PAPELERA, "originales", os.path.relpath(t["ruta"], t["raiz"]))
|
||||
except ValueError:
|
||||
return ""
|
||||
return otra if os.path.isfile(otra) else ""
|
||||
|
||||
|
||||
def cuadro_de(id_, cual, segundo, ancho=1600):
|
||||
"""Un cuadro (JPEG) del original o del convertido de un trabajo, en el segundo pedido; se guarda en
|
||||
datos/cuadros para no sacarlo dos veces. Devuelve la ruta del JPEG o lanza ValueError."""
|
||||
con = db()
|
||||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||||
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||||
con.close()
|
||||
if not t:
|
||||
raise ValueError("no existe ese trabajo")
|
||||
segundo = max(0.0, float(segundo or 0))
|
||||
carpeta = os.path.join(DATOS, "cuadros")
|
||||
os.makedirs(carpeta, exist_ok=True)
|
||||
salida = os.path.join(carpeta, f"{id_}_{'o' if cual == 'original' else 'c'}_{int(round(segundo * 10))}.jpg")
|
||||
if os.path.isfile(salida) and os.path.getsize(salida) > 0:
|
||||
return salida
|
||||
if cual == "original":
|
||||
src = _ruta_original(dict(t))
|
||||
if not src:
|
||||
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
|
||||
else:
|
||||
src = t["salida"]
|
||||
if not os.path.isfile(src):
|
||||
raise ValueError("el convertido ya no está donde se hizo")
|
||||
if cual == "original" and t["motor"] == "braw":
|
||||
dec = decodificador_braw()
|
||||
if not dec.get("ok"):
|
||||
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||||
ficha = os.path.join(DATOS, f"cuadro_{id_}.json")
|
||||
guardar_json(ficha, {"src": src, "segundo": segundo, "salida": salida, "ffmpeg": ruta_herramienta("ffmpeg"),
|
||||
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho})
|
||||
try:
|
||||
ev = [e for e in _lector_braw("cuadro", ficha, espera=180) if e.get("estado")]
|
||||
finally:
|
||||
try:
|
||||
os.remove(ficha)
|
||||
except OSError:
|
||||
pass
|
||||
if not ev or ev[-1].get("estado") != "listo":
|
||||
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo sacar el cuadro")
|
||||
else:
|
||||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", f"{segundo:.3f}", "-i", src,
|
||||
"-frames:v", "1", "-vf", f"scale={ancho}:-2:flags=bicubic,format=yuvj420p", "-q:v", "2", "-f", "image2", salida]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, creationflags=SIN_VENTANA)
|
||||
if r.returncode != 0 or not os.path.isfile(salida):
|
||||
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo sacar el cuadro")
|
||||
return salida
|
||||
|
||||
|
||||
def calidad_de(id_, segundos=10, ancho=1920):
|
||||
"""Nota de calidad (VMAF, 0 a 100) del convertido de un trabajo contra su original, sobre un tramo del medio
|
||||
del clip. Se guarda en el trabajo (nota, nota_min). Lanza ValueError si no se puede."""
|
||||
con = db()
|
||||
t = con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone()
|
||||
f = con.execute("SELECT fps, dur FROM archivos WHERE ruta = ?", (t["ruta"],)).fetchone() if t else None
|
||||
con.close()
|
||||
if not t:
|
||||
raise ValueError("no existe ese trabajo")
|
||||
src = _ruta_original(dict(t))
|
||||
if not src:
|
||||
raise ValueError("el original ya no está (ni en su sitio ni en _Para borrar/originales)")
|
||||
if not os.path.isfile(t["salida"]):
|
||||
raise ValueError("el convertido ya no está donde se hizo")
|
||||
fps = float((f["fps"] if f else 0) or 0) or 25.0
|
||||
dur = float((f["dur"] if f else 0) or 0)
|
||||
total = int(dur * fps) if dur else 0
|
||||
n = max(24, min(int(segundos * fps), 300, total or 300))
|
||||
desde = max(0, (total - n) // 2) if total else 0
|
||||
hilos = max(2, min(16, (os.cpu_count() or 8) - 2))
|
||||
carpeta = os.path.join(DATOS, "cuadros")
|
||||
os.makedirs(carpeta, exist_ok=True)
|
||||
log = f"calidad_{id_}.json"
|
||||
t0 = time.time()
|
||||
if t["motor"] == "braw":
|
||||
dec = decodificador_braw()
|
||||
if not dec.get("ok"):
|
||||
raise ValueError(dec.get("motivo") or "no está el decodificador de Blackmagic RAW")
|
||||
ficha = os.path.join(DATOS, f"calidad_{id_}.ficha.json")
|
||||
guardar_json(ficha, {"src": src, "conv": t["salida"], "desde": desde, "cuadros": n, "ffmpeg": ruta_herramienta("ffmpeg"),
|
||||
"color": t["color"] or "rec709", "sdk": dec.get("carpeta") or "", "ancho": ancho, "log": log, "cwd": carpeta, "vmaf_hilos": hilos})
|
||||
try:
|
||||
ev = [e for e in _lector_braw("calidad", ficha, espera=900) if e.get("estado")]
|
||||
finally:
|
||||
try:
|
||||
os.remove(ficha)
|
||||
except OSError:
|
||||
pass
|
||||
if not ev or ev[-1].get("estado") != "listo":
|
||||
raise ValueError((ev[-1].get("log") if ev else "") or "el lector de BRAW no pudo medir")
|
||||
nota, minimo, cuadros = ev[-1]["vmaf"], ev[-1]["minimo"], ev[-1]["medidos"]
|
||||
else:
|
||||
fc = (f"[0:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[r];"
|
||||
f"[1:v]format=yuv420p10le,scale={ancho}:-2:flags=bicubic,setpts=N/FRAME_RATE/TB[d];"
|
||||
f"[d][r]libvmaf=n_threads={hilos}:log_fmt=json:log_path={log}")
|
||||
ss = f"{desde / fps:.6f}"
|
||||
cmd = [ruta_herramienta("ffmpeg"), "-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", ss, "-i", src, "-ss", ss, "-i", t["salida"],
|
||||
"-filter_complex", fc, "-frames:v", str(n), "-an", "-f", "null", "-"]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=900, cwd=carpeta, creationflags=SIN_VENTANA)
|
||||
ruta_log = os.path.join(carpeta, log)
|
||||
if r.returncode != 0 or not os.path.isfile(ruta_log):
|
||||
raise ValueError((r.stderr or "").strip()[-300:] or "ffmpeg no pudo medir (¿este ffmpeg trae libvmaf?)")
|
||||
with open(ruta_log, "r", encoding="utf-8") as fh:
|
||||
j = json.load(fh)
|
||||
v = (j.get("pooled_metrics") or {}).get("vmaf") or {}
|
||||
nota, minimo, cuadros = round(float(v.get("mean") or 0), 1), round(float(v.get("min") or 0), 1), len(j.get("frames") or [])
|
||||
try:
|
||||
os.remove(os.path.join(carpeta, log))
|
||||
except OSError:
|
||||
pass
|
||||
set_trabajo(id_, nota=nota, nota_min=minimo)
|
||||
return {"id": id_, "vmaf": nota, "minimo": minimo, "cuadros": cuadros, "desde_seg": round(desde / fps, 2), "seg": round(time.time() - t0, 1)}
|
||||
|
||||
|
||||
def recuperar_huerfanas():
|
||||
"""Trabajos que quedaron «corriendo» sin una cola viva: el programa se cerró (o se actualizó) a mitad de
|
||||
una conversión y nadie volvía a tocarlos. Bloqueaban «Desconectar», no salían en «En curso» y el paso
|
||||
@@ -3338,6 +3525,7 @@ def iniciar_cola():
|
||||
COLA["cancelar"] = False
|
||||
COLA["hechos"] = 0
|
||||
COLA["inicio"] = time.time()
|
||||
COLA["ritmo"] = []
|
||||
COLA["hilo"] = threading.Thread(target=bucle_cola, daemon=True)
|
||||
COLA["hilo"].start()
|
||||
return True
|
||||
@@ -5665,9 +5853,35 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json(motores())
|
||||
if ruta == "/api/optimizar/cola":
|
||||
con = db()
|
||||
filas = [dict(r) for r in con.execute("SELECT * FROM trabajos ORDER BY id DESC LIMIT 500").fetchall()]
|
||||
# con la duración y los fps del clip, para decir cuánto va de cada uno y para «comparar»
|
||||
filas = [dict(r) for r in con.execute("SELECT t.*, a.dur AS dur, a.fps AS fps FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
|
||||
"ORDER BY t.id DESC LIMIT 500").fetchall()]
|
||||
for f in filas: # un original ya reemplazado vive en _Para borrar/originales: su ficha está ahí
|
||||
if f.get("dur") is None and f["estado"] == "reemplazado":
|
||||
otra = _ruta_original(f)
|
||||
a = con.execute("SELECT dur, fps FROM archivos WHERE ruta = ?", (otra,)).fetchone() if otra else None
|
||||
if a:
|
||||
f["dur"], f["fps"] = a["dur"], a["fps"]
|
||||
con.close()
|
||||
return self._json({"trabajos": filas, "cola": {"activa": COLA["activa"], "actual": COLA["actual"], "mensaje": COLA["mensaje"]}})
|
||||
try:
|
||||
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 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))
|
||||
except (ValueError, TypeError) as ex:
|
||||
return self._json({"error": str(ex)}, 400)
|
||||
with open(jpg, "rb") as fh:
|
||||
cuerpo = fh.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/jpeg")
|
||||
self.send_header("Content-Length", str(len(cuerpo)))
|
||||
self.send_header("Cache-Control", "private, max-age=86400")
|
||||
self.end_headers()
|
||||
self.wfile.write(cuerpo)
|
||||
return
|
||||
if ruta == "/api/stock":
|
||||
tipos = [t for t in (q.get("tipos") or "Video").split(",") if t]
|
||||
return self._json(stock_viejo(os.path.normpath(q["raiz"]), q.get("meses", CONFIG["optimizar"]["meses_minimos"]), tipos))
|
||||
@@ -5757,7 +5971,7 @@ 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"},
|
||||
permitidas = {"optimizar": {"meses_minimos", "min_mb", "perfil", "formato", "chroma", "color", "salida", "sufijo", "auto_reemplazar"},
|
||||
"respaldo": {"destino"}, "actualizaciones": {"auto"}}
|
||||
for k, v in d.items():
|
||||
if k in permitidas and isinstance(v, dict):
|
||||
@@ -5879,6 +6093,12 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
con.execute(f"DELETE FROM trabajos WHERE estado IN ('pendiente','error','cancelado','reemplazado','descartado') AND id IN ({','.join('?' * len(ids))})", ids)
|
||||
con.close()
|
||||
return self._json({"ok": True, "apartados": ap})
|
||||
if acc == "primero": # «primero este»: pasa delante de los que esperan
|
||||
con = db()
|
||||
with con:
|
||||
con.execute("UPDATE trabajos SET orden = (SELECT COALESCE(MAX(orden), 0) + 1 FROM trabajos) WHERE id = ? AND estado = 'pendiente'", (int(d.get("id") or 0),))
|
||||
con.close()
|
||||
return self._json({"ok": True})
|
||||
if acc == "reintentar":
|
||||
con = db()
|
||||
with con:
|
||||
@@ -5887,6 +6107,11 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
return self._json({"ok": True})
|
||||
if ruta == "/api/optimizar/verificar":
|
||||
return self._json({"resultados": verificar(d.get("ids", []))})
|
||||
if ruta == "/api/optimizar/calidad": # la nota VMAF de un convertido: tarda unos segundos
|
||||
try:
|
||||
return self._json(calidad_de(int(d.get("id") or 0)))
|
||||
except (ValueError, TypeError, subprocess.TimeoutExpired) as ex:
|
||||
return self._json({"error": str(ex)}, 400)
|
||||
if ruta == "/api/optimizar/reemplazar":
|
||||
return self._json({"resultados": reemplazar(d.get("ids", []))})
|
||||
if ruta == "/api/stock/papelera":
|
||||
|
||||
@@ -371,6 +371,18 @@ def p_convertir():
|
||||
return '%s: convertido (%s), verificado y reemplazado; original apartado' % (fmt, t['motor'])
|
||||
|
||||
|
||||
def p_comparar():
|
||||
# el original ya se reemplazó (está en _Para borrar/originales): el cuadro tiene que salir de ahí
|
||||
for cual in ('original', 'convertido'):
|
||||
r = urllib.request.urlopen(BASE + '/api/optimizar/cuadro' + q(id=IDS['trabajo'], cual=cual, t=1.5), timeout=120)
|
||||
assert r.headers.get('Content-Type') == 'image/jpeg' and len(r.read()) > 2000, 'no salió el cuadro %s' % cual
|
||||
c, r = post('/api/optimizar/calidad', {'id': IDS['trabajo']})
|
||||
assert c == 200 and 30 < r['vmaf'] <= 100 and r['cuadros'] > 10, r
|
||||
t = next(x for x in get('/api/optimizar/cola')['trabajos'] if x['id'] == IDS['trabajo'])
|
||||
assert t['nota'] == r['vmaf'] and t.get('dur'), t
|
||||
return 'dos cuadros JPEG y nota VMAF %s sobre %d cuadros (%.0f s)' % (r['vmaf'], r['cuadros'], r['seg'])
|
||||
|
||||
|
||||
def p_braw():
|
||||
m = get('/api/optimizar/motores')['braw']
|
||||
if not BRAW:
|
||||
@@ -540,7 +552,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), ('Convertir un BRAW', p_braw), ('Control de la cola', p_cola_control),
|
||||
('Convertir, verificar, reemplazar', p_convertir), ('Comparar original y convertido', p_comparar), ('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),
|
||||
|
||||
@@ -345,7 +345,47 @@
|
||||
.nombrar li .r{font-size:11px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
details.mas>summary{cursor:pointer;color:var(--mut);font-size:12.5px;margin:8px 0}
|
||||
/* convertir */
|
||||
.cfila{display:grid;grid-template-columns:24px 110px 1fr 150px 80px;gap:10px;padding:7px 0;border-bottom:1px solid var(--line2);font-size:12.5px;align-items:center}
|
||||
.cfila{display:grid;grid-template-columns:24px 110px minmax(0,1fr) 150px 70px 110px;gap:10px;padding:7px 0;border-bottom:1px solid var(--line2);font-size:12.5px;align-items:center}
|
||||
.cfila .acts{text-align:right;white-space:nowrap}
|
||||
.cfila .acts button{background:none;border:0;color:var(--acc);font-size:12px;font-weight:600;padding:0 4px;cursor:pointer}
|
||||
.cfila .nom .sub2 .carp{font-family:ui-monospace,Consolas,monospace}
|
||||
.cgrupo{display:flex;gap:10px;align-items:center;padding:12px 0 6px;font-size:12.5px;border-top:1px solid var(--line);margin-top:6px}
|
||||
.cgrupo.primero{border-top:0;margin-top:0}
|
||||
.cgrupo .pl{background:none;border:0;padding:0;color:var(--mut);width:14px;cursor:pointer;display:inline-flex}
|
||||
.cgrupo .cab{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:700;color:var(--mut);white-space:nowrap}
|
||||
.cgrupo .cab.corre{color:var(--acc)}
|
||||
.excl{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:12px 0 4px;border-top:1px dashed var(--line);margin-top:6px;font-size:12.5px;color:var(--mut)}
|
||||
.excl button{background:var(--panel2);border:1px solid var(--line);border-radius:99px;padding:1px 9px;font-size:12px;color:var(--tx);cursor:pointer}
|
||||
.excl button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)}
|
||||
.excl button.ojo{background:var(--warn2);color:var(--warn)}
|
||||
.momento{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:8px}
|
||||
.momento.corre{border-color:var(--acc)} .momento.listo{border-color:var(--ok)}
|
||||
.momento .l1{display:flex;gap:12px;align-items:center;flex-wrap:wrap;font-size:13.5px}
|
||||
.momento .l3{font-size:12.5px}
|
||||
.momento .bar.grande{height:10px}
|
||||
.momento button.grande{font-size:14px;padding:9px 16px;border-radius:6px;font-weight:700}
|
||||
.momento .ico{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;font-size:13px}
|
||||
.antes{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 16px;margin-bottom:12px;display:flex;flex-direction:column;gap:6px}
|
||||
.antes .l1,.antes .l3{display:flex;gap:16px;align-items:center;flex-wrap:wrap;font-size:12.5px}
|
||||
.antes .l2{display:flex;gap:12px;align-items:center}
|
||||
.antes .l2 b{font-size:22px;white-space:nowrap}.antes .l2 b.acc{color:var(--acc)}
|
||||
.antes .pista{flex:1;height:18px;background:var(--panel2);border-radius:99px;overflow:hidden;display:flex}
|
||||
.antes .pista i.conv{display:block;height:100%;background:var(--acc);border-radius:99px 0 0 99px}
|
||||
.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}
|
||||
.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}
|
||||
.cmp .marco img{width:100%;height:100%;object-fit:contain;display:block;cursor:crosshair}
|
||||
.cmp .lupa{position:absolute;width:220px;height:220px;border:2px solid var(--acc);border-radius:8px;background-repeat:no-repeat;background-color:#000;pointer-events:none;display:none;box-shadow:0 4px 14px rgba(0,0,0,.3)}
|
||||
.cmp .marco.con .lupa{display:block}
|
||||
.cmpnota{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:12px}
|
||||
.cmpnota>div{border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-size:12.5px;display:flex;flex-direction:column;gap:4px}
|
||||
.cmpnota b.n{font-size:26px;color:var(--ok)} .cmpnota b.n.baja{color:var(--warn)}
|
||||
.cmpt{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:12.5px;color:var(--mut);margin-top:10px}
|
||||
.cmpt button{font-size:12px;padding:2px 8px}
|
||||
.cmpt button.on{background:var(--acc2);border-color:var(--acc);color:var(--acc)}
|
||||
.cfila:last-child{border-bottom:0}
|
||||
.cfila .nom{min-width:0}
|
||||
.cfila .nom .clip{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
|
||||
@@ -581,7 +621,8 @@
|
||||
</div></details></span></div>
|
||||
<p class="ayuda">Convierte los clips de proyectos ya entregados a un formato más ligero. <b>El original no se toca</b> hasta que reemplaces. <span class="q">?</span><span class="mas">Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a <span class="path">_Para borrar/originales</span> y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW se leen con el decodificador de Blackmagic que instala DaVinci Resolve, sin tener que abrirlo; todo se codifica con ffmpeg, con la tarjeta gráfica cuando la hay. Lo que ya viene comprimido de cámara (H.264 o H.265 de pocos megabits) no se ofrece: ahorraría poco y perdería calidad. Nada se borra.</span></p>
|
||||
<div class="toolbar" style="margin-bottom:10px"><label>formato de salida <select id="optFormato"></select></label><span id="fmtQue" class="mut" style="font-size:12px"></span><span class="sep"></span><span id="motores" class="mut" style="font-size:12px"><span class="spin"></span> detectando GPU y CPU…</span></div>
|
||||
<div class="cards c4" id="optCards"></div>
|
||||
<div id="optCards"></div>
|
||||
<div id="momento"></div>
|
||||
<div class="toolbar">
|
||||
<button id="btnBuscarClips" class="p">Buscar clips</button>
|
||||
<button id="btnEncolar" disabled>Convertir</button>
|
||||
@@ -682,9 +723,10 @@
|
||||
|
||||
<h3>Convertidor</h3>
|
||||
<dl>
|
||||
<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>
|
||||
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. La lista es una sola: cada clip con su estado de verdad (candidato, en espera, convirtiendo con su barra, convertido, verificado, falló); arriba se elige qué ver (por convertir, en cola, terminados, con fallo, excluidos) y por qué ordenar (estado, nombre, fecha, tamaño, ahorro; otra vez invierte el orden). <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW los lee el decodificador de Blackmagic que viene con DaVinci Resolve, sin abrirlo. No se ofrece lo que ya viene comprimido de cámara ni lo que ya convirtió el programa: reconvertirlo ahorra poco y pierde calidad.</i></dd>
|
||||
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Arriba hay un solo botón grande, el que toca en ese momento: buscar, convertir, detener la tanda (con cuánto falta y a qué hora acaba) o reemplazar lo verificado. La lista es una sola, por carpetas: cada clip con su estado de verdad (candidato, en espera, convirtiendo con su barra y lo que le queda, convertido, verificado, falló); «primero este» adelanta un clip; se elige qué ver (por convertir, en cola, terminados, con fallo, excluidos, y los excluidos por grupos con su porqué) y por qué ordenar (carpeta, estado, nombre, fecha, tamaño, ahorro; otra vez invierte el orden). <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW los lee el decodificador de Blackmagic que viene con DaVinci Resolve, sin abrirlo. No se ofrece lo que ya viene comprimido de cámara ni lo que ya convirtió el programa: reconvertirlo ahorra poco y pierde calidad.</i></dd>
|
||||
<dt>Verificar</dt><dd>Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.</dd>
|
||||
<dt>Reemplazar</dt><dd>El convertido ocupa el sitio del original, con su misma fecha, y el original se aparta a <span class="path">_Para borrar\originales</span>.</dd>
|
||||
</dl>
|
||||
@@ -1563,7 +1605,21 @@ $('#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: 'estado', inverso: false};
|
||||
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'carpeta', inverso: false}, CONV_EXC = '', CONV_PLEGADAS = new Set();
|
||||
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){
|
||||
m = m || '';
|
||||
if (m.startsWith('ya es una conversión')) return 'ya los convirtió el programa';
|
||||
if (m.startsWith('ya viene comprimido')) return 'ya vienen comprimidos de cámara';
|
||||
if (m.startsWith('ya se probó')) return 'ya se probaron con esta calidad';
|
||||
if (m.startsWith('tiene menos de')) return m.replace('tiene menos de', 'de menos de');
|
||||
if (m.startsWith('no se pudo leer')) return 'no se pudieron leer';
|
||||
if (m.startsWith('es un exportado')) return 'exportados y masters';
|
||||
if (m.startsWith('stock')) return 'stock de Envato';
|
||||
if (m.startsWith('tiene canal alfa')) return 'con canal alfa';
|
||||
return m.split(':')[0];
|
||||
}
|
||||
$('#btnBuscarClips').onclick = () => accion($('#btnBuscarClips'), async () => {
|
||||
const cv = (CARPETA && CARPETA.convertir) || {};
|
||||
if (cv.sin_analizar > 0 || (cv.analisis && cv.analisis.estado === 'error')) {
|
||||
@@ -1614,10 +1670,13 @@ async function cargarCola(){
|
||||
bloquear('btnReemplazar', verif ? '' : (listos ? 'hay convertidos sin verificar' : 'x')); $('#btnReemplazar').textContent = verif ? `Reemplazar ${verif} verificado${verif > 1 ? 's' : ''}` : 'Reemplazar';
|
||||
const validos = CAND.filter(c => !c.excluido), ahora = validos.reduce((s,c)=>s+c.bytes,0), despues = validos.reduce((s,c)=>s+c.estimado,0);
|
||||
const hechosB = ts.filter(t => t.bytes_out).reduce((s,t) => s + (t.bytes_in - t.bytes_out), 0);
|
||||
$('#optCards').innerHTML = (CAND.length || ts.length) ? [[validos.length, 'clips convertibles'], [gb(ahora), 'pesan hoy'], [gb(despues), 'pesarían (estimado)'], [hechosB ? (hechosB > 0 ? '−' : '+') + gb(Math.abs(hechosB)) : `${verif}`, hechosB > 0 ? `ya liberados · ${verif} verificados` : hechosB < 0 ? `de más (formato de edición) · ${verif} verificados` : 'verificados']].map(([b,s]) => `<div class="card"><b>${b}</b><small>${s}</small></div>`).join('') : '';
|
||||
const reemp = n('reemplazado');
|
||||
// antes y después, en una barra: lo que pesa hoy, lo que pesará, y lo que se libera
|
||||
const pConv = Math.max(2, Math.min(98, Math.round(despues / (ahora || 1) * 100)));
|
||||
$('#optCards').innerHTML = (CAND.length || ts.length) ? `<div class="antes"><div class="l1"><span class="cabsec" style="margin:0">Antes y después</span><span class="sep"></span><span class="mut">${validos.length} clip${validos.length !== 1 ? 's' : ''} convertible${validos.length !== 1 ? 's' : ''}</span></div>${ahora ? `<div class="l2"><b>${gb(ahora)}</b><div class="pista"><i class="conv" style="width:${pConv}%"></i><i class="libre" style="width:${100 - pConv}%"></i></div><b class="acc">${gb(despues)}</b></div><div class="l3 mut"><span><i class="sw conv"></i>lo que pesarán los convertidos</span><span><i class="sw libre"></i>lo que se libera: ${gb(Math.max(0, ahora - despues))}</span><span class="sep"></span><span>${hechosB > 0 ? `<b class="ok">${gb(hechosB)} menos</b> en ${verif + reemp} ya convertidos${reemp ? '' : ' (se libera al reemplazar)'}` : `${verif} verificados`}</span></div>` : `<div class="l3 mut"><span>${hechosB > 0 ? `<b class="ok">${gb(hechosB)} menos</b> en ${verif + reemp} clips convertidos${reemp ? '' : ' (se libera al reemplazar)'}` : 'nada convertible ahora mismo'}</span></div>`}</div>` : '';
|
||||
// Una sola lista: los candidatos con el estado VIVO de su trabajo (la ficha de «Buscar» se quedaba con
|
||||
// «corriendo» en un clip que ya había acabado), más los trabajos de clips que ya no son candidatos. Se
|
||||
// ordena y se filtra desde la barra de arriba, y el que se está convirtiendo lleva su barra en la casilla.
|
||||
// «corriendo» en un clip que ya había acabado), más los trabajos de clips que ya no son candidatos. Va por
|
||||
// carpetas (la ruta una sola vez) o plana si se ordena por otra cosa, y el que se convierte lleva su barra.
|
||||
const fmt = $('#optFormato').value || 'h265_10bit';
|
||||
const porRuta = {};
|
||||
ts.forEach(t => { const k = (t.ruta || '').toLowerCase(); if (!porRuta[k] || (orden[t.estado] ?? 9) < (orden[porRuta[k].estado] ?? 9)) porRuta[k] = t; });
|
||||
@@ -1626,52 +1685,133 @@ async function cargarCola(){
|
||||
const vistas = new Set(CAND.map(c => (c.ruta || '').toLowerCase()));
|
||||
ts.forEach(t => { if (!vistas.has((t.ruta || '').toLowerCase())) filas.push({c: null, i: -1, t}); });
|
||||
resumenCand();
|
||||
const nombre = f => f.c ? f.c.rel : (f.t.ruta || '').split(/[\\/]/).pop();
|
||||
const relDe = f => f.c ? f.c.rel : (f.t.raiz && (f.t.ruta || '').toLowerCase().startsWith(f.t.raiz.toLowerCase()) ? f.t.ruta.slice(f.t.raiz.length).replace(/^[\\/]/, '') : (f.t.ruta || ''));
|
||||
const nombre = f => ultimo(relDe(f));
|
||||
const carpeta = f => padreDe(relDe(f));
|
||||
const bytes = f => f.c ? f.c.bytes : (f.t.bytes_in || 0);
|
||||
const sale = f => f.t && f.t.bytes_out ? f.t.bytes_out : f.c ? f.c.estimado : 0;
|
||||
const ahorro = f => bytes(f) && sale(f) ? 1 - sale(f) / bytes(f) : -9;
|
||||
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 = {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).sort(CLAVES[CONV_ORDEN.campo] || CLAVES.estado);
|
||||
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);
|
||||
if (CONV_ORDEN.inverso) lista.reverse();
|
||||
const cuenta = {}; filas.forEach(f => cuenta[grupo(f)] = (cuenta[grupo(f)] || 0) + 1);
|
||||
const E = ESTADO && ESTADO.cola; const corr = ts.find(t => t.estado === 'corriendo');
|
||||
const E = ESTADO && ESTADO.cola, corr = ts.find(t => t.estado === 'corriendo'), eta = d.cola.eta;
|
||||
const fmtDe = x => esc(((ESTADO.formatos || {})[x || 'h265_10bit'] || {}).nombre || x || '').split(' · ')[0];
|
||||
const filaConv = f => {
|
||||
const primeroPend = (ts.filter(t => t.estado === 'pendiente').sort((a, b) => (b.orden || 0) - (a.orden || 0) || a.id - b.id)[0] || {}).id;
|
||||
const filaConv = (f, conCarpeta) => {
|
||||
const {c, t, i} = f;
|
||||
const motorT = t ? (t.motor === 'resolve' ? 'Resolve' : (t.motor === 'braw' ? 'BRAW · ' : '') + (MOTORES && MOTORES.formatos[t.formato || 'h265_10bit'] === 'gpu' ? 'GPU' : 'CPU')) + ' · ' + fmtDe(t.formato) : c.motor === 'braw' ? 'lector de BRAW' : c.motor === 'resolve' ? 'Resolve' : 'ffmpeg';
|
||||
const corre = t && t.estado === 'corriendo', pct = corre ? Math.max(0, Math.min(100, Math.round(t.pct || 0))) : 0;
|
||||
const est = corre ? `<span class="est prog ${pct >= 50 ? 'media' : ''}" style="--p:${pct}%" title="convirtiendo: ${pct} %">${pct} %</span>` : t ? `<span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}</span>` : `<span class="est candidato">${c.excluido ? 'no' : 'candidato'}</span>`;
|
||||
let sub = c ? `${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${motorT}` : motorT;
|
||||
if (corre) sub = `<span class="acc">convirtiendo${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''}</span> · ${sub}`;
|
||||
if (corre) { const a = eta && eta.actual && eta.actual.id === t.id ? eta.actual : null; sub = `<span class="acc">convirtiendo${a && a.dur ? ` · ${tiempoLargo(a.dur - a.resto)} de ${tiempoLargo(a.dur)} · quedan ${tiempoLargo(a.resto_seg)}` : ''}${E && E.mensaje ? ' · ' + esc(E.mensaje) : ''}</span> · ${motorT}`; }
|
||||
else if (t && t.estado === 'error') sub = `<span class="bad" title="${esc(t.log || '')}">${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}</span>`;
|
||||
else if (t && t.estado === 'descartado') sub = `<span class="mut">${esc(t.log || '')}</span>`;
|
||||
else if (t && t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió')) sub = `<span class="mut">${esc(t.log)}</span>`;
|
||||
else if (t && ['listo', 'verificado', 'reemplazado'].includes(t.estado)) sub = `${esc((t.salida || '').split(/[\\/]/).slice(-2).join(' / '))} · ${motorT}`;
|
||||
else if (t && ['listo', 'verificado', 'reemplazado'].includes(t.estado)) sub = `${t.nota ? `nota de calidad <b>${t.nota} / 100</b> · ` : ''}${esc((t.salida || '').split(/[\\/]/).pop())} · ${motorT}`;
|
||||
else if (c && c.excluido) sub += ' · ' + esc(c.excluido);
|
||||
if (conCarpeta) sub = `<span class="carp">${esc(carpeta(f) || '(raíz)')}</span> · ` + sub;
|
||||
const errCls = t && (t.estado === 'error' || t.estado === 'descartado') ? ' err' : '';
|
||||
const bIn = bytes(f), bOut = t && t.bytes_out ? t.bytes_out : 0, bEst = sale(f);
|
||||
const tam = bOut ? `${gb(bIn)} → ${gb(bOut)}` : c ? `${gb(c.bytes)} → ≈ ${gb(c.estimado)}` : gb(bIn);
|
||||
const ah = bIn && bEst ? (bEst <= bIn ? '−' + Math.round((1 - bEst / bIn) * 100) : '+' + Math.round((bEst / bIn - 1) * 100)) + '%' : '';
|
||||
const acts = t && ['listo', 'verificado', 'reemplazado'].includes(t.estado) ? `<button data-comparar="${t.id}" title="Ver el mismo cuadro en el original y en el convertido">comparar</button>` : t && t.estado === 'pendiente' && t.id !== primeroPend ? `<button data-primero="${t.id}" title="Pasa delante de los que esperan">primero este</button>` : '';
|
||||
const titulo = c ? `${esc(c.codec)} ${esc(c.perfil)} ${esc(c.pix_fmt)} · ${esc(c.res)} · ${esc(c.fps)} fps · ${c.kbps} kb/s → ≤ ${c.kbps_objetivo} kb/s` : esc(t.ruta || '');
|
||||
return `<div class="cfila ${c && c.excluido ? 'exc' : ''} ${corre ? 'activa' : ''}">${c ? `<input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} ${c.excluido || c.en_cola ? 'disabled' : ''} aria-label="marcar">` : '<span></span>'}${est}<span class="nom" title="${titulo}"><span class="clip">${esc(nombre(f))}</span><span class="sub2${errCls}">${sub}</span></span><span class="tam">${tam}</span><span class="ah${ah.startsWith('+') ? ' bad' : ''}">${ah}</span></div>`;
|
||||
return `<div class="cfila ${c && c.excluido ? 'exc' : ''} ${corre ? 'activa' : ''}">${c ? `<input type="checkbox" data-i="${i}" ${c.sel ? 'checked' : ''} ${c.excluido || c.en_cola ? 'disabled' : ''} aria-label="marcar">` : '<span></span>'}${est}<span class="nom" title="${titulo}"><span class="clip">${esc(nombre(f))}</span><span class="sub2${errCls}">${sub}</span></span><span class="tam">${tam}</span><span class="ah${ah.startsWith('+') ? ' bad' : ''}">${ah}</span><span class="acts">${acts}</span></div>`;
|
||||
};
|
||||
// ---- el momento: un solo botón grande, el que toca ahora
|
||||
const sel = CAND.filter(c => c.sel), libres = CAND.filter(c => !c.excluido && !c.en_cola);
|
||||
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) {
|
||||
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>`;
|
||||
} else if (pend) {
|
||||
mo = `<div class="momento"><div class="l1"><b>${pend} clip${pend > 1 ? 's' : ''} en espera, con la cola parada</b><span class="mut">lo que ya iba se retoma desde cero</span><span class="sep"></span><button class="p grande" id="moReanudar">Reanudar la cola</button></div></div>`;
|
||||
} else if (vers.length) {
|
||||
const bIn = vers.reduce((s, t) => s + (t.bytes_in || 0), 0), bOut = vers.reduce((s, t) => s + (t.bytes_out || 0), 0);
|
||||
mo = `<div class="momento listo"><div class="l1"><span class="ico">✓</span><span><b>${vers.length} clip${vers.length > 1 ? 's' : ''} convertido${vers.length > 1 ? 's' : ''} y verificado${vers.length > 1 ? 's' : ''}</b> · ${gb(bIn)} → ${gb(bOut)}${notaMedia ? ` · nota media ${notaMedia} / 100` : ''}</span><span class="sep"></span><button class="lnk" id="moComparar">comparar unos cuantos</button><button class="ok grande" id="moReemplazar">Reemplazar ${vers.length > 1 ? 'los ' + vers.length + ' originales' : 'el original'} · libera ${gb(Math.max(0, bIn - bOut))}</button></div><div class="l3 mut">Los originales van a _Para borrar/originales, no se borran, y se deshace desde Historial. Si quieres verlo antes, «comparar» enseña el mismo cuadro en los dos y mide la calidad.</div></div>`;
|
||||
} else if (sel.length) {
|
||||
const segs = sel.reduce((s, c) => s + (c.dur || 0) / (c.motor === 'braw' ? 1 : 2.5), 0);
|
||||
mo = `<div class="momento"><div class="l1"><button class="p grande" id="moConvertir">Convertir ${sel.length} clip${sel.length > 1 ? 's' : ''} · ${gb(sel.reduce((s, c) => s + c.bytes, 0))} → ≈ ${gb(sel.reduce((s, c) => s + c.estimado, 0))}</button><span class="mut">unas ${tiempoLargo(segs)} en la tarjeta · el original no se toca</span><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="moAuto" ${(ESTADO.config.optimizar || {}).auto_reemplazar ? 'checked' : ''}> y reemplazar solo cuando estén verificados</label></div></div>`;
|
||||
} else if (CAND.length) {
|
||||
mo = `<div class="momento"><div class="l1"><b>${libres.length ? `${libres.length} clip${libres.length > 1 ? 's' : ''} por convertir, ninguno marcado` : 'Nada por convertir ahora mismo'}</b><span class="mut">${libres.length ? 'marca los que quieras en la lista, o todos los válidos' : 'lo que había ya está convertido o no compensa; con el tiempo saldrá material nuevo'}</span></div></div>`;
|
||||
} else {
|
||||
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;
|
||||
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());
|
||||
liga('moComparar', () => { const v = vers.slice().sort((a, b) => (b.bytes_in || 0) - (a.bytes_in || 0))[0]; if (v) comparar(v.id); });
|
||||
const ma = $('#moAuto'); if (ma) ma.onchange = seguro(async () => { await api('/api/config', {optimizar: {auto_reemplazar: ma.checked}}); ESTADO.config.optimizar.auto_reemplazar = ma.checked; });
|
||||
// ---- la lista
|
||||
let html = '';
|
||||
if (E && E.activa && E.total) { const pctTot = Math.round(((E.hechos || 0) + ((E.actual && E.actual.pct) || 0) / 100) / E.total * 100); html += `<div class="colabar"><b>Convirtiendo ${Math.min((E.hechos || 0) + 1, E.total)} de ${E.total}</b><span class="b"><i style="width:${pctTot}%"></i></span><b>${pctTot} %</b>${corr ? `<span class="mut">${esc((corr.ruta || '').split(/[\\/]/).pop())} · ${Math.round(corr.pct || 0)} %</span>` : ''}</div>`; }
|
||||
else if (pend) html += `<div class="colabar" style="background:var(--panel2)"><b>${pend} en espera</b><span class="mut">la cola está parada: pulsa Reanudar</span></div>`;
|
||||
if (filas.length) {
|
||||
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 = [['estado', 'estado'], ['nombre', 'nombre'], ['fecha', 'fecha'], ['tamano', 'tamaño'], ['ahorro', 'ahorro']];
|
||||
const ORD = [['carpeta', 'carpeta'], ['estado', 'estado'], ['nombre', 'nombre'], ['fecha', 'fecha'], ['tamano', 'tamaño'], ['ahorro', 'ahorro']];
|
||||
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>`;
|
||||
html += lista.slice(0, 800).map(filaConv).join('') + (lista.length > 800 ? `<div class="mut" style="padding:8px 0">… y ${lista.length - 800} más: usa un filtro de «ver»</div>` : '') + (!lista.length ? '<div class="mut" style="padding:8px 0">Nada en este grupo.</div>' : '');
|
||||
const tope = lista.slice(0, 800);
|
||||
if (CONV_ORDEN.campo === 'carpeta') {
|
||||
// por carpetas: la ruta una sola vez, y en cada fila solo el nombre del clip
|
||||
const gs = []; tope.forEach(f => { const k = carpeta(f); let g = gs.find(x => x.k === k); if (!g) { g = {k, filas: []}; gs.push(g); } g.filas.push(f); });
|
||||
// primero las carpetas con algo que hacer (la que se convierte, las que esperan, las convertibles, las
|
||||
// terminadas) y al final las que solo tienen excluidos; a igual cosa, por nombre
|
||||
const rangoG = g => Math.min(...g.filas.map(rango));
|
||||
gs.sort((a, b) => (rangoG(a) - rangoG(b)) * (CONV_ORDEN.inverso ? -1 : 1) || a.k.localeCompare(b.k, 'es') * (CONV_ORDEN.inverso ? -1 : 1));
|
||||
html += gs.map((g, gi) => {
|
||||
const marc = g.filas.filter(f => f.c && !f.c.excluido && !f.c.en_cola), todos = marc.length && marc.every(f => f.c.sel);
|
||||
const corre = g.filas.some(f => f.t && f.t.estado === 'corriendo'), enCola = g.filas.filter(f => grupo(f) === 'cola').length, hechos = g.filas.filter(f => grupo(f) === 'hechos').length;
|
||||
const plegada = CONV_PLEGADAS.has(g.k), bIn = g.filas.reduce((s, f) => s + bytes(f), 0), bOut = g.filas.reduce((s, f) => s + sale(f), 0);
|
||||
return `<div class="cgrupo ${gi ? '' : 'primero'}"><input type="checkbox" data-grupo="${esc(g.k)}" ${todos ? 'checked' : ''} ${marc.length ? '' : 'disabled'} aria-label="marcar la carpeta"><button class="pl" data-plegar="${esc(g.k)}" aria-label="${plegada ? 'desplegar' : 'plegar'}">${plegada ? '▸' : '▾'}</button><b>${esc((g.k || '').split(/[\\/]/).filter(Boolean).join(' › ') || '(raíz)')}</b><span class="mut">${g.filas.length} clip${g.filas.length > 1 ? 's' : ''} · ${gb(bIn)}${bOut ? ' → ' + (g.filas.some(f => f.t && f.t.bytes_out) ? '' : '≈ ') + gb(bOut) : ''}</span><span class="sep"></span><span class="cab ${corre ? 'corre' : ''}">${corre ? 'convirtiendo' : enCola ? `en cola · ${enCola}` : hechos ? `terminados · ${hechos}` : ''}</span></div>` + (plegada ? '' : g.filas.map(f => filaConv(f, false)).join(''));
|
||||
}).join('');
|
||||
} else html += tope.map(f => filaConv(f, true)).join('');
|
||||
html += (lista.length > 800 ? `<div class="mut" style="padding:8px 0">… y ${lista.length - 800} más: usa un filtro de «ver»</div>` : '') + (!lista.length ? '<div class="mut" style="padding:8px 0">Nada en este grupo.</div>' : '');
|
||||
// los excluidos, en grupos y con su porqué
|
||||
const exc = {}; CAND.filter(c => c.excluido).forEach(c => { const k = claveExc(c.excluido); exc[k] = (exc[k] || 0) + 1; });
|
||||
const ks = Object.keys(exc).sort((a, b) => exc[b] - exc[a]);
|
||||
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(); });
|
||||
const ct = $('#candTodos'); if (ct) ct.onchange = e => { CAND.forEach(c => c.sel = e.target.checked && !c.excluido && !c.en_cola); cargarCola(); };
|
||||
$$('#convLista [data-ver]').forEach(b => b.onclick = () => { CONV_VER = b.dataset.ver; 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(); });
|
||||
$$('#convLista [data-ver]').forEach(b => b.onclick = () => { CONV_VER = b.dataset.ver; if (CONV_VER !== 'no') CONV_EXC = ''; cargarCola(); });
|
||||
$$('#convLista [data-exc]').forEach(b => b.onclick = () => { CONV_EXC = b.dataset.exc; CONV_VER = CONV_EXC ? 'no' : 'todos'; 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));
|
||||
}
|
||||
// ---- comparar: el mismo cuadro en el original y en el convertido, con lupa, y la nota de calidad
|
||||
function comparar(id){
|
||||
const t = COLA_TS.find(x => x.id === id); if (!t) return;
|
||||
const durC = t.dur || 0, nom = (t.ruta || '').split(/[\\/]/).pop();
|
||||
const puntos = durC > 2 ? [0.1, 0.5, 0.9].map(p => Math.round(durC * p * 10) / 10) : [0];
|
||||
let tAct = puntos[Math.floor(puntos.length / 2)];
|
||||
const src = cual => `/api/optimizar/cuadro?id=${id}&cual=${cual}&t=${tAct}`;
|
||||
const lado = (cual, tit, det) => `<div class="lado"><div style="display:flex;gap:8px;align-items:baseline"><b>${tit}</b><span class="mut" style="font-size:12px">${det}</span></div><div class="marco" data-lado="${cual}"><img src="${src(cual)}" alt="${tit}: cuadro en el segundo ${tAct}"><div class="lupa"></div></div></div>`;
|
||||
const notaHtml = () => t.nota ? `<b class="n ${t.nota < 93 ? 'baja' : ''}">${t.nota} <span style="font-size:13px;color:var(--mut);font-weight:500">/ 100</span></b><span class="mut">VMAF sobre unos 10 s del medio del clip · el cuadro peor, ${t.nota_min}. De 93 arriba no se nota a simple vista.</span>` : `<button class="p" id="cmpMedir">Medir la calidad</button><span class="mut">compara unos 10 s del medio del clip con el original; tarda entre medio minuto y dos.</span>`;
|
||||
const fm = (ESTADO.formatos || {})[t.formato || 'h265_10bit'] || {};
|
||||
modal(`Comparar: ${nom}`, `<div class="cmp">${lado('original', 'Original', `${gb(t.bytes_in)}${t.motor === 'braw' ? ' · BRAW revelado con la misma curva' : ''}`)}${lado('convertido', 'Convertido', `${t.bytes_out ? gb(t.bytes_out) + ' · ' : ''}${esc((fm.nombre || '').split(' · ')[0])}`)}</div>
|
||||
<div class="cmpt"><span>cuadro en el segundo</span>${puntos.map(p => `<button data-t="${p}" class="${p === tAct ? 'on' : ''}">${p}</button>`).join('')}<span style="margin-left:8px">pasa el ratón por la imagen: la lupa amplía el mismo recorte en los dos</span></div>
|
||||
<div class="cmpnota"><div><span class="cabsec" style="margin:0">Nota de calidad</span><div id="cmpNota" style="display:flex;flex-direction:column;gap:4px">${notaHtml()}</div></div><div><span class="cabsec" style="margin:0">Se conserva</span><span>${durC ? tiempoLargo(durC) + ' · ' : ''}${t.fps ? t.fps + ' fps · ' : ''}código de tiempo · audio · etiquetas de la cámara · ${fm.nombre && fm.nombre.includes('10') ? '10 bits de color' : 'el color del original'}</span></div><div>${t.estado === 'verificado' && !(t.formato || '').startsWith('prores') ? `<button class="ok" id="cmpReemplazar">Reemplazar este original</button><span class="mut">va a _Para borrar/originales; se deshace desde Historial</span>` : t.estado === 'reemplazado' ? '<span class="mut">El original ya se reemplazó: está en _Para borrar/originales.</span>' : '<span class="mut">Cuando esté verificado se podrá reemplazar desde aquí.</span>'}</div></div>`, null);
|
||||
$('#modal').classList.add('ancho');
|
||||
const marcos = $$('#modalCuerpo .marco');
|
||||
const lupa = (ev) => {
|
||||
const m = ev.currentTarget, img = m.querySelector('img'), r = img.getBoundingClientRect(), Z = 3;
|
||||
const rx = Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)), ry = Math.max(0, Math.min(1, (ev.clientY - r.top) / r.height));
|
||||
marcos.forEach(mm => { const im = mm.querySelector('img'), rr = im.getBoundingClientRect(), l = mm.querySelector('.lupa'); mm.classList.add('con'); l.style.backgroundImage = `url("${im.src}")`; l.style.backgroundSize = `${rr.width * Z}px ${rr.height * Z}px`; l.style.backgroundPosition = `${-(rx * rr.width * Z - 110)}px ${-(ry * rr.height * Z - 110)}px`; l.style.left = `${Math.max(0, Math.min(rr.width - 220, rx * rr.width - 110))}px`; l.style.top = `${Math.max(0, Math.min(rr.height - 220, ry * rr.height - 110))}px`; });
|
||||
};
|
||||
marcos.forEach(m => { m.onmousemove = lupa; m.onmouseleave = () => marcos.forEach(mm => mm.classList.remove('con')); const im = m.querySelector('img'); im.onerror = () => { m.innerHTML = `<span style="padding:12px;text-align:center">no se pudo sacar el cuadro ${m.dataset.lado === 'original' ? 'del original' : 'del convertido'}</span>`; }; });
|
||||
$$('#modalCuerpo [data-t]').forEach(b => b.onclick = () => { tAct = +b.dataset.t; $$('#modalCuerpo [data-t]').forEach(x => x.classList.toggle('on', x === b)); marcos.forEach(m => { const im = m.querySelector('img'); if (im) im.src = src(m.dataset.lado); }); });
|
||||
const bm = $('#cmpMedir'); if (bm) bm.onclick = () => accion(bm, async () => { $('#cmpNota').innerHTML = '<span class="spin"></span> midiendo…'; try { const r = await api('/api/optimizar/calidad', {id}); t.nota = r.vmaf; t.nota_min = r.minimo; $('#cmpNota').innerHTML = notaHtml(); } catch (e) { $('#cmpNota').innerHTML = `<span class="bad">${esc(e.message)}</span>`; } });
|
||||
const br = $('#cmpReemplazar'); if (br) br.onclick = () => accion(br, async () => { const r = await api('/api/optimizar/reemplazar', {ids: [id]}); const x = r.resultados[0] || {}; aviso(x.ok ? `Reemplazado: ${x.motivo}` : `No se reemplazó: ${x.motivo}`, x.ok ? 'ok' : 'bad', x.ok ? {texto: 'Ver historial', ir: () => irA('historial')} : null); $('#modal').classList.remove('on'); cargarCola(); });
|
||||
}
|
||||
$('#btnColaToggle').onclick = () => accion($('#btnColaToggle'), async () => { await api('/api/optimizar/control', {accion: $('#btnColaToggle').textContent === 'Detener' ? 'detener' : 'iniciar'}); await cargarEstado(); cargarCola(); });
|
||||
const idsPorEstado = async (...est) => { const d = await api('/api/optimizar/cola'); const raiz = raizActual().toLowerCase(); return d.trabajos.filter(t => est.includes(t.estado) && (t.raiz || '').toLowerCase() === raiz && !(est.includes('verificado') && (t.formato || '').startsWith('prores'))).map(t => t.id); };
|
||||
@@ -2138,7 +2278,7 @@ const asistente = seguro(async (raiz, escanearAntes) => {
|
||||
const NOMBRE_PASO = {plan: 'pensando el plan', ordenar: 'ordenando carpetas y archivos', stock: 'apartando stock viejo', duplicados: 'juntando repetidos', analizar: 'leyendo los clips', convertir: 'convirtiendo a 10 bits', respaldo: 'copiando el respaldo', fin: 'listo'};
|
||||
|
||||
// ---------------- modal ----------------
|
||||
function modal(titulo, html, onSi){ $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalSi').disabled = false; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }
|
||||
function modal(titulo, html, onSi){ $('#modal').classList.remove('ancho'); $('#modalTitulo').textContent = titulo; $('#modalCuerpo').innerHTML = html; $('#modal').classList.add('on'); $('#modalSi').style.display = onSi ? '' : 'none'; $('#modalSi').textContent = 'Confirmar'; $('#modalSi').disabled = false; $('#modalNo').textContent = onSi ? 'Cancelar' : 'Cerrar'; $('#modalSi').onclick = () => { $('#modal').classList.remove('on'); onSi && onSi(); }; $('#modalNo').onclick = () => $('#modal').classList.remove('on'); setTimeout(() => { const f = $('#modalCuerpo input, #modalCuerpo select') || $('#modalSi'); if (f && f.offsetParent) f.focus(); }, 30); }
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape' && $('#modal').classList.contains('on')) $('#modal').classList.remove('on'); });
|
||||
$('#modal').addEventListener('click', e => { if (e.target === $('#modal')) $('#modal').classList.remove('on'); });
|
||||
|
||||
|
||||
Referencia en una nueva incidencia
Block a user