Comparar commits
10 Commits
41155cd834
...
main
| Autor | SHA1 | Fecha | |
|---|---|---|---|
| 0720abcf11 | |||
|
|
07f7428869 | ||
|
|
58197ffd67 | ||
|
|
00b84b4aa7 | ||
|
|
c91473aea4 | ||
|
|
8ef3af6ad7 | ||
|
|
eafd1984f3 | ||
|
|
7eb5899f04 | ||
|
|
c91152a3dd | ||
|
|
e74b8f7ece |
92
.github/workflows/publicar.yml
vendido
Archivo normal
92
.github/workflows/publicar.yml
vendido
Archivo normal
@@ -0,0 +1,92 @@
|
||||
# Compila, prueba y publica OrganiLAB desde un PC de Windows del estudio.
|
||||
#
|
||||
# Corre en un «runner» propio de GitHub instalado en ese PC (con la etiqueta «organilab»), no en las máquinas de
|
||||
# GitHub: el NAS solo se alcanza desde la red del estudio, y la clave que firma las actualizaciones no sale de ese
|
||||
# PC. Cómo prepararlo: README, sección «Compilar y publicar en el estudio».
|
||||
#
|
||||
# Se lanza de dos formas:
|
||||
# - subiendo una etiqueta con las notas: git tag -a v3.8.0 -m "Lo que verán al actualizar" y git push origin v3.8.0
|
||||
# - a mano, en GitHub › Actions › «Publicar OrganiLAB» › Run workflow, escribiendo las notas.
|
||||
name: Publicar OrganiLAB
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
notas:
|
||||
description: 'Notas de la versión (lo que verán las copias instaladas al actualizar)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: publicar-organilab
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publicar:
|
||||
runs-on: [self-hosted, windows, organilab]
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: La versión del código y la de la etiqueta coinciden
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$v = (Select-String -Path organizador.py -Pattern '^VERSION = "(.+)"').Matches[0].Groups[1].Value
|
||||
if ("v$v" -ne $env:GITHUB_REF_NAME) { throw "organizador.py dice $v y la etiqueta es $env:GITHUB_REF_NAME" }
|
||||
|
||||
- name: Pruebas
|
||||
shell: pwsh
|
||||
run: |
|
||||
py -3.12 probar.py; if ($LASTEXITCODE) { exit 1 }
|
||||
py -3.12 prueba_orden.py; if ($LASTEXITCODE) { exit 1 }
|
||||
py -3.12 prueba_funcional.py; if ($LASTEXITCODE) { exit 1 }
|
||||
|
||||
- name: labstream-os, al lado (el manifiesto se publica desde ahí)
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path ..\labstream-os)) { git clone https://github.com/Labstream2026/labstream-os.git ..\labstream-os }
|
||||
git -C ..\labstream-os fetch -q origin main
|
||||
|
||||
- name: Empaquetar
|
||||
shell: pwsh
|
||||
env:
|
||||
NOTAS_A_MANO: ${{ inputs.notas }}
|
||||
run: |
|
||||
$n = $env:NOTAS_A_MANO
|
||||
if (-not $n) { $n = (git tag -l --format='%(contents)' $env:GITHUB_REF_NAME) -join "`n" }
|
||||
if (-not $n -or -not $n.Trim()) { throw "Faltan las notas: pon un mensaje en la etiqueta (git tag -a vX.Y.Z -m '...')" }
|
||||
$env:NOTAS = $n.Trim()
|
||||
py -3.12 empaquetar.py; if ($LASTEXITCODE) { exit 1 }
|
||||
|
||||
- name: Probar el paquete como lo instalaría alguien
|
||||
shell: pwsh
|
||||
run: py -3.12 prueba_ventana.py --paquete dist\OrganizadorLabstream; if ($LASTEXITCODE) { exit 1 }
|
||||
|
||||
- name: Publicar (NAS y manifiesto)
|
||||
shell: bash
|
||||
run: bash publicar.sh
|
||||
|
||||
- name: Comprobar lo publicado
|
||||
shell: pwsh
|
||||
run: py -3.12 verificar_publicado.py; if ($LASTEXITCODE) { exit 1 }
|
||||
|
||||
- name: Guardar el manifiesto en el repositorio
|
||||
shell: pwsh
|
||||
run: |
|
||||
git -C ..\labstream-os checkout -- public/herramientas/organizador.json
|
||||
$v = (Select-String -Path organizador.py -Pattern '^VERSION = "(.+)"').Matches[0].Groups[1].Value
|
||||
Copy-Item manifiesto.json $env:RUNNER_TEMP\manifiesto.json
|
||||
git fetch -q origin main
|
||||
git checkout -q -B main origin/main
|
||||
Copy-Item $env:RUNNER_TEMP\manifiesto.json manifiesto.json
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add manifiesto.json
|
||||
git diff --cached --quiet; if ($LASTEXITCODE) { git commit -q -m "Manifiesto de la $v publicada"; git push -q origin main }
|
||||
40
LICENCIA-ES.md
Archivo normal
40
LICENCIA-ES.md
Archivo normal
@@ -0,0 +1,40 @@
|
||||
# Licencia de las apps públicas de Labstream (resumen en español)
|
||||
|
||||
Las apps marcadas como **públicas** en el catálogo se distribuyen bajo la **Licencia MIT**.
|
||||
El texto legal que vale es el de `LICENSE` (en inglés, como es habitual en esta licencia);
|
||||
esto es una explicación.
|
||||
|
||||
## Qué puedes hacer
|
||||
|
||||
- **Descargarlas, instalarlas y usarlas** gratis, para lo que quieras, también en trabajos
|
||||
comerciales.
|
||||
- **Copiarlas, modificarlas y redistribuirlas**, enteras o en parte, con o sin cambios.
|
||||
- Incluirlas en otros programas, incluso de pago.
|
||||
|
||||
## Única condición
|
||||
|
||||
- Conservar el aviso de copyright y el texto de la licencia (`LICENSE`) en cualquier copia o
|
||||
parte sustancial del programa.
|
||||
|
||||
## Sin garantía
|
||||
|
||||
Los programas se entregan **tal cual**, sin garantía de ningún tipo. Labstream S.A.S. no
|
||||
responde por daños derivados de su uso.
|
||||
|
||||
## Lo que la licencia no cubre
|
||||
|
||||
- La **marca Labstream** y su logotipo: puedes usar el código, no presentar otro producto como
|
||||
si fuera de Labstream.
|
||||
- Las **apps privadas** y las **apps de clientes**: tienen las condiciones del contrato de cada
|
||||
proyecto.
|
||||
- Los **componentes de terceros** que cada app incluye: tienen su propia licencia, listada en
|
||||
el `TERCEROS.md` de cada repositorio.
|
||||
|
||||
## Datos personales
|
||||
|
||||
Las apps de escritorio públicas no envían datos a internet ni a Labstream. Las apps web
|
||||
guardan los datos en servidores de Labstream en Colombia y se rigen por la política de
|
||||
tratamiento de datos personales de Labstream S.A.S. (Ley 1581 de 2012 y Decreto 1377 de 2013).
|
||||
Consultas: hola@labstreamsas.com.
|
||||
|
||||
Labstream S.A.S. · NIT 900.665.024-5 · Bogotá, Colombia
|
||||
21
LICENSE
Archivo normal
21
LICENSE
Archivo normal
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Labstream S.A.S. (NIT 900.665.024-5), Bogotá, Colombia
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
212
README.md
212
README.md
@@ -135,12 +135,17 @@ Reglas que hacen que funcione:
|
||||
- **Flujo:** Analizar (ffprobe) → Ver candidatos → Encolar → Verificar (duración, códec, 10 bits) →
|
||||
Reemplazar (el original y su `.XML` de Sony, o el `.sidecar` de un BRAW, van a `_Para borrar/originales`).
|
||||
La salida conserva la fecha de modificación del original y se llama `nombre_10bit.mp4` junto al original.
|
||||
Una conversión que pesa más del 80 % del clip, o que es la reconversión de algo ya convertido, no se
|
||||
ofrece para reemplazar: queda «no compensa» y lo que salió va a `_Para borrar/descartados`.
|
||||
Una conversión que ahorra menos del mínimo pedido menos 10 puntos (con el 30 % de entrada, menos del 20 %),
|
||||
o que es la reconversión de algo ya convertido, no se ofrece para reemplazar: queda «no compensa» y lo que
|
||||
salió va a `_Para borrar/descartados`.
|
||||
- Se ofrece lo que convertido pesaría al menos un 30 % menos (`optimizar.ahorro_minimo`, en «cambiar»). Lo que
|
||||
sale se estima por el códec de origen, porque la cola codifica a calidad constante: lo que guarda cada cuadro
|
||||
entero (BRAW, ProRes, XAVC-I), por la tasa objetivo; lo comprimido entre cuadros (H.264, AV1, VP9), como mucho
|
||||
a 0,6 veces su tasa y nunca por encima del objetivo; un H.265, a 0,93 veces la suya, hasta el techo de 1,8
|
||||
veces el objetivo.
|
||||
- Se excluyen solos: lo que ya convirtió el programa (lleva su sufijo y está en el códec que el sufijo dice),
|
||||
lo que ya viene comprimido y no compensa tocar (H.265, AV1 o VP9 por debajo del doble de la tasa
|
||||
objetivo; H.264 por debajo de la tasa objetivo), lo que tiene canal alfa, los exportados/masters (salvo
|
||||
que se marque «incluir exportados») y lo más reciente que N meses.
|
||||
lo que ahorraría menos del mínimo, lo apartado como repetido (en `Duplicados`), lo que tiene canal alfa, los
|
||||
exportados/masters (salvo que se marque «incluir exportados») y lo más reciente que N meses.
|
||||
|
||||
Estimación con los inventarios actuales: los 1,9 TB de BRAW de Danney y David Reyes quedarían en unos
|
||||
110 GB; los intermedios ProRes/XAVC de más de 1 GB (≈ 1 TB entre los dos) en menos de 100 GB.
|
||||
@@ -245,7 +250,37 @@ y nunca pisa un MP4 que ya exista; las raíces de unidad (`N:\`) ya se consultan
|
||||
bash publicar.sh # sube los zips al NAS, deja el manifiesto en GitHub y despliega el OS
|
||||
|
||||
`empaquetar.py` no deja salir un paquete con un binario sin firmar ni uno cuyo motor no arranque. Con
|
||||
`SOLO_LOCAL=si` construye sin tocar el manifiesto del OS (para ensayar).
|
||||
`SOLO_LOCAL=si` construye sin tocar el manifiesto del OS (para ensayar). `verificar_publicado.py` comprueba
|
||||
después lo que sirve el OS (versión, firma, que es el mismo manifiesto y la huella del paquete).
|
||||
|
||||
### Compilar y publicar en el estudio
|
||||
|
||||
`.github/workflows/publicar.yml` hace todo lo de arriba solo, en un PC de Windows del estudio registrado como
|
||||
«runner» propio de GitHub. No en las máquinas de GitHub: el NAS solo se alcanza desde la red del estudio, y la
|
||||
clave que firma las actualizaciones no tiene que salir de ese PC (quien la tenga puede mandar una actualización a
|
||||
todos los OrganiLAB instalados).
|
||||
|
||||
Preparar el PC una vez (con la sesión del usuario que publicará):
|
||||
|
||||
1. Python 3.12 (`py -3.12`), Git para Windows (trae Git Bash, que usa `publicar.sh`) y ffmpeg (el «full build»
|
||||
de Gyan; si no está en la ruta de WinGet, la variable de entorno `FFMPEG_DIR` apunta a su carpeta `bin`).
|
||||
2. La clave de firma en `%USERPROFILE%\.labstream\organizador-firma.json` (copiada desde el PC donde está hoy, y
|
||||
con copia de seguridad aparte), y la llave del NAS en `%USERPROFILE%\.ssh\editorias_nas`.
|
||||
3. Permiso de escritura en GitHub para `labstream-organizador` y `labstream-os` (Git Credential Manager, con la
|
||||
cuenta de Labstream).
|
||||
4. En GitHub › `labstream-organizador` › Settings › Actions › Runners › «New self-hosted runner» › Windows: seguir
|
||||
los pasos, y al configurarlo añadir la etiqueta `organilab`. Instalarlo como servicio con ese mismo usuario
|
||||
(para que vea la clave y las llaves), en un PC que esté encendido.
|
||||
|
||||
Publicar una versión: subir `VERSION` en `organizador.py`, y
|
||||
|
||||
git tag -a v3.8.0 -m "Lo que verán las copias instaladas al actualizar"
|
||||
git push origin v3.8.0
|
||||
|
||||
o en GitHub › Actions › «Publicar OrganiLAB» › «Run workflow», escribiendo las notas. El trabajo comprueba que la
|
||||
etiqueta y `VERSION` coinciden, pasa las cuatro baterías de pruebas, empaqueta, prueba el paquete arrancándolo,
|
||||
publica, comprueba lo publicado y guarda `manifiesto.json` en `main`. Si una prueba falla, se detiene ahí y no
|
||||
se publica nada.
|
||||
|
||||
Cuando el OS sirve el manifiesto nuevo, cada copia instalada lo ve al abrirse (o al pulsar «Actualizar ahora»),
|
||||
descarga el zip «app» (15 MB), comprueba el sha256 y se sustituye sola sin tocar `datos\` ni `ffmpeg\`.
|
||||
@@ -880,6 +915,171 @@ Pruebas: `prueba_orden.py` 24 casos (5 nuevos: pieza, bandeja, tarjeta con una c
|
||||
que rechazar, versiones con ffprobe simulado, y la pasada automática con sus frenos); `prueba_funcional.py`
|
||||
29 de 29 (3 nuevas, por la interfaz de verdad del servidor); `probar.py` 19 y `prueba_ventana.py` 18.
|
||||
|
||||
3.9.0, la cola más rápida. Munka: «ayúdame a mejorar todo lo que se pueda en la app». Medido en su equipo el
|
||||
14-oct-2026, con 1.920 clips de Vía Sumapaz en espera (31,8 horas de clip, 1,58 TB, unas 26 horas de cola): el
|
||||
codificador de la RTX 5080 iba al 46-74 %, la tarjeta entera al 9-18 % y su descompresor al 0 %.
|
||||
|
||||
- **La tarjeta descomprime** los H.264, H.265, AV1, VP9 y MPEG-2 (NVDEC): hasta ahora lo hacía el procesador y la
|
||||
tarjeta esperaba. Con dos clips 4K de la cola de Sumapaz, un H.264 pasó de 0,54× a 0,86× tiempo real y un H.265
|
||||
de 0,70× a 0,85×, con el mismo archivo de salida. Si con ella un clip no sale, se repite con el procesador.
|
||||
- **Dos clips a la vez** (la RTX 5080 tiene dos codificadores): 1,29× tiempo real entre los dos, frente a unos
|
||||
0,77× de uno solo. Juntando las dos cosas, un H.264 de esos va más del doble de rápido. Se cambia en «cambiar» ›
|
||||
«a la vez» (automático: dos con tarjeta NVIDIA, uno sin ella). La ventana, la barra de abajo y la de Windows
|
||||
enseñan los dos, y el «termina hacia las…» cuenta con ellos.
|
||||
- **Las conversiones sueltas se cierran al arrancar.** Una de las 10:04, de antes de actualizar a la 3.8.0 (el
|
||||
lector de BRAW y su ffmpeg), seguía colgada a las 17:00 con una sesión del codificador y memoria de la tarjeta.
|
||||
Ahora, al abrir, se cierran las que no tienen quien las espere (no las de la pasada de la noche) y se borra su
|
||||
«.parcial»; y «Detener» cierra también el ffmpeg del lector de BRAW, que quedaba vivo.
|
||||
- **La cola, entera.** Se pedían los 500 trabajos más recientes de todas las carpetas: con 1.920 en espera en Vía
|
||||
Sumapaz, los de Línea Gene no salían (ni su «Reemplazar 12 verificados») y el «termina hacia las…» contaba 500
|
||||
en espera, no 1.920 (decía 7 horas y eran 26). Ahora se pide la cola de la carpeta, entera, y el cálculo mira todo.
|
||||
- **Lo que ya no compensa no se convierte**: lo que se encoló con la regla de antes y con la del 30 % ya no se
|
||||
ofrecería (en Sumapaz, 21 H.265) sale de la cola al llegarle el turno, y se dice cuántos.
|
||||
|
||||
Y antes de publicarla, Munka: «que al cerrar cierre todos los procesos, hasta las colas que faltan y los que quedan a
|
||||
mitad; que al continuar una cola de renderizado la continúe y vuelva a generar los videos que hagan falta,
|
||||
continuando por donde iba; que identifique bien el material y haga la búsqueda de grandes archivos de video; y
|
||||
colocaría convertir antes de mover los videos, dado que si muevo y luego convierto no funciona bien».
|
||||
|
||||
- **Cerrar para todo.** Hasta ahora, cerrar con una conversión en marcha dejaba el programa oculto trabajando hasta
|
||||
acabar, y al actualizar (que cerraba de golpe) quedaban procesos sueltos. Ahora cerrar avisa y lo para todo: la
|
||||
cola, la lectura de clips, el respaldo y lo que se trae de una tarjeta (un «Aplicar» se deja acabar: son
|
||||
segundos). Lo que no se cierra por las buenas se cierra con todo lo que lanzó, y su archivo a medias se borra.
|
||||
Si se vuelve a abrir mientras se para, la ventana vuelve, ya sin nada en marcha. Probado con dos conversiones 4K
|
||||
de verdad: se paró todo en 0,9 s, sin un ffmpeg vivo ni un «.parcial».
|
||||
- **Continuar sigue por donde iba.** Lo que se corta a medias (Detener, cerrar, actualizar) vuelve a esperar en su
|
||||
sitio, delante de los demás, y se hace entero otra vez (un MP4 a medias no se puede seguir); antes quedaba
|
||||
«cancelado» y «Reanudar» no lo retomaba: dos BRAW cortados al actualizar se quedaron fuera de la cola. Al
|
||||
continuar, también vuelve lo que falló por un corte («fallo inesperado») y los convertidos que ya no están (borrados
|
||||
o movidos fuera de la app), en las carpetas conectadas. El botón se llama ahora «Continuar».
|
||||
- **Actualizar con la cola en marcha.** Ya no hay que esperar: la cola se detiene, y al volver a abrirse sigue sola.
|
||||
- **Lo grande, a la vista.** Encima de la lista del Convertidor, el vídeo de más del tamaño pedido que hay en la
|
||||
carpeta y qué pasa con cada parte: lo que se ofrece y en cuánto quedaría, lo que ya está en la cola, lo convertido
|
||||
por reemplazar, los exportados y masters que no se ofrecen con un botón para incluirlos, lo que ahorraría poco y lo
|
||||
apartado como repetido. En sus discos: 353 GB de exportados no se ofrecían (la casilla estaba apagada) y 757 GB ya
|
||||
convertidos esperaban «Reemplazar». La lectura de clips no se dejaba nada: de los vídeos de más de 300 MB, solo uno
|
||||
quedaba sin leer.
|
||||
- **Convertir antes de ordenar.** En los pasos de la barra lateral y en Inicio, «Convertir» va antes de «Ordenar» (y
|
||||
apartar lo repetido, antes de convertir). Mover y luego convertir ya funciona desde la 3.8 (la cola sigue a los
|
||||
clips), pero quedaban dos cosas: un clip que se está convirtiendo no se puede mover (ffmpeg lo tiene abierto), y
|
||||
si estaba en una carpeta que el plan renombraba, la carpeta se copiaba entera y luego se borraba lo que se
|
||||
dejaba. Ahora lo que se convierte en ese momento se queda (y su carpeta), con el porqué en los fallos («se estaban
|
||||
convirtiendo en ese momento»), y una carpeta con algo abierto ya no se copia: se queda donde está.
|
||||
|
||||
Pruebas: `prueba_orden.py` 49 casos (ocho nuevos: dos a la vez y «Detener», las conversiones sueltas, la tarjeta
|
||||
descomprime y su vuelta al procesador si falla, lo que ya no compensa, cerrar para todo y continuar, rehacer lo que
|
||||
falta, y no mover lo que se convierte ni copiar una carpeta con algo abierto); `prueba_funcional.py` 33 de 33 (la
|
||||
conversión de verdad ya descomprime con la tarjeta), `probar.py` 19 y `prueba_ventana.py` 18 (cerrar con trabajo en
|
||||
marcha lo para todo).
|
||||
|
||||
3.8.1, convertir todo lo que ahorre un 30 %. Munka: «quiero que si un archivo se puede optimizar así sea un 30 %
|
||||
se convierta, la idea es aprovechar lo máximo el espacio».
|
||||
|
||||
- **La regla, por ahorro**: se ofrece todo lo que convertido pesaría al menos un 30 % menos, y el porcentaje se
|
||||
cambia en «cambiar» (vale también para la pasada automática y para Inicio). Hasta ahora se miraba si el clip
|
||||
pasaba de 1 vez (H.264) o 2 veces (H.265, AV1, VP9) la tasa objetivo, y eso fallaba por los dos lados.
|
||||
- **La estimación, medida**: la cola codifica a calidad constante, así que lo que sale depende de cómo venía el
|
||||
clip. Sobre las 286 conversiones hechas, un H.264 de cámara quedó entre el 9 y el 26 % de su peso, un AV1 de
|
||||
móvil o de cámara entre el 9 y el 11 %, lo que guarda cada cuadro entero entre el 1 y el 6 %, y un H.265 de
|
||||
cámara entre el 85 y el 99 %. La estimación nueva acierta la decisión en los 102 clips que se pudieron cotejar
|
||||
y queda del lado prudente. Con ella entran los 11 AV1 de Línea Gene (21,2 GB, que la estimación deja en unos
|
||||
11 GB; si se portan como los dos AV1 ya convertidos, unos 2 GB) y dejan de ofrecerse los H.265 de cámara: la regla vieja
|
||||
había mandado a convertir 6 que luego no adelgazaron.
|
||||
- **Lo apartado como repetido** (en una carpeta `Duplicados`) ya no se ofrece: es para borrar, no para convertir.
|
||||
- El descarte después de convertir sigue al mínimo: se descarta lo que ahorre menos del mínimo menos 10 puntos
|
||||
(con el 30 %, menos del 20 %, como hasta ahora).
|
||||
|
||||
Y lo que salió al cotejar, archivo por archivo, cada disco con el inventario (Munka: «quiero saber si están todos
|
||||
los archivos del disco, si hizo bien su escaneo»). El escaneo estaba completo: en los cinco clientes no faltaba
|
||||
ningún archivo que existiera al escanear (lo que no estaba eran los convertidos hechos después, y en Diana Méndez
|
||||
lo que alguien vació de _Para borrar). Pero había tres cosas:
|
||||
|
||||
- **Las mayúsculas de las carpetas.** Windows no distingue «Altas Print» de «ALTAS PRINT». Si el plan movía a la
|
||||
primera y en el disco estaba la segunda, el archivo caía en la que había, pero el inventario apuntaba la ruta
|
||||
del plan: en Vía Sumapaz, 1.039 fotos con la carpeta mal escrita, y «Altas print», «Altas Print» y «ALTAS
|
||||
PRINT» como tres carpetas donde en el disco hay una. Ahora se escribe la carpeta como está en el disco, y las
|
||||
carpetas se recuentan donde quedaron al final de la tanda.
|
||||
- **Mover mientras se escanea.** Un proxy que la app apartó a _Para borrar a las 10:02, en pleno escaneo de Vía
|
||||
Sumapaz, quedó dos veces en el inventario. Ahora, si la app mueve algo de una carpeta mientras se recorre, se
|
||||
recorre otra vez (hasta tres; si sigue moviéndose, lo avisa).
|
||||
- **Lo ya convertido, dos veces.** Con la 3.7 los 11 verificados de Línea Gene se volvieron a encolar en su sitio
|
||||
nuevo junto con los 136 que faltaban, y uno ya se había convertido otra vez. Ahora, al reengancharlos, la
|
||||
conversión que aún espera se quita; la que ya se hizo deja «no compensa» a la vieja, y «Limpiar terminados»
|
||||
aparta su archivo.
|
||||
|
||||
Pruebas: `prueba_orden.py` 42 casos (tres nuevos: las mayúsculas del disco, escanear mientras se mueve, y lo ya
|
||||
convertido que se volvió a encolar; el de «lo ya comprimido» pasa a ser el del 30 %: H.265 holgado y justo, H.264
|
||||
por debajo del objetivo, AV1 de móvil, Duplicados, el ajuste al 45 % y el descarte); `prueba_funcional.py` 33 de
|
||||
33, `probar.py` 19 y `prueba_ventana.py` 18.
|
||||
|
||||
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»). 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
|
||||
red dos veces por movimiento, a 41 ms con el NAS ocupado, y buscaba lo de dentro de cada carpeta recorriendo
|
||||
la tabla entera. Mientras tanto «Buscar clips» esperaba un minuto y se rendía. Ahora lo que hay que preguntar
|
||||
al disco se pregunta antes, todo a la vez y en paralelo, y lo de dentro de cada carpeta se busca por índice:
|
||||
1,3 segundos para los 2.493 movimientos, con las mismas filas de archivos que la versión anterior (comparado
|
||||
tabla por tabla) y 183 carpetas que la anterior perdía del inventario y sí existen en el disco. Y la lectura
|
||||
de clips, si la base está ocupada, espera y reintenta. Leídos así los clips de Sumapaz: 2.760, 2.319
|
||||
convertibles (1,97 TB, unos 0,33 TB convertidos, 145 de ellos BRAW).
|
||||
- **Los 753 que no se movieron**: cada evento de «Fotos varias» guarda la misma foto en «Alta» y en «Baja» con
|
||||
el mismo nombre, y el plan juntaba las dos en una sola carpeta; la segunda chocaba con la primera y, como la
|
||||
app nunca renombra, se quedaba donde estaba (no se perdió nada). Ahora las subcarpetas con sentido bajo una
|
||||
sesión («Alta», «Baja», «Seleccionadas»…) se conservan debajo de la cámara, el plan avisa antes si dos archivos
|
||||
de una misma fila acabarían en el mismo sitio, y «Fiesta 6 Años» ya no pierde el 6 (un número entre dos
|
||||
palabras es parte del nombre; los prefijos y las fechas se siguen quitando).
|
||||
- **Los fallos de «Aplicar», explicados**: se agrupan por motivo (ya había un archivo con ese nombre, estaba
|
||||
abierto en otro programa, sin permiso, ya no estaba, la copia entre discos no quedó completa, se cortó la red)
|
||||
y cada grupo dice qué hacer, con ejemplos.
|
||||
- **El banco de material, en llano**: cada fila dice «69 clips de la URSA (BRAW) en el tema «Lanzamiento
|
||||
Sumapaz» → se quedan en el mismo tema, en 2023-12-23 › URSA_BRAW», en vez de dos rutas largas; arriba, qué
|
||||
hace el paso con un ejemplo, y qué quiere decir «probable» y «dudoso».
|
||||
- **Inicio, como lista con ✓**: lo hecho se queda en su sitio con «Hecho hoy a las 07:41: …» en vez de
|
||||
desaparecer (el tipo de trabajo, el stock, los repetidos, la bandeja, convertir), arriba se cuentan los
|
||||
hechos, y lo que acaba de pasar a hecho se ilumina. «Basura y proxies» deja de pedirse cuando lo que queda es
|
||||
lo de «No tocar» o lo que va sin marcar.
|
||||
- **Fechas raras** (Revisar): las tandas de clips de una cámara con el reloj sin poner (2008, 2009…), con una
|
||||
pista de su fecha real; se pone la del primer clip y la app corre los demás lo mismo. La corrección se guarda
|
||||
por archivo (nombre y peso), sobrevive a volver a escanear y a que el plan los mueva, y se deshace.
|
||||
- **Escaneo profundo** (por defecto; se apaga en Ajustes): después de escanear, la app lee cada clip (códec,
|
||||
duración, resolución) en segundo plano y solo lo nuevo.
|
||||
- **Buscar en todos**: el buscador de la barra lateral encuentra un archivo o una carpeta por su nombre en todas
|
||||
las carpetas de clientes, con o sin tildes, y abre su carpeta.
|
||||
- **Otros discos**: si el cliente está también en otros discos (Vía Sumapaz: K:, Q: y dos en T:), Inicio lo
|
||||
dice y deja traer cada sitio con copia comprobada; el original no se toca.
|
||||
- **El banco, por temas o por fecha**: en «Qué va a pasar» se elige; por fecha, cada grabación va a la
|
||||
00_Grabacion de su mes con su tema de etiqueta.
|
||||
- **«Calcular de nuevo» más rápido**: las comprobaciones del plan contra el disco van al final y en paralelo; en
|
||||
Sumapaz después de aplicar, de 58 s a 20 s.
|
||||
- **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` 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
|
||||
forma de organización del material», «no hay reorganización y faltan funciones», y una captura de «Qué va a
|
||||
pasar» con el botón «Calcular» gris y ningún plan. Lo medido el 14-oct-2026 sobre `Y:\Sumapaz` (76.559
|
||||
|
||||
@@ -122,9 +122,10 @@ def _sha256(ruta):
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def tarea_en_marcha():
|
||||
"""Texto de la tarea que impide actualizar ahora, o '' si se puede."""
|
||||
if O.COLA.get("activa"):
|
||||
def tarea_en_marcha(sin_cola=False):
|
||||
"""Texto de la tarea que está en marcha, o ''. Para actualizar (`sin_cola`), la cola no cuenta: se detiene, y
|
||||
sigue sola al volver a abrirse (ver O.detener_todo)."""
|
||||
if O.COLA.get("activa") and not sin_cola:
|
||||
return "hay una conversión en marcha"
|
||||
if O.RESPALDO.get("estado") == "corriendo":
|
||||
return "hay una copia de respaldo en marcha"
|
||||
@@ -173,7 +174,7 @@ def _descargar_y_aplicar(relanzar_args):
|
||||
ESTADO["estado"] = "error"
|
||||
ESTADO["error"] = "la actualización automática solo funciona en el paquete (OrganizadorLabstream.exe); desde el código se actualiza con git"
|
||||
return False
|
||||
ocupado = tarea_en_marcha()
|
||||
ocupado = tarea_en_marcha(sin_cola=True)
|
||||
if ocupado:
|
||||
ESTADO["estado"] = "error"
|
||||
ESTADO["error"] = f"no se actualiza ahora: {ocupado}. Espera a que termine."
|
||||
@@ -270,6 +271,8 @@ def _descargar_y_aplicar(relanzar_args):
|
||||
f"rmdir /s /q \"{_bat(carpeta)}\" >nul 2>&1\r\n"
|
||||
"timeout /t 2 /nobreak >nul\r\n"
|
||||
"(goto) 2>nul & del \"%~f0\"\r\n")
|
||||
ESTADO["estado"] = "deteniendo"
|
||||
O.detener_todo(seguir=True) # la conversión que va se corta y vuelve a la cola; sigue sola al volver
|
||||
ESTADO["estado"] = "reiniciando"
|
||||
ESTADO["aplicando"] = True
|
||||
# con su PROPIA consola: sin ella, timeout/tasklist se quedan colgados y la copia nunca ocurre
|
||||
|
||||
@@ -46,7 +46,7 @@ MOTOR = f"python{sys.version_info.major}{sys.version_info.minor}" # python
|
||||
SIN_LIB = {"site-packages", "test", "idlelib", "tkinter", "turtledemo", "ensurepip", "venv", "lib2to3", "pydoc_data", "__pycache__"}
|
||||
SIN_DLL = ("_tkinter", "tcl", "tk8", "zlib1", "_test", "_ctypes_test", "xxlimited")
|
||||
|
||||
ffbin = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
ffbin = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("FFMPEG_DIR", "") # en el PC compilador: FFMPEG_DIR
|
||||
if not ffbin:
|
||||
for c in glob.glob(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin")):
|
||||
ffbin = c
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"nombre": "OrganiLAB",
|
||||
"version": "3.7.0",
|
||||
"builtAt": "2026-10-14T12:28:59.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-3.7.0.zip",
|
||||
"size": 195488709,
|
||||
"sha256": "5d134f41a56084d55e216666310131994f00567b23e5708193cd86774d1b27fc",
|
||||
"version": "3.9.0",
|
||||
"builtAt": "2026-10-14T22:55:40.000Z",
|
||||
"archivo": "OrganizadorLabstream-portatil-3.9.0.zip",
|
||||
"size": 195523321,
|
||||
"sha256": "bf9154e1b3ae724653c2653d509aad1d26a6120f5a7883ab751f1a6f37191208",
|
||||
"app": {
|
||||
"archivo": "OrganizadorLabstream-app-3.7.0.zip",
|
||||
"size": 11676130,
|
||||
"sha256": "9a2121c87c8ec72a2b69c7bfec6bd8bd8c649f025b583504caea147fd066d1c8",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-3.7.0.zip"
|
||||
"archivo": "OrganizadorLabstream-app-3.9.0.zip",
|
||||
"size": 11710742,
|
||||
"sha256": "4b83b9fcbe89318283722d4f93a9699a7e56c7a2ef49bfa7da7875ff8b66bc4f",
|
||||
"url": "/api/herramientas/OrganizadorLabstream-app-3.9.0.zip"
|
||||
},
|
||||
"notes": "3.7.0: «Calcular qué va a pasar» ya no se queda gris después de la primera vez (al cambiar de carpeta no salía ningún plan), y «Reanudar» la cola vuelve a funcionar tras «Detener». Una carpeta «No tocar» es un banco de material: los temas se quedan y, dentro, las grabaciones van por fecha y cámara, todo sin marcar para que decidas tú. «Qué pesa» entra en cada carpeta hasta el fondo y dice qué guarda, de qué cámaras y de qué fechas. Un proxy sin su original ya no se aparta solo, y «hacer de una vez» solo mueve lo marcado. Inicio y «Qué hay» cargan mucho más rápido en carpetas grandes, e Inicio avisa si el tipo de trabajo de la carpeta no es el del cliente.",
|
||||
"notes": "3.9.0: la cola convierte más del doble de rápido: la tarjeta descomprime los clips (antes lo hacía el procesador y la tarjeta esperaba) y van dos a la vez (se cambia en «cambiar» › «a la vez»). Cerrar el programa lo para todo y la próxima vez «Continuar» sigue por donde iba: lo que quedó a medias se hace entero otra vez, y también vuelve lo que falló por un corte y los convertidos que ya no están. Se puede actualizar con la cola en marcha: se detiene y sigue sola al volver. Al abrir se cierran las conversiones que quedaron sueltas. El Convertidor enseña lo grande de cada carpeta y qué pasa con cada parte, con un botón para incluir los exportados y masters. «Convertir» va antes de «Ordenar», y lo que se está convirtiendo no se mueve. La cola de más de 500 se ve entera y su «termina hacia las…» cuenta todo.",
|
||||
"firma": {
|
||||
"alg": "rsa2048-pkcs1v15-sha256",
|
||||
"valor": "b9560e1f5ac54a4790fb27280bb2cbd75443de1435994af4a40e787f971e7e1db24baba84f581459cefc3b2c96cca3f9d0422150fe717b650052d762a65d19d100a9ebd16099e45db85550b53e45739b8e52b1846787e689dae79e024ace18ff49a7db54e16a05e7e22b6efd44b7f233d3e3603cfaba4549f46cf2953198aa877f6a61fbc1cbf325e4b4bcdd81ab137e802bc6a589c9e4dc307002a82294f4387410088895fb5a0befaccc758e8db74bbb7d919b7242b8a8287c7dc595fea2a0dd31b8d3e4568da54b2da4959fdb4931d3c6e4f33c5037dd3b4ba810deba94befe33ead0390ab84830053bbeeda5e3cec310f8fac7d7bf1444a7b8042a2ff2a8"
|
||||
"valor": "58d27cbf2f22948c6b370816aab377b055bdf63937567f9cb07e505e4ff751814115915897793313fbc804683c62392e94bf69c5dea402b6bc757beee625dd075be847260d012fb037ab16d345a339f39e49149f9cc4a4b5cabd7140693b3d0129280557e729c3a08afff1afffb0da935e7c616537c3feb0e35f06ad136c7f208eee2aa041f65af771888a67cad8bfc8da295ff94702ae3b9a10c9d383a16918288ac0a9b30d6be27498a10e1e6682148b09d9b20ff21b65ea986cca8851fbcce348d959cc30f19ce511ceae1a055394e282a461f344e7f398331ff2670aea8755380da7d4675e4682f670a62d0304c471ba07e5a2448f5cf68ec3c51d255f8f"
|
||||
}
|
||||
}
|
||||
1525
organizador.py
1525
organizador.py
La diferencia del archivo ha sido suprimido porque es demasiado grande
Cargar Diff
@@ -212,7 +212,12 @@ def p_tipo_y_pesa():
|
||||
# y la ficha se queda, hecha, con lo que se eligió y cuándo: no desaparece
|
||||
t = {x['clave']: x for x in get('/api/carpeta/acciones' + q(raiz=RAIZ))['acciones']}['tipo']
|
||||
assert t['estado'] == 'hecho' and 'Piezas mes a mes' in (t.get('hecho') or ''), t
|
||||
return '«Qué pesa» entra hasta %d niveles bajo 2026; el tipo de la carpeta y el del cliente se igualan' % d2['niveles']
|
||||
# buscar en todos los clientes, y las fechas raras (aquí no hay ninguna)
|
||||
b = get('/api/buscar' + q(q='reel botox'))
|
||||
assert b['total'] >= 1 and all('reel botox' in x['nombre'].lower() for x in b['resultados']), b
|
||||
rl = get('/api/relojes' + q(raiz=RAIZ))
|
||||
assert rl['grupos'] == [] and rl['corregidos'] == [], rl
|
||||
return '«Qué pesa» entra hasta %d niveles bajo 2026; el tipo de la carpeta y el del cliente se igualan; buscar encuentra %d' % (d2['niveles'], b['total'])
|
||||
|
||||
|
||||
def p_nombrar():
|
||||
@@ -392,7 +397,7 @@ def p_analizar():
|
||||
assert clip and not clip[0]['excluido'], (clip or cand)[:1]
|
||||
IDS['ruta'] = clip[0]['ruta']
|
||||
comprimido = [x for x in cand if x['ruta'].lower().endswith('c0021.mp4')]
|
||||
assert comprimido and comprimido[0]['excluido'].startswith('ya viene comprimido (H.264'), (comprimido or cand)[:1]
|
||||
assert comprimido and comprimido[0]['excluido'].startswith('convertido ahorraría solo un'), (comprimido or cand)[:1]
|
||||
return 'leyó %d clip(s) · C0020.MP4 %s %s a %d kb/s: se ofrece · C0021.MP4 a %d kb/s: no' % (
|
||||
len(cand), clip[0]['codec'], clip[0]['res'], clip[0]['kbps'], comprimido[0]['kbps'])
|
||||
|
||||
@@ -640,3 +645,4 @@ if malas:
|
||||
print('== últimas líneas del servidor ==')
|
||||
print(open(os.path.join(TMP, 'servidor.log'), encoding='utf-8', errors='replace').read()[-1500:])
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
sys.exit(1 if malas else 0) # la compilación del estudio se detiene si algo falla
|
||||
|
||||
600
prueba_orden.py
600
prueba_orden.py
@@ -12,6 +12,7 @@ import time
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
AQUI = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -402,6 +403,77 @@ class Motor(unittest.TestCase):
|
||||
self.assertTrue(d["niveles"] >= 3 and "Lugares" in [h["nombre"] for h in d["hijos"]], d)
|
||||
self.assertIn("banco", [a["clave"] for a in O.acciones(raiz)["acciones"]])
|
||||
|
||||
def test_alta_y_baja_no_chocan(self):
|
||||
"""Vía Sumapaz: cada evento guardaba la misma foto en «Alta» y en «Baja»; el plan las juntaba en una
|
||||
sola carpeta y 753 no se movieron. Las subcarpetas con sentido se conservan."""
|
||||
raiz = cliente("AltaBaja")
|
||||
for sub, tam in (("Alta", 8192), ("Baja", 2048)):
|
||||
for n in ("IMG_5154.jpg", "IMG_5155.jpg"):
|
||||
mk(raiz, f"Fotos varias/2022/Enero/Evento recaudadoras/{sub}/{n}", 2022, 1, 2, tam)
|
||||
mk(raiz, "Fotos varias/2022/Diciembre/Fiesta 6 Años/IMG_3135.jpg", 2022, 12, 14)
|
||||
antes = nombres(raiz)
|
||||
p = plan(raiz)
|
||||
self.assertFalse([f for f in p["filas"] if "OJO" in f["motivo"]], [f["motivo"] for f in p["filas"]])
|
||||
por = {}
|
||||
for f in p["filas"]:
|
||||
for a in f["archivos"] or []:
|
||||
por[a] = f["destino_rel"]
|
||||
alta = [d for a, d in por.items() if os.sep + "Alta" + os.sep in a]
|
||||
baja = [d for a, d in por.items() if os.sep + "Baja" + os.sep in a]
|
||||
self.assertTrue(alta and baja and all(d.endswith("Alta") for d in alta) and all(d.endswith("Baja") for d in baja), por)
|
||||
self.assertTrue(any("Fiesta 6 Años" in d for d in por.values()), por)
|
||||
aplicar(raiz, p) # sin errores: ninguna foto choca con su gemela
|
||||
self.assertEqual(nombres(raiz), antes)
|
||||
|
||||
def test_reloj_de_una_camara(self):
|
||||
"""Una cámara con el reloj sin poner: se corrige con la fecha real del primer clip, sigue corregida al
|
||||
volver a escanear, el plan la lleva a su mes de verdad, y se deshace."""
|
||||
raiz = cliente("Reloj")
|
||||
mk(raiz, "Vía Mujeres/Drone/DJI_0001.MP4", 2009, 1, 19, 4096)
|
||||
mk(raiz, "Vía Mujeres/Drone/DJI_0002.MP4", 2009, 1, 20, 4096)
|
||||
plan(raiz) # escanea
|
||||
r = O.relojes(raiz)
|
||||
self.assertEqual([(g["fuente"], g["n"], g["desde"]) for g in r["grupos"]], [("DJI", 2, "2009-01-19")], r)
|
||||
c = O.corregir_reloj(raiz, r["grupos"][0]["dir"], "DJI", "2024-03-12")
|
||||
self.assertTrue(c.get("ok") and c["n"] == 2, c)
|
||||
con = O.db()
|
||||
fechas = sorted((x["fecha"][:10], x["fecha_origen"]) for x in con.execute("SELECT fecha, fecha_origen FROM archivos WHERE raiz = ? AND nombre LIKE 'DJI_%'", (raiz,)))
|
||||
con.close()
|
||||
self.assertEqual(fechas, [("2024-03-12", "corregida"), ("2024-03-13", "corregida")])
|
||||
p = plan(raiz) # vuelve a escanear: la corrección se mantiene
|
||||
self.assertFalse(O.relojes(raiz)["grupos"])
|
||||
self.assertTrue(any("2024" in f["destino_rel"] and "03- Marzo" in f["destino_rel"] for f in p["filas"]), [f["destino_rel"] for f in p["filas"]])
|
||||
d = O.deshacer_reloj(raiz, O.relojes(raiz)["corregidos"][0]["grupo"])
|
||||
self.assertTrue(d.get("ok"), d)
|
||||
self.assertEqual(O.relojes(raiz)["grupos"][0]["desde"], "2009-01-19")
|
||||
# y buscar la encuentra, con tilde o sin ella
|
||||
b = O.buscar("via mujeres dji_0002")
|
||||
self.assertEqual([x["nombre"] for x in b["resultados"]], ["DJI_0002.MP4"], b)
|
||||
self.assertEqual(O.buscar("x").get("error"), "escribe al menos dos letras")
|
||||
|
||||
def test_banco_por_fecha(self):
|
||||
"""El banco también se puede ordenar por fecha: a la 00_Grabacion de su mes, con el tema de etiqueta."""
|
||||
raiz = cliente("BancoFecha")
|
||||
mk(raiz, "Archivo (No tocar)/Lugares/Peajes/Drone/DJI_20240312101500_0001_D.MP4", 2024, 3, 12)
|
||||
mk(raiz, "Archivo (No tocar)/Lugares/Peajes/Final.mp4", 2024, 4, 2)
|
||||
O.CONFIG.setdefault("bancos", {})[os.path.normpath(raiz).lower()] = {O.R.norm("Archivo (No tocar)"): "fecha"}
|
||||
self.addCleanup(lambda: O.CONFIG["bancos"].pop(os.path.normpath(raiz).lower(), None))
|
||||
p = plan(raiz)
|
||||
self.assertEqual([b["modo"] for b in p["bancos"]], ["fecha"])
|
||||
banco = [f for f in p["filas"] if f["paso"] == "banco"]
|
||||
self.assertEqual([f["destino_rel"] for f in banco], [rel("2024", "03- Marzo", "00_Grabacion", "12- Martes", "Peajes", "OSMO")])
|
||||
self.assertFalse(banco[0]["marcado"])
|
||||
|
||||
def test_otros_discos(self):
|
||||
"""El mismo cliente en otros discos: se ofrece traerlo, sin contar las carpetas ya registradas."""
|
||||
raiz = cliente("Repartido")
|
||||
previo = dict(O._DISCOS_CACHE)
|
||||
self.addCleanup(lambda: (O._DISCOS_CACHE.clear(), O._DISCOS_CACHE.update(previo)))
|
||||
O._DISCOS_CACHE.update(ts=time.time(), datos={"discos": [{"letra": "K:", "unc": r"\\nas\Entregas", "nombre": ""}],
|
||||
"clientes": {"Repartido": [r"K:\Repartido viejo", raiz]}})
|
||||
s = O.otros_sitios(raiz)
|
||||
self.assertEqual([(x["ruta"], x["unc"]) for x in s], [(r"K:\Repartido viejo", r"\\nas\Entregas")])
|
||||
|
||||
def test_el_convertidor_no_repite(self):
|
||||
"""22 clips se habían convertido dos veces y algunos tres («…_10bit_10bit_10bit.mp4»)."""
|
||||
raiz = cliente("Convertidos")
|
||||
@@ -430,6 +502,60 @@ class Motor(unittest.TestCase):
|
||||
self.assertEqual(R.firma_archivo("C0001_arch.mp4"), "SONY")
|
||||
self.assertTrue(R.es_conversion("C0001_10bit.mp4")) # y los de siempre siguen valiendo
|
||||
|
||||
def test_el_inventario_escribe_la_carpeta_como_el_disco(self):
|
||||
"""Vía Sumapaz, 14-oct-2026: el plan movió fotos a «Altas Print» y «Altas print», que en el disco son la misma
|
||||
«ALTAS PRINT»; el inventario apuntó tres carpetas distintas con 1.039 fotos mal escritas."""
|
||||
raiz = cliente("Mayusculas")
|
||||
nr = os.path.normpath(raiz)
|
||||
a = mk(raiz, "Sueltas/foto1.jpg", 2024, 5, 3)
|
||||
b = mk(raiz, "Sueltas/foto2.jpg", 2024, 5, 3)
|
||||
mk(raiz, "2024/05- Mayo/_Fotos/ALTAS PRINT/foto0.jpg", 2024, 5, 3)
|
||||
O.escanear(raiz)
|
||||
movidos = []
|
||||
O.mover(a, os.path.join(nr, "2024", "05- Mayo", "_Fotos", "Altas Print", "foto1.jpg"), "prueba", movidos)
|
||||
O.mover(b, os.path.join(nr, "2024", "05- mayo", "_fotos", "altas print", "foto2.jpg"), "prueba", movidos)
|
||||
bien = os.path.join(nr, "2024", "05- Mayo", "_Fotos", "ALTAS PRINT")
|
||||
self.assertEqual([d for _, d in movidos], [os.path.join(bien, "foto1.jpg"), os.path.join(bien, "foto2.jpg")])
|
||||
O.actualizar_inventario(movidos)
|
||||
con = O.db()
|
||||
rutas = {r[0] for r in con.execute("SELECT ruta FROM archivos WHERE raiz = ?", (nr,))}
|
||||
carpetas = [r[0] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ? AND lower(ruta) = lower(?)", (nr, bien))]
|
||||
con.close()
|
||||
self.assertTrue({os.path.join(bien, n) for n in ("foto0.jpg", "foto1.jpg", "foto2.jpg")} <= rutas, rutas)
|
||||
self.assertEqual(carpetas, [bien])
|
||||
# una carpeta que no existía se crea como la escribe el plan, y las siguientes la siguen igual
|
||||
c = mk(raiz, "Sueltas/foto3.jpg", 2024, 5, 3)
|
||||
d = mk(raiz, "Sueltas/foto4.jpg", 2024, 5, 3)
|
||||
movidos = []
|
||||
O.mover(c, os.path.join(nr, "Nueva Carpeta", "foto3.jpg"), "prueba", movidos)
|
||||
O.mover(d, os.path.join(nr, "NUEVA CARPETA", "foto4.jpg"), "prueba", movidos)
|
||||
self.assertEqual({os.path.dirname(x) for _, x in movidos}, {os.path.join(nr, "Nueva Carpeta")})
|
||||
|
||||
def test_escaneo_con_movimientos_a_la_vez(self):
|
||||
"""Vía Sumapaz, 14-oct-2026: un proxy apartado a _Para borrar en pleno escaneo quedó dos veces en el
|
||||
inventario (visto en su sitio y luego en la papelera). Si la app mueve algo mientras se recorre, se recorre otra vez."""
|
||||
raiz = cliente("EnMarcha")
|
||||
nr = os.path.normpath(raiz)
|
||||
p = mk(raiz, "2024/09- Septiembre/03- Botellitas/Proxy/Botellas Vr2.mov", 2024, 9, 3)
|
||||
mk(raiz, "2024/09- Septiembre/03- Botellitas/Botellas Vr2.mov", 2024, 9, 3, tam=4096)
|
||||
real = O._fechas_de_rodaje
|
||||
llamadas = []
|
||||
|
||||
def a_la_vez(filas):
|
||||
llamadas.append(len(filas))
|
||||
if len(llamadas) == 1: # después de recorrer, antes de guardar: la app aparta el proxy
|
||||
O.mover(p, os.path.join(nr, R.PAPELERA, "proxies", os.path.relpath(p, nr)), "aparte", [])
|
||||
return real(filas)
|
||||
O._fechas_de_rodaje = a_la_vez
|
||||
self.addCleanup(setattr, O, "_fechas_de_rodaje", real)
|
||||
O.escanear(raiz)
|
||||
self.assertEqual(len(llamadas), 2)
|
||||
con = O.db()
|
||||
proxies = [r[0] for r in con.execute("SELECT ruta FROM archivos WHERE raiz = ? AND nombre = 'Botellas Vr2.mov' AND bytes = 2048", (nr,))]
|
||||
con.close()
|
||||
self.assertEqual(proxies, [os.path.join(nr, R.PAPELERA, "proxies", os.path.relpath(p, nr))])
|
||||
self.assertEqual(O.PROGRESO[nr]["estado"], "listo")
|
||||
|
||||
def test_inventario_de_otra_version(self):
|
||||
"""Lo que apunta el escaneo cambió: un inventario viejo se relee antes de planear nada."""
|
||||
raiz = cliente("Viejo")
|
||||
@@ -729,38 +855,65 @@ class Convertidor(unittest.TestCase):
|
||||
con.close()
|
||||
return t
|
||||
|
||||
def test_lo_ya_comprimido_no_se_reconvierte(self):
|
||||
"""9 clips que ya eran H.265 se reconvirtieron para quedarse en el 95 % de su peso."""
|
||||
def test_se_convierte_lo_que_ahorra_un_30(self):
|
||||
"""Munka, 14-oct-2026: «si un archivo se puede optimizar así sea un 30 % se convierta». Medido sobre las 286
|
||||
conversiones hechas: un H.265 de cámara se queda en el 85-99 % (6 se reconvirtieron para nada), y en cambio
|
||||
los AV1 de 85 Mb/s que no se ofrecían quedan en la décima parte."""
|
||||
raiz = cliente("Comprimidos")
|
||||
g = "2025/06- Junio/00_Grabacion/"
|
||||
for n in ("A014_06170738_C019.mp4", "C0001.MP4", "C0002.MP4", "C0003.MP4", "C0004.MP4", "C0005.MP4"):
|
||||
for n in ("A014_06170738_C019.mp4", "C0001.MP4", "C0002.MP4", "C0003.MP4", "C0004.MP4", "C0005.MP4", "C0006.MP4",
|
||||
"A015_05250930_C004.mp4", "Duplicados/C0003.MP4"):
|
||||
mk(raiz, g + n, 2025, 6, 17)
|
||||
O.escanear(raiz)
|
||||
self.leidos(raiz, {
|
||||
"A014_06170738_C019.mp4": {"codec": "h264", "perfil": "Main", "ancho": 4608, "alto": 2592, "kbps": 25000},
|
||||
"C0001.MP4": {"codec": "h264", "perfil": "High 4:2:2 Intra", "pix_fmt": "yuv422p10le", "fps": 59.94, "kbps": 596000},
|
||||
"C0002.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "fps": 59.94, "kbps": 72000},
|
||||
"C0003.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000},
|
||||
"C0004.MP4": {"codec": "av1", "perfil": "Main", "kbps": 30000},
|
||||
"C0004.MP4": {"codec": "av1", "perfil": "Main", "kbps": 22000},
|
||||
"C0005.MP4": {"codec": "prores", "perfil": "HQ", "pix_fmt": "yuv422p10le", "kbps": 870000},
|
||||
"C0006.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "kbps": 200000},
|
||||
"A015_05250930_C004.mp4": {"codec": "av1", "perfil": "Main", "ancho": 2160, "alto": 3840, "fps": 59.94, "kbps": 83000},
|
||||
})
|
||||
con = O.db()
|
||||
with con:
|
||||
con.execute("UPDATE archivos SET analizado = 1, codec = 'h264', perfil = 'High', pix_fmt = 'yuv420p', ancho = 3840, alto = 2160, "
|
||||
"fps = 30, dur = 60, kbps = 120000 WHERE nombre = 'C0003.MP4'") # el clip y su copia en Duplicados
|
||||
con.close()
|
||||
nr = os.path.normpath(raiz)
|
||||
cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0)}
|
||||
self.assertTrue(cs["A014_06170738_C019.mp4"].startswith("ya viene comprimido (H.264 a 25 Mb/s)"), cs) # por debajo del objetivo (30)
|
||||
self.assertTrue(cs["C0002.MP4"].startswith("ya viene comprimido (H.265 a 72 Mb/s)"), cs)
|
||||
self.assertTrue(cs["C0004.MP4"].startswith("ya viene comprimido (AV1 a 30 Mb/s)"), cs)
|
||||
# lo que guarda cada cuadro entero (XAVC-I, ProRes) y el H.264 muy holgado sí adelgazan de verdad
|
||||
cands = {os.path.relpath(c["ruta"], os.path.join(nr, g.replace("/", S))): c for c in O.candidatos(nr, 0, "equilibrado", 0)}
|
||||
cs = {k: c["excluido"] for k, c in cands.items()}
|
||||
# un H.265 de cámara casi no adelgaza: no se ofrece; uno muy holgado (el techo es 1,8 veces el objetivo), sí
|
||||
self.assertTrue(cs["C0002.MP4"].startswith("convertido ahorraría solo un 7 % (H.265 a 72 Mb/s): se convierte desde un 30 %"), cs)
|
||||
self.assertEqual(cs["C0006.MP4"], "")
|
||||
# el H.264 por debajo del objetivo y los AV1 (el de móvil a 83 Mb/s, uno a 22), que antes no se ofrecían, ahora sí
|
||||
self.assertEqual([cs["A014_06170738_C019.mp4"], cs["A015_05250930_C004.mp4"], cs["C0004.MP4"]], ["", "", ""])
|
||||
# lo que guarda cada cuadro entero (XAVC-I, ProRes) y el H.264 muy holgado, como siempre
|
||||
self.assertEqual([cs["C0001.MP4"], cs["C0003.MP4"], cs["C0005.MP4"]], ["", "", ""])
|
||||
# lo apartado como repetido no se convierte: se borra
|
||||
self.assertTrue(cs[S.join(("Duplicados", "C0003.MP4"))].startswith("está apartado como repetido"), cs)
|
||||
# lo que se estima: un AV1 a 83 Mb/s, como mucho el objetivo (49 % menos); un H.264 a 25, como mucho 0,6 veces
|
||||
a = cands["A015_05250930_C004.mp4"]
|
||||
self.assertAlmostEqual(a["estimado"] / (83000 * 1000 / 8 * 60), 1 - 0.49, delta=0.01)
|
||||
# el mínimo se cambia: pidiendo un 45 %, el H.264 a 25 Mb/s (39 %) ya no entra
|
||||
cs45 = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0, ahorro=45)}
|
||||
self.assertTrue(cs45["A014_06170738_C019.mp4"].startswith("convertido ahorraría solo un 39 %"), cs45)
|
||||
self.assertEqual(cs45["A015_05250930_C004.mp4"], "")
|
||||
# a un formato de edición no se le aplica: ahí no se busca que pese menos
|
||||
cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0, False, "prores_lt")}
|
||||
self.assertEqual(cs["A014_06170738_C019.mp4"], "")
|
||||
# y la lista de acciones cuenta solo lo que se puede aligerar
|
||||
self.assertEqual(cs["C0002.MP4"], "")
|
||||
# la lista de acciones cuenta lo que se puede aligerar
|
||||
ajustes = dict(O.CONFIG["optimizar"])
|
||||
self.addCleanup(lambda: O.CONFIG["optimizar"].update(ajustes))
|
||||
O.CONFIG["optimizar"].update(min_mb=0, meses_minimos=0)
|
||||
conv = [a for a in O.acciones(raiz)["acciones"] if a["clave"] == "convertir"]
|
||||
self.assertEqual(len(conv), 1)
|
||||
self.assertTrue(conv[0]["cifra"].startswith("3 clips"), conv[0])
|
||||
self.assertTrue(conv[0]["cifra"].startswith("7 clips"), conv[0])
|
||||
# y después de convertir se descarta lo que ahorre menos del mínimo con 10 puntos de margen (con 30, del 20 %)
|
||||
t = {"formato": "h265_10bit", "ruta": "C0002.MP4", "bytes_in": 100, "bytes_out": 81}
|
||||
self.assertTrue(O._no_compensa(t).startswith("no compensa: pesa el 81 %"))
|
||||
self.assertEqual(O._no_compensa(dict(t, bytes_out=79)), "")
|
||||
O.CONFIG["optimizar"]["ahorro_minimo"] = 50
|
||||
self.assertTrue(O._no_compensa(dict(t, bytes_out=61)).startswith("no compensa"))
|
||||
|
||||
def test_un_nombre_parecido_no_es_una_conversion(self):
|
||||
"""El sufijo solo no basta: «Entrevista_HQ.mp4» en H.264 no lo hizo este programa."""
|
||||
@@ -892,7 +1045,8 @@ class Convertidor(unittest.TestCase):
|
||||
hilo.join(40)
|
||||
self.assertFalse(hilo.is_alive())
|
||||
t = self.trabajo(ids[2])
|
||||
self.assertEqual(t["estado"], "cancelado")
|
||||
self.assertEqual(t["estado"], "pendiente") # vuelve a esperar: al continuar se hace entero
|
||||
self.assertTrue(t["log"].startswith("se detuvo a mitad"), t["log"])
|
||||
self.assertFalse(os.path.exists(t["salida"]) or os.path.exists(t["salida"] + ".parcial.mp4"))
|
||||
self.assertEqual([x for x in os.listdir(O.DATOS) if x.startswith("braw_")], [])
|
||||
|
||||
@@ -1020,6 +1174,421 @@ 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_lo_ya_convertido_no_se_convierte_dos_veces(self):
|
||||
"""Línea Gene, 14-oct-2026: con la 3.7 los 11 verificados se volvieron a encolar en su sitio nuevo, y uno ya
|
||||
se había convertido otra vez. Al reengancharlos: la conversión que aún espera sobra; la ya hecha deja de más
|
||||
la vieja."""
|
||||
raiz = cliente("DosVeces")
|
||||
nr = os.path.normpath(raiz)
|
||||
a = mk(raiz, "Material Sony/20250722_Labstream3805.MP4", 2025, 7, 22, tam=300000)
|
||||
b = mk(raiz, "Material Sony/20250722_Labstream3766.MP4", 2025, 7, 22, tam=300001)
|
||||
O.escanear(raiz)
|
||||
self.leidos(raiz, {os.path.basename(x): {"codec": "h264", "perfil": "High", "kbps": 120000} for x in (a, b)})
|
||||
ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr)
|
||||
for i in (ia, ib):
|
||||
with open(self.trabajo(i)["salida"], "wb") as fh:
|
||||
fh.write(b"x" * 90000)
|
||||
O.set_trabajo(i, estado="verificado", bytes_out=90000)
|
||||
# la 3.7: los clips se movieron, la cola no se enteró, y se volvieron a encolar en el sitio nuevo
|
||||
nueva = os.path.join(nr, "2025", "07- Julio", "00_Grabacion", "22- Martes", "SONY")
|
||||
os.makedirs(nueva)
|
||||
a2, b2 = os.path.join(nueva, os.path.basename(a)), os.path.join(nueva, os.path.basename(b))
|
||||
os.rename(a, a2)
|
||||
os.rename(b, b2)
|
||||
with open(os.path.splitext(a2)[0] + "_10bit.mp4", "wb") as fh: # el de a ya se convirtió otra vez allí
|
||||
fh.write(b"y" * 90000)
|
||||
con = O.db()
|
||||
with con:
|
||||
ids = []
|
||||
for ruta, estado in ((a2, "verificado"), (b2, "pendiente")):
|
||||
ids.append(con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, bytes_out, creado, raiz, formato) "
|
||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", (ruta, os.path.splitext(ruta)[0] + "_10bit.mp4", "ffmpeg", "equilibrado", "420",
|
||||
"rec709", 21150, estado, os.path.getsize(ruta), 90000, time.time(), nr,
|
||||
"h265_10bit")).lastrowid)
|
||||
con.close()
|
||||
O.escanear(raiz)
|
||||
ta, tb = self.trabajo(ia), self.trabajo(ib)
|
||||
# el de a ya se había vuelto a convertir: sobra el viejo, que queda «no compensa» con su porqué
|
||||
self.assertEqual(ta["estado"], "descartado")
|
||||
self.assertIn("se volvió a convertir en su sitio nuevo", ta["log"])
|
||||
self.assertEqual(self.trabajo(ids[0])["estado"], "verificado")
|
||||
# el de b aún esperaba: se quita de la cola, y el viejo se reengancha (se reemplaza junto a su original)
|
||||
con = O.db()
|
||||
self.assertIsNone(con.execute("SELECT id FROM trabajos WHERE id = ?", (ids[1],)).fetchone())
|
||||
con.close()
|
||||
self.assertEqual((tb["estado"], tb["ruta"]), ("verificado", b2))
|
||||
r = O.reemplazar([ib])[0]
|
||||
self.assertTrue(r["ok"], r)
|
||||
self.assertTrue(os.path.isfile(os.path.join(nueva, os.path.basename(tb["salida"]))))
|
||||
|
||||
def _cola_falsa(self, durante=0.4):
|
||||
"""Sustituye la conversión por una que tarda cuenta["durante"] segundos (deja su «.parcial» mientras) y cuenta
|
||||
cuántas van a la vez."""
|
||||
cuenta = {"vivos": 0, "maximo": 0, "vistos": [], "durante": durante}
|
||||
candado = threading.Lock()
|
||||
|
||||
def falso(t):
|
||||
with candado:
|
||||
cuenta["vivos"] += 1
|
||||
cuenta["maximo"] = max(cuenta["maximo"], cuenta["vivos"])
|
||||
cuenta["vistos"].append(t["id"])
|
||||
with open(t["salida"] + ".parcial.mp4", "wb") as fh:
|
||||
fh.write(b"p" * 100)
|
||||
O.set_trabajo(t["id"], pct=40)
|
||||
fin = time.time() + cuenta["durante"]
|
||||
while time.time() < fin and not O.COLA["cancelar"]:
|
||||
time.sleep(0.02)
|
||||
with candado:
|
||||
cuenta["vivos"] -= 1
|
||||
if O.COLA["cancelar"]:
|
||||
O._quitar_parcial(t["salida"] + ".parcial.mp4")
|
||||
O._devolver_a_la_cola(t["id"])
|
||||
return
|
||||
os.remove(t["salida"] + ".parcial.mp4")
|
||||
with open(t["salida"], "wb") as fh:
|
||||
fh.write(b"x" * 1000)
|
||||
O.set_trabajo(t["id"], estado="listo", pct=100, bytes_out=1000, fin=time.time())
|
||||
for k, v in {"correr_ffmpeg": falso, "_verificar_listos": lambda ids: None, "editando": lambda: "",
|
||||
"_ya_no_compensa": lambda t: ""}.items():
|
||||
self.addCleanup(setattr, O, k, getattr(O, k))
|
||||
setattr(O, k, v)
|
||||
self.addCleanup(O.CONFIG["optimizar"].pop, "simultaneas", None)
|
||||
return cuenta
|
||||
|
||||
def test_dos_a_la_vez(self):
|
||||
"""RTX 5080 del estudio, 14-oct-2026: con un clip a la vez el codificador iba al 46-74 %; dos a la vez salen
|
||||
a 1,29× tiempo real entre los dos, frente a 0,77×. Nunca van más de los pedidos, ningún clip se convierte
|
||||
dos veces, y «Detener» para a todos."""
|
||||
raiz = cliente("ALaVez")
|
||||
nr = os.path.normpath(raiz)
|
||||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||||
clips = [mk(raiz, g + f"C00{i:02d}.MP4", 2025, 11, 11, tam=300000 + i) for i in range(8)]
|
||||
O.escanear(raiz)
|
||||
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
|
||||
cuenta = self._cola_falsa()
|
||||
O.CONFIG["optimizar"]["simultaneas"] = 2
|
||||
# lo que otras pruebas dejaran esperando no entra en esta tanda (y se devuelve al acabar)
|
||||
con = O.db()
|
||||
otros = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'pendiente'")]
|
||||
with con:
|
||||
con.execute("UPDATE trabajos SET estado = 'aparte' WHERE estado = 'pendiente'")
|
||||
con.close()
|
||||
|
||||
def devolver():
|
||||
c = O.db()
|
||||
with c:
|
||||
c.executemany("UPDATE trabajos SET estado = 'pendiente' WHERE id = ?", [(i,) for i in otros])
|
||||
c.close()
|
||||
self.addCleanup(devolver)
|
||||
ids = O.encolar(clips[:5], "equilibrado", "420", "rec709", nr)
|
||||
self.assertEqual(len(ids), 5)
|
||||
self.assertTrue(O.iniciar_cola())
|
||||
time.sleep(0.2)
|
||||
e = O.eta_cola()
|
||||
self.assertEqual(e["paralelo"], 2)
|
||||
self.assertEqual(len(e["actuales"]), 2)
|
||||
O.COLA["hilo"].join(30)
|
||||
self.assertEqual(cuenta["maximo"], 2)
|
||||
self.assertEqual(sorted(cuenta["vistos"]), sorted(ids))
|
||||
self.assertEqual({self.trabajo(i)["estado"] for i in ids}, {"listo"})
|
||||
self.assertEqual((O.COLA["actuales"], O.COLA["activa"], O.COLA["actual"]), ({}, False, None))
|
||||
# Detener: los dos que van se cortan, los que esperan se quedan esperando
|
||||
cuenta.update(maximo=0, vistos=[])
|
||||
ids = O.encolar(clips[5:], "equilibrado", "420", "rec709", nr)
|
||||
self.assertTrue(O.iniciar_cola())
|
||||
time.sleep(0.2)
|
||||
O.COLA["cancelar"], O.COLA["activa"] = True, False # lo que hace «Detener»
|
||||
O.COLA["hilo"].join(30)
|
||||
estados = sorted(self.trabajo(i)["estado"] for i in ids)
|
||||
self.assertEqual(estados, ["pendiente", "pendiente", "pendiente"]) # los cortados vuelven a esperar
|
||||
self.assertFalse(O.COLA["cancelar"])
|
||||
con = O.db()
|
||||
with con:
|
||||
con.execute(f"DELETE FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)
|
||||
con.close()
|
||||
|
||||
def test_cerrar_para_todo_y_continuar(self):
|
||||
"""Munka, 14-oct-2026: «que al cerrar cierre todos los procesos, hasta las colas que faltan y los que quedan a
|
||||
mitad; que al continuar una cola la continúe y vuelva a generar los videos que hagan falta, por donde iba»."""
|
||||
raiz = cliente("CerrarTodo")
|
||||
nr = os.path.normpath(raiz)
|
||||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||||
clips = [mk(raiz, g + f"C01{i:02d}.MP4", 2025, 11, 11, tam=310000 + i) for i in range(3)]
|
||||
O.escanear(raiz)
|
||||
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
|
||||
cuenta = self._cola_falsa(durante=30)
|
||||
O.CONFIG["optimizar"]["simultaneas"] = 2
|
||||
con = O.db()
|
||||
otros = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'pendiente'")]
|
||||
with con:
|
||||
con.execute("UPDATE trabajos SET estado = 'aparte' WHERE estado = 'pendiente'")
|
||||
con.close()
|
||||
|
||||
def devolver():
|
||||
c = O.db()
|
||||
with c:
|
||||
c.executemany("UPDATE trabajos SET estado = 'pendiente' WHERE id = ?", [(i,) for i in otros])
|
||||
c.close()
|
||||
self.addCleanup(devolver)
|
||||
ids = O.encolar(clips, "equilibrado", "420", "rec709", nr)
|
||||
self.assertTrue(O.iniciar_cola())
|
||||
fin = time.time() + 10
|
||||
while time.time() < fin and len(cuenta["vistos"]) < 2:
|
||||
time.sleep(0.05)
|
||||
parciales = [self.trabajo(i)["salida"] + ".parcial.mp4" for i in cuenta["vistos"]]
|
||||
self.assertTrue(all(os.path.exists(p) for p in parciales))
|
||||
# cerrar (o actualizar): para todo, lo que iba vuelve a esperar sin su archivo a medias
|
||||
t0 = time.time()
|
||||
r = O.detener_todo(seguir=True)
|
||||
self.assertLess(time.time() - t0, 10)
|
||||
self.assertEqual((r["cola"], r["pendientes"]), (True, 3))
|
||||
self.assertFalse(O.COLA["activa"] or O.COLA["actuales"])
|
||||
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["pendiente"] * 3)
|
||||
self.assertTrue(all(self.trabajo(i)["log"].startswith("se detuvo a mitad (iba al 40 %)") for i in cuenta["vistos"]))
|
||||
self.assertFalse(any(os.path.exists(p) for p in parciales))
|
||||
self.assertTrue(os.path.exists(O._ruta_seguir()))
|
||||
# al volver a abrir, la cola sigue sola por donde iba: primero los que se cortaron
|
||||
cuenta.update(vistos=[], durante=0.2)
|
||||
primeros = sorted(ids)[:2]
|
||||
self.assertTrue(O._seguir_al_abrir())
|
||||
self.assertFalse(os.path.exists(O._ruta_seguir()))
|
||||
O.COLA["hilo"].join(30)
|
||||
self.assertEqual(sorted(cuenta["vistos"][:2]), primeros)
|
||||
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["listo"] * 3)
|
||||
self.assertFalse(O._seguir_al_abrir()) # sin nada pendiente de seguir, no arranca
|
||||
|
||||
def test_al_continuar_se_rehace_lo_que_falta(self):
|
||||
"""Lo cortado por un «Detener» de antes (cancelado), lo que falló por un corte («fallo inesperado») y el
|
||||
convertido que ya no está vuelven a la cola; un error de verdad y lo que sigue en su sitio, no."""
|
||||
raiz = cliente("Rehacer")
|
||||
nr = os.path.normpath(raiz)
|
||||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||||
clips = [mk(raiz, g + f"C02{i:02d}.MP4", 2025, 11, 11, tam=320000 + i) for i in range(5)]
|
||||
O.escanear(raiz)
|
||||
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
|
||||
ids = O.encolar(clips, "equilibrado", "420", "rec709", nr)
|
||||
O.set_trabajo(ids[0], estado="cancelado")
|
||||
O.set_trabajo(ids[1], estado="error", log="fallo inesperado: [WinError 5] Acceso denegado: braw_961.json")
|
||||
O.set_trabajo(ids[2], estado="error", log="el decodificador no pudo abrir el clip")
|
||||
for i in ids[3:]:
|
||||
O.set_trabajo(i, estado="verificado", bytes_out=1000)
|
||||
with open(self.trabajo(ids[4])["salida"], "wb") as fh:
|
||||
fh.write(b"x" * 1000)
|
||||
self.assertEqual(O.recuperar_huerfanas(), 2)
|
||||
self.assertEqual(O.rehacer_perdidos(), 1)
|
||||
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["pendiente", "pendiente", "error", "pendiente", "verificado"])
|
||||
self.assertEqual(self.trabajo(ids[3])["log"], "el convertido ya no estaba: se vuelve a hacer")
|
||||
con = O.db()
|
||||
with con:
|
||||
con.execute(f"DELETE FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)
|
||||
con.close()
|
||||
|
||||
def test_no_se_mueve_lo_que_se_convierte(self):
|
||||
"""«Si muevo y luego convierto no funciona bien»: lo que espera en la cola se mueve y la cola lo sigue, pero
|
||||
lo que se está convirtiendo ahora (ffmpeg lo tiene abierto) se queda, diciendo por qué. Y una carpeta con
|
||||
algo abierto ya no se copia entera para luego dejarla a medias."""
|
||||
raiz = cliente("NoMover")
|
||||
nr = os.path.normpath(raiz)
|
||||
a = mk(raiz, "Material Sony/20250722_Labstream5001.MP4", 2025, 7, 22, tam=300000)
|
||||
b = mk(raiz, "Material Sony/20250731_Labstream5002.MP4", 2025, 7, 31, tam=300001)
|
||||
O.escanear(raiz)
|
||||
self.leidos(raiz, {os.path.basename(x): {"codec": "h264", "perfil": "High", "kbps": 120000} for x in (a, b)})
|
||||
ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr)
|
||||
O.set_trabajo(ia, estado="corriendo")
|
||||
p = O.Plan(O.raiz_cfg(raiz)).correr()
|
||||
r = O.aplicar(raiz, [f for f in p["filas"] if f["marcado"]])
|
||||
self.assertTrue(os.path.exists(a)) # el que se convierte, en su sitio
|
||||
self.assertFalse(os.path.exists(b)) # el que espera, movido (y la cola con él)
|
||||
self.assertEqual(r["motivos"]["convirtiendo"]["n"], 1)
|
||||
self.assertIn("00_Grabacion", self.trabajo(ib)["ruta"])
|
||||
con = O.db()
|
||||
with con:
|
||||
con.execute("DELETE FROM trabajos WHERE id IN (?, ?)", (ia, ib))
|
||||
con.close()
|
||||
# una carpeta con un archivo abierto: no se copia
|
||||
d = os.path.join(nr, "Abierta")
|
||||
os.makedirs(d)
|
||||
f = os.path.join(d, "clip.mov")
|
||||
with open(f, "wb") as fh:
|
||||
fh.write(b"x" * 1000)
|
||||
abierto = open(f, "rb")
|
||||
self.addCleanup(abierto.close)
|
||||
try:
|
||||
O.mover(d, os.path.join(nr, "Destino Abierta"), "prueba", [])
|
||||
except OSError as ex:
|
||||
self.assertIn("en uso", str(ex))
|
||||
self.assertFalse(os.path.exists(os.path.join(nr, "Destino Abierta")))
|
||||
self.assertTrue(os.path.isfile(f))
|
||||
else:
|
||||
self.skipTest("este disco deja renombrar una carpeta con un archivo abierto")
|
||||
|
||||
def test_conversiones_sueltas(self):
|
||||
"""14-oct-2026: el lector de BRAW y el ffmpeg de una conversión de antes de actualizar seguían colgados siete
|
||||
horas después. Al arrancar se cierran los que no tienen quien los espere, y no los de un programa vivo."""
|
||||
base = r"C:\Programas\OrganiLAB"
|
||||
P = lambda pid, ppid, nombre, cmd: {"ProcessId": pid, "ParentProcessId": ppid, "Name": nombre, "CommandLine": cmd}
|
||||
ff = base + r"\ffmpeg\ffmpeg.exe"
|
||||
procesos = [
|
||||
P(100, 50, "OrganizadorLabstream.exe", f'"{base}\\OrganizadorLabstream.exe"'), # este programa
|
||||
P(101, 100, "OrganizadorLabstream.exe", f"{base}\\OrganizadorLabstream.exe {base}\\braw.py convertir x.json"), # su lector
|
||||
P(102, 101, "ffmpeg.exe", f'{ff} -i pipe:0 "V:\\G\\a_10bit.mp4.parcial.mp4"'), # el ffmpeg del lector
|
||||
P(103, 100, "ffmpeg.exe", f"{ff} -i a.mp4 b.parcial.mp4"), # uno suyo
|
||||
P(200, 65492, "OrganizadorLabstream.exe", f"{base}\\OrganizadorLabstream.exe {base}\\braw.py convertir y.json"), # lector suelto
|
||||
P(201, 200, "ffmpeg.exe", f"{ff} -i pipe:0 V:\\G\\b.parcial.mp4"), # y su ffmpeg
|
||||
P(300, 60, "OrganizadorLabstream.exe", f'"{base}\\OrganizadorLabstream.exe" --automatico'), # la pasada de la noche
|
||||
P(301, 300, "ffmpeg.exe", f"{ff} -i c.mp4 c.parcial.mp4"), # y su ffmpeg
|
||||
P(401, 4000, "ffmpeg.exe", f"{ff} -i d.mp4 d.parcial.mp4"), # su programa ya no está
|
||||
P(500, 1, "ffmpeg.exe", r"C:\otro\ffmpeg.exe -i e.mp4 e.mp4"), # de otro programa
|
||||
]
|
||||
self.assertEqual(sorted(p["ProcessId"] for p in O._conversores_sueltos(procesos, base, 100)), [200, 401])
|
||||
|
||||
def test_la_tarjeta_descomprime(self):
|
||||
"""Con la cola en marcha, el descompresor de la tarjeta estaba al 0 %: un H.264 4K pasa de 0,54× a 0,86× tiempo
|
||||
real si lo descomprime ella. Lo que ella no lee (ProRes) sigue por el procesador, y si con ella falla, se repite."""
|
||||
real = O.motores
|
||||
self.addCleanup(setattr, O, "motores", real)
|
||||
O.motores = lambda: {"gpu": {"nvdec": True, "hevc": True}}
|
||||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "gpu"), ["-hwaccel", "cuda"])
|
||||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "hevc"}, "gpu"), ["-hwaccel", "cuda"])
|
||||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "prores"}, "gpu"), [])
|
||||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "cpu"), [])
|
||||
O.motores = lambda: {"gpu": {"nvdec": False}}
|
||||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "gpu"), [])
|
||||
O.motores = real
|
||||
ff = shutil.which("ffmpeg")
|
||||
if not ff:
|
||||
self.skipTest("sin ffmpeg")
|
||||
raiz = cliente("Descomprime")
|
||||
nr = os.path.normpath(raiz)
|
||||
clip = os.path.join(nr, "2025", "11- Noviembre", "00_Grabacion", "SONY", "C0001.MP4")
|
||||
os.makedirs(os.path.dirname(clip))
|
||||
subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=320x180:rate=30:duration=1",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", clip], check=True)
|
||||
O.escanear(raiz)
|
||||
self.leidos(raiz, {"C0001.MP4": {"codec": "h264", "perfil": "High", "ancho": 320, "alto": 180, "fps": 30, "dur": 1, "kbps": 900}})
|
||||
id_ = O.encolar([clip], "equilibrado", "420", "rec709", nr)[0]
|
||||
# una descompresión que no arranca: se repite con el procesador y sale igual
|
||||
self.addCleanup(setattr, O, "_decodificar_con_tarjeta", O._decodificar_con_tarjeta)
|
||||
O._decodificar_con_tarjeta = lambda f, motor: ["-hwaccel", "no_existe"]
|
||||
O.correr_ffmpeg(self.trabajo(id_))
|
||||
t = self.trabajo(id_)
|
||||
self.assertEqual(t["estado"], "listo", t["log"])
|
||||
self.assertTrue(os.path.isfile(t["salida"]))
|
||||
self.assertNotIn("-hwaccel", t["log"])
|
||||
|
||||
def test_lo_que_ya_no_compensa_no_se_convierte(self):
|
||||
"""Se encolaron con la regla de antes: un H.265 de cámara que se quedaría en el 93 % no se convierte."""
|
||||
raiz = cliente("YaNoCompensa")
|
||||
nr = os.path.normpath(raiz)
|
||||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||||
a, b = mk(raiz, g + "C0001.MP4", 2025, 11, 11, tam=300000), mk(raiz, g + "C0002.MP4", 2025, 11, 11, tam=300001)
|
||||
O.escanear(raiz)
|
||||
self.leidos(raiz, {"C0001.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "fps": 59.94, "kbps": 72000},
|
||||
"C0002.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000}})
|
||||
ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr)
|
||||
self.assertTrue(O._ya_no_compensa(self.trabajo(ia)).startswith("convertido ahorraría solo un 7 %"))
|
||||
self.assertEqual(O._ya_no_compensa(self.trabajo(ib)), "")
|
||||
con = O.db()
|
||||
with con:
|
||||
con.execute("DELETE FROM trabajos WHERE id IN (?, ?)", (ia, ib))
|
||||
con.close()
|
||||
|
||||
def test_el_codificador_que_toca(self):
|
||||
fm = O.FORMATOS["h265_10bit"]
|
||||
t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000}
|
||||
@@ -1092,6 +1661,7 @@ class Convertidor(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
unittest.main(verbosity=2, exit=False)
|
||||
r = unittest.main(verbosity=2, exit=False)
|
||||
finally:
|
||||
shutil.rmtree(TMP, ignore_errors=True)
|
||||
sys.exit(0 if r.result.wasSuccessful() else 1) # la compilación del estudio se detiene si algo falla
|
||||
|
||||
@@ -278,7 +278,8 @@ def desde_el_codigo(tmp):
|
||||
anota("abrirlo otra vez no abre una segunda copia", p.returncode == 0 and len(V._ventanas(O._marca())) == 1,
|
||||
f"código {p.returncode} · {len(V._ventanas(O._marca()))} ventana(s)")
|
||||
|
||||
# cerrar con trabajo en marcha: avisa, se oculta, vuelve si se abre otra vez y se cierra sola al acabar
|
||||
# cerrar con trabajo en marcha: avisa, se quita de la vista y lo para todo (un movimiento de archivos se deja
|
||||
# acabar); si se abre otra vez mientras tanto, vuelve; y al acabar de parar se cierra sola
|
||||
O.APLICANDO.update(total=10, hechas=3, raiz="ensayo")
|
||||
U.PostMessageW(h, WM_CLOSE, 0, 0)
|
||||
d = espera(lambda: dialogo_de(h), 8)
|
||||
@@ -286,7 +287,7 @@ def desde_el_codigo(tmp):
|
||||
anota("al cerrar con trabajo en marcha, avisa de qué está haciendo", "moviendo archivos" in aviso, aviso[:90])
|
||||
if d:
|
||||
U.PostMessageW(d, WM_COMMAND, 1, 0)
|
||||
anota("la ventana se quita de la vista y el programa sigue", espera(lambda: v.oculta and not U.IsWindowVisible(h), 8))
|
||||
anota("la ventana se quita de la vista mientras se para todo", espera(lambda: v.oculta and not U.IsWindowVisible(h), 8))
|
||||
otra_copia()
|
||||
anota("abrirlo otra vez devuelve esa misma ventana", espera(lambda: not v.oculta and U.IsWindowVisible(h), 8))
|
||||
U.PostMessageW(h, WM_CLOSE, 0, 0)
|
||||
@@ -294,7 +295,7 @@ def desde_el_codigo(tmp):
|
||||
if d:
|
||||
U.PostMessageW(d, WM_COMMAND, 1, 0)
|
||||
espera(lambda: v.oculta, 8)
|
||||
O.APLICANDO.update(hechas=10) # el trabajo termina: el programa tiene que cerrarse solo
|
||||
O.APLICANDO.update(hechas=10) # el movimiento termina: con todo parado, el programa se cierra solo
|
||||
|
||||
fin, forzado = threading.Event(), []
|
||||
|
||||
|
||||
13
reglas.py
13
reglas.py
@@ -614,7 +614,18 @@ def etiqueta_grabacion(nombre: str) -> str:
|
||||
|
||||
def sobra(w):
|
||||
return w in FUENTE_PALABRAS or w.isdigit() or MESES_ALIAS.get(w, w) in MESES or w in ("-", "_")
|
||||
utiles = [p for p in palabras if not all(sobra(w) for w in norm(p).split()) and norm(p)]
|
||||
|
||||
def palabra_sobra(p):
|
||||
return all(sobra(w) for w in norm(p).split())
|
||||
utiles = []
|
||||
for i, p in enumerate(palabras):
|
||||
if not norm(p):
|
||||
continue
|
||||
if not palabra_sobra(p):
|
||||
utiles.append(p)
|
||||
elif p.isdigit() and len(p) <= 2 and 0 < i < len(palabras) - 1 \
|
||||
and not palabra_sobra(palabras[i - 1]) and not palabra_sobra(palabras[i + 1]):
|
||||
utiles.append(p) # «Fiesta 6 Años»: un número entre dos palabras es parte del nombre
|
||||
if not utiles:
|
||||
return ""
|
||||
return " ".join(utiles)
|
||||
|
||||
@@ -261,6 +261,21 @@
|
||||
.ac{display:grid;grid-template-columns:26px minmax(0,1fr) auto;gap:14px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px}
|
||||
.ac.hecho{background:transparent;border-style:dashed;opacity:.62}
|
||||
.ac .hc{margin-top:4px;font-size:12.5px;font-weight:600;color:var(--ok)}
|
||||
.motivos{margin-top:8px;display:grid;gap:6px;font-size:12.5px}
|
||||
.motivos .mt{padding:8px 10px;border-radius:8px;background:var(--panel2);color:var(--tx)}
|
||||
.motivos .ej{margin-top:3px;font-size:11.5px}
|
||||
.reloj{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px 12px;border:1px solid var(--line);border-radius:8px;margin-bottom:8px;font-size:12.5px}
|
||||
.reloj.hecho{border-style:dashed;opacity:.8}
|
||||
.reloj .path,.sitio .path{word-break:normal;overflow-wrap:anywhere}
|
||||
.reloj .acts{display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
|
||||
@media(max-width:820px){.reloj{grid-template-columns:1fr}.reloj .acts{justify-content:flex-start}}
|
||||
.sitio{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:8px 0;border-top:1px solid var(--line);font-size:12.5px}
|
||||
.sitio .acts{display:flex;gap:10px}
|
||||
.buscar-todo{margin:10px 0 0}
|
||||
.buscar-todo input{width:100%;box-sizing:border-box}
|
||||
.busca td{vertical-align:top;font-size:12.5px}
|
||||
.modob{margin:0 0 10px;padding:8px 12px;border-radius:8px;background:var(--panel2);font-size:12.5px;display:grid;gap:4px}
|
||||
.modob .lnk{padding:0}.modob .lnk.on{font-weight:700;text-decoration:underline}
|
||||
.ac.hecho .hc{opacity:1}
|
||||
.ac.recien{opacity:1;animation:recien 3s ease-out}
|
||||
@keyframes recien{0%{background:var(--ok2);border-color:var(--ok);border-style:solid}70%{background:var(--ok2)}100%{background:transparent}}
|
||||
@@ -429,6 +444,16 @@
|
||||
.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 */
|
||||
.grandes{border:1px solid var(--line);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:12.5px;display:flex;flex-wrap:wrap;gap:6px 18px;align-items:baseline}
|
||||
.grandes b{font-size:13px}.grandes .t{color:var(--mut);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;font-weight:600;flex-basis:100%}
|
||||
.grandes button{font-size:12px;padding:2px 8px}
|
||||
.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)}
|
||||
@@ -514,6 +539,7 @@
|
||||
<select id="raiz" aria-hidden="true"></select>
|
||||
<div id="zonaSoltar" role="button" tabindex="0" title="Suelta aquí una carpeta de cliente para añadirla, o haz clic para pegar la ruta">Suelta aquí la carpeta<br>de un cliente para añadirla</div>
|
||||
<button class="lnk" id="btnElegir" style="width:100%;margin-top:4px" title="Abre el selector de carpetas de Windows">Elegir carpeta…</button>
|
||||
<div class="buscar-todo"><input type="search" id="buscarTodo" placeholder="Buscar un archivo en todos…" aria-label="Buscar un archivo en todos los clientes" title="Escribe parte del nombre y pulsa Enter"></div>
|
||||
<div class="lado-pie"><img src="/static/labstream.png" alt="Labstream Studio"><span id="reloj"></span><button id="btnBuscarAct" title="Consulta Labstream OS y avisa si hay una versión nueva">Buscar actualizaciones</button><span id="actUltima" class="mut" style="font-size:11px"></span></div>
|
||||
</aside>
|
||||
<div id="principal">
|
||||
@@ -566,7 +592,7 @@
|
||||
<div id="mapaCargando" class="hint" style="display:none"><span class="spin"></span> Leyendo el inventario de la carpeta… En una carpeta grande tarda unos segundos.</div>
|
||||
<div id="mapaVacio" class="hint">Esta carpeta todavía no está escaneada. Vuelve a <b>Carpeta</b> y pulsa <b>Escanear</b> (solo lee nombres y tamaños; no toca nada).</div>
|
||||
<div id="revisarLleno" style="display:none">
|
||||
<div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a></div>
|
||||
<div class="tabs" id="tabsRev"><a data-s="hay" class="on">Qué hay</a><a data-s="plan">Qué va a pasar</a><a data-s="nombrar">Por nombrar <span class="badge" id="badgeNombrar">…</span></a><a data-s="stock">Stock viejo</a><a data-s="vacias">Carpetas vacías</a><a data-s="relojes">Fechas raras <span class="badge cero" id="badgeRelojes">0</span></a></div>
|
||||
|
||||
<div class="sub on" id="s-hay">
|
||||
<div class="cards" id="cards"></div>
|
||||
@@ -620,6 +646,10 @@
|
||||
<div class="box" id="stockCaja" style="display:none;max-height:420px;overflow:auto"><table id="tablaStock"><thead><tr><th></th><th>Carpeta</th><th>Mes de la pieza</th><th class="num">Archivos</th><th class="num">Peso</th><th>Ejemplos</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
|
||||
<div class="sub" id="s-relojes">
|
||||
<p class="ayuda">Clips cuya cámara tenía el reloj sin poner: su fecha dice 2008 o 2009, y el plan los ordenaría en un año que no es. Pon la fecha real del primer clip de cada tanda y la app corre los demás lo mismo. <span class="q">?</span><span class="mas">Se agrupan por carpeta y cámara. La pista sale de otros clips de esa carpeta que sí traen bien la fecha, o del año que diga la ruta. La corrección se guarda por archivo: sobrevive a volver a escanear y a que el plan los mueva de carpeta. Se deshace desde aquí.</span></p>
|
||||
<div id="relojes" class="mut">…</div>
|
||||
</div>
|
||||
<div class="sub" id="s-vacias">
|
||||
<p class="ayuda">Carpetas sin un solo archivo dentro: lo que queda cuando el material ya se movió. <b>Es lo único que la app borra</b>, y solo porque no hay nada que perder. <span class="q">?</span><span class="mas">Se comprueba en el disco antes de enseñarlas, y otra vez justo antes de borrarlas: si alguien dejó algo dentro mientras tanto, esa se salta y se avisa. <b>Deshacer</b> las vuelve a crear con su nombre. Las que son un enlace o una unión de Windows no se tocan nunca, aunque parezcan vacías. Fíjate en los nombres antes de marcar: una carpeta vacía llamada «03- Exfoliación química» es lo único que queda de esa pieza.</span></p>
|
||||
<div class="toolbar">
|
||||
@@ -665,12 +695,14 @@
|
||||
<div class="cab"><h2 class="v">Convertidor</h2><span class="sub">clips de cámara <b id="convCriterio">con más de 3 meses y más de 300 MB</b> · <details class="ajc" id="convAjustes"><summary>cambiar</summary><div class="toolbar">
|
||||
<label>con más de <input type="number" id="optMeses" value="3" min="0" style="width:60px"> meses</label>
|
||||
<label>mínimo <input type="number" id="optMin" value="300" min="0" style="width:70px"> MB</label>
|
||||
<label title="Se ofrece lo que convertido pesaría al menos este tanto por ciento menos">que ahorre al menos <input type="number" id="optAhorro" value="30" min="0" max="90" style="width:56px"> %</label>
|
||||
<label>calidad <select id="optPerfil"></select></label>
|
||||
<label>croma <select id="optChroma"><option value="420">4:2:0 10 bits (compatible)</option><option value="422">4:2:2 10 bits (solo ffmpeg, pesa más)</option></select></label>
|
||||
<label>BRAW en <select id="optColor"><option value="rec709">Rec.709 (como se vio al grabar)</option><option value="log">Log de cámara (plano, más latitud)</option></select></label>
|
||||
<label><input type="checkbox" id="optExport"> incluir exportados</label>
|
||||
<label title="Cuántos clips convierte la cola a la vez. Automático: dos con tarjeta NVIDIA, uno sin ella. Cuenta desde la próxima vez que arranque la cola.">a la vez <select id="optSimult"><option value="0">automático</option><option value="1">1</option><option value="2">2</option><option value="3">3</option></select></label>
|
||||
</div></details></span></div>
|
||||
<p class="ayuda">Convierte los clips de proyectos ya entregados a un formato más ligero. <b>El original no se toca</b> hasta que reemplaces. <span class="q">?</span><span class="mas">Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a <span class="path">_Para borrar/originales</span> y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW se leen con el decodificador de Blackmagic que instala DaVinci Resolve, sin tener que abrirlo; todo se codifica con ffmpeg, con la tarjeta gráfica cuando la hay. Lo que ya viene comprimido de cámara (H.264 o H.265 de pocos megabits) no se ofrece: ahorraría poco y perdería calidad. Nada se borra.</span></p>
|
||||
<p class="ayuda">Convierte los clips de proyectos ya entregados a un formato más ligero. <b>El original no se toca</b> hasta que reemplaces. <span class="q">?</span><span class="mas">Buscar lee los clips (códec, duración, resolución) y calcula cuánto pesarían. La cola convierte un clip tras otro en segundo plano, como Media Encoder; puedes cambiar de vista. Cada clip terminado se verifica solo (códec, bits, duración). Al reemplazar, el original se aparta a <span class="path">_Para borrar/originales</span> y el archivo nuevo queda en su lugar con el mismo nombre más un sufijo. Los BRAW se leen con el decodificador de Blackmagic que instala DaVinci Resolve, sin tener que abrirlo; todo se codifica con ffmpeg, con la tarjeta gráfica cuando la hay. Se ofrece todo lo que convertido pesaría al menos un 30 % menos (se cambia en «cambiar»); lo calcula por el códec de cada clip: un H.264 o un AV1 de cámara suelen quedar en la décima parte, y un H.265 de cámara casi no adelgaza. Si al terminar ahorra menos de lo pedido (con 10 puntos de margen), se descarta y se queda el original. Nada se borra.</span></p>
|
||||
<div class="toolbar" style="margin-bottom:10px"><label>formato de salida <select id="optFormato"></select></label><span id="fmtQue" class="mut" style="font-size:12px"></span><span class="sep"></span><span id="motores" class="mut" style="font-size:12px"><span class="spin"></span> detectando GPU y CPU…</span></div>
|
||||
<div id="optCards"></div>
|
||||
<div id="momento"></div>
|
||||
@@ -687,6 +719,7 @@
|
||||
<button class="lnk" id="btnQuitarEspera" style="display:none">Quitar los que esperan</button>
|
||||
</div>
|
||||
<div id="anaEstado" class="mut" style="margin:-6px 0 10px"></div>
|
||||
<div id="convGrandes" class="grandes" style="display:none"></div>
|
||||
<div id="tanda"></div>
|
||||
<details class="mas" id="colaAjustes" style="margin-bottom:12px"><summary>Cuándo trabaja la cola y qué mete sola</summary><div class="ajcola" id="colaAjustesCuerpo"></div></details>
|
||||
<div class="box" id="convCaja"><div id="convLista"><span class="mut">Pulsa «Buscar clips» para ver qué se puede convertir en esta carpeta.</span></div></div>
|
||||
@@ -753,6 +786,11 @@
|
||||
|
||||
<h3>Revisar y ordenar</h3>
|
||||
<dl>
|
||||
<dt>Escaneo profundo</dt><dd>Después de leer nombres, tamaños y fechas, la app lee cada clip: códec, duración y resolución. Va en segundo plano y solo lee lo nuevo. <i>Cómo: se apaga en Ajustes › Escaneo; sin él, los repetidos «con otra calidad» quedan por comparar y no se sabe qué conviene convertir hasta buscar clips.</i></dd>
|
||||
<dt>Fechas raras</dt><dd>Clips cuya cámara tenía el reloj sin poner (2008, 2009…). Pones la fecha real del primer clip de cada tanda y la app corre los demás lo mismo; la corrección sobrevive a volver a escanear y se deshace desde ahí.</dd>
|
||||
<dt>Buscar en todos</dt><dd>El buscador de la barra lateral encuentra un archivo o una carpeta por su nombre en todas las carpetas de clientes escaneadas, con o sin tildes, y abre su carpeta.</dd>
|
||||
<dt>Otros discos</dt><dd>Si el cliente también está en otros discos, Inicio lo dice y deja traer cada sitio a esta carpeta con copia comprobada, igual que «Traer material»: el original no se toca. Una carpeta de entregas o de exportados puede ser una copia que conviene dejar donde está.</dd>
|
||||
<dt>Banco por fecha</dt><dd>Un banco de material se ordena por temas (los temas se quedan; dentro, por fecha y cámara) o, si lo eliges en «Qué va a pasar», por fecha: cada grabación va a la 00_Grabacion de su mes con su tema de etiqueta.</dd>
|
||||
<dt>Qué pesa</dt><dd>Toca cualquier bloque para entrar en esa carpeta, nivel a nivel, hasta el fondo. Al lado dice qué guarda, de qué cámaras o grabadoras, de qué fechas, cuántos proxies y basura se pueden apartar, y qué hace la app con ella. <i>Cómo: sale del inventario del escaneo, que entra en todas las carpetas; en Inicio se ve cuántas leyó y hasta qué profundidad.</i></dd>
|
||||
<dt>Banco de material</dt><dd>Una carpeta de material ordenada por temas (Lugares, Social, Ambiental…). Los temas se quedan como están; dentro de cada uno, las grabaciones van por fecha de rodaje y por cámara: «Lugares\Peajes\Drone» pasa a «Lugares\Peajes\2024-03-12\DJI». Una carpeta cuyo nombre diga «No tocar» se trata así, y todo lo que la app propone dentro va sin marcar: lo marcas tú en «Qué va a pasar», donde el banco es un paso propio. Otra carpeta se puede marcar como banco en «Por nombrar».</dd>
|
||||
<dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd>
|
||||
@@ -785,7 +823,7 @@
|
||||
<dt>Comparar</dt><dd>En un clip convertido, enseña el mismo cuadro del original y del convertido, lado a lado, en tres instantes del clip; al pasar el ratón, una lupa amplía el mismo recorte en los dos. «Medir la calidad» da la nota de 0 a 100 (VMAF) sobre unos diez segundos del medio del clip; de 93 arriba no se nota a simple vista. Desde ahí se reemplaza ese original solo. <i>Cómo: un BRAW se revela con la misma curva con que se convirtió; si el original ya se reemplazó, se busca en _Para borrar/originales. La nota se guarda en el trabajo y sale en la lista.</i></dd>
|
||||
<dt>Buscar clips</dt><dd>Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. <i>Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero, los exportados y lo que ya convirtió este programa, para no pasar un clip dos veces.</i></dd>
|
||||
<dt>Formato y calidad</dt><dd>H.265 10 bits (el recomendado, unas diez veces más ligero), H.265 ligero, H.264 o ProRes proxy, LT y HQ; calidad de ahorro, equilibrada o máxima. «Motores» te dice qué acelera tu tarjeta gráfica.</dd>
|
||||
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Arriba hay un solo botón grande, el que toca en ese momento: buscar, convertir, detener la tanda (con cuánto falta y a qué hora acaba) o reemplazar lo verificado. La lista es una sola, por carpetas: cada clip con su estado de verdad (candidato, en espera, convirtiendo con su barra y lo que le queda, convertido, verificado, falló); «primero este» adelanta un clip; se elige qué ver (por convertir, en cola, terminados, con fallo, excluidos, y los excluidos por grupos con su porqué) y por qué ordenar (carpeta, estado, nombre, fecha, tamaño, ahorro; otra vez invierte el orden). <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW los lee el decodificador de Blackmagic que viene con DaVinci Resolve, sin abrirlo. No se ofrece lo que ya viene comprimido de cámara ni lo que ya convirtió el programa: reconvertirlo ahorra poco y pierde calidad.</i></dd>
|
||||
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Arriba hay un solo botón grande, el que toca en ese momento: buscar, convertir, detener la tanda (con cuánto falta y a qué hora acaba) o reemplazar lo verificado. La lista es una sola, por carpetas: cada clip con su estado de verdad (candidato, en espera, convirtiendo con su barra y lo que le queda, convertido, verificado, falló); «primero este» adelanta un clip; se elige qué ver (por convertir, en cola, terminados, con fallo, excluidos, y los excluidos por grupos con su porqué) y por qué ordenar (carpeta, estado, nombre, fecha, tamaño, ahorro; otra vez invierte el orden). <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW los lee el decodificador de Blackmagic que viene con DaVinci Resolve, sin abrirlo. Se ofrece lo que convertido ahorraría al menos un 30 % (se cambia en «cambiar»), calculado por el códec de origen; no se ofrece lo que ya convirtió el programa ni lo apartado como repetido.</i></dd>
|
||||
<dt>Verificar</dt><dd>Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.</dd>
|
||||
<dt>Reemplazar</dt><dd>El convertido ocupa el sitio del original, con su misma fecha, y el original se aparta a <span class="path">_Para borrar\originales</span>.</dd>
|
||||
</dl>
|
||||
@@ -846,6 +884,9 @@
|
||||
<div class="box" style="margin-top:18px"><h3>Actualizaciones</h3>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="actAuto" style="margin-top:3px"><span>Al arrancar, consultar si hay una versión nueva en Labstream OS. <span class="mut">Es lo único que sale a la red; sin esto el programa no habla con nada fuera de este equipo. El botón «Buscar actualizaciones» sigue funcionando a mano.</span></span></label>
|
||||
</div>
|
||||
<div class="box" style="margin-top:18px"><h3>Escaneo</h3>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px"><input type="checkbox" id="escProfundo" style="margin-top:3px"><span>Escaneo profundo: después de leer nombres, tamaños y fechas, leer cada clip (códec, duración, resolución). <span class="mut">Va en segundo plano y solo lee lo que no se había leído. Con esto se sabe qué repetidos son el mismo clip con otra calidad y qué conviene convertir. En una carpeta grande del NAS, la primera vez tarda un rato; se detiene desde Convertidor.</span></span></label>
|
||||
</div>
|
||||
<details class="mas" style="margin-top:18px"><summary>Avanzado: plantillas de estructura por tipo de trabajo</summary>
|
||||
<div class="box" style="margin-top:10px">
|
||||
<div class="mut" style="margin-bottom:8px">Ruta relativa a la carpeta del cliente. Variables: {anio} {mes} {nombre} {sub} {rel}.</div>
|
||||
@@ -932,7 +973,7 @@ function cfgRaiz(){ return (ESTADO.raices||[]).find(r => r.clave === raizActual(
|
||||
function irA(t){ $$('nav a').forEach(x => x.classList.toggle('on', x.dataset.t === t)); $$('section').forEach(s => s.classList.toggle('on', s.id === 't-' + t)); pintarIndice(); cargarVista(t); }
|
||||
function vistaActual(){ return ($$('nav a').find(a => a.classList.contains('on')) || {}).dataset?.t || 'carpeta'; }
|
||||
$$('nav a').forEach(a => { a.onclick = () => irA(a.dataset.t); a.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); irA(a.dataset.t); } }; });
|
||||
function subRev(s){ const venia = $('#s-plan').classList.contains('on'); $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); if (s === 'plan' && !venia && PLAN) setTimeout(animarEjemplo, 60); }
|
||||
function subRev(s){ const venia = $('#s-plan').classList.contains('on'); $$('#tabsRev a').forEach(x => x.classList.toggle('on', x.dataset.s === s)); $$('#t-revisar .sub').forEach(x => x.classList.toggle('on', x.id === 's-' + s)); if (s === 'plan' && !PLAN && MAPA && MAPA.escaneado) $('#btnPlan').click(); if (s === 'plan' && !venia && PLAN) setTimeout(animarEjemplo, 60); if (s === 'relojes') cargarRelojes().catch(e => aviso(e.message, 'bad')); }
|
||||
$$('#tabsRev a').forEach(a => a.onclick = () => subRev(a.dataset.s));
|
||||
const cargarVista = seguro(async t => {
|
||||
t = t || vistaActual();
|
||||
@@ -958,7 +999,7 @@ async function cargarEstado(){
|
||||
sel.value = (antes && ESTADO.raices.some(r => r.clave === antes)) ? antes : '';
|
||||
pintarIndice();
|
||||
const fmt = $('#optFormato'); if (!fmt.options.length && ESTADO.formatos) { fmt.innerHTML = Object.entries(ESTADO.formatos).map(([k, v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); fmt.value = ESTADO.config.optimizar.formato || 'h265_10bit'; pintarFormato(); cargarMotores(); }
|
||||
const perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); perf.value = ESTADO.config.optimizar.perfil; $('#optMeses').value = ESTADO.config.optimizar.meses_minimos; $('#optMin').value = ESTADO.config.optimizar.min_mb; $('#optChroma').value = ESTADO.config.optimizar.chroma; $('#optColor').value = ESTADO.config.optimizar.color; pintarCriterio(); }
|
||||
const perf = $('#optPerfil'); if (!perf.options.length) { perf.innerHTML = Object.entries(ESTADO.perfiles).map(([k,v]) => `<option value="${k}">${esc(v.nombre)}</option>`).join(''); perf.value = ESTADO.config.optimizar.perfil; $('#optMeses').value = ESTADO.config.optimizar.meses_minimos; $('#optMin').value = ESTADO.config.optimizar.min_mb; $('#optAhorro').value = ESTADO.config.optimizar.ahorro_minimo ?? 30; $('#optSimult').value = String(ESTADO.config.optimizar.simultaneas ?? 0); $('#optChroma').value = ESTADO.config.optimizar.chroma; $('#optColor').value = ESTADO.config.optimizar.color; pintarCriterio(); }
|
||||
const r = cfgRaiz(); const a = r && r.analisis;
|
||||
if (a && a.estado === 'corriendo') $('#anaEstado').innerHTML = `<span class="spin"></span> leyendo clips: ${a.hechos} de ${a.total} <button class="lnk" id="btnAnaDetener">Detener</button>`;
|
||||
else if (a && a.error && vistaActual() === 'convertir') $('#anaEstado').innerHTML = `<span class="warn">${esc(a.error)}</span>`;
|
||||
@@ -1073,6 +1114,38 @@ $('#btnTraer').onclick = () => {
|
||||
$('#trAbrir').onclick = e => { e.preventDefault(); api('/api/entrada/abrir', {raiz}).then(() => aviso('Bandeja de entrada abierta en el Explorador. Lo que dejes ahí saldrá en la lista de la carpeta.', 'ok')).catch(er => aviso(er.message, 'bad')); };
|
||||
};
|
||||
|
||||
// ---------------- el mismo cliente en otros discos: traerlo aquí ----------------
|
||||
function juntarModal(a){
|
||||
const raiz = raizActual(), sitios = a.sitios || [];
|
||||
const nombreDe = s => ('De ' + s.ruta.replace(':', '')).replace(/[\\/]+/g, ' ').trim();
|
||||
const filas = sitios.map((s, i) => `<div class="sitio"><div class="tx"><b class="path">${esc(s.ruta)}</b><div class="mut">${esc([s.unc, s.disco].filter(Boolean).join(' · '))}</div><div id="sitioInfo${i}" class="${s.traido ? 'ok' : 'mut'}">${s.traido ? '✓ Traído ' + esc(fecha(s.traido)) : ''}</div></div><div class="acts">${s.traido ? '' : `<button class="lnk" data-cuanto="${i}">¿Cuánto es?</button><button class="p" data-traer="${i}">Traer aquí</button>`}</div></div>`).join('');
|
||||
modal(a.titulo, `<p>Cada sitio se copia a la bandeja de entrada de esta carpeta, y <b>cada archivo copiado se vuelve a leer para comprobar que quedó igual</b>. <b>El original no se toca</b>: cuando lo hayas revisado, lo borras tú si quieres.</p>${a.detalle ? `<p class="mut" style="font-size:12.5px">${esc(a.detalle)}</p>` : ''}<div class="sitios">${filas}</div><label style="display:block;margin-top:10px"><input type="checkbox" id="jnOrdenar" checked> repartirlo por mes, día de rodaje y cámara al terminar de copiar</label>`, null);
|
||||
$('#modal').classList.add('ancho');
|
||||
$$('#modalCuerpo [data-cuanto]').forEach(b => b.onclick = () => accion(b, async () => {
|
||||
const i = b.dataset.cuanto, s = sitios[+i];
|
||||
const r = await api('/api/entrada/plan', {raiz, origen: s.ruta, nombre: nombreDe(s)});
|
||||
$('#sitioInfo' + i).innerHTML = `${num(r.n)} archivo${r.n !== 1 ? 's' : ''} · <b>${gb(r.bytes)}</b>${r.libre != null ? ` · en el disco de este cliente quedan ${gb(r.libre)} libres` : ''}`;
|
||||
}));
|
||||
$$('#modalCuerpo [data-traer]').forEach(b => b.onclick = () => accion(b, async () => {
|
||||
const s = sitios[+b.dataset.traer];
|
||||
const r = await api('/api/entrada/traer', {raiz, origen: s.ruta, nombre: nombreDe(s), ordenar: $('#jnOrdenar').checked});
|
||||
$('#modal').classList.remove('on');
|
||||
aviso(`Trayendo ${num(r.n)} archivo${r.n !== 1 ? 's' : ''} (${gb(r.bytes)}) de ${s.ruta}. El avance está en la barra de abajo; el original no se toca.`, 'ok');
|
||||
await cargarEstado();
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------- buscar un archivo en todos los clientes ----------------
|
||||
const buscarTodo = seguro(async () => {
|
||||
const t = $('#buscarTodo').value.trim(); if (!t) return;
|
||||
const d = await api('/api/buscar?q=' + encodeURIComponent(t));
|
||||
const filas = d.resultados.map((r, i) => `<tr><td><b>${esc(r.nombre)}</b><div class="mut path" style="font-size:11.5px">${esc(r.rel)}</div></td><td>${esc(r.cliente)}</td><td class="mut">${esc(r.rol)}${r.apartado ? ' · apartado' : ''}</td><td class="num">${gb(r.bytes)}</td><td class="mut">${esc(fechaLarga(r.fecha))}</td><td><button class="lnk" data-abrir="${i}">Abrir la carpeta</button></td></tr>`).join('');
|
||||
modal(`«${t}» en todas las carpetas`, `<p class="mut">${d.total ? `${num(d.total)} resultado${d.total !== 1 ? 's' : ''} en ${d.carpetas} carpetas de clientes${d.total > d.resultados.length ? `; aquí, los ${d.resultados.length} primeros` : ''}.` : `Nada con ese nombre en las ${d.carpetas} carpetas de clientes.`} Busca en lo escaneado: un disco sin registrar no entra.</p>${filas ? `<div style="max-height:60vh;overflow:auto"><table class="busca"><thead><tr><th>Archivo</th><th>Cliente</th><th>Qué es</th><th class="num">Peso</th><th>Fecha</th><th></th></tr></thead><tbody>${filas}</tbody></table></div>` : ''}`, null);
|
||||
$('#modal').classList.add('ancho');
|
||||
$$('#modalCuerpo [data-abrir]').forEach(b => b.onclick = seguro(async () => { await api('/api/abrir', {ruta: d.resultados[+b.dataset.abrir].dir}); }));
|
||||
});
|
||||
$('#buscarTodo').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); buscarTodo(); } };
|
||||
|
||||
// ---------------- orden automático (portada) ----------------
|
||||
const ESTADO_AUTO = {'al dia': ['ok', 'al día'], 'con dudas': ['', 'con dudas'], 'con errores': ['bad', 'con avisos'], 'demasiado': ['warn', 'sin tocar'], 'sin conexion': ['mut', 'sin conexión'], 'sin leer': ['warn', 'sin leer']};
|
||||
async function pintarAuto(){
|
||||
@@ -1096,7 +1169,7 @@ function tareasDe(E){
|
||||
if (r.progreso && r.progreso.estado === 'corriendo') { const p = r.progreso; const est = r.escaneo && r.escaneo.n ? Math.min(99, Math.round(p.n * 100 / r.escaneo.n)) : null; T.push({raiz: r.clave, q: 'Escaneando', d: `${r.cliente} · ${num(p.n)} archivos · ${gb(p.bytes)}`, pct: est}); }
|
||||
if (r.analisis && r.analisis.estado === 'corriendo') { const a = r.analisis; T.push({raiz: r.clave, q: 'Leyendo clips', d: `${r.cliente} · ${a.hechos} de ${a.total}`, pct: a.total ? Math.round(a.hechos * 100 / a.total) : null}); }
|
||||
});
|
||||
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctClip = a ? (a.pct || 0) : 0; const pctTot = tot ? Math.round((hechos + pctClip / 100) / tot * 100) : null; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${Math.min(hechos + 1, tot)} de ${tot}` : 'Convirtiendo', d: `${a ? (a.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(pctClip) + ' % de este clip · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
|
||||
if (E.cola.activa) { const n = E.cola.n || {}; const a = E.cola.actual; const vs = (E.cola.actuales && E.cola.actuales.length) ? E.cola.actuales : (a ? [a] : []); const tot = E.cola.total || 0, hechos = E.cola.hechos || 0; const pctVan = vs.reduce((s, x) => s + (x.pct || 0), 0); const pctTot = tot ? Math.round((hechos + pctVan / 100) / tot * 100) : null; const numeros = vs.length > 1 ? `${Math.min(hechos + 1, tot)} y ${Math.min(hechos + 2, tot)}` : `${Math.min(hechos + 1, tot)}`; T.push({raiz: a ? a.raiz : null, q: tot ? `Convirtiendo ${numeros} de ${tot}` : 'Convirtiendo', d: `${vs.map(x => (x.ruta || '').split(/[\\/]/).pop() + ' · ' + Math.round(x.pct || 0) + ' %').join(' · ')}${vs.length ? ' · ' : ''}${n.pendiente || 0} en espera${E.cola.mensaje ? ' · ' + E.cola.mensaje : ''}`, pct: pctTot, cancelar: () => api('/api/optimizar/control', {accion: 'detener'})}); }
|
||||
if (E.asistente.activo) { const a = E.asistente; T.push({raiz: a.raiz, q: 'Asistente: ' + (NOMBRE_PASO[a.paso] || a.paso || '…'), d: `${porNombre(a.raiz)} · ${a.total ? num(a.hechas) + ' de ' + num(a.total) + ' · ' : ''}${(a.texto || '').slice(-60)}`, pct: a.total ? a.pct : null}); }
|
||||
else if (E.aplicando && E.aplicando.total && E.aplicando.hechas < E.aplicando.total) T.push({q: 'Moviendo', d: E.aplicando.actual || '', pct: Math.round(E.aplicando.unidades_total ? E.aplicando.unidades * 100 / E.aplicando.unidades_total : E.aplicando.hechas * 100 / E.aplicando.total)});
|
||||
if (E.respaldo.estado === 'corriendo') { const e = E.respaldo; T.push({q: 'Copiando respaldo', d: `${num(e.hechos)} de ${num(e.total)} · ${gb(e.bytes)} de ${gb(e.bytes_total)}`, pct: e.bytes_total ? Math.round(e.bytes / e.bytes_total * 100) : null, cancelar: () => api('/api/respaldo/detener', {})}); }
|
||||
@@ -1235,6 +1308,8 @@ const IR_ACCION = {
|
||||
$('#modalSi').textContent = 'Usar este';
|
||||
},
|
||||
nombrar: () => { irA('revisar'); subRev('nombrar'); },
|
||||
relojes: () => { irA('revisar'); subRev('relojes'); },
|
||||
juntar: (_, a) => juntarModal(a),
|
||||
stock: () => { irA('revisar'); subRev('stock'); if (!STOCK.length) $('#btnStock').click(); },
|
||||
vacias: () => { irA('revisar'); subRev('vacias'); if (!VACIAS || VACIAS.raiz !== raizActual()) $('#btnVacias').click(); },
|
||||
mapa: () => irA('mapa'),
|
||||
@@ -1330,6 +1405,7 @@ function pasosDe(C, r){
|
||||
: verif ? {e:'listo', t:'Convertir', d:`${verif} verificado${verif > 1 ? 's' : ''} por reemplazar`, corto: `${verif} por reemplazar`}
|
||||
: reemp ? {e:'hecho', t:'Convertir', d:`${reemp} clip${reemp > 1 ? 's' : ''} ya convertido${reemp > 1 ? 's' : ''}`, corto: `${reemp} hechos`}
|
||||
: {e:'pend', t:'Convertir', d:`${gb(cv.camara_bytes)} de cámara${cv.raw_bytes ? ' · ' + gb(cv.raw_bytes) + ' BRAW' : ''}`, corto: gb(cv.camara_bytes)});
|
||||
{ const io = P.findIndex(x => x.t === 'Ordenar'), ic = P.findIndex(x => x.t === 'Convertir'); if (io >= 0 && ic > io) P.splice(io, 0, P.splice(ic, 1)[0]); } // convertir antes de mover
|
||||
const rp = C.respaldo || {};
|
||||
P.push(!esc ? {e:'bloq', t:'Respaldar', d:'Después de escanear'} : rp.estado === 'corriendo' ? {e:'curso', t:'Respaldar', d:'Copiando…', corto: 'copiando'}
|
||||
: rp.plan ? {e:'listo', t:'Respaldar', d:`${num(rp.plan.n)} archivos · ${gb(rp.plan.bytes)} listos para copiar`, corto: gb(rp.plan.bytes)}
|
||||
@@ -1372,6 +1448,7 @@ async function cargarMapa(){
|
||||
try { m0 = await api('/api/mapa?raiz=' + encodeURIComponent(raiz)); } finally { $('#mapaCargando').style.display = 'none'; }
|
||||
if (raizActual() !== raiz) return;
|
||||
MAPA = m0;
|
||||
cargarRelojes().catch(() => {}); // la cuenta de «Fechas raras»
|
||||
$('#mapaVacio').style.display = MAPA.escaneado ? 'none' : 'block';
|
||||
$('#revisarLleno').style.display = MAPA.escaneado ? 'block' : 'none';
|
||||
cargarDiscos().catch(() => {});
|
||||
@@ -1469,6 +1546,32 @@ async function cargarDiscos(){
|
||||
$('#discos').innerHTML = `<table><thead><tr><th>Unidad</th><th>Nombre</th><th>Nivel</th><th style="width:22%">Uso</th><th class="num">Libre</th><th>Carpetas de primer nivel</th></tr></thead><tbody>${d.discos.map(x => `<tr><td><b>${x.letra}</b><div class="cell-small">${esc(x.unc)}</div></td><td>${esc(x.nombre)}</td><td><span class="tag">${esc(x.nivel)}</span></td><td><div class="bar"><i style="width:${Math.round((x.total-x.libre)/x.total*100)}%;background:${(x.total-x.libre)/x.total > .9 ? 'var(--bad)' : 'var(--acc)'}"></i></div><div class="cell-small">${gb(x.total-x.libre)} de ${gb(x.total)}</div></td><td class="num">${gb(x.libre)}</td><td>${x.carpetas.slice(0,40).map(c => `<span class="chip" style="${c.cliente ? 'border-color:var(--acc)' : ''}" title="${esc(c.cliente||'')}">${esc(c.nombre)}</span>`).join('')}${x.carpetas.length > 40 ? ` <span class="mut">+${x.carpetas.length-40}</span>` : ''}</td></tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
// ---------------- revisar: fechas raras (una cámara con el reloj sin poner) ----------------
|
||||
const fechaLarga = f => { const [a, m, d] = (f || '').split('-'); return d ? `${+d} ${MESES_UI[+m - 1].slice(0, 3).toLowerCase()} ${a}` : (f || ''); };
|
||||
async function cargarRelojes(){
|
||||
const raiz = raizActual(); if (!raiz) return;
|
||||
const d = await api('/api/relojes?raiz=' + encodeURIComponent(raiz));
|
||||
if (raizActual() !== raiz) return;
|
||||
const bd = $('#badgeRelojes'); bd.textContent = d.grupos.length; bd.classList.toggle('cero', !d.grupos.length);
|
||||
$('#relojes').classList.remove('mut');
|
||||
$('#relojes').innerHTML = (d.grupos.length ? d.grupos.map((g, i) => `<div class="reloj"><div class="tx"><b class="path">${esc(g.rel)}</b><div class="mut">${esc(g.camara || 'cámara sin identificar')} · ${num(g.n)} clip${g.n !== 1 ? 's' : ''} · ${gb(g.bytes)} · por ejemplo «${esc(g.ejemplo)}»</div><div>La cámara dice <b>${fechaLarga(g.desde)}</b>${g.hasta !== g.desde ? ` a <b>${fechaLarga(g.hasta)}</b>` : ''}.${g.sugerencia ? ` <span class="mut">Pista: ${esc(g.por_que)}.</span>` : ''}</div></div><div class="acts"><label>El primero se grabó el <input type="date" data-f="${i}" value="${esc(g.sugerencia || '')}"></label><button class="p" data-c="${i}">Corregir</button></div></div>`).join('')
|
||||
: '<p class="ok" style="margin:6px 0">No queda ningún clip con fecha imposible.</p>')
|
||||
+ (d.corregidos.length ? '<div class="cabsec" style="margin-top:16px">Ya corregidas</div>' + d.corregidos.map((c, i) => `<div class="reloj hecho"><div class="tx">✓ <b class="path">${esc(c.rel || '')}</b> · ${esc(c.camara || '')} · ${num(c.n)} clip${c.n !== 1 ? 's' : ''} corrido${c.n !== 1 ? 's' : ''} ${c.dias > 0 ? '+' : ''}${num(c.dias)} días · ${esc(fecha(c.ts))}</div><div class="acts"><button class="lnk" data-d="${i}">Deshacer</button></div></div>`).join('') : '');
|
||||
$$('#relojes [data-c]').forEach(b => b.onclick = () => {
|
||||
const g = d.grupos[+b.dataset.c], f = $(`#relojes [data-f="${b.dataset.c}"]`).value;
|
||||
if (!f) { aviso('Pon la fecha en que se grabó el primer clip.', 'bad'); return; }
|
||||
accion(b, async () => {
|
||||
const r = await api('/api/relojes', {raiz, dir: g.dir, fuente: g.fuente, fecha: f});
|
||||
aviso(`Listo: ${num(r.n)} clip${r.n !== 1 ? 's' : ''} con su fecha real (${r.dias > 0 ? '+' : ''}${num(r.dias)} días). El plan se vuelve a calcular.`, 'ok');
|
||||
PLAN = null; estadoPlan(); await cargarRelojes();
|
||||
});
|
||||
});
|
||||
$$('#relojes [data-d]').forEach(b => b.onclick = () => accion(b, async () => {
|
||||
await api('/api/relojes/deshacer', {raiz, grupo: d.corregidos[+b.dataset.d].grupo});
|
||||
aviso('Corrección deshecha: esos clips vuelven a la fecha de su cámara.', 'ok'); PLAN = null; estadoPlan(); await cargarRelojes();
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------- revisar: por nombrar ----------------
|
||||
async function cargarVoc(){
|
||||
if (!raizActual() || !(MAPA && MAPA.escaneado)) { $('#nombrar').innerHTML = '<li class="mut">Escanea primero.</li>'; return; }
|
||||
@@ -1637,18 +1740,33 @@ function animarEjemplo(){
|
||||
setTimeout(() => { if (v.isConnected) fin(); }, 1500);
|
||||
}
|
||||
// ---- la lista: de dónde → a dónde, con la cuenta; el porqué solo si se pide
|
||||
// lo que comparten el origen y el destino: así se dice solo lo que cambia
|
||||
function comunes(o, d){ const a = trozos(o), b = trozos(d); let i = 0; while (i < a.length && i < b.length && a[i].toLowerCase() === b[i].toLowerCase()) i++; return {comun: a.slice(0, i), soloO: a.slice(i), soloD: b.slice(i)}; }
|
||||
const corta = segs => segs.length > 2 ? '…\\' + segs.slice(-2).join('\\') : segs.join('\\');
|
||||
function deHtml(r){
|
||||
if (r.archivos && r.paso === 'banco') {
|
||||
const c = comunes(r.origen_rel, r.destino_rel), cam = ultimo(r.destino_rel);
|
||||
return `<b>${r.archivos.length} clip${r.archivos.length > 1 ? 's' : ''} de ${esc(NOMBRE_CAMARA[cam] || cam)}</b><span class="en">en el tema «${esc(ultimo(c.comun.join('\\')) || r.banco || '')}»${c.soloO.length ? `, dentro de «${esc(c.soloO.join(' › '))}»` : ''}</span>`;
|
||||
}
|
||||
if (r.archivos && r.origen_rel === '(toda la raíz)') return `<b>${r.archivos.length} archivos de ${r.cubeta === 'PROXY' ? 'proxy' : 'basura'}</b><span class="en">por toda la carpeta</span>`;
|
||||
if (r.archivos) return `<b>${r.archivos.length} archivo${r.archivos.length > 1 ? 's' : ''}</b><span class="en">en ${esc(r.origen_rel)}</span>`;
|
||||
return `<b>${esc(ultimo(r.origen_rel))}</b><span class="en">en ${esc(padreDe(r.origen_rel) || 'la raíz')}</span>`;
|
||||
}
|
||||
function aHtml(r){
|
||||
if (r.archivos && r.paso === 'banco') {
|
||||
const c = comunes(r.origen_rel, r.destino_rel);
|
||||
if (c.comun.length && !/^\d{4}$/.test(c.soloD[0] || '') && /^\d{4}-\d{2}-\d{2}$/.test(c.soloD[0] || '')) return `se quedan en el mismo tema, en <b>${esc(c.soloD.join(' › '))}</b> <span class="mut">(día de rodaje › cámara)</span>`;
|
||||
return `<b>${esc(corta(trozos(r.destino_rel)))}</b>`;
|
||||
}
|
||||
if (r.accion === 'renombrar') return `pasa a llamarse <b>${esc(ultimo(r.destino_rel))}</b>`;
|
||||
if (!r.archivos && ultimo(r.destino_rel) === ultimo(r.origen_rel)) return esc(padreDe(r.destino_rel) || 'la raíz');
|
||||
return esc(r.destino_rel);
|
||||
}
|
||||
// el nombre de cámara de cada carpeta de fuente, para decirlo en llano
|
||||
const NOMBRE_CAMARA = {URSA_BRAW: 'la URSA (BRAW)', BLACKMAGIC_MP4: 'la Blackmagic (MP4)', SONY: 'la Sony', OSMO: 'la Osmo', DJI: 'el dron DJI', DJI_MIC: 'el DJI Mic', TASCAM: 'la Tascam', ZOOM: 'la Zoom', ATOMOS: 'el Atomos', CELULAR: 'celular', FOTOS: 'cámara de fotos', AUDIO: 'grabadora', OTROS: 'otra cámara'};
|
||||
const SEG_EXPLICA = {alta: 'La app está segura.', media: 'Probable: la fecha sale del reloj de la cámara, que suele estar bien.', baja: 'Dudoso: la app no está segura; míralo antes de marcarlo.'};
|
||||
function filaHtml(r, ej){
|
||||
return `<div class="fila ${r.confianza} ${r.marcado ? '' : 'sinmarcar'} ${ej && ej.id === r.id ? 'esej' : ''}" data-id="${r.id}"><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar"><div class="de">${deHtml(r)}</div><span class="fl">→</span><div class="a">${aHtml(r)}${r.editado ? ' <span class="tag">destino cambiado</span>' : ''}</div><div class="meta">${r.n ? `${num(r.n)} arch. · ${gb(r.bytes)}` : ''}</div><div class="acts">${r.confianza !== 'alta' ? `<span class="tag ${r.confianza}">${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>` : ''}<button class="ed" data-ej="${r.id}" title="Verlo arriba, como ejemplo">ejemplo</button>${r.archivos ? '' : `<button class="ed" data-ed="${r.id}" title="Cambiar a dónde va">cambiar destino</button>`}</div><div class="pq">${esc(motivoLlano(r.motivo))}</div></div>`;
|
||||
return `<div class="fila ${r.confianza} ${r.marcado ? '' : 'sinmarcar'} ${ej && ej.id === r.id ? 'esej' : ''}" data-id="${r.id}"><input type="checkbox" ${r.marcado ? 'checked' : ''} aria-label="marcar"><div class="de">${deHtml(r)}</div><span class="fl">→</span><div class="a">${aHtml(r)}${r.editado ? ' <span class="tag">destino cambiado</span>' : ''}</div><div class="meta">${r.n ? `${num(r.n)} arch. · ${gb(r.bytes)}` : ''}</div><div class="acts">${r.confianza !== 'alta' ? `<span class="tag ${r.confianza}" title="${esc(r.cosmetico ? 'Solo cambia la forma del nombre.' : SEG_EXPLICA[r.confianza] || '')}">${r.cosmetico ? 'cosmético' : SEG_TXT[r.confianza]}</span>` : ''}<button class="ed" data-ej="${r.id}" title="Verlo arriba, como ejemplo">ejemplo</button>${r.archivos ? '' : `<button class="ed" data-ed="${r.id}" title="Cambiar a dónde va">cambiar destino</button>`}</div><div class="pq">${esc(motivoLlano(r.motivo))}</div></div>`;
|
||||
}
|
||||
function pintarLista(){
|
||||
const caja = $('#planLista'), p = PASOS_PLAN.find(x => x.k === PLAN_PASO);
|
||||
@@ -1659,11 +1777,21 @@ function pintarLista(){
|
||||
const grupos = rs => { const g = {}; rs.forEach(r => (g[r.cubeta] = g[r.cubeta] || []).push(r)); return Object.keys(g).map(c => `<div class="grp">${esc(NOMBRE_CUBETA[c] || c)} · ${g[c].length}</div>` + g[c].map(r => filaHtml(r, ej)).join('')).join(''); };
|
||||
const nt = sin.filter(r => r.no_tocar), cos = sin.filter(r => r.cosmetico && !r.no_tocar), dud = sin.filter(r => !r.cosmetico && !r.no_tocar);
|
||||
const explica = [nt.length ? `${nt.length === 1 ? 'uno está' : nt.length + ' están'} dentro de una carpeta que dice «No tocar»: no se mueve${nt.length === 1 ? '' : 'n'} hasta que ${nt.length === 1 ? 'lo' : 'los'} marques tú` : '', cos.length ? `${cos.length === 1 ? 'uno solo cambia' : cos.length + ' solo cambian'} el nombre a la forma «NN- Título» (como «01 - Celular» → «01- Celular»): da igual hacerlo o no` : '', dud.length ? `${dud.length} ${dud.length === 1 ? 'es dudoso' : 'son dudosos'}: la app no está segura y no ${dud.length === 1 ? 'lo' : 'los'} mueve sin que lo marques tú` : ''].filter(Boolean).join('; ');
|
||||
caja.innerHTML = `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}${PLAN_PASO === 'banco' && sin.length > 1 ? `<button class="lnk" id="btnMarcarBanco">marcar los ${sin.length}</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`;
|
||||
const bancos = (PLAN && PLAN.bancos) || [];
|
||||
const modoB = PLAN_PASO === 'banco' && bancos.length ? `<div class="modob">${bancos.map((b, i) => `<div>Cómo se ordena «${esc(b.nombre)}»: <button class="lnk ${b.modo !== 'fecha' ? 'on' : ''}" data-modob="${i}" data-m="temas">por temas</button> · <button class="lnk ${b.modo === 'fecha' ? 'on' : ''}" data-modob="${i}" data-m="fecha">por fecha</button> <span class="mut">${b.modo === 'fecha' ? 'Cada grabación va a la 00_Grabacion de su mes, con su tema de etiqueta; lo montado se queda en el banco.' : 'Los temas se quedan; dentro de cada uno, por fecha de rodaje y cámara.'}</span></div>`).join('')}</div>` : '';
|
||||
const ejB = PLAN_PASO === 'banco' ? filasPaso('banco').find(r => r.archivos) : null;
|
||||
const explicaB = ejB ? `<div class="hint" style="margin:0 0 10px">Este paso no saca nada del banco: <b>cada tema se queda donde está</b>, y dentro de él las grabaciones se separan <b>por día de rodaje y por cámara</b>. Por ejemplo, los ${ejB.archivos.length} clips de <span class="path">${esc(corta(trozos(ejB.origen_rel)))}</span> pasan a <span class="path">${esc(corta(trozos(ejB.destino_rel)))}</span>. Lo montado (exportados, proyectos, fotos sueltas) no se toca. Va todo sin marcar porque la carpeta dice «No tocar»: si te convence, «marcar los ${filasPaso('banco').length}» y aplicar; se deshace desde Historial.</div>` : '';
|
||||
caja.innerHTML = modoB + explicaB + `<div class="lista ${PLAN_POR_QUE ? 'porque' : ''}"><div class="lcab"><b>${marc.length ? `Lo que va a hacer este paso · ${marc.length}` : f ? 'Nada marcado con ese filtro' : 'Este paso no tiene nada marcado'}</b><span class="sep"></span><label class="mut" style="font-size:12.5px"><input type="checkbox" id="chkPorQue" ${PLAN_POR_QUE ? 'checked' : ''}> ver por qué</label></div>${grupos(marc)}${sin.length ? `<div class="opc"><div class="lcab"><div><b>Sin marcar · ${sin.length}</b> <span class="mut">${explica}.</span></div><span class="sep"></span>${cos.length ? `<button class="lnk" id="btnMarcarCosm">marcar los cosméticos</button>` : ''}${PLAN_PASO === 'banco' && sin.length > 1 ? `<button class="lnk" id="btnMarcarBanco">marcar los ${sin.length}</button>` : ''}<button class="lnk" id="btnVerOpc">${PLAN_VER_OPC ? 'ocultar la lista' : 'ver la lista'}</button></div>${PLAN_VER_OPC ? grupos(sin) : ''}</div>` : ''}</div>`;
|
||||
$('#chkPorQue').onchange = e => { PLAN_POR_QUE = e.target.checked; caja.querySelector('.lista').classList.toggle('porque', PLAN_POR_QUE); };
|
||||
const bo = $('#btnVerOpc'); if (bo) bo.onclick = () => { PLAN_VER_OPC = !PLAN_VER_OPC; pintarLista(); };
|
||||
const bc = $('#btnMarcarCosm'); if (bc) bc.onclick = () => { cos.forEach(r => r.marcado = true); pintarPlan(); };
|
||||
const bb = $('#btnMarcarBanco'); if (bb) bb.onclick = () => { sin.forEach(r => r.marcado = true); pintarPlan(); };
|
||||
$$('#planLista [data-modob]').forEach(b => b.onclick = () => accion(b, async () => {
|
||||
const bco = bancos[+b.dataset.modob]; if (bco.modo === b.dataset.m) return;
|
||||
await api('/api/banco/modo', {raiz: raizActual(), nombre: bco.nombre, modo: b.dataset.m});
|
||||
PLAN = null; PLAN_PASO = 'banco'; await calcularPlan();
|
||||
aviso(b.dataset.m === 'fecha' ? `«${bco.nombre}» se ordena ahora por fecha.` : `«${bco.nombre}» se ordena ahora por temas.`, 'ok');
|
||||
}));
|
||||
// marcar o desmarcar una fila cambia las cifras, no la lista: así el foco se queda donde está
|
||||
$$('#planLista .fila').forEach(el => { const r = PLAN.filas.find(x => x.id === +el.dataset.id); el.querySelector('input[type=checkbox]').onchange = e => { r.marcado = e.target.checked; el.classList.toggle('sinmarcar', !r.marcado); pintarCabPlan(); estadoPlan(); }; });
|
||||
$$('#planLista [data-ej]').forEach(b => b.onclick = () => { const i = ejemplosDe(PLAN_PASO).findIndex(x => x.id === +b.dataset.ej); if (i >= 0) { PLAN_EJ = i; pintarEjemplo(); pintarLista(); $('#ejCaja').scrollIntoView({behavior: 'smooth', block: 'nearest'}); } });
|
||||
@@ -1679,11 +1807,28 @@ function pintarAvance(s){
|
||||
caja.innerHTML = `<div class="avance"><div class="l1"><span class="spin"></span><b>Moviendo…</b><span>${u} % · ${s.hechas || 0} de ${s.total} cambio${s.total > 1 ? 's' : ''}</span><span class="sep"></span><span class="mut">${num(s.movidos || 0)} movimiento${s.movidos === 1 ? '' : 's'}${s.fallos ? ` · <span class="bad">${s.fallos} no se pudieron</span>` : ''}</span></div><div class="bar grande"><i style="width:${u}%"></i></div><div class="l3 mut">${s.actual ? `ahora: «${esc(s.actual)}»${s.archivo ? ' · ' + esc(s.archivo) : ''}` : 'empezando…'}</div></div>`;
|
||||
return;
|
||||
}
|
||||
const r = s.res, falt = r.errores.length;
|
||||
caja.innerHTML = `<div class="avance listo ${falt ? 'conerr' : ''}"><div class="l1"><span class="ico">${falt ? '!' : '✓'}</span><b>${falt ? 'Hecho, con tropiezos.' : 'Listo.'}</b><span>${r.filas} de ${s.pedidas} cambio${s.pedidas > 1 ? 's' : ''} · ${num(r.movimientos)} movimiento${r.movimientos === 1 ? '' : 's'} · ${gb(s.bytes)} · ${tiempoDe(s.seg)}</span><span class="sep"></span>${r.lote ? `<button class="lnk" id="avDeshacer">Deshacer esto</button>` : ''}</div><div class="bar grande"><i style="width:100%"></i></div>${falt ? `<div class="l3 bad">${falt} no se pudieron mover. <button class="lnk" id="avErr" style="padding:0 4px">ver cuáles</button></div>` : ''}<div class="l3 mut" id="avQuedan"><span class="spin"></span> mirando qué queda…</div></div>`;
|
||||
const r = s.res, falt = r.fallos != null ? r.fallos : r.errores.length;
|
||||
caja.innerHTML = `<div class="avance listo ${falt ? 'conerr' : ''}"><div class="l1"><span class="ico">${falt ? '!' : '✓'}</span><b>${falt ? 'Hecho, con tropiezos.' : 'Listo.'}</b><span>${r.filas} de ${s.pedidas} cambio${s.pedidas > 1 ? 's' : ''} · ${num(r.movimientos)} movimiento${r.movimientos === 1 ? '' : 's'} · ${gb(s.bytes)} · ${tiempoDe(s.seg)}</span><span class="sep"></span>${r.lote ? `<button class="lnk" id="avDeshacer">Deshacer esto</button>` : ''}</div><div class="bar grande"><i style="width:100%"></i></div>${falt ? `<div class="l3 bad">${num(falt)} no se pudieron mover. Nada se perdió: se quedaron donde estaban. <button class="lnk" id="avErr" style="padding:0 4px">ver cuáles</button></div>${motivosHtml(r.motivos)}` : ''}<div class="l3 mut" id="avQuedan"><span class="spin"></span> mirando qué queda…</div></div>`;
|
||||
if (r.lote) $('#avDeshacer').onclick = () => deshacer(r.lote);
|
||||
if (falt) $('#avErr').onclick = () => modal('No se pudieron mover', `<pre>${esc(r.errores.join('\n'))}</pre>`, null);
|
||||
}
|
||||
// por qué no se pudo mover algo, en llano, y qué hacer con cada clase de fallo
|
||||
const MOTIVO_FALLO = {
|
||||
existe: ['en el destino ya había un archivo con el mismo nombre', 'La app nunca renombra (Resolve y Premiere enlazan por nombre), así que esos se quedaron donde estaban. Suele ser la misma foto o el mismo clip dos veces, por ejemplo en «Alta» y en «Baja»: mira «Juntar material repetido», o calcula de nuevo y cambia el destino de esa fila.'],
|
||||
en_uso: ['estaban abiertos en otro programa', 'Cierra Resolve, Premiere o la ventana del Explorador que los tenga abiertos, pulsa «Calcular de nuevo» y aplica: solo intentará lo que faltó.'],
|
||||
permiso: ['Windows no dio permiso para moverlos', 'Suele ser una carpeta del NAS de solo lectura o de otro usuario. Revisa los permisos de esa carpeta compartida.'],
|
||||
no_esta: ['ya no estaban donde decía el plan', 'Alguien los movió o los borró después de calcular. Pulsa «Calcular de nuevo».'],
|
||||
convirtiendo: ['se estaban convirtiendo en ese momento', 'Un clip que se convierte no se puede mover, ni la carpeta que lo tiene: se quedaron donde estaban. Cuando acabe la cola, pulsa «Calcular de nuevo» y aplica: se moverán junto con su convertido.'],
|
||||
copia: ['la copia entre discos no quedó completa', 'El original no se tocó. Vuelve a aplicar; si se repite, mira el espacio libre y la conexión con el NAS.'],
|
||||
red: ['se cortó la conexión con el disco de red', 'Comprueba que la unidad sigue conectada y vuelve a aplicar: solo intentará lo que faltó.'],
|
||||
otro: ['por otra razón', 'Mira la lista con «ver cuáles».'],
|
||||
};
|
||||
function motivosHtml(m){
|
||||
const ks = Object.keys(m || {}).sort((a, b) => m[b].n - m[a].n);
|
||||
if (!ks.length) return '';
|
||||
return `<div class="motivos">${ks.map(k => { const [que, hacer] = MOTIVO_FALLO[k] || MOTIVO_FALLO.otro; const x = m[k];
|
||||
return `<div class="mt"><b>${num(x.n)} porque ${esc(que)}.</b> ${esc(hacer)}${x.ejemplos && x.ejemplos.length ? `<div class="mut ej">Por ejemplo: ${x.ejemplos.slice(0, 3).map(e => `<span class="path">${esc(e)}</span>`).join(', ')}</div>` : ''}</div>`; }).join('')}</div>`;
|
||||
}
|
||||
function pintarQueda(){
|
||||
const el = $('#avQuedan'); if (!el || !PLAN) return;
|
||||
const sin = PLAN.filas.filter(r => !r.marcado), marc = PLAN.filas.filter(r => r.marcado);
|
||||
@@ -1753,8 +1898,12 @@ function resumenStock(){ const s = STOCK.filter(g => g.sel); bloquear('btnStockP
|
||||
$('#btnStockPapelera').onclick = () => { const s = STOCK.filter(g => g.sel && g.raiz === raizActual()); const rutas = s.flatMap(g => g.rutas); if (!rutas.length) return aviso('La lista es de otra carpeta. Busca de nuevo.', 'bad'); modal('Apartar stock de Envato', `<p><b>${rutas.length}</b> archivos (${gb(s.reduce((a,g)=>a+g.bytes,0))}) se mueven a <span class="path">_Para borrar/stock</span> dentro de la carpeta del cliente. Nada se borra; se deshace desde Historial.</p>`, () => accion($('#btnStockPapelera'), async () => { const r = await api('/api/stock/papelera', {raiz: raizActual(), rutas}); aviso(`${r.movimientos} archivos de stock apartados.`, 'ok', {texto: 'Ver historial', ir: () => irA('historial')}); STOCK = []; pintarStock(); $('#stockCaja').style.display = 'none'; MAPA = null; await cargarEstado(); })); $('#modalSi').textContent = 'Apartar'; };
|
||||
|
||||
// ---------------- convertir ----------------
|
||||
function pintarCriterio(){ $('#convCriterio').textContent = `con más de ${+$('#optMeses').value || 0} meses y más de ${+$('#optMin').value || 0} MB`; }
|
||||
['optMeses','optMin'].forEach(id => $('#' + id).oninput = pintarCriterio);
|
||||
const ahorroPedido = () => { const v = $('#optAhorro').value; return v === '' ? 30 : Math.max(0, Math.min(90, Math.round(+v) || 0)); };
|
||||
function pintarCriterio(){ $('#convCriterio').textContent = `con más de ${+$('#optMeses').value || 0} meses, más de ${+$('#optMin').value || 0} MB y que ahorren al menos un ${ahorroPedido()} %`; }
|
||||
['optMeses','optMin','optAhorro'].forEach(id => $('#' + id).oninput = pintarCriterio);
|
||||
$('#optSimult').onchange = () => api('/api/config', {optimizar: {simultaneas: +$('#optSimult').value}}).then(() => aviso(`La cola convertirá ${+$('#optSimult').value || 'automáticamente uno o dos'}${+$('#optSimult').value === 1 ? ' clip' : (+$('#optSimult').value ? ' clips' : '')} a la vez desde la próxima vez que arranque.`, 'ok')).catch(e => aviso(e.message, 'bad'));
|
||||
// el ahorro mínimo vale también para la pasada automática y para Inicio: se guarda al cambiarlo
|
||||
$('#optAhorro').onchange = () => api('/api/config', {optimizar: {ahorro_minimo: ahorroPedido()}}).then(() => { if (CAND.length) return cargarCandidatos(); }).catch(e => aviso(e.message, 'bad'));
|
||||
let COLA_TS = [], CONV_VER = 'todos', CONV_ORDEN = {campo: 'carpeta', inverso: false}, CONV_EXC = '', CONV_PLEGADAS = new Set(), CONV_BUSCA = '', CONV_CRITERIO = '';
|
||||
const NOMBRE_CAM = {braw: 'BRAW', h264: 'H.264', hevc: 'H.265', h265: 'H.265', prores: 'ProRes', mpeg4: 'MPEG-4', mpeg2video: 'MPEG-2', dnxhd: 'DNxHD', vp9: 'VP9', av1: 'AV1'};
|
||||
const MES_LARGO = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
|
||||
@@ -1773,7 +1922,8 @@ const tiempoLargo = s => { s = Math.max(0, Math.round(s || 0)); if (s < 60) retu
|
||||
function claveExc(m){
|
||||
m = m || '';
|
||||
if (m.startsWith('ya es una conversión')) return 'ya los convirtió el programa';
|
||||
if (m.startsWith('ya viene comprimido')) return 'ya vienen comprimidos de cámara';
|
||||
if (m.startsWith('convertido ahorraría solo')) return `ahorrarían menos del ${(m.match(/desde un (\d+) %/) || [])[1] || 30} %`;
|
||||
if (m.startsWith('está apartado como repetido')) return 'apartados como repetidos';
|
||||
if (m.startsWith('ya se probó')) return 'ya se probaron con esta calidad';
|
||||
if (m.startsWith('tiene menos de')) return m.replace('tiene menos de', 'de menos de');
|
||||
if (m.startsWith('no se pudo leer')) return 'no se pudieron leer';
|
||||
@@ -1807,18 +1957,41 @@ async function cargarMotores(){
|
||||
$$('#optFormato option').forEach(o => { const m = MOTORES.formatos[o.value]; o.disabled = !m; o.textContent = (ESTADO.formatos[o.value] || {}).nombre + (m === 'gpu' ? ' · GPU' : m === 'cpu' ? ' · CPU' : ' · no disponible'); });
|
||||
}
|
||||
async function cargarCandidatos(){
|
||||
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}&formato=${$('#optFormato').value}`;
|
||||
const q = `raiz=${encodeURIComponent(raizActual())}&meses=${+$('#optMeses').value || 0}&perfil=${$('#optPerfil').value}&min_mb=${+$('#optMin').value || 0}&exportados=${$('#optExport').checked ? 1 : 0}&formato=${$('#optFormato').value}&ahorro=${ahorroPedido()}`;
|
||||
const d = await api('/api/optimizar/candidatos?' + q); CAND = d.candidatos.map(c => ({...c, sel: !c.excluido && !c.en_cola}));
|
||||
$('#anaEstado').textContent = '';
|
||||
pintarGrandes();
|
||||
if (!CAND.length) aviso('No se encontraron clips con esos criterios. Prueba a cambiar los meses o el tamaño mínimo.', '');
|
||||
await cargarCola();
|
||||
}
|
||||
// Lo que pesa de la carpeta, de un vistazo: cuánto vídeo grande hay y qué pasa con cada parte. Munka, 14-oct-2026:
|
||||
// «que identifique bien el material y haga la búsqueda de grandes archivos de video». En sus discos, 353 GB de
|
||||
// exportados y masters no se ofrecían porque la casilla estaba apagada, y 757 GB ya convertidos esperaban «Reemplazar».
|
||||
function pintarGrandes(){
|
||||
const caja = $('#convGrandes'); if (!caja) return;
|
||||
if (!CAND.length) { caja.style.display = 'none'; return; }
|
||||
const g = {}; const suma = (k, c) => { const x = g[k] = g[k] || {n: 0, b: 0, e: 0}; x.n++; x.b += c.bytes || 0; x.e += c.estimado || 0; };
|
||||
CAND.forEach(c => { const m = c.excluido || ''; suma(c.en_cola ? (['listo', 'verificado'].includes(c.en_cola) ? 'hechos' : 'cola') : !m ? 'ofrece' : m.startsWith('es un exportado') ? 'export' : m.startsWith('está apartado') ? 'repes' : m.startsWith('convertido ahorraría') ? 'poco' : m.startsWith('ya es una conversión') ? 'ya' : 'otros', c); });
|
||||
const tot = CAND.reduce((s, c) => s + (c.bytes || 0), 0), v = k => g[k] || {n: 0, b: 0, e: 0};
|
||||
const trozo = (k, txt, extra = '') => v(k).n ? `<span><b>${num(v(k).n)}</b> ${txt} · ${gb(v(k).b)}${extra}</span>` : '';
|
||||
caja.innerHTML = `<span class="t">Vídeo de más de ${+$('#optMin').value || 0} MB en esta carpeta: ${num(CAND.length)} archivos · ${gb(tot)}</span>`
|
||||
+ trozo('ofrece', 'se ofrecen', ` → ≈ ${gb(v('ofrece').e)}`)
|
||||
+ trozo('cola', 'en la cola')
|
||||
+ trozo('hechos', 'ya convertidos, por reemplazar', ' (se liberan al reemplazar)')
|
||||
+ trozo('export', 'exportados y masters sin incluir', ` → ≈ ${gb(v('export').e)} <button id="grInclExp">incluirlos</button>`)
|
||||
+ trozo('poco', 'ahorrarían poco')
|
||||
+ trozo('repes', 'apartados como repetidos (para borrar)')
|
||||
+ trozo('ya', 'convertidos por el programa')
|
||||
+ trozo('otros', 'por otros motivos (alfa, stock, recientes…)');
|
||||
caja.style.display = '';
|
||||
const b = $('#grInclExp'); if (b) b.onclick = () => { $('#optExport').checked = true; cargarCandidatos().catch(e => aviso(e.message, 'bad')); };
|
||||
}
|
||||
function resumenCand(){ const s = CAND.filter(c => c.sel); const validos = CAND.filter(c => !c.excluido && !c.en_cola); bloquear('btnEncolar', validos.length ? (s.length ? '' : 'x') : 'x'); $('#btnEncolar').textContent = s.length ? `Convertir ${s.length} marcado${s.length > 1 ? 's' : ''}` : 'Convertir'; }
|
||||
$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a <b>${esc(fm.nombre)}</b>, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola o su salida ya existe (el mismo clip en MP4 y MOV, o una conversión anterior).`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = 'Convertir'; };
|
||||
$('#btnEncolar').onclick = () => { const s = CAND.filter(c => c.sel); const fm = (ESTADO.formatos || {})[$('#optFormato').value] || {nombre: $('#optFormato').value}; modal('Convertir', `<p><b>${s.length}</b> clips (${gb(s.reduce((a,c)=>a+c.bytes,0))}) se convierten a <b>${esc(fm.nombre)}</b>, uno tras otro, en segundo plano. Pesarían ≈ ${gb(s.reduce((a,c)=>a+c.estimado,0))}.</p><p><b>El original no se toca.</b> Cuando terminen y estén verificados, decides si reemplazar.</p>`, () => accion($('#btnEncolar'), async () => { await api('/api/config', {optimizar: {meses_minimos: +$('#optMeses').value || 0, min_mb: +$('#optMin').value || 0, ahorro_minimo: ahorroPedido(), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value}}); const r = await api('/api/optimizar/encolar', {raiz: raizActual(), rutas: s.map(c => c.ruta), perfil: $('#optPerfil').value, formato: $('#optFormato').value, chroma: $('#optChroma').value, color: $('#optColor').value, iniciar: true}); aviso(`${r.ids.length} clips en cola. El avance está en la barra de abajo.`, 'ok'); if (r.ids.length < s.length) aviso(`${s.length - r.ids.length} no se encolaron: ya estaban en cola o su salida ya existe (el mismo clip en MP4 y MOV, o una conversión anterior).`, ''); await cargarCandidatos(); await cargarEstado(); })); $('#modalSi').textContent = 'Convertir'; };
|
||||
const EST_TXT = {pendiente:'en espera', corriendo:'convirtiendo', listo:'convertido', verificado:'verificado', reemplazado:'reemplazado', error:'falló', cancelado:'cancelado', descartado:'no compensa'};
|
||||
async function cargarCola(){
|
||||
const d = await api('/api/optimizar/cola');
|
||||
const raiz = raizActual();
|
||||
const d = await api('/api/optimizar/cola' + (raiz ? '?raiz=' + encodeURIComponent(raiz) : ''));
|
||||
const ts = d.trabajos.filter(t => !raiz || (t.raiz || '').toLowerCase() === raiz.toLowerCase());
|
||||
const orden = {corriendo:0, pendiente:1, listo:2, verificado:2, error:3, descartado:4, reemplazado:5, cancelado:6};
|
||||
ts.sort((x, y) => (orden[x.estado] ?? 9) - (orden[y.estado] ?? 9) || y.id - x.id);
|
||||
@@ -1826,7 +1999,7 @@ async function cargarCola(){
|
||||
const n = k => ts.filter(t => t.estado === k).length;
|
||||
const pend = n('pendiente'), listos = n('listo'), verif = ts.filter(t => t.estado === 'verificado' && !(t.formato || '').startsWith('prores')).length, err = n('error') + n('cancelado');
|
||||
const est = $('#colaEstado'); est.className = 'est ' + (d.cola.activa ? 'corriendo' : err ? 'error' : verif ? 'verificado' : 'pendiente'); est.textContent = d.cola.activa ? `convirtiendo${d.cola.mensaje ? ' · ' + d.cola.mensaje : ''}` : pend ? `${pend} en espera, parada` : ts.length ? `${verif} verificados · ${n('reemplazado')} reemplazados${err ? ` · ${err} con error` : ''}${n('descartado') ? ` · ${n('descartado')} que no compensan` : ''}` : 'sin actividad';
|
||||
const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Reanudar';
|
||||
const tg = $('#btnColaToggle'); tg.style.display = (d.cola.activa || pend) ? '' : 'none'; tg.textContent = d.cola.activa ? 'Detener' : 'Continuar';
|
||||
$('#btnReintentar').style.display = err ? '' : 'none'; $('#btnQuitar').style.display = ts.some(t => ['error','cancelado','reemplazado','descartado'].includes(t.estado)) ? '' : 'none'; $('#btnVerificar').style.display = listos ? '' : 'none';
|
||||
$('#btnQuitarEspera').style.display = (!d.cola.activa && pend) ? '' : 'none';
|
||||
bloquear('btnReemplazar', verif ? '' : (listos ? 'hay convertidos sin verificar' : 'x')); $('#btnReemplazar').textContent = verif ? `Reemplazar ${verif} verificado${verif > 1 ? 's' : ''}` : 'Reemplazar';
|
||||
@@ -1869,10 +2042,10 @@ async function cargarCola(){
|
||||
const corre = t && t.estado === 'corriendo', pct = corre ? Math.max(0, Math.min(100, Math.round(t.pct || 0))) : 0;
|
||||
const est = corre ? `<span class="est prog ${pct >= 50 ? 'media' : ''}" style="--p:${pct}%" title="convirtiendo: ${pct} %">${pct} %</span>` : t ? `<span class="est ${t.estado}">${EST_TXT[t.estado] || esc(t.estado)}</span>` : `<span class="est candidato">${c.excluido ? 'no' : 'candidato'}</span>`;
|
||||
let sub = c ? `${esc(c.fecha)} · ${esc(c.codec)} ${esc(c.res)} · ${dur(c.dur)} · ${motorT}` : motorT;
|
||||
if (corre) { 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.actuales || []).find(x => x.id === t.id) || (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;
|
||||
@@ -1892,11 +2065,14 @@ async function cargarCola(){
|
||||
if (d.cola.activa && d.cola.esperando) {
|
||||
mo = `<div class="momento"><div class="l1"><span class="spin"></span><b>La cola está en pausa</b><span class="mut">${esc(d.cola.esperando)} · ${pend} en espera · se cambia en «Cuándo trabaja la cola»</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div></div>`;
|
||||
} else if (d.cola.activa) {
|
||||
const total = E && E.total ? E.total : (pend + (corr ? 1 : 0)), hechosT = E ? (E.hechos || 0) : 0;
|
||||
const pctTot = total ? Math.round((hechosT + (corr ? (corr.pct || 0) / 100 : 0)) / total * 100) : 0;
|
||||
mo = `<div class="momento corre"><div class="l1"><span class="spin"></span><b>Convirtiendo ${Math.min(hechosT + 1, total)} de ${total}</b><span class="mut">${eta && eta.seg ? `quedan unas <b>${tiempoLargo(eta.seg)}</b> · termina hacia las <b>${hora(eta.termina)}</b>${eta.medido ? ` · va a ${eta.vel.toFixed(1)}× tiempo real` : ' · estimado, hasta que acabe el primero'}` : 'calculando cuánto falta…'}</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div><div class="bar grande"><i style="width:${pctTot}%"></i></div><div class="l3 mut">${corr ? `ahora: <b>${esc((corr.ruta || '').split(/[\\/]/).pop())}</b> · ${Math.round(corr.pct || 0)} %${eta && eta.actual ? ` · quedan ${tiempoLargo(eta.actual.resto_seg)} de este clip` : ''} · ${eta ? eta.en_espera : pend} en espera` : 'empezando…'}</div></div>`;
|
||||
const corrs = ts.filter(t => t.estado === 'corriendo');
|
||||
const total = E && E.total ? E.total : (pend + (corrs.length || (corr ? 1 : 0))), hechosT = E ? (E.hechos || 0) : 0;
|
||||
const pctTot = total ? Math.round((hechosT + corrs.reduce((s, x) => s + (x.pct || 0), 0) / 100) / total * 100) : 0;
|
||||
const restoDe = x => { const a = eta && (eta.actuales || []).find(y => y.id === x.id); return a ? ` · quedan ${tiempoLargo(a.resto_seg)}` : ''; };
|
||||
const ahora = corrs.length > 1 ? `a la vez: ${corrs.map(x => `<b>${esc((x.ruta || '').split(/[\\/]/).pop())}</b> ${Math.round(x.pct || 0)} %${restoDe(x)}`).join(' · ')} · ${eta ? eta.en_espera : pend} en espera` : '';
|
||||
mo = `<div class="momento corre"><div class="l1"><span class="spin"></span><b>Convirtiendo ${corrs.length > 1 ? `${Math.min(hechosT + 1, total)} y ${Math.min(hechosT + 2, total)}` : Math.min(hechosT + 1, total)} de ${total}</b><span class="mut">${eta && eta.seg ? `quedan unas <b>${tiempoLargo(eta.seg)}</b> · termina hacia las <b>${hora(eta.termina)}</b>${eta.medido ? ` · va a ${eta.vel.toFixed(1)}× tiempo real` : ' · estimado, hasta que acabe el primero'}` : 'calculando cuánto falta…'}</span><span class="sep"></span><button class="bad" id="moDetener">Detener</button></div><div class="bar grande"><i style="width:${pctTot}%"></i></div><div class="l3 mut">${ahora || (corr ? `ahora: <b>${esc((corr.ruta || '').split(/[\\/]/).pop())}</b> · ${Math.round(corr.pct || 0)} %${restoDe(corr)} de este clip · ${eta ? eta.en_espera : pend} en espera` : 'empezando…')}</div></div>`;
|
||||
} else if (pend) {
|
||||
mo = `<div class="momento"><div class="l1"><b>${pend} clip${pend > 1 ? 's' : ''} en espera, con la cola parada</b><span class="mut">lo que ya iba se retoma desde cero</span><span class="sep"></span><button class="p grande" id="moReanudar">Reanudar la cola</button></div></div>`;
|
||||
mo = `<div class="momento"><div class="l1"><b>${pend} clip${pend > 1 ? 's' : ''} en espera, con la cola parada</b><span class="mut">sigue por donde iba; lo que quedó a medias se hace entero otra vez</span><span class="sep"></span><button class="p grande" id="moReanudar">Continuar la cola</button></div></div>`;
|
||||
} else if (vers.length) {
|
||||
const bIn = vers.reduce((s, t) => s + (t.bytes_in || 0), 0), bOut = vers.reduce((s, t) => s + (t.bytes_out || 0), 0);
|
||||
mo = `<div class="momento listo"><div class="l1"><span class="ico">✓</span><span><b>${vers.length} clip${vers.length > 1 ? 's' : ''} convertido${vers.length > 1 ? 's' : ''} y verificado${vers.length > 1 ? 's' : ''}</b> · ${gb(bIn)} → ${gb(bOut)}${notaMedia ? ` · nota media ${notaMedia} / 100` : ''}</span><span class="sep"></span><button class="lnk" id="moComparar">comparar unos cuantos</button><button class="ok grande" id="moReemplazar">Reemplazar ${vers.length > 1 ? 'los ' + vers.length + ' originales' : 'el original'} · libera ${gb(Math.max(0, bIn - bOut))}</button></div><div class="l3 mut">Los originales van a _Para borrar/originales, no se borran, y se deshace desde Historial. Si quieres verlo antes, «comparar» enseña el mismo cuadro en los dos y mide la calidad.</div></div>`;
|
||||
@@ -2011,23 +2187,50 @@ 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(); });
|
||||
}
|
||||
$('#btnColaToggle').onclick = () => accion($('#btnColaToggle'), async () => { await api('/api/optimizar/control', {accion: $('#btnColaToggle').textContent === 'Detener' ? 'detener' : 'iniciar'}); await cargarEstado(); cargarCola(); });
|
||||
const idsPorEstado = async (...est) => { const d = await api('/api/optimizar/cola'); const raiz = raizActual().toLowerCase(); return d.trabajos.filter(t => est.includes(t.estado) && (t.raiz || '').toLowerCase() === raiz && !(est.includes('verificado') && (t.formato || '').startsWith('prores'))).map(t => t.id); };
|
||||
const idsPorEstado = async (...est) => { const d = await api('/api/optimizar/cola?raiz=' + encodeURIComponent(raizActual())); const raiz = raizActual().toLowerCase(); return d.trabajos.filter(t => est.includes(t.estado) && (t.raiz || '').toLowerCase() === raiz && !(est.includes('verificado') && (t.formato || '').startsWith('prores'))).map(t => t.id); };
|
||||
$('#btnVerificar').onclick = () => accion($('#btnVerificar'), async () => { const ids = await idsPorEstado('listo'); const r = await api('/api/optimizar/verificar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} verificados.`, ok === r.resultados.length ? 'ok' : 'bad'); if (ok < r.resultados.length) modal('Verificación', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'FALLA'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); });
|
||||
$('#btnReemplazar').onclick = () => accion($('#btnReemplazar'), async () => { const ids = await idsPorEstado('verificado'); modal('Reemplazar originales', `<p><b>${ids.length}</b> originales se apartan a <span class="path">_Para borrar/originales</span> dentro de su carpeta (no se borran). El MP4 10 bits queda en su lugar.</p><p>Cuando estés seguro, una persona vacía _Para borrar a mano.</p>`, () => accion($('#btnReemplazar'), async () => { const r = await api('/api/optimizar/reemplazar', {ids}); const ok = r.resultados.filter(x => x.ok).length; aviso(`${ok} de ${r.resultados.length} reemplazados.`, ok === r.resultados.length ? 'ok' : 'bad', {texto: 'Ver historial', ir: () => irA('historial')}); if (ok < r.resultados.length) modal('Reemplazo', `<pre>${esc(r.resultados.map(x => `#${x.id} ${x.ok ? 'OK' : 'NO'} · ${x.motivo}`).join('\n'))}</pre>`, null); cargarCola(); })); $('#modalSi').textContent = 'Reemplazar'; });
|
||||
$('#btnReintentar').onclick = () => accion($('#btnReintentar'), async () => { const ids = await idsPorEstado('error', 'cancelado'); await api('/api/optimizar/control', {accion: 'reintentar', ids}); await api('/api/optimizar/control', {accion: 'iniciar'}); cargarCola(); });
|
||||
@@ -2091,6 +2294,9 @@ function pintarAjustes(){
|
||||
const ac = $('#actAuto');
|
||||
ac.checked = (ESTADO.config.actualizaciones || {}).auto === true;
|
||||
ac.onchange = () => api('/api/config', {actualizaciones: {auto: ac.checked}}).then(cargarEstado).then(() => aviso(ac.checked ? 'Al arrancar consultará si hay versión nueva.' : 'Ya no sale a la red al arrancar.', 'ok')).catch(e => aviso(e.message, 'bad'));
|
||||
const ep = $('#escProfundo');
|
||||
ep.checked = (ESTADO.config.escaneo || {}).profundo !== false;
|
||||
ep.onchange = () => api('/api/config', {escaneo: {profundo: ep.checked}}).then(cargarEstado).then(() => aviso(ep.checked ? 'Escaneo profundo: después de escanear se leerá cada clip.' : 'El escaneo ya no lee los clips: solo nombres, tamaños y fechas.', 'ok')).catch(e => aviso(e.message, 'bad'));
|
||||
if (!AJ) {
|
||||
const c = ESTADO.config; AJ = {clientes: []};
|
||||
Object.entries(c.clientes).forEach(([k, v]) => AJ.clientes.push({nombre: k, orig: k, tipo: v.tipo, alias: (v.alias || []).join('; '), rutas: (c.raices || []).filter(r => r.cliente === k).map(r => r.ruta)}));
|
||||
@@ -2509,8 +2715,8 @@ function pintarActualizacion(){
|
||||
if (a.aplicando || a.estado === 'descargando' || a.estado === 'verificando' || a.estado === 'reiniciando') { if (!ACT_SIGUIENDO) seguirActualizacion(); return; }
|
||||
if (!a.disponible) { caja.style.display = 'none'; return; }
|
||||
caja.style.display = ''; caja.classList.add('actualizacion');
|
||||
caja.innerHTML = `<h3>Hay una versión nueva: ${esc(a.disponible.version)} <span class="mut">(tienes la ${esc(ESTADO.version)})</span></h3><p class="mut">${esc(a.disponible.notes || '')}</p><div class="toolbar"><button id="btnActualizar" class="p">Actualizar ahora</button><span class="mut">${(a.disponible.size / 1e6).toFixed(0)} MB · el programa se cierra y vuelve a abrirse solo; tus datos no se tocan. No lo hagas con una conversión o copia en marcha.</span></div>` + (a.error ? `<p class="bad">${esc(a.error)}</p>` : '');
|
||||
$('#btnActualizar').onclick = () => accion($('#btnActualizar'), async () => { if (huella(ESTADO).cola || huella(ESTADO).resp || huella(ESTADO).asis) throw new ApiError('Hay una tarea en marcha. Espera a que termine antes de actualizar.'); const r = await api('/api/actualizacion/instalar', {}); if (!r.lanzado) throw new ApiError(r.error || 'No se pudo lanzar la actualización'); seguirActualizacion(); });
|
||||
caja.innerHTML = `<h3>Hay una versión nueva: ${esc(a.disponible.version)} <span class="mut">(tienes la ${esc(ESTADO.version)})</span></h3><p class="mut">${esc(a.disponible.notes || '')}</p><div class="toolbar"><button id="btnActualizar" class="p">Actualizar ahora</button><span class="mut">${(a.disponible.size / 1e6).toFixed(0)} MB · el programa se cierra y vuelve a abrirse solo; tus datos no se tocan. Si está convirtiendo, la cola se detiene y sigue sola al volver (el clip que iba se hace entero otra vez).</span></div>` + (a.error ? `<p class="bad">${esc(a.error)}</p>` : '');
|
||||
$('#btnActualizar').onclick = () => accion($('#btnActualizar'), async () => { if (huella(ESTADO).resp || huella(ESTADO).asis) throw new ApiError('Hay una copia o el asistente en marcha. Espera a que termine antes de actualizar.'); const r = await api('/api/actualizacion/instalar', {}); if (!r.lanzado) throw new ApiError(r.error || 'No se pudo lanzar la actualización'); seguirActualizacion(); });
|
||||
}
|
||||
async function seguirActualizacion(){
|
||||
ACT_SIGUIENDO = true; const caja = $('#actualizacionCaja'); caja.style.display = ''; irA('carpeta');
|
||||
|
||||
31
ventana.py
31
ventana.py
@@ -391,7 +391,7 @@ class Ventana:
|
||||
self.url, self.titulo, self.icono, self.perfil, self.memoria = url, titulo, icono, perfil, memoria
|
||||
self.base, self.marca, self.tamano, self.minimo, self.fondo = base, marca, tamano, minimo, fondo
|
||||
self.relanzar, self.depurar = relanzar, depurar
|
||||
self.al_cerrar = self.al_soltar = self.al_abrir = None
|
||||
self.al_cerrar = self.al_soltar = self.al_abrir = self.al_detener = None
|
||||
self.hwnd = None
|
||||
self.oculta = False # cerrada a la vista, pero viva hasta que termine el trabajo
|
||||
self.lista = False # la interfaz está cargada y a la vista
|
||||
@@ -500,9 +500,22 @@ class Ventana:
|
||||
if not que:
|
||||
return self._destruir()
|
||||
u = _dll().u
|
||||
r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras la ventana, el trabajo sigue hasta "
|
||||
"terminar y después el programa se cierra solo. Para verlo, vuelve a abrir OrganiLAB.",
|
||||
self.titulo, MB_OKCANCEL | MB_ICONINFORMATION)
|
||||
if not self.al_detener:
|
||||
r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras la ventana, el trabajo sigue hasta "
|
||||
"terminar y después el programa se cierra solo. Para verlo, vuelve a abrir OrganiLAB.",
|
||||
self.titulo, MB_OKCANCEL | MB_ICONINFORMATION)
|
||||
if r != IDOK:
|
||||
return
|
||||
self._recordar_sitio()
|
||||
self.oculta = True
|
||||
self.progreso(None)
|
||||
u.ShowWindow(self.hwnd, SW_HIDE)
|
||||
return
|
||||
# Cerrar para todo (Munka, 14-oct-2026): la conversión que va se corta y vuelve a la cola, y la próxima vez
|
||||
# «Continuar» sigue por donde iba. Antes el trabajo seguía oculto y, al actualizar, quedaban procesos sueltos.
|
||||
r = u.MessageBoxW(self.hwnd, f"OrganiLAB está trabajando: {que}.\n\nSi cierras, se detiene todo. Lo que iba a medias "
|
||||
"vuelve a la cola, y la próxima vez «Continuar» sigue por donde iba (lo cortado se hace entero otra vez).",
|
||||
self.titulo, MB_OKCANCEL | MB_ICONQUESTION)
|
||||
if r != IDOK:
|
||||
return
|
||||
self._recordar_sitio()
|
||||
@@ -510,6 +523,16 @@ class Ventana:
|
||||
self.progreso(None)
|
||||
u.ShowWindow(self.hwnd, SW_HIDE)
|
||||
|
||||
def parar():
|
||||
try:
|
||||
self.al_detener()
|
||||
except Exception as ex: # noqa
|
||||
_log("al_detener falló:", repr(ex))
|
||||
finally:
|
||||
if self.oculta: # si mientras se paraba la volvieron a abrir, se queda abierta (ya sin nada en marcha)
|
||||
self.cerrar()
|
||||
threading.Thread(target=parar, daemon=True).start()
|
||||
|
||||
def _volver(self):
|
||||
"""La abrieron otra vez mientras trabajaba oculta: vuelve a la vista."""
|
||||
u = _dll().u
|
||||
|
||||
39
verificar_publicado.py
Archivo normal
39
verificar_publicado.py
Archivo normal
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Comprueba lo que sirve Labstream OS después de publicar: la versión, que la firma del manifiesto es válida, que
|
||||
es el mismo manifiesto que el local, y que el paquete que se descarga tiene la huella y el tamaño que dice.
|
||||
Termina con error si algo no cuadra (la compilación del estudio se detiene ahí).
|
||||
|
||||
py -3.12 verificar_publicado.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import io
|
||||
import json
|
||||
import hashlib
|
||||
import urllib.request
|
||||
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
|
||||
REPO = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, REPO)
|
||||
import firma # noqa: E402
|
||||
import actualizador as A # noqa: E402
|
||||
|
||||
BASE = "https://os.labstreamsas.com"
|
||||
|
||||
|
||||
def pedir(url):
|
||||
return urllib.request.urlopen(urllib.request.Request(url, headers={"Cache-Control": "no-cache"}), timeout=120).read()
|
||||
|
||||
|
||||
m = json.loads(pedir(BASE + "/herramientas/organizador.json"))
|
||||
local = json.load(open(os.path.join(REPO, "manifiesto.json"), encoding="utf-8"))
|
||||
firma_ok = firma.verificar(firma.texto_manifiesto(m), str(m["firma"]["valor"]), A.CLAVE_PUBLICA)
|
||||
igual = m == local
|
||||
datos = pedir(BASE + m["app"]["url"])
|
||||
huella_ok = hashlib.sha256(datos).hexdigest() == m["app"]["sha256"] and len(datos) == m["app"]["size"]
|
||||
print("servidor: versión", m["version"], "· local:", local.get("version"))
|
||||
print("firma del manifiesto:", "válida" if firma_ok else "NO VÁLIDA")
|
||||
print("coincide con el manifiesto local:", igual)
|
||||
print(f"descarga del paquete: {len(datos)} bytes · huella y tamaño iguales a los del manifiesto: {huella_ok}")
|
||||
print("notas:", (m.get("notes") or "")[:120], "…")
|
||||
sys.exit(0 if (firma_ok and igual and huella_ok) else 1)
|
||||
Referencia en una nueva incidencia
Block a user