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
|
||||
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
|
||||
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
|
||||
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
|
||||
por fecha, otros discos); `prueba_funcional.py` 33 de 33 (buscar y «Fechas raras» por la interfaz del servidor);
|
||||
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, 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.
|
||||
|
||||
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(
|
||||
"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()}
|
||||
# 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 carpetas WHERE raiz = ?", (raiz,))
|
||||
con.executemany(
|
||||
@@ -651,7 +653,7 @@ def escanear(ruta):
|
||||
"anio_ruta, mes_ruta, fecha, fecha_origen, es_basura, es_proxy, es_stock) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
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:
|
||||
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]))
|
||||
@@ -676,6 +678,10 @@ def escanear(ruta):
|
||||
prog["estado"] = "listo"
|
||||
if fallos:
|
||||
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
|
||||
except Exception as ex: # noqa
|
||||
prog["estado"] = "error"
|
||||
@@ -2419,6 +2425,16 @@ def aplicar(raiz, filas):
|
||||
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()))
|
||||
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
|
||||
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)
|
||||
@@ -2441,7 +2457,12 @@ def aplicar(raiz, filas):
|
||||
if not os.path.exists(a):
|
||||
continue
|
||||
try:
|
||||
antes = len(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
|
||||
fallos_fila += 1
|
||||
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).
|
||||
tocadas = _tocadas(movidos)
|
||||
existe = _existen({d for d, _ in tocadas})
|
||||
cola = _cola_tras_mover(con, movidos, marcas)
|
||||
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:
|
||||
en_papelera = 1 if any(m in a for m in marcas) else 0
|
||||
con.execute("UPDATE archivos SET ruta = ?, dir = ?, nombre = ?, "
|
||||
@@ -2538,6 +2562,122 @@ def actualizar_inventario(movidos):
|
||||
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):
|
||||
"""(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."""
|
||||
@@ -3348,6 +3488,7 @@ def estimar_bytes(f, perfil, formato):
|
||||
def candidatos(raiz, meses, perfil, min_mb, incluir_exportados=False, formato="h265_10bit"):
|
||||
fm = FORMATOS.get(formato, FORMATOS["h265_10bit"])
|
||||
lee_braw = bool(decodificador_braw().get("ok"))
|
||||
reubicar_trabajos(raiz)
|
||||
con = db()
|
||||
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
|
||||
@@ -4036,6 +4177,27 @@ def _verificar_listos(ids):
|
||||
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():
|
||||
try:
|
||||
while COLA["activa"]:
|
||||
@@ -4045,6 +4207,14 @@ def bucle_cola():
|
||||
con.close()
|
||||
if t and not _esperar_turno():
|
||||
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:
|
||||
COLA["actual"] = t["id"]
|
||||
COLA["empezo"] = time.time()
|
||||
@@ -4478,7 +4648,10 @@ def verificar(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()
|
||||
res = []
|
||||
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")
|
||||
continue
|
||||
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
|
||||
rel = os.path.relpath(t["ruta"], raiz)
|
||||
destino = os.path.join(raiz, R.PAPELERA, "originales", rel)
|
||||
@@ -7425,6 +7619,7 @@ class Manejador(BaseHTTPRequestHandler):
|
||||
con.close()
|
||||
return self._json({"ok": True})
|
||||
if acc == "reintentar":
|
||||
reubicar_trabajos(ids=d.get("ids", []))
|
||||
con = db()
|
||||
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", []))
|
||||
@@ -8010,7 +8205,7 @@ def main():
|
||||
except OSError:
|
||||
pass
|
||||
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:
|
||||
print(f"{huerfanas} conversiones quedaron a medias la última vez: vuelven a la cola en espera (Reanudar).")
|
||||
try:
|
||||
|
||||
@@ -1091,6 +1091,102 @@ class Convertidor(unittest.TestCase):
|
||||
fh.write(b"z" * 999) # alguien dejó otro archivo con ese nombre
|
||||
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):
|
||||
fm = O.FORMATOS["h265_10bit"]
|
||||
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 .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}
|
||||
/* 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>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)}
|
||||
@@ -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}`; }
|
||||
else if (t && t.estado === 'error') sub = `<span class="bad" title="${esc(t.log || '')}">${esc(((t.log || '').trim().split('\n').pop() || 'falló sin decir por qué').slice(0, 320))}</span>`;
|
||||
else if (t && t.estado === 'descartado') sub = `<span class="mut">${esc(t.log || '')}</span>`;
|
||||
else if (t && t.estado === 'pendiente' && (t.log || '').startsWith('se interrumpió')) sub = `<span class="mut">${esc(t.log)}</span>`;
|
||||
else if (t && 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 (c && c.excluido) sub += ' · ' + esc(c.excluido);
|
||||
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 notaHtml = () => t.nota ? `<b class="n ${t.nota < 93 ? 'baja' : ''}">${t.nota} <span style="font-size:13px;color:var(--mut);font-weight:500">/ 100</span></b><span class="mut">VMAF sobre unos 10 s del medio del clip · el cuadro peor, ${t.nota_min}. De 93 arriba no se nota a simple vista.</span>` : `<button class="p" id="cmpMedir">Medir la calidad</button><span class="mut">compara unos 10 s del medio del clip con el original; tarda entre medio minuto y dos.</span>`;
|
||||
const fm = (ESTADO.formatos || {})[t.formato || 'h265_10bit'] || {};
|
||||
modal(`Comparar: ${nom}`, `<div class="cmp">${lado('original', 'Original', `${gb(t.bytes_in)}${t.motor === 'braw' ? ' · BRAW revelado con la misma curva' : ''}`)}${lado('convertido', 'Convertido', `${t.bytes_out ? gb(t.bytes_out) + ' · ' : ''}${esc((fm.nombre || '').split(' · ')[0])}`)}</div>
|
||||
<div class="cmpt"><span>cuadro en el segundo</span>${puntos.map(p => `<button data-t="${p}" class="${p === tAct ? 'on' : ''}">${p}</button>`).join('')}<span style="margin-left:8px">pasa el ratón por la imagen: la lupa amplía el mismo recorte en los dos</span></div>
|
||||
<div class="cmpnota"><div><span class="cabsec" style="margin:0">Nota de calidad</span><div id="cmpNota" style="display:flex;flex-direction:column;gap:4px">${notaHtml()}</div></div><div><span class="cabsec" style="margin:0">Se conserva</span><span>${durC ? tiempoLargo(durC) + ' · ' : ''}${t.fps ? t.fps + ' fps · ' : ''}código de tiempo · audio · etiquetas de la cámara · ${fm.nombre && fm.nombre.includes('10') ? '10 bits de color' : 'el color del original'}</span></div><div>${t.estado === 'verificado' && !(t.formato || '').startsWith('prores') ? `<button class="ok" id="cmpReemplazar">Reemplazar este original</button><span class="mut">va a _Para borrar/originales; se deshace desde Historial</span>` : t.estado === 'reemplazado' ? '<span class="mut">El original ya se reemplazó: está en _Para borrar/originales.</span>' : '<span class="mut">Cuando esté verificado se podrá reemplazar desde aquí.</span>'}</div></div>`, null);
|
||||
modal(`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="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></div></div>`, null);
|
||||
$('#modal').classList.add('ancho');
|
||||
const marcos = $$('#modalCuerpo .marco');
|
||||
const lupa = (ev) => {
|
||||
const m = ev.currentTarget, img = m.querySelector('img'), r = img.getBoundingClientRect(), Z = 3;
|
||||
const rx = Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width)), ry = Math.max(0, Math.min(1, (ev.clientY - r.top) / r.height));
|
||||
marcos.forEach(mm => { const im = mm.querySelector('img'), rr = im.getBoundingClientRect(), l = mm.querySelector('.lupa'); mm.classList.add('con'); l.style.backgroundImage = `url("${im.src}")`; l.style.backgroundSize = `${rr.width * Z}px ${rr.height * Z}px`; l.style.backgroundPosition = `${-(rx * rr.width * Z - 110)}px ${-(ry * rr.height * Z - 110)}px`; l.style.left = `${Math.max(0, Math.min(rr.width - 220, rx * rr.width - 110))}px`; l.style.top = `${Math.max(0, Math.min(rr.height - 220, ry * rr.height - 110))}px`; });
|
||||
const marcos = $$('#modalCuerpo .marco'), cuerpo = $('#cmpCuerpo');
|
||||
// El marco toma la forma del clip. En vertical, los dos cuadros tan altos como quepan con la columna de la
|
||||
// nota al lado; en apaisado, como siempre, a todo el ancho.
|
||||
const ajustar = () => {
|
||||
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); }); });
|
||||
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(); });
|
||||
|
||||
Referencia en una nueva incidencia
Block a user