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:
25
README.md
25
README.md
@@ -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
|
||||||
|
|||||||
201
organizador.py
201
organizador.py
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(); });
|
||||||
|
|||||||
Referencia en una nueva incidencia
Block a user