La cola de conversión sigue a los clips cuando se mueven, y «Comparar» en vertical

Los trabajos de la cola apuntaban al clip por su ruta y nadie los avisaba al moverlo: en Línea Gene el plan
llevó «Material Sony» a 00_Grabacion y 138 conversiones fallaron, los 11 verificados no se podían reemplazar y
volver a escanear no los reenganchaba. Ahora se mueven con el clip (plan, Deshacer, renombrar), el convertido
acompaña a su original, al escanear se reenganchan por nombre y peso, antes de convertir se comprueba que el
clip esté, y reemplazar junta antes el convertido con su original. El comparador toma la forma del clip y la
lupa ya no estira el recorte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-14 09:33:12 -05:00
padre e74b8f7ece
commit c91152a3dd
Se han modificado 4 ficheros con 360 adiciones y 16 borrados

Ver fichero

@@ -912,7 +912,25 @@ que rechazar, versiones con ffprobe simulado, y la pasada automática con sus fr
3.8.0, lo que faltaba. Munka, después de la 3.7.0: «al hacer un cambio en el plan me debería dar un chequeo 3.8.0, lo que faltaba. Munka, después de la 3.7.0: «al hacer un cambio en el plan me debería dar un chequeo
para saber que ya se hizo», «construye las mejoras que planteas» y, aplicando el plan de Vía Sumapaz, 753 para saber que ya se hizo», «construye las mejoras que planteas» y, aplicando el plan de Vía Sumapaz, 753
movimientos que no se pudieron y «database is locked» al buscar clips para convertir («lo más importante»). movimientos que no se pudieron y «database is locked» al buscar clips para convertir («lo más importante»). Y
antes de publicarla: «moví unos archivos y luego no se pudieron volver a renderizar, y al volver a escanear no
los volvió a encontrar», y el comparador, que en vertical no se veía bien.
- **La cola de conversión sigue a los clips.** Apuntaba a cada clip por su ruta y nadie la avisaba al moverlo:
en Línea Gene el plan llevó «Material Sony» a 00_Grabacion, 138 conversiones en espera fallaron buscando cada
clip donde ya no estaba, los 11 verificados no se podían reemplazar (y su «_10bit» se quedó en la carpeta
vieja, porque se convirtió después de escanear y el plan no lo conocía), y «Buscar clips» volvía a ofrecer
esos mismos originales como si no se hubieran convertido. Ahora, cuando la app mueve algo (el plan, Deshacer,
renombrar en el mapa) los trabajos se van con su clip y el convertido sale junto a él en el sitio nuevo; el
convertido que está junto a su original lo acompaña aunque el plan no lo conociera. Si los movió otra cosa
(el Explorador, la 3.7), al volver a escanear la cola los busca por nombre y peso y los que habían fallado
por eso vuelven a esperar turno; justo antes de convertir cada clip se comprueba que esté, y si no, falla
diciendo qué hacer. «Reemplazar» lleva antes el convertido junto a su original si quedaron separados. Lo que
la app aparta a _Para borrar o a Duplicados no se sigue: eso no se convierte. Y volver a escanear conserva
la lectura de un clip que solo cambió de carpeta (mismo nombre, peso y fecha): no hay que leerlo otra vez.
- **Comparar, en vertical**: el marco toma la forma del clip; uno vertical se ve a lo alto, los dos cuadros uno
junto al otro y la nota a su lado, en vez de una tira estrecha en medio de un marco apaisado. La lupa amplía
el recorte sin estirarlo (antes lo deformaba en todo lo que no era 16:9) y se ajusta al tamaño del cuadro.
- **«database is locked» al buscar clips.** Después de «Aplicar» en una carpeta grande, el inventario se ponía - **«database is locked» al buscar clips.** Después de «Aplicar» en una carpeta grande, el inventario se ponía
al día con la base tomada durante más de diez minutos (en Sumapaz, 2.493 movimientos): preguntaba al disco de al día con la base tomada durante más de diez minutos (en Sumapaz, 2.493 movimientos): preguntaba al disco de
@@ -955,8 +973,9 @@ movimientos que no se pudieron y «database is locked» al buscar clips para con
- **Compilar en el estudio**: `.github/workflows/publicar.yml` y la guía de arriba; las pruebas devuelven error - **Compilar en el estudio**: `.github/workflows/publicar.yml` y la guía de arriba; las pruebas devuelven error
si algo falla, y `verificar_publicado.py` está en el repositorio. si algo falla, y `verificar_publicado.py` está en el repositorio.
Pruebas: `prueba_orden.py` 38 casos (cuatro nuevos: «Alta» y «Baja», el reloj de una cámara y buscar, el banco Pruebas: `prueba_orden.py` 39 casos (cinco nuevos: «Alta» y «Baja», el reloj de una cámara y buscar, el banco
por fecha, otros discos); `prueba_funcional.py` 33 de 33 (buscar y «Fechas raras» por la interfaz del servidor); por fecha, otros discos, y la cola que sigue al clip: el plan, Deshacer, el Explorador y volver a escanear, justo
antes de convertir, y reemplazar con el convertido en otra carpeta); `prueba_funcional.py` 33 de 33 (buscar y «Fechas raras» por la interfaz del servidor);
`probar.py` 19 y `prueba_ventana.py` 18. `probar.py` 19 y `prueba_ventana.py` 18.
3.7.0, la auditoría de Vía Sumapaz. Munka: «creo que el escáner de archivos fue superficial», «no me sale la 3.7.0, la auditoría de Vía Sumapaz. Munka: «creo que el escáner de archivos fue superficial», «no me sale la

Ver fichero

@@ -644,6 +644,8 @@ def escanear(ruta):
previo = {r["ruta"]: r for r in con.execute( previo = {r["ruta"]: r for r in con.execute(
"SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, " "SELECT ruta, bytes, mtime, codec, perfil, pix_fmt, ancho, alto, fps, dur, kbps, alfa, acodec, "
"pistas_audio, analizado FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()} "pistas_audio, analizado FROM archivos WHERE raiz = ? AND analizado = 1", (raiz,)).fetchall()}
# y la de los que solo cambiaron de carpeta (movidos con el Explorador): mismo nombre, peso y fecha
por_firma = {(os.path.basename(r["ruta"]).lower(), r["bytes"]): r for r in previo.values()}
con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,)) con.execute("DELETE FROM archivos WHERE raiz = ?", (raiz,))
con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,)) con.execute("DELETE FROM carpetas WHERE raiz = ?", (raiz,))
con.executemany( con.executemany(
@@ -651,7 +653,7 @@ def escanear(ruta):
"anio_ruta, mes_ruta, fecha, fecha_origen, es_basura, es_proxy, es_stock) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", "anio_ruta, mes_ruta, fecha, fecha_origen, es_basura, es_proxy, es_stock) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
filas) filas)
for f in filas: for f in filas:
pv = previo.get(f[0]) pv = previo.get(f[0]) or por_firma.get((f[3].lower(), f[5]))
if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2: if pv and pv["bytes"] == f[5] and abs(pv["mtime"] - f[6]) < 2:
con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?", con.execute("UPDATE archivos SET codec=?, perfil=?, pix_fmt=?, ancho=?, alto=?, fps=?, dur=?, kbps=?, alfa=?, acodec=?, pistas_audio=?, analizado=1 WHERE ruta=?",
(pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0])) (pv["codec"], pv["perfil"], pv["pix_fmt"], pv["ancho"], pv["alto"], pv["fps"], pv["dur"], pv["kbps"], pv["alfa"], pv["acodec"], pv["pistas_audio"], f[0]))
@@ -676,6 +678,10 @@ def escanear(ruta):
prog["estado"] = "listo" prog["estado"] = "listo"
if fallos: if fallos:
prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})" prog["error"] = f"{len(fallos)} sitios no se pudieron leer (el primero: {fallos[0]})"
try:
reubicar_trabajos(raiz) # la cola encuentra los clips que se movieron desde el último escaneo
except Exception: # noqa
pass
olvidar_plan(raiz, recalcular=False) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco olvidar_plan(raiz, recalcular=False) # el plan anterior ya no vale: se calcula de nuevo sobre el inventario fresco
except Exception as ex: # noqa except Exception as ex: # noqa
prog["estado"] = "error" prog["estado"] = "error"
@@ -2419,6 +2425,16 @@ def aplicar(raiz, filas):
m["ejemplos"].append(que) m["ejemplos"].append(que)
filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower())) filas = sorted(filas, key=lambda f: (0 if f.get("archivos") else 1, -len(os.path.normpath(f["origen"]).split(os.sep)), f["origen"].lower()))
unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas) unidades_total = sum(len(f["archivos"]) if f.get("archivos") else 1 for f in filas)
# el convertido que está junto a su original lo acompaña: si no, el original se iba a 00_Grabacion y su
# «_10bit» se quedaba en la carpeta vieja (el inventario aún no lo tenía si se convirtió después de escanear)
con = db()
convertidos = defaultdict(list)
for r in con.execute("SELECT ruta, salida FROM trabajos WHERE estado IN ('listo','verificado') AND salida IS NOT NULL"):
if os.path.dirname(r["salida"]).lower() == os.path.dirname(r["ruta"] or "").lower():
convertidos[os.path.normpath(r["ruta"])].append(os.path.normpath(r["salida"]))
con.close()
en_plan = {os.path.normpath(x).lower() for f in filas for x in (f.get("archivos") or ())}
marcas = [os.sep + p + os.sep for p in R.PAPELERAS]
fallos = 0 fallos = 0
APLICANDO.update(hechas=0, total=len(filas), actual="", archivo="", raiz=_clave(raiz), unidades=0, APLICANDO.update(hechas=0, total=len(filas), actual="", archivo="", raiz=_clave(raiz), unidades=0,
unidades_total=unidades_total, movidos=0, fallos=0, lote=lote, inicio=time.time(), fin=0) unidades_total=unidades_total, movidos=0, fallos=0, lote=lote, inicio=time.time(), fin=0)
@@ -2441,7 +2457,12 @@ def aplicar(raiz, filas):
if not os.path.exists(a): if not os.path.exists(a):
continue continue
try: try:
antes = len(movidos)
mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos) mover(a, especiales.get(a) or os.path.join(f["destino"], os.path.basename(a)), lote, movidos)
llego = movidos[-1][1] if len(movidos) > antes else ""
for s in convertidos.get(os.path.normpath(a), ()):
if llego and s.lower() not in en_plan and not any(m in llego for m in marcas) and os.path.isfile(s):
mover(s, os.path.join(os.path.dirname(llego), os.path.basename(s)), lote, movidos)
except Exception as ex: # noqa except Exception as ex: # noqa
fallos_fila += 1 fallos_fila += 1
fallo(ex, os.path.relpath(a, raiz)) fallo(ex, os.path.relpath(a, raiz))
@@ -2504,7 +2525,10 @@ def actualizar_inventario(movidos):
# carpeta puede aparecer a mitad de la tanda, renombrada por un movimiento anterior). # carpeta puede aparecer a mitad de la tanda, renombrada por un movimiento anterior).
tocadas = _tocadas(movidos) tocadas = _tocadas(movidos)
existe = _existen({d for d, _ in tocadas}) existe = _existen({d for d, _ in tocadas})
cola = _cola_tras_mover(con, movidos, marcas)
with con: with con:
for id_, c in cola:
con.execute(f"UPDATE trabajos SET {', '.join(k + ' = ?' for k in c)} WHERE id = ?", (*c.values(), id_))
for de, a in movidos: for de, a in movidos:
en_papelera = 1 if any(m in a for m in marcas) else 0 en_papelera = 1 if any(m in a for m in marcas) else 0
con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, " con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, "
@@ -2538,6 +2562,122 @@ def actualizar_inventario(movidos):
olvidar_plan(k) olvidar_plan(k)
# Qué sigue la cola cuando la app mueve algo. El clip, en todo lo que aún lo usa (el «descartado» también:
# así no se vuelve a probar a la misma calidad tras moverlo). El convertido, en lo que ya salió. «corriendo»
# no: ese clip lo tiene abierto ffmpeg, y Windows no deja moverlo. Un «reemplazado» guarda en su ruta dónde
# estaba el original, para encontrarlo en _Para borrar\originales: esa no se toca.
_SIGUEN_CLIP = ("pendiente", "error", "cancelado", "listo", "verificado", "descartado")
_SIGUEN_SALIDA = ("listo", "verificado", "reemplazado")
def _cola_tras_mover(con, movidos, marcas):
"""La cola de conversión apunta a cada clip por su ruta. Cuando el plan (o Deshacer, o apartar) lo mueve, el
trabajo se va con él: el que esperaba se convierte en el sitio nuevo y su convertido sale allí, y el ya
convertido se reemplaza donde está ahora su original. Medido el 14-oct-2026 en Línea Gene: el plan llevó
los clips de «Material Sony» a 00_Grabacion y 138 conversiones en espera fallaron buscando cada clip donde
ya no estaba; los 11 verificados no se podían reemplazar, y «Buscar clips» volvía a ofrecer esos mismos
originales como si nunca se hubieran convertido. Lo que va a la papelera de la app no se sigue: un clip
apartado no se convierte. Devuelve [(id, {columna: valor})]."""
ts = [dict(r) for r in con.execute("SELECT id, ruta, salida, estado, raiz, formato FROM trabajos WHERE estado != 'corriendo'")]
if not ts:
return []
antes = {t["id"]: (t["ruta"], t["salida"]) for t in ts}
apartes = list(marcas) + [os.sep + R.DUPLICADOS + os.sep] # ni lo que se aparta como repetido
for de, a in movidos:
if any(m in a for m in apartes):
continue
pre = de + os.sep
for t in ts:
for k, siguen in (("ruta", _SIGUEN_CLIP), ("salida", _SIGUEN_SALIDA)):
v = t[k] or ""
if t["estado"] in siguen and (v == de or v.startswith(pre)):
t[k] = a + v[len(de):]
cambios = []
for t in ts:
ruta0, salida0 = antes[t["id"]]
if (t["ruta"], t["salida"]) == (ruta0, salida0):
continue
c = {"ruta": t["ruta"], "salida": t["salida"]}
if t["ruta"] != ruta0:
c["raiz"] = _raiz_de(t["ruta"]) or t["raiz"]
if t["estado"] in ("pendiente", "error", "cancelado"):
# aún no hay convertido: saldrá junto al clip, en su sitio nuevo
c["salida"] = salida_para(t["ruta"], c["raiz"], t["formato"] or "h265_10bit")
cambios.append((t["id"], c))
return cambios
def reubicar_trabajos(raiz=None, ids=None):
"""Vuelve a enlazar con su clip los trabajos de la cola que apuntan a donde el inventario ya no lo tiene: lo
movió la 3.7 o una anterior sin avisar a la cola, o alguien con el Explorador y luego se volvió a escanear.
Se busca el mismo nombre y el mismo peso en el inventario de la misma carpeta de cliente, fuera de la
papelera, y solo si sale uno. El que había fallado por eso vuelve a la cola, con su convertido junto al clip
en el sitio nuevo; el que ya se encoló de nuevo allí se quita (sobraba). Igual con el convertido de los ya
hechos, si se movió. Devuelve cuántos se enlazaron."""
con = db()
try:
escaneadas = {(r["raiz"] or "").lower() for r in con.execute("SELECT raiz FROM escaneos")}
filtro, args = "", []
if raiz:
filtro += " AND lower(t.raiz) = ?"
args.append(os.path.normpath(raiz).lower())
if ids:
filtro += f" AND t.id IN ({','.join('?' * len(ids))})"
args += [int(i) for i in ids]
estados = ",".join(f"'{e}'" for e in _SIGUEN_CLIP)
sin_clip = [dict(r) for r in con.execute(
"SELECT t.* FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.ruta "
f"WHERE a.ruta IS NULL AND t.estado IN ({estados}){filtro}", args)]
sin_salida = [dict(r) for r in con.execute(
"SELECT t.* FROM trabajos t LEFT JOIN archivos a ON a.ruta = t.salida "
f"WHERE a.ruta IS NULL AND t.salida IS NOT NULL AND t.bytes_out > 0 AND t.estado IN ('listo','verificado','reemplazado'){filtro}", args)]
# sin inventario de esa carpeta (no escaneada, o de otro equipo) que no esté no dice nada
sin_clip = [t for t in sin_clip if (t["raiz"] or "").lower() in escaneadas]
sin_salida = [t for t in sin_salida if (t["raiz"] or "").lower() in escaneadas]
if not sin_clip and not sin_salida:
return 0
nombres = sorted({os.path.basename(t["ruta"] or "") for t in sin_clip} | {os.path.basename(t["salida"] or "") for t in sin_salida})
donde = defaultdict(list)
for i in range(0, len(nombres), 400):
parte = nombres[i:i + 400]
for r in con.execute(f"SELECT ruta, raiz, nombre, bytes FROM archivos WHERE es_basura = 0 AND nombre IN ({','.join('?' * len(parte))})", parte):
if os.sep + R.DUPLICADOS + os.sep not in r["ruta"]:
donde[((r["raiz"] or "").lower(), r["nombre"], r["bytes"])].append(r["ruta"])
ocupadas = {(r["ruta"], r["formato"] or "h265_10bit") for r in con.execute(
"SELECT ruta, formato FROM trabajos WHERE estado IN ('pendiente','corriendo','listo','verificado','reemplazado')")}
n = 0
with con:
for t in sin_clip:
sitio = donde.get(((t["raiz"] or "").lower(), os.path.basename(t["ruta"] or ""), t["bytes_in"]))
if not sitio or len(sitio) != 1:
continue
nueva, fmt = sitio[0], t["formato"] or "h265_10bit"
if (nueva, fmt) in ocupadas and t["estado"] in ("pendiente", "error", "cancelado"):
con.execute("DELETE FROM trabajos WHERE id = ?", (t["id"],))
continue
if (nueva, fmt) in ocupadas:
continue
c = {"ruta": nueva}
if t["estado"] in ("pendiente", "error", "cancelado"):
c["salida"] = salida_para(nueva, t["raiz"], fmt)
if t["estado"] == "error":
c.update(estado="pendiente", pct=0)
if t["estado"] in ("pendiente", "error"):
c["log"] = f"el clip se había movido; ahora está en «{os.path.relpath(os.path.dirname(nueva), t['raiz'])}» y se convierte allí"
con.execute(f"UPDATE trabajos SET {', '.join(k + ' = ?' for k in c)} WHERE id = ?", (*c.values(), t["id"]))
if t["estado"] in ("pendiente", "error", "listo", "verificado"):
ocupadas.add((nueva, fmt))
n += 1
for t in sin_salida:
sitio = donde.get(((t["raiz"] or "").lower(), os.path.basename(t["salida"] or ""), t["bytes_out"]))
if sitio and len(sitio) == 1:
con.execute("UPDATE trabajos SET salida = ? WHERE id = ?", (sitio[0], t["id"]))
n += 1
return n
finally:
con.close()
def _rango(prefijo): def _rango(prefijo):
"""(desde, hasta) para pedir por índice todo lo que empieza por `prefijo`: «ruta >= desde AND ruta < hasta». """(desde, hasta) para pedir por índice todo lo que empieza por `prefijo`: «ruta >= desde AND ruta < hasta».
«LIKE 'X\\%'» recorría la tabla entera (76.559 filas en Sumapaz) en cada movimiento.""" «LIKE 'X\\%'» recorría la tabla entera (76.559 filas en Sumapaz) en cada movimiento."""
@@ -3348,6 +3488,7 @@ def estimar_bytes(f, perfil, formato):
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"): def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"):
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"]) fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
lee_braw = bool(decodificador_braw().get("ok")) lee_braw = bool(decodificador_braw().get("ok"))
reubicar_trabajos(raiz)
con = db() con = db()
tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad tope = limite_meses(meses) if int(meses or 0) > 0 else None # con 0 meses no se filtra por edad
# es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales # es_basura = 0 deja fuera los originales ya reemplazados, que viven en _Para borrar/originales
@@ -4036,6 +4177,27 @@ def _verificar_listos(ids):
COLA["mensaje"] = f"no se pudo verificar: {ex}" COLA["mensaje"] = f"no se pudo verificar: {ex}"
def _clip_presente(t):
"""Justo antes de convertir: si el clip ya no está donde dice el trabajo, se busca en el inventario (se
movió). Si no aparece, el trabajo falla diciendo por qué y qué hacer, sin llamar a ffmpeg (antes salía su
«No such file or directory», en inglés y sin pista). Devuelve el trabajo al día, o None."""
if os.path.isfile(t["ruta"]):
return t
if reubicar_trabajos(ids=[t["id"]]):
con = db()
nuevo = con.execute("SELECT * FROM trabajos WHERE id = ?", (t["id"],)).fetchone()
con.close()
if nuevo and nuevo["estado"] == "pendiente" and os.path.isfile(nuevo["ruta"]):
return dict(nuevo)
try:
donde = os.path.relpath(os.path.dirname(t["ruta"]), t["raiz"])
except ValueError:
donde = os.path.dirname(t["ruta"])
set_trabajo(t["id"], estado="error", log=f"el clip ya no está en «{donde}»: se movió o se borró fuera de OrganiLAB. "
"Vuelve a escanear la carpeta: la cola lo buscará en su sitio nuevo y lo convertirá allí.")
return None
def bucle_cola(): def bucle_cola():
try: try:
while COLA["activa"]: while COLA["activa"]:
@@ -4045,6 +4207,14 @@ def bucle_cola():
con.close() con.close()
if t and not _esperar_turno(): if t and not _esperar_turno():
break break
if t:
t = _clip_presente(dict(t))
if not t:
continue
if not t and lote_resolve:
lote_resolve = [x for x in map(_clip_presente, lote_resolve) if x]
if not lote_resolve:
continue
if t: if t:
COLA["actual"] = t["id"] COLA["actual"] = t["id"]
COLA["empezo"] = time.time() COLA["empezo"] = time.time()
@@ -4478,7 +4648,10 @@ def verificar(ids):
def reemplazar(ids): def reemplazar(ids):
"""El original se va a _Para borrar/originales (misma raíz). Nunca se borra.""" """El original se va a _Para borrar/originales (misma raíz). Nunca se borra. Si el convertido quedó en otra
carpeta que su original (se movió uno y no el otro), antes se lleva junto a él: el convertido ocupa el
sitio del original, que es lo que promete reemplazar."""
reubicar_trabajos(ids=ids)
con = db() con = db()
res = [] res = []
lote = "optimizar-" + time.strftime("%Y%m%d-%H%M%S") # un lote por reemplazo: se deshace solo este, no toda la historia lote = "optimizar-" + time.strftime("%Y%m%d-%H%M%S") # un lote por reemplazo: se deshace solo este, no toda la historia
@@ -4503,6 +4676,27 @@ def reemplazar(ids):
set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar") set_trabajo(id_, estado="error", log="la salida verificada desapareció antes de reemplazar")
continue continue
raiz = t["raiz"] raiz = t["raiz"]
if not os.path.isfile(t["ruta"]):
res.append({"id": id_, "ok": False, "motivo": "el original ya no está donde se convirtió; vuelve a escanear la carpeta y la cola lo encontrará"})
continue
if (CONFIG["optimizar"].get("salida") != "espejo"
and os.path.dirname(t["salida"]).lower() != os.path.dirname(t["ruta"]).lower()):
junto = os.path.join(os.path.dirname(t["ruta"]), os.path.basename(t["salida"]))
if os.path.exists(junto):
res.append({"id": id_, "ok": False, "motivo": f"el convertido está en otra carpeta y junto al original ya hay un «{os.path.basename(junto)}»"})
continue
try:
_renombrar(t["salida"], junto)
except OSError as ex:
res.append({"id": id_, "ok": False, "motivo": f"el convertido está en otra carpeta y no se pudo llevar junto al original: {ex}"})
continue
movimientos += 1
bitacora({"lote": lote, "op": "mover", "de": t["salida"], "a": junto, "trabajo": id_})
with con:
con.execute("UPDATE archivos SET ruta = ?, dir = ? WHERE ruta = ?", (junto, os.path.dirname(junto), t["salida"]))
set_trabajo(id_, salida=junto)
t = dict(t)
t["salida"] = junto
raiz_lote = raiz_lote or raiz raiz_lote = raiz_lote or raiz
rel = os.path.relpath(t["ruta"], raiz) rel = os.path.relpath(t["ruta"], raiz)
destino = os.path.join(raiz, R.PAPELERA, "originales", rel) destino = os.path.join(raiz, R.PAPELERA, "originales", rel)
@@ -7425,6 +7619,7 @@ class Manejador(BaseHTTPRequestHandler):
con.close() con.close()
return self._json({"ok": True}) return self._json({"ok": True})
if acc == "reintentar": if acc == "reintentar":
reubicar_trabajos(ids=d.get("ids", []))
con = db() con = db()
with con: with con:
con.execute(f"UPDATE trabajos SET estado = 'pendiente', pct = 0, log = '' WHERE estado IN ('error','cancelado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", [])) con.execute(f"UPDATE trabajos SET estado = 'pendiente', pct = 0, log = '' WHERE estado IN ('error','cancelado') AND id IN ({','.join('?' * len(d.get('ids', [])))})", d.get("ids", []))
@@ -8010,7 +8205,7 @@ def main():
except OSError: except OSError:
pass pass
huerfanas = recuperar_huerfanas() huerfanas = recuperar_huerfanas()
threading.Thread(target=lambda: (repasar_verificados(), braws_al_lector()), daemon=True).start() threading.Thread(target=lambda: (reubicar_trabajos(), repasar_verificados(), braws_al_lector()), daemon=True).start()
if huerfanas: if huerfanas:
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).") print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).")
try: try:

Ver fichero

@@ -1091,6 +1091,102 @@ class Convertidor(unittest.TestCase):
fh.write(b"z" * 999) # alguien dejó otro archivo con ese nombre fh.write(b"z" * 999) # alguien dejó otro archivo con ese nombre
self.assertTrue(O._salida_ajena(self.trabajo(nuevos[0]))) self.assertTrue(O._salida_ajena(self.trabajo(nuevos[0])))
def test_la_cola_sigue_al_clip(self):
"""Línea Gene, 14-oct-2026: el plan llevó «Material Sony» a 00_Grabacion; 138 conversiones en espera
fallaron buscando cada clip donde ya no estaba, los 11 verificados no se podían reemplazar (y su «_10bit»
se quedó en la carpeta vieja), y volver a escanear no los reenganchaba."""
raiz = cliente("ColaSigue")
nr = os.path.normpath(raiz)
g = "Material Sony/"
a = mk(raiz, g + "20250722_Labstream3796.MP4", 2025, 7, 22, tam=300000)
b = mk(raiz, g + "20250722_Labstream3797.MP4", 2025, 7, 22, tam=300001)
c = mk(raiz, g + "20250731_Labstream4033.MP4", 2025, 7, 31, tam=300002)
d = mk(raiz, g + "20250731_Labstream4034.MP4", 2025, 7, 31, tam=300003)
O.escanear(raiz)
self.leidos(raiz, {os.path.basename(x): {"codec": "h264", "perfil": "High", "kbps": 120000} for x in (a, b, c, d)})
ia, ib, ic = O.encolar([a, b, c], "equilibrado", "420", "rec709", nr)
O.set_trabajo(ib, estado="error", log="falló antes por otra cosa")
# el plan se calcula antes de que c termine de convertirse: su «_10bit» aún no está en el inventario
p = O.Plan(O.raiz_cfg(raiz)).correr()
sc = self.trabajo(ic)["salida"]
with open(sc, "wb") as fh:
fh.write(b"x" * 120000)
O.set_trabajo(ic, estado="verificado", bytes_out=120000)
r = aplicar(raiz, p)
self.assertFalse(os.path.exists(a) or os.path.exists(c))
# 1) los trabajos se fueron con sus clips; el que espera sale junto al clip en su sitio nuevo
ta, tb, tc = (self.trabajo(i) for i in (ia, ib, ic))
for t in (ta, tb, tc):
self.assertTrue(os.path.isfile(t["ruta"]), t)
self.assertIn("00_Grabacion", t["ruta"])
self.assertEqual(ta["salida"], os.path.splitext(ta["ruta"])[0] + "_10bit.mp4")
self.assertEqual((ta["estado"], tb["estado"]), ("pendiente", "error"))
# …y el convertido acompañó a su original, aunque el plan no lo conocía
self.assertEqual(os.path.dirname(tc["salida"]), os.path.dirname(tc["ruta"]))
self.assertTrue(os.path.isfile(tc["salida"]))
self.assertFalse(os.path.exists(sc))
# «Buscar clips» no vuelve a ofrecer lo que ya está en la cola o convertido
cs = {os.path.basename(x["ruta"]): x["en_cola"] for x in O.candidatos(nr, 0, "equilibrado", 0)}
self.assertEqual((cs["20250722_Labstream3796.MP4"], cs["20250731_Labstream4033.MP4"], cs["20250731_Labstream4034.MP4"]),
("pendiente", "verificado", ""))
# 2) Deshacer los devuelve, y la cola vuelve con ellos
O.deshacer(r["lote"])
ta, tc = self.trabajo(ia), self.trabajo(ic)
self.assertEqual((ta["ruta"], tc["ruta"], tc["salida"]), (a, c, sc))
self.assertTrue(os.path.isfile(sc))
self.assertEqual(ta["salida"], os.path.splitext(a)[0] + "_10bit.mp4")
# 3) movidos con el Explorador: justo antes de convertir, el que no está falla diciendo qué hacer…
otra = os.path.join(nr, "Otra carpeta")
os.makedirs(otra)
a2, c2 = os.path.join(otra, os.path.basename(a)), os.path.join(otra, os.path.basename(c))
os.rename(a, a2)
os.rename(c, c2) # el original verificado se va; su convertido, no
self.assertIsNone(O._clip_presente(self.trabajo(ia)))
ta = self.trabajo(ia)
self.assertEqual(ta["estado"], "error")
self.assertIn("Vuelve a escanear", ta["log"])
# …y al volver a escanear se reengancha: vuelve a la cola en su sitio nuevo, sin volver a leer el clip
O.escanear(raiz)
ta, tc = self.trabajo(ia), self.trabajo(ic)
self.assertEqual((ta["ruta"], ta["estado"]), (a2, "pendiente"))
self.assertEqual(ta["salida"], os.path.splitext(a2)[0] + "_10bit.mp4")
self.assertTrue(ta["log"].startswith("el clip se había movido"), ta["log"])
self.assertEqual((tc["ruta"], tc["salida"]), (c2, sc))
con = O.db()
leido = con.execute("SELECT analizado, codec FROM archivos WHERE ruta = ?", (a2,)).fetchone()
con.close()
self.assertEqual(tuple(leido), (1, "h264"))
self.assertEqual(O._clip_presente(self.trabajo(ia))["ruta"], a2)
# 4) un intento fallido que ya se volvió a encolar en el sitio nuevo sobra: se quita
bn = os.path.join(otra, os.path.basename(b))
os.rename(b, bn)
O.escanear(raiz)
self.assertEqual(self.trabajo(ib)["ruta"], bn) # el de b (error) se reenganchó y vuelve a esperar
self.assertEqual(self.trabajo(ib)["estado"], "pendiente")
con = O.db()
with con:
viejo = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (b, b + "_10bit.mp4", "ffmpeg", "equilibrado", "420", "rec709", 21150, "error",
300001, time.time(), nr, "h265_10bit")).lastrowid
con.close()
O.reubicar_trabajos(nr)
con = O.db()
self.assertIsNone(con.execute("SELECT id FROM trabajos WHERE id = ?", (viejo,)).fetchone())
con.close()
# 5) reemplazar con el convertido en otra carpeta: primero se lleva junto al original
r = O.reemplazar([ic])[0]
self.assertTrue(r["ok"], r)
junto = os.path.join(otra, os.path.basename(sc))
self.assertTrue(os.path.isfile(junto))
self.assertFalse(os.path.exists(sc) or os.path.exists(c2))
self.assertTrue(os.path.isfile(os.path.join(nr, R.PAPELERA, "originales", "Otra carpeta", os.path.basename(c2))))
self.assertEqual(self.trabajo(ic)["salida"], junto)
def test_el_codificador_que_toca(self): def test_el_codificador_que_toca(self):
fm = O.FORMATOS["h265_10bit"] fm = O.FORMATOS["h265_10bit"]
t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000} t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000}

Ver fichero

@@ -444,6 +444,13 @@
.cmp .marco img{width:100%;height:100%;object-fit:contain;display:block;cursor:crosshair} .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 .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} .cmp .marco.con .lupa{display:block}
/* en vertical (móvil, cámara girada): los dos cuadros a lo alto, uno junto al otro, y la nota y lo demás en una
columna a su derecha. Antes el marco era siempre apaisado y el clip quedaba como una tira en medio */
.cmpcuerpo.vert{display:grid;grid-template-columns:auto minmax(260px,1fr);gap:22px;align-items:start}
.cmpcuerpo.vert .cmp{grid-template-columns:auto auto;justify-content:start}
.cmpcuerpo.vert .cmp .lado{min-width:auto}
.cmpcuerpo.vert .cmpt{margin-top:26px}
.cmpcuerpo.vert .cmpnota{grid-template-columns:1fr}
.cmpnota{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:12px} .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>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)} .cmpnota b.n{font-size:26px;color:var(--ok)} .cmpnota b.n.baja{color:var(--warn)}
@@ -2002,7 +2009,7 @@ async function cargarCola(){
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}`; } 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 === '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 === '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 && t.estado === 'pendiente' && t.log) sub = `<span class="mut">${esc(t.log)}</span>`;
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 (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); else if (c && c.excluido) sub += ' · ' + esc(c.excluido);
if (conCarpeta) sub = `<span class="carp">${esc(carpeta(f) || '(raíz)')}</span> · ` + sub; if (conCarpeta) sub = `<span class="carp">${esc(carpeta(f) || '(raíz)')}</span> · ` + sub;
@@ -2141,17 +2148,44 @@ function comparar(id){
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 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 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'] || {}; 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> modal(`Comparar: ${nom}`, `<div class="cmpcuerpo" id="cmpCuerpo"><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="cmpinfo"><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); <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></div></div>`, null);
$('#modal').classList.add('ancho'); $('#modal').classList.add('ancho');
const marcos = $$('#modalCuerpo .marco'); const marcos = $$('#modalCuerpo .marco'), cuerpo = $('#cmpCuerpo');
const lupa = (ev) => { // El marco toma la forma del clip. En vertical, los dos cuadros tan altos como quepan con la columna de la
const m = ev.currentTarget, img = m.querySelector('img'), r = img.getBoundingClientRect(), Z = 3; // nota al lado; en apaisado, como siempre, a todo el ancho.
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)); const ajustar = () => {
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`; }); const im = marcos.map(m => m.querySelector('img')).find(x => x && x.naturalWidth);
if (!im || !document.body.contains(cuerpo)) return;
const ar = im.naturalWidth / im.naturalHeight, vert = ar < 0.95;
cuerpo.classList.toggle('vert', vert);
marcos.forEach(m => { m.style.aspectRatio = `${im.naturalWidth} / ${im.naturalHeight}`; m.style.width = m.style.height = ''; });
if (vert) {
const cabe = cuerpo.clientWidth - 280 - 22 - 14;
const h = Math.max(220, Math.min(window.innerHeight * 0.68, 780, cabe / (2 * ar)));
marcos.forEach(m => { m.style.height = `${Math.round(h)}px`; m.style.width = `${Math.round(h * ar)}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>`; }; }); const alCambiar = () => { if (!document.body.contains(cuerpo)) return window.removeEventListener('resize', alCambiar); ajustar(); };
window.addEventListener('resize', alCambiar);
// dónde está el cuadro dentro de la imagen (sin las franjas, si las hubiera): la lupa amplía ESO, sin estirarlo
const caja = im => { const r = im.getBoundingClientRect(), nw = im.naturalWidth || r.width, nh = im.naturalHeight || r.height, s = Math.min(r.width / nw, r.height / nh), w = nw * s, h = nh * s; return {left: r.left + (r.width - w) / 2, top: r.top + (r.height - h) / 2, width: w, height: h}; };
const lupa = (ev) => {
const img = ev.currentTarget.querySelector('img'); if (!img) return;
const r = caja(img), 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'), l = mm.querySelector('.lupa'); if (!im || !l) return;
const rr = caja(im), mr = mm.getBoundingClientRect(), L = Math.round(Math.max(120, Math.min(220, Math.min(rr.width, rr.height) * 0.5)));
mm.classList.add('con'); l.style.width = l.style.height = `${L}px`;
l.style.backgroundImage = `url("${im.src}")`; l.style.backgroundSize = `${rr.width * Z}px ${rr.height * Z}px`;
l.style.backgroundPosition = `${-(rx * rr.width * Z - L / 2)}px ${-(ry * rr.height * Z - L / 2)}px`;
l.style.left = `${rr.left - mr.left + Math.max(0, Math.min(rr.width - L, rx * rr.width - L / 2))}px`;
l.style.top = `${rr.top - mr.top + Math.max(0, Math.min(rr.height - L, ry * rr.height - L / 2))}px`;
});
};
marcos.forEach(m => { m.onmousemove = lupa; m.onmouseleave = () => marcos.forEach(mm => mm.classList.remove('con')); const im = m.querySelector('img'); im.addEventListener('load', ajustar); if (im.complete && im.naturalWidth) ajustar(); 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); }); }); $$('#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 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(); }); 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(); });