OrganiLAB 1.6.1: organizador, actualizador, interfaz 1.6 y publicación

Primer commit del proyecto. Incluye el rediseño 1.6 (riel de pasos en la barra
lateral), el actualizador con reintentos y respaldo, publicar.sh y la nueva
opción de desconectar carpetas (1.6.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-09-15 14:26:02 -05:00
commit 8c5153e8ed
Se han modificado 20 ficheros con 6481 adiciones y 0 borrados

10
.gitignore vendido Archivo normal
Ver fichero

@@ -0,0 +1,10 @@
# generado al empaquetar
build/
dist/
*.zip
__pycache__/
*.pyc
# datos locales de la instalación (inventario, config, bitácora)
datos/
# copias de respaldo puntuales
_respaldo-*/

261
AUDITORIA-2026-09-15.html Archivo normal
Ver fichero

@@ -0,0 +1,261 @@
<title>Auditoría OrganiLAB 1.4.3</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root{
--bg:#f6f7f5; --panel:#ffffff; --ink:#1c2733; --ink-2:#4a5561; --ink-3:#7a8490;
--line:#dde2e0; --accent:#2457b8; --accent-soft:#e6eefb;
--crit:#b3261e; --crit-soft:#fbe9e7; --high:#b85a0c; --high-soft:#fdeee0;
--med:#8a6d00; --med-soft:#fbf3d5; --low:#526070; --low-soft:#e9edf1;
--mono:"IBM Plex Mono",ui-monospace,Consolas,monospace;
--sans:"IBM Plex Sans",system-ui,Segoe UI,sans-serif;
}
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
--bg:#151a1f; --panel:#1d242b; --ink:#e7ebee; --ink-2:#b4bcc5; --ink-3:#7f8994;
--line:#2e373f; --accent:#7ea6f0; --accent-soft:#1f2c42;
--crit:#ff7b72; --crit-soft:#3a1f1d; --high:#f0a35c; --high-soft:#3a2a18;
--med:#e3c15a; --med-soft:#353017; --low:#a9b4bf; --low-soft:#262d34;
}}
:root[data-theme="dark"]{
--bg:#151a1f; --panel:#1d242b; --ink:#e7ebee; --ink-2:#b4bcc5; --ink-3:#7f8994;
--line:#2e373f; --accent:#7ea6f0; --accent-soft:#1f2c42;
--crit:#ff7b72; --crit-soft:#3a1f1d; --high:#f0a35c; --high-soft:#3a2a18;
--med:#e3c15a; --med-soft:#353017; --low:#a9b4bf; --low-soft:#262d34;
}
body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.55;padding:0 20px;padding-block:32px 64px}
.wrap{max-width:820px;margin:0 auto}
h1{font-size:28px;font-weight:600;margin:0 0 6px;text-wrap:balance;letter-spacing:-.01em}
h2{font-size:18px;font-weight:600;margin:40px 0 12px;padding-top:16px;border-top:1px solid var(--line)}
p{margin:0 0 12px;max-width:68ch}
.meta{color:var(--ink-2);font-size:14px;margin-bottom:24px}
.meta code{font-family:var(--mono);font-size:13px}
.tally{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:20px 0 8px}
.tally div{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px 12px}
.tally b{display:block;font-size:24px;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.1}
.tally span{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3)}
.tally .c b{color:var(--crit)} .tally .h b{color:var(--high)} .tally .m b{color:var(--med)} .tally .l b{color:var(--low)}
.verdict{background:var(--accent-soft);border-left:3px solid var(--accent);padding:12px 16px;border-radius:0 6px 6px 0;margin:16px 0 8px}
.verdict p:last-child{margin:0}
ol.top{padding-left:22px;margin:0}
ol.top li{margin-bottom:8px}
.f{display:grid;grid-template-columns:78px 1fr;gap:12px;padding:14px 0;border-bottom:1px solid var(--line)}
.f:last-child{border-bottom:0}
.chip{align-self:start;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;padding:3px 8px;border-radius:4px;text-align:center}
.chip.c{background:var(--crit-soft);color:var(--crit)} .chip.h{background:var(--high-soft);color:var(--high)}
.chip.m{background:var(--med-soft);color:var(--med)} .chip.l{background:var(--low-soft);color:var(--low)}
.f h3{font-size:15px;font-weight:600;margin:0 0 4px}
.f p{font-size:14px;color:var(--ink-2);margin:0 0 6px}
.f p:last-child{margin:0}
.loc{font-family:var(--mono);font-size:12.5px;color:var(--ink-3)}
code{font-family:var(--mono);font-size:13px;background:var(--low-soft);padding:1px 5px;border-radius:3px}
.ok{color:var(--ink-2);font-size:14px}
ul{padding-left:20px;margin:0 0 12px} li{margin-bottom:4px}
@media (max-width:520px){.tally{grid-template-columns:repeat(2,1fr)} .f{grid-template-columns:1fr;gap:6px} .chip{justify-self:start}}
</style>
<div class="wrap">
<h1>Auditoría OrganiLAB 1.4.3</h1>
<p class="meta">Organizador Labstream · código en <code>C:\Users\Munka\Claude\labstream-organizador</code> · 15 sep 2026 · solo lectura, no se modificó nada.</p>
<div class="verdict">
<p><strong>Veredicto.</strong> La app cumple hoy su promesa de no borrar: en la bitácora real (raíz <code>V:\Galeano</code>, 1.154 movimientos, 6 lotes) no hay un solo error. Pero hay dos fallas que sí pueden destruir material de cliente por una ruta que la promesa no cubre: la <strong>conversión a 10 bits</strong> sobrescribe salidas cuando un clip existe en MP4 y MOV, y el <strong>renombrado silencioso</strong> con «(2)» rompe el emparejamiento de clips Sony con sus XML. Además, el servidor local acepta órdenes de cualquier página web abierta en el navegador, lo que permite mover archivos o instalar un ejecutable ajeno.</p>
</div>
<div class="tally">
<div class="c"><b>4</b><span>Críticos</span></div>
<div class="h"><b>11</b><span>Altos</span></div>
<div class="m"><b>15</b><span>Medios</span></div>
<div class="l"><b>12</b><span>Bajos</span></div>
</div>
<h2>Qué arreglar primero</h2>
<ol class="top">
<li><strong>Salidas de conversión a prueba de colisión.</strong> Conservar la extensión original en el nombre (<code>clip.mov_10bit.mp4</code>), rechazar encolar si otro trabajo ya reclama esa salida, y nunca hacer <code>os.remove</code> ni <code>os.replace</code> sobre un archivo existente. En «Reemplazar», volver a comprobar que la salida existe y usar un lote único por reemplazo.</li>
<li><strong>Cerrar el API local.</strong> Rechazar peticiones cuyo <code>Host</code> no sea <code>127.0.0.1:8765</code>, exigir <code>Content-Type: application/json</code> y un token aleatorio por arranque que la interfaz envíe. Quitar <code>ffmpeg</code>, <code>ffprobe</code>, <code>python_resolve</code> y <code>actualizaciones.url</code> de lo que <code>/api/config</code> puede cambiar.</li>
<li><strong>Nunca renombrar por colisión.</strong> Convertir la colisión en aviso en el plan y en error en ejecución (saltar el archivo y registrarlo), en vez del sufijo «(2)». Reconocer el sidecar Sony <code>C0001M01.XML</code> en las reglas.</li>
<li><strong>Contención de rutas.</strong> En aplicar, stock a papelera, duplicados y respaldo, rechazar cualquier origen o destino que no cuelgue de la raíz. Corregir la comprobación de papelera, que aún busca «papelera» y no «_Para borrar».</li>
<li><strong>Actualizador seguro.</strong> Validar versión y nombre de archivo del manifiesto, exigir sha256 y HTTPS del mismo host, no actualizar con trabajos en curso, y hacer copia de la instalación antes del robocopy para poder volver atrás.</li>
</ol>
<h2>Seguridad de los datos</h2>
<div class="f"><span class="chip c">Crítico</span><div>
<h3>MP4 y MOV del mismo clip comparten una sola salida de conversión</h3>
<p>La salida se forma quitando la extensión y añadiendo <code>_10bit.mp4</code>. <code>clip.mov</code> y <code>clip.mp4</code> producen el mismo destino; el segundo trabajo sobrescribe al primero, la verificación aprueba a ambos y «Reemplazar» manda los dos originales a <code>_Para borrar/originales</code>. Solo sobrevive un convertido. El puente con Resolve lo hace explícito con <code>os.remove(dst)</code>. El asistente automatiza toda la cadena sin revisión humana.</p>
<p class="loc">organizador.py:1272-1278, 1289, 1373, 1487-1553 · resolve_bridge.py:240-241 · asistente.py:267-270</p>
</div></div>
<div class="f"><span class="chip h">Alto</span><div>
<h3>Colisiones de nombre se resuelven renombrando con «(2)»</h3>
<p>Al fusionar grabaciones de varias tarjetas Sony en un mismo <code>00_Grabacion/SONY/</code>, el segundo <code>C0001.MP4</code> pasa a <code>C0001 (2).MP4</code>. Ese nombre ya no se reconoce como cámara y el siguiente plan lo propone mover a Exportados. Su XML queda huérfano y Resolve o Premiere no pueden reenlazar. No hay aviso en el plan.</p>
<p class="loc">organizador.py:868-875, 894, 692-750, 1541 · reglas.py:102</p>
</div></div>
<div class="f"><span class="chip h">Alto</span><div>
<h3>La carpeta «_Para borrar» se vuelve a planificar como basura en cada pasada</h3>
<p>La comprobación sigue buscando el nombre viejo «papelera». Como el escaneo marca <code>_Para borrar</code> como basura, el plan propone moverla dentro de sí misma con confianza «alta» y marcada por defecto. Hoy falla con «Cannot move a directory into itself» y deja una carpeta parásita; cualquier cambio futuro en <code>mover()</code> lo convertiría en un automovimiento recursivo.</p>
<p class="loc">organizador.py:816, 827 · reglas.py:539-540</p>
</div></div>
<div class="f"><span class="chip h">Alto</span><div>
<h3>Archivos bloqueados dejan copias huérfanas sin registrar</h3>
<p>Si un clip está abierto en Resolve, <code>os.rename</code> falla y el respaldo <code>shutil.move</code> copia el archivo pero no puede borrar el origen. La excepción sale antes de escribir la bitácora: origen intacto, copia de varios GB en destino, nada registrado. Reintentar crea copias «(2)».</p>
<p class="loc">organizador.py:895-899</p>
</div></div>
<div class="f"><span class="chip h">Alto</span><div>
<h3>Todos los reemplazos comparten el lote «optimizar»</h3>
<p>Deshacer ese lote devuelve a su sitio todos los originales reemplazados en la historia de la app. «Reemplazar» tampoco vuelve a comprobar que el convertido siga existiendo: si se borró entre la verificación y el reemplazo, el original se va sin sustituto.</p>
<p class="loc">organizador.py:1538, 972-994</p>
</div></div>
<div class="f"><span class="chip h">Alto</span><div>
<h3>Destinos editables fuera de la raíz y movimientos entre unidades sin verificar</h3>
<p>El campo de destino del plan acepta rutas absolutas y <code>..\..</code> sin comprobar contención. Entre unidades, <code>shutil.move</code> copia y borra sin comparar tamaño ni hash, y «Deshacer» solo usa <code>os.rename</code>, así que no puede revertirlo.</p>
<p class="loc">organizador.py:2104, 436-437, 878-900, 985</p>
</div></div>
<div class="f"><span class="chip m">Medio</span><div>
<h3>Deshacer omite entradas en silencio y mezcla lotes del mismo segundo</h3>
<p>Si el destino ya no existe o el origen está ocupado, la entrada se salta sin avisar. El id de lote tiene resolución de segundo; la bitácora del proyecto ya muestra un lote con dos «fin». Las carpetas vaciadas se borran sin registro.</p>
<p class="loc">organizador.py:983, 907, 957-969</p>
</div></div>
<div class="f"><span class="chip m">Medio</span><div>
<h3>Aplicación parcial ante rutas largas</h3>
<p>Una fila de 500 archivos se detiene en el primer fallo. Las rutas bajo <code>_Para borrar/basura/</code> superan fácilmente 260 caracteres en NAS; no hay comprobación previa ni prefijo <code>\\?\</code>.</p>
<p class="loc">organizador.py:913-928</p>
</div></div>
<div class="f"><span class="chip m">Medio</span><div>
<h3>El puente con Resolve modifica y guarda el proyecto abierto del editor</h3>
<p>Si no puede crear su propio proyecto, usa el que el editor tiene abierto: cambia resolución y fps, borra todos los trabajos de render en cola, añade bins y timelines, y guarda. Los temporales <code>_render_*</code> quedan dentro de la carpeta de cámara del cliente si algo falla o se cancela.</p>
<p class="loc">resolve_bridge.py:105-114, 155-159, 185, 260, 151, 250-255 · organizador.py:1440</p>
</div></div>
<div class="f"><span class="chip l">Bajo</span><div>
<h3>Masters dentro de una pieza son candidatos a conversión</h3>
<p>Solo se excluye el contexto «Exportados». Un <code>01- Pieza/final.mp4</code> se ofrece para convertir y tras «Reemplazar» el master entregado acaba en <code>_Para borrar/originales</code>. «Apartar duplicados» sin selección aplica todos los grupos.</p>
<p class="loc">organizador.py:1257, 2166</p>
</div></div>
<h2>Reglas de reconocimiento</h2>
<div class="f"><span class="chip h">Alto</span><div>
<h3>El sidecar Sony C0001M01.XML no se reconoce</h3>
<p>El patrón solo admite <code>c0001.xml</code>. El XML va a <code>OTROS/</code> o a <code>01_Proyectos/Edicion</code> mientras el MP4 va a <code>SONY/</code>. Sin embargo «Reemplazar» depende de ese nombre exacto para moverlo junto al clip.</p>
<p class="loc">reglas.py:101-102 · organizador.py:1541</p>
</div></div>
<div class="f"><span class="chip m">Medio</span><div>
<h3>BRAW nunca llega a ser candidato a conversión</h3>
<p>ffprobe no lee BRAW, el análisis lo marca como error y los candidatos exigen análisis correcto. La rama BRAW es inalcanzable: toda la vía Resolve está muerta desde la interfaz.</p>
<p class="loc">organizador.py:1218-1226, 1241</p>
</div></div>
<div class="f"><span class="chip m">Medio</span><div>
<h3>Duplicados sin comprobar contenido</h3>
<p>Se agrupan solo por nombre y tamaño, sin hash ni siquiera parcial. Clips de cámara a bitrate fijo con igual duración se declaran idénticos y uno se aparta. La pareja «versión» separa también un MOV intermedio de su MP4 master.</p>
<p class="loc">organizador.py:1697-1723</p>
</div></div>
<div class="f"><span class="chip m">Medio</span><div>
<h3>Carpetas «1» a «12» bajo un año se renombran como meses con confianza alta</h3>
<p>Piezas numeradas sin título (<code>2025/3</code>) pasan a <code>03- Marzo</code> marcadas por defecto.</p>
<p class="loc">reglas.py:62 · organizador.py:187-195</p>
</div></div>
<div class="f"><span class="chip l">Bajo</span><div>
<h3>Otros</h3>
<p>Raíces de unidad (<code>N:\</code>) rompen las consultas por prefijo y dejan el mapa vacío. <code>.dat</code> e <code>.idx</code> se tratan como basura. Cualquier carpeta llamada «Cache» se clasifica como proxy. El año de clips Blackmagic se infiere de la fecha de modificación.</p>
<p class="loc">organizador.py:1066-1071, 1802 · reglas.py:156, 159, 268-277</p>
</div></div>
<h2>Seguridad</h2>
<div class="f"><span class="chip c">Crítico</span><div>
<h3>Cualquier página web puede dar órdenes al servidor local</h3>
<p>Escucha solo en 127.0.0.1, pero no valida <code>Host</code> ni <code>Origin</code>, no pide token y parsea JSON sin mirar <code>Content-Type</code>. Un <code>fetch</code> en modo no-cors desde cualquier sitio abierto en el navegador pasa sin preflight. Con eso se puede reorganizar cualquier carpeta, mover rutas fuera de la raíz vía <code>/api/stock/papelera</code> o copiar el perfil entero a un recurso de red ajeno vía respaldo. <code>/api/respaldo/plan</code> es un GET que escribe config.</p>
<p class="loc">organizador.py:2459, 1916-1923, 1615-1629, 2025-2034, 2169-2177</p>
</div></div>
<div class="f"><span class="chip c">Crítico</span><div>
<h3>Dos cadenas de ejecución de código desde ese mismo agujero</h3>
<p>Uno: <code>/api/config</code> acepta cualquier clave, incluida la ruta de <code>ffprobe</code>; un UNC de atacante se ejecuta al analizar. Dos: cambiar <code>actualizaciones.url</code> y llamar a <code>/api/actualizacion/instalar</code> descarga un zip ajeno, lo comprueba contra el sha256 del propio atacante y lo lanza como ejecutable.</p>
<p class="loc">organizador.py:2060-2067, 144-150, 1181-1185, 2113 · actualizador.py:47, 62, 115</p>
</div></div>
<div class="f"><span class="chip c">Crítico</span><div>
<h3>El actualizador confía en todo lo que dice el manifiesto</h3>
<p>Sin firma. El sha256 se omite si el campo falta. La URL de descarga puede apuntar a otro host y a http. La cadena de versión se inserta sin validar en el <code>.bat</code> (inyección de comandos) y el nombre de archivo permite <code>..\</code> para escribir fuera de la carpeta de actualización.</p>
<p class="loc">actualizador.py:49-51, 62, 99, 114-116, 138, 144</p>
</div></div>
<div class="f"><span class="chip h">Alto</span><div>
<h3>La actualización mata la app con trabajos en curso y no puede volver atrás</h3>
<p><code>os._exit</code> a los 1,5 s salta la espera de conversión, respaldo y asistente. El bat solo espera a ese PID, no a otras instancias del exe. Robocopy sobrescribe archivo a archivo sin copia previa; un fallo a mitad deja exe viejo con <code>_internal</code> nuevo. El bat se escribe en cp1252 sin <code>chcp</code>, así que rutas con acentos fallan en consola OEM 850.</p>
<p class="loc">actualizador.py:159, 147-149, 142</p>
</div></div>
<p class="ok">Correcto: no hay <code>shell=True</code>, el handler de <code>/static/</code> no permite traversal, enlaces simbólicos no se siguen, <code>#recycle</code> y <code>@eadir</code> se omiten, <code>datos\</code> y <code>ffmpeg\</code> quedan fuera del robocopy.</p>
<h2>Interfaz</h2>
<div class="f"><span class="chip h">Alto</span><div>
<h3>Estado viejo al cambiar de raíz</h3>
<p>Cambiar la raíz solo limpia plan y candidatos. Con la tabla de stock de la raíz A en pantalla y la raíz B seleccionada, «Mover a _Para borrar» envía rutas de A con raíz B; el servidor no valida y <code>relpath</code> con <code>..</code> saca el destino fuera de la papelera. «Copiar respaldo» sigue activo con el plan de otra raíz.</p>
<p class="loc">index.html:461, 478, 487 · organizador.py:1615-1629, 2171</p>
</div></div>
<div class="f"><span class="chip h">Alto</span><div>
<h3>Errores del servidor se tratan como éxito</h3>
<p><code>api()</code> alerta pero devuelve el objeto de error; los llamadores hacen <code>.filter</code> o <code>.map</code> sobre él y revientan. Si el servidor no responde JSON, la promesa muere sin manejar y botones como «Calcular plan» quedan deshabilitados para siempre.</p>
<p class="loc">index.html:419, 470, 481, 584-590, 614, 625</p>
</div></div>
<div class="f"><span class="chip h">Alto</span><div>
<h3>Doble clic duplica trabajos de conversión</h3>
<p>«Encolar y arrancar» no se deshabilita y <code>encolar()</code> hace SELECT y luego INSERT sin bloqueo en hilos distintos. Igual con Verificar, Calcular respaldo y Stock a papelera.</p>
<p class="loc">index.html:625, 635, 487, 478 · organizador.py:1289-1295</p>
</div></div>
<div class="f"><span class="chip m">Medio</span><div>
<h3>Otros de interfaz</h3>
<ul>
<li>Ajustes se repinta desde el sondeo cada 2,5 s y descarta lo que se está escribiendo.</li>
<li>Claves de raíz sin normalizar: una raíz con barra final aparece «no escaneada» para siempre.</li>
<li>El plan pinta todas las filas sin límite y se repinta entero en cada tecla del filtro; con 25.000 archivos son MB de JSON.</li>
<li>Valores de perfil, croma y nombres de plantilla se pintan sin escapar; combinado con el CSRF es XSS persistente. Los nombres de archivo sí se escapan.</li>
<li>Doble escape en el título del asistente: «Brigard &amp; Urrutia» se ve con <code>&amp;amp;</code>.</li>
<li>Modal sin rol, sin Escape ni foco; enlaces sin <code>href</code> no operables por teclado; contraste del ámbar por debajo de AA. Google Fonts cargado en una herramienta que puede estar sin red.</li>
</ul>
<p class="loc">index.html:648-659, 463, 441, 585-601, 630, 655, 717, 414, 190-197, 8</p>
</div></div>
<h2>Robustez y rendimiento</h2>
<div class="f"><span class="chip h">Alto</span><div>
<h3>Hilos de fondo mueren en silencio y dejan banderas atascadas</h3>
<p><code>analizar()</code> no captura excepciones: un «database is locked» durante un escaneo grande deja el análisis en «corriendo» hasta reiniciar. <code>bucle_cola()</code> sin try/finally deja <code>COLA.activa</code> en true y el cierre de ventana nunca termina. <code>copiar_respaldo</code> escribe «corriendo» pero el cierre espera «copiando».</p>
<p class="loc">organizador.py:1211-1229, 1452-1474, 2129, 1833, 2486</p>
</div></div>
<div class="f"><span class="chip m">Medio</span><div>
<h3>Consultas que escanean la tabla entera</h3>
<p><code>archivos_bajo()</code> usa LIKE sobre índice binario y hace full scan; se llama una vez por carpeta no reconocida y una vez por cada elemento movido. Mover 12.000 autoguardados de After Effects son 12.000 barridos de 25.000 filas. Duplicados y Mapa hacen <code>os.path.exists</code> por SMB en cada archivo de más de 5 MB.</p>
<p class="loc">organizador.py:242-247, 265, 949, 1694, 1059</p>
</div></div>
<div class="f"><span class="chip l">Bajo</span><div>
<h3>Otros</h3>
<ul>
<li>El modo <code>python_resolve</code> apunta a un archivo que no se distribuye en el empaquetado.</li>
<li>El zip portátil lleva la lista completa de clientes, alias y mapa de unidades del estudio.</li>
<li>Los <code>resolve_lote_*.json</code> se acumulan en <code>datos\</code>; los bat generados llevan <code>\r\r\n</code>.</li>
<li>Endpoints muertos: <code>/api/actualizacion/buscar</code>, <code>/api/duplicados</code> GET, y <code>modalTexto</code> nunca conectado.</li>
<li>Sin pruebas automáticas; la prueba manual del README es anterior al cambio de nombre de la papelera, por eso se coló ese bug.</li>
</ul>
<p class="loc">organizador.py:1400-1401, 2023, 2110 · empaquetar.py:47-57 · index.html:510</p>
</div></div>
<h2>Método</h2>
<p>Tres revisiones en paralelo sobre <code>organizador.py</code>, <code>reglas.py</code>, <code>resolve_bridge.py</code>, <code>actualizador.py</code>, <code>empaquetar.py</code> y <code>static/index.html</code>, con ejecución de las funciones de reglas contra casos límite y comprobación del contenido del zip de la app. Los hallazgos críticos se verificaron por segunda vez leyendo el código. Se revisó además la bitácora real de la copia portátil 1.4.1 en Descargas, que es la que se ha usado sobre el NAS.</p>
</div>

10
Iniciar.bat Archivo normal
Ver fichero

@@ -0,0 +1,10 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
where py >nul 2>nul
if %errorlevel%==0 (
py -3.12 organizador.py || py -3 organizador.py
) else (
python organizador.py
)
pause

56
OrganizadorLabstream.spec Archivo normal
Ver fichero

@@ -0,0 +1,56 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_all
datas = []
binaries = []
hiddenimports = ['resolve_bridge', 'reglas', 'asistente', 'actualizador']
tmp_ret = collect_all('webview')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('clr_loader')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
tmp_ret = collect_all('pythonnet')
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
a = Analysis(
['organizador.py'],
pathex=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=['tkinter'],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='OrganizadorLabstream',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['static/organilab.ico'],
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='OrganizadorLabstream',
)

235
PLAN-INTERFAZ-2026-09-15.html Archivo normal
Ver fichero

@@ -0,0 +1,235 @@
<title>OrganiLAB, rediseño de interfaz</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root{
--bg:#f6f7f5; --panel:#ffffff; --ink:#1c2733; --ink-2:#4a5561; --ink-3:#7a8490;
--line:#dde2e0; --accent:#2457b8; --accent-soft:#e6eefb; --warn:#b85a0c; --warn-soft:#fdeee0;
--ok:#2f7a4a; --ok-soft:#e4f3e9;
--mono:"IBM Plex Mono",ui-monospace,Consolas,monospace;
--sans:"IBM Plex Sans",system-ui,Segoe UI,sans-serif;
}
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){
--bg:#151a1f; --panel:#1d242b; --ink:#e7ebee; --ink-2:#b4bcc5; --ink-3:#7f8994;
--line:#2e373f; --accent:#7ea6f0; --accent-soft:#1f2c42; --warn:#f0a35c; --warn-soft:#3a2a18;
--ok:#7fce9a; --ok-soft:#1c3324;
}}
:root[data-theme="dark"]{
--bg:#151a1f; --panel:#1d242b; --ink:#e7ebee; --ink-2:#b4bcc5; --ink-3:#7f8994;
--line:#2e373f; --accent:#7ea6f0; --accent-soft:#1f2c42; --warn:#f0a35c; --warn-soft:#3a2a18;
--ok:#7fce9a; --ok-soft:#1c3324;
}
body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.55;padding:0 20px;padding-block:32px 64px}
.wrap{max-width:820px;margin:0 auto}
h1{font-size:28px;font-weight:600;margin:0 0 6px;letter-spacing:-.01em;text-wrap:balance}
h2{font-size:18px;font-weight:600;margin:40px 0 12px;padding-top:16px;border-top:1px solid var(--line)}
h3{font-size:15px;font-weight:600;margin:18px 0 6px}
p{margin:0 0 12px;max-width:68ch}
.meta{color:var(--ink-2);font-size:14px;margin-bottom:20px}
code{font-family:var(--mono);font-size:13px;background:var(--accent-soft);padding:1px 5px;border-radius:3px}
.verdict{background:var(--accent-soft);border-left:3px solid var(--accent);padding:12px 16px;border-radius:0 6px 6px 0;margin:16px 0}
.verdict p:last-child{margin:0}
ul,ol{padding-left:22px;margin:0 0 12px} li{margin-bottom:6px}
table{border-collapse:collapse;width:100%;font-size:14px;margin:8px 0 16px}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:11.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3);font-weight:600}
.tw{overflow-x:auto}
.obs{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:12px 0 16px}
.obs div{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:12px 14px;font-size:14px}
.obs b{display:block;margin-bottom:4px}
.obs .vi{color:var(--ink-2)} .obs .pq{color:var(--warn)}
.fase{display:grid;grid-template-columns:120px 1fr;gap:14px;padding:14px 0;border-bottom:1px solid var(--line)}
.fase:last-child{border-bottom:0}
.fase .k{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--ink-3);font-weight:600;padding-top:3px}
.fase .k b{display:block;font-size:15px;color:var(--ink);text-transform:none;letter-spacing:0;font-weight:600;margin-bottom:2px}
.fase ul{margin:0}
.fase p{font-size:14px;color:var(--ink-2);margin:0 0 6px}
.wire{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:12px;margin:12px 0 16px;overflow-x:auto}
.wire svg{display:block;width:100%;max-width:760px;height:auto;font-family:var(--sans)}
.cap{font-size:12.5px;color:var(--ink-3);margin-top:6px}
.pill{display:inline-block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;padding:2px 7px;border-radius:4px;background:var(--ok-soft);color:var(--ok);vertical-align:middle;margin-left:6px}
@media (max-width:560px){.obs{grid-template-columns:1fr}.fase{grid-template-columns:1fr;gap:6px}}
</style>
<div class="wrap">
<h1>OrganiLAB, rediseño de interfaz</h1>
<p class="meta">Diagnóstico de la versión 1.4.3 recorriendo las ocho pantallas en vivo, puntos de mejora y plan por fases. 15 sep 2026.</p>
<div class="verdict">
<p><strong>El problema de fondo.</strong> La app está organizada por <em>funciones internas</em> (Plan, Nombres, Aligerar, Copia, Ajustes) y no por lo que una persona quiere hacer con una carpeta de cliente. Cada pestaña es una herramienta suelta, sin orden entre ellas, y ninguna dice en qué punto del proceso estás ni qué falta para poder seguir. El asistente paso a paso ya existe, pero solo se llega a él arrastrando la carpeta sobre el .exe: la interfaz principal no lo ofrece.</p>
</div>
<h2>Lo que vi al recorrerla</h2>
<div class="obs">
<div><b>Inicio con carpeta elegida dice «Elige una carpeta»</b><span class="vi">David Reyes está seleccionado y hay inventario de 4.667 archivos, pero el título pide elegir, hay un recuadro gris vacío y dice «Escanea para ver el diagnóstico».</span><br><span class="pq">Es la primera pantalla y contradice el estado real. Ahí nace la confusión.</span></div>
<div><b>Ocho pestañas, cinco escondidas tras «Más»</b><span class="vi">Inicio, Qué hay, Historial visibles. Nombres, Plan detallado, Aligerar a 10 bits, Copia de seguridad y Ajustes ocultas.</span><br><span class="pq">Lo que hace el trabajo de verdad (Plan, Aligerar) está oculto. Lo visible es lo que no actúa.</span></div>
<div><b>Nada dice el orden</b><span class="vi">¿Primero Nombres o primero Plan? ¿Aligerar antes o después de organizar? ¿Copia de seguridad requiere haber aligerado?</span><br><span class="pq">El README explica un flujo de cinco pasos. La interfaz no lo refleja en ningún sitio.</span></div>
<div><b>Botones sin estado</b><span class="vi">«Copiar» aparece verde sin haber calculado. «Aplicar seleccionadas» gris sin decir por qué. «Volver a escanear» sin saber si hace falta.</span><br><span class="pq">No hay forma de saber qué está habilitado, por qué, ni qué desbloquea.</span></div>
<div><b>Procesos largos sin superficie</b><span class="vi">Escanear, analizar, convertir y copiar corren en segundo plano. Solo se ven si estás en la pestaña correcta en ese momento.</span><br><span class="pq">Es lo que describes como «no es visual qué hace». No hay barra global de tareas.</span></div>
<div><b>Vocabulario cruzado</b><span class="vi">Raíz, cliente, carpeta, lote, cubeta, confianza, contexto, rol, «Nombres que usamos», «Aligerar».</span><br><span class="pq">Términos del código llegan a pantalla. Una persona ve «raíz» y no sabe que es su carpeta de cliente.</span></div>
<div><b>Aligerar mezcla cuatro herramientas en una pantalla</b><span class="vi">Filtros, analizar, candidatos, cola de conversión, verificar, reemplazar y debajo el stock de Envato.</span><br><span class="pq">Seis botones numerados del 1 al 5 más «Marcar todos» en un mismo bloque, sin separación de etapas.</span></div>
<div><b>El tour es texto en un modal</b><span class="vi">Ocho ventanas seguidas explicando cada pestaña la primera vez.</span><br><span class="pq">Se lee una vez y se olvida. La ayuda debe vivir en la pantalla, en el momento en que hace falta.</span></div>
</div>
<h2>Principios para el rediseño</h2>
<ol>
<li><strong>Una carpeta, un recorrido.</strong> La unidad de trabajo es la carpeta de cliente. Todo lo demás cuelga de ella y sigue un orden fijo: escanear, revisar, ordenar, aligerar, respaldar.</li>
<li><strong>El estado siempre visible.</strong> En cada momento se ve en qué paso está esa carpeta, qué está hecho, qué está en curso y qué falta para el siguiente paso.</li>
<li><strong>Un botón principal por pantalla.</strong> El resto son secundarios. Un botón deshabilitado siempre explica por qué al lado.</li>
<li><strong>Las tareas largas tienen su propio sitio.</strong> Una barra de tareas global en la parte inferior muestra escaneos, análisis, conversiones y copias, con progreso y desde cualquier pantalla.</li>
<li><strong>Hablar como en el estudio.</strong> «Carpeta del cliente» en vez de raíz. «Qué va a pasar» en vez de plan. «Convertir a 10 bits» en vez de aligerar. «Reglas de carpetas» en vez de nombres.</li>
</ol>
<h2>Estructura propuesta</h2>
<div class="tw"><table>
<tr><th>Hoy (8 pestañas)</th><th>Propuesta (5 vistas)</th><th>Qué cambia</th></tr>
<tr><td>Inicio</td><td><strong>Carpeta</strong> · el recorrido</td><td>Pasa a ser el panel de estado y el camino de cinco pasos. Incluye el asistente paso a paso como opción principal.</td></tr>
<tr><td>Qué hay + Plan detallado + Nombres</td><td><strong>Revisar y ordenar</strong></td><td>Diagnóstico arriba, plan abajo, en la misma vista. «Nombres» se convierte en un panel lateral que aparece solo para las carpetas no reconocidas que bloquean el plan.</td></tr>
<tr><td>Aligerar a 10 bits (con stock incluido)</td><td><strong>Convertir</strong></td><td>Tres etapas claras y separadas: elegir clips, convertir, verificar y reemplazar. El stock de Envato pasa a ser un paso del recorrido, no un apéndice.</td></tr>
<tr><td>Copia de seguridad</td><td><strong>Respaldar</strong></td><td>Igual, pero bloqueada con explicación hasta que haya conversiones verificadas.</td></tr>
<tr><td>Historial</td><td><strong>Historial</strong></td><td>Se mantiene. Se añade filtro por carpeta y el lote muestra «qué se hizo» en palabras, no solo rutas.</td></tr>
<tr><td>Ajustes</td><td>Ajustes (icono de engranaje)</td><td>Fuera de la navegación principal.</td></tr>
</table></div>
<h3>Cómo se vería la vista «Carpeta»</h3>
<div class="wire">
<svg viewBox="0 0 760 330" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Boceto de la vista Carpeta con recorrido de cinco pasos y barra de tareas">
<style>
.t{font-size:11px;fill:var(--ink)} .s{font-size:9.5px;fill:var(--ink-3)} .b{font-size:12px;font-weight:600;fill:var(--ink)}
.box{fill:var(--panel);stroke:var(--line)} .side{fill:var(--bg);stroke:var(--line)}
.done{fill:var(--ok-soft);stroke:var(--ok)} .cur{fill:var(--accent-soft);stroke:var(--accent)} .todo{fill:var(--panel);stroke:var(--line);stroke-dasharray:3 3}
.btn{fill:var(--accent)} .btnt{font-size:10px;fill:#fff;font-weight:600}
</style>
<rect class="side" x="0.5" y="0.5" width="150" height="329"/>
<text class="b" x="12" y="22">Carpetas</text>
<rect class="cur" x="8" y="34" width="134" height="26" rx="4"/><text class="t" x="16" y="51">David Reyes · 1,36 TB</text>
<text class="t" x="16" y="82">Danney Gómez · 3,14 TB</text>
<text class="t" x="16" y="106">Camilo Ortega · 3,10 TB</text>
<rect class="todo" x="8" y="122" width="134" height="30" rx="4"/><text class="s" x="22" y="141">+ Añadir carpeta de cliente</text>
<text class="b" x="170" y="24">David Reyes</text>
<text class="s" x="170" y="40">Z:\Dermatologas\David Reyes · escaneada hoy 10:17 · 4.667 archivos · 1,36 TB</text>
<rect class="done" x="170" y="56" width="108" height="58" rx="5"/><text class="b" x="180" y="74">1 Escanear</text><text class="s" x="180" y="90">Hecho hoy</text><text class="s" x="180" y="103">Volver a escanear</text>
<rect class="done" x="286" y="56" width="108" height="58" rx="5"/><text class="b" x="296" y="74">2 Revisar</text><text class="s" x="296" y="90">6 avisos, 301 basura</text><text class="s" x="296" y="103">Ver diagnóstico</text>
<rect class="cur" x="402" y="56" width="108" height="58" rx="5"/><text class="b" x="412" y="74">3 Ordenar</text><text class="s" x="412" y="90">Plan listo: 42 movimientos</text><text class="s" x="412" y="103">4 carpetas por nombrar</text>
<rect class="todo" x="518" y="56" width="108" height="58" rx="5"/><text class="b" x="528" y="74">4 Convertir</text><text class="s" x="528" y="90">723 GB en BRAW</text><text class="s" x="528" y="103">Después de ordenar</text>
<rect class="todo" x="634" y="56" width="108" height="58" rx="5"/><text class="b" x="644" y="74">5 Respaldar</text><text class="s" x="644" y="90">Sin destino</text><text class="s" x="644" y="103">Requiere paso 4</text>
<rect class="box" x="170" y="128" width="572" height="120" rx="5"/>
<text class="b" x="184" y="150">Siguiente: ordenar la carpeta</text>
<text class="t" x="184" y="170">El plan mueve 42 cosas dentro de la misma carpeta. Nada se borra: la basura y los proxies van a _Para borrar.</text>
<text class="t" x="184" y="188">Antes de aplicar, dime qué son 4 carpetas que no reconozco: «Material», «Render», «Osmo», «Reels».</text>
<rect class="btn" x="184" y="206" width="132" height="26" rx="4"/><text class="btnt" x="196" y="223">Nombrar las 4 carpetas</text>
<rect class="box" x="326" y="206" width="112" height="26" rx="4"/><text class="t" x="338" y="223">Ver el plan completo</text>
<rect class="box" x="448" y="206" width="132" height="26" rx="4"/><text class="t" x="460" y="223">Asistente paso a paso</text>
<rect class="side" x="150.5" y="288" width="609" height="41"/>
<text class="s" x="170" y="303">TAREAS EN CURSO</text>
<rect class="box" x="170" y="308" width="360" height="14" rx="3"/><rect class="btn" x="170" y="308" width="216" height="14" rx="3"/>
<text class="t" x="540" y="319">Analizando clips · 60 % · 12 min</text>
</svg>
<p class="cap">Recorrido de cinco pasos con estado por paso, un bloque «Siguiente» con un solo botón principal, y una barra de tareas en curso siempre visible.</p>
</div>
<h2>Puntos de mejora concretos</h2>
<h3>Estado y orientación</h3>
<ul>
<li>Corregir Inicio: si hay carpeta seleccionada, el título es el nombre del cliente y la ruta. «Elige una carpeta» solo cuando no hay ninguna. Hoy la clave de la carpeta no se normaliza y por eso aparece «no escaneada» aunque lo esté.</li>
<li>Recorrido de cinco pasos con tres estados (hecho, en curso, pendiente) y el motivo cuando está bloqueado.</li>
<li>Bloque «Siguiente» que dice en una frase qué conviene hacer ahora y ofrece un botón principal.</li>
<li>Sidebar con el estado de cada carpeta de un vistazo (última acción, pendientes).</li>
</ul>
<h3>Tareas en curso</h3>
<ul>
<li>Barra inferior global con cada tarea: qué es, sobre qué carpeta, porcentaje, tiempo estimado, botón de cancelar.</li>
<li>Al terminar, aviso no intrusivo con resultado y enlace («Se movieron 42 elementos. Ver en Historial»).</li>
<li>Mientras corre una tarea sobre una carpeta, los botones que la afectarían quedan deshabilitados con el motivo visible.</li>
</ul>
<h3>Acciones y botones</h3>
<ul>
<li>Un botón principal por pantalla, en color. Secundarios en borde. Destructivos (mover a _Para borrar) con confirmación que resume «qué, cuánto y a dónde».</li>
<li>Botón deshabilitado siempre con texto al lado: «Calcula primero», «Requiere conversiones verificadas».</li>
<li>Quitar la numeración 1-5 de Aligerar y separar en tres bloques con título: Elegir clips, Convertir, Verificar y reemplazar.</li>
<li>Eliminar el doble clic accidental: deshabilitar mientras la petición está en vuelo.</li>
</ul>
<h3>Lenguaje</h3>
<div class="tw"><table>
<tr><th>Hoy</th><th>Propuesta</th></tr>
<tr><td>Raíz</td><td>Carpeta del cliente</td></tr>
<tr><td>Plan detallado / Generar plan</td><td>Qué va a pasar / Calcular</td></tr>
<tr><td>Nombres que usamos</td><td>Reglas de carpetas (qué es cada carpeta)</td></tr>
<tr><td>Aligerar a 10 bits</td><td>Convertir a 10 bits</td></tr>
<tr><td>Confianza alta / media / baja</td><td>Seguro / Revisar / Dudoso</td></tr>
<tr><td>Lote</td><td>Cambio del 12 sep 5:35 (con «Deshacer»)</td></tr>
<tr><td>Cubeta, contexto, rol</td><td>No aparecen en pantalla</td></tr>
</table></div>
<h3>Tablas y datos</h3>
<ul>
<li>Plan: agrupar por tipo de movimiento (basura, proxies, grabación, exportados) con conteo y peso por grupo, colapsados por defecto. Hoy son cientos de filas sueltas.</li>
<li>Historial: primera columna en palabras («Apartó 84 stock viejos», «Ordenó grabación de marzo») y las rutas al desplegar.</li>
<li>Qué hay: el mapa de peso sirve, pero las alertas deben enlazar al paso que las resuelve.</li>
</ul>
<h3>Ayuda</h3>
<ul>
<li>Sustituir el tour de ocho modales por una línea de ayuda contextual bajo el título de cada vista, y un «¿Qué hace esto?» al lado de cada botón principal.</li>
<li>Mantener la frase «Nada se borra» visible junto a cualquier botón que mueva archivos.</li>
</ul>
<h2>Plan para seguir</h2>
<div class="fase"><div class="k"><b>Fase 0</b>1 a 2 días</div><div>
<p>Arreglos rápidos sin tocar la estructura. Cambian la percepción de inmediato.</p>
<ul>
<li>Inicio muestra el estado real de la carpeta seleccionada (normalizar claves de raíz).</li>
<li>Renombrar pestañas y textos según la tabla de lenguaje.</li>
<li>Botones deshabilitados con motivo al lado; deshabilitar durante peticiones en vuelo.</li>
<li>Manejo de errores en la interfaz: los fallos del servidor se muestran, no revientan la pantalla.</li>
</ul></div></div>
<div class="fase"><div class="k"><b>Fase 1</b>1 semana</div><div>
<p>La vista «Carpeta» con el recorrido de cinco pasos y el bloque «Siguiente». Es la pieza que resuelve la confusión.</p>
<ul>
<li>Definir el estado de cada paso a partir de los datos que ya tiene el servidor (escaneo, plan, cola, respaldo).</li>
<li>Nuevo endpoint <code>/api/carpeta/estado</code> que devuelva ese resumen de una vez.</li>
<li>Integrar el asistente paso a paso como botón desde la interfaz, no solo desde el .exe.</li>
</ul></div></div>
<div class="fase"><div class="k"><b>Fase 2</b>1 semana</div><div>
<p>Barra de tareas global.</p>
<ul>
<li>Un solo sondeo de estado que alimente la barra y todas las vistas, en vez de tres bucles paralelos.</li>
<li>Progreso, tiempo estimado y cancelar para escaneo, análisis, conversión y copia.</li>
<li>Avisos al terminar con enlace al resultado.</li>
</ul></div></div>
<div class="fase"><div class="k"><b>Fase 3</b>1 a 2 semanas</div><div>
<p>Consolidar las pestañas en las cinco vistas propuestas.</p>
<ul>
<li>«Revisar y ordenar»: diagnóstico y plan juntos, plan agrupado por tipo, reglas de carpetas como panel lateral.</li>
<li>«Convertir»: tres bloques separados; el stock de Envato como paso propio del recorrido.</li>
<li>Historial en palabras con filtro por carpeta.</li>
</ul></div></div>
<div class="fase"><div class="k"><b>En paralelo</b>desde hoy</div><div>
<p>Los cuatro hallazgos críticos de la auditoría de código van antes o junto con la fase 1, porque el rediseño va a poner el «Convertir» y el «Aplicar» más a mano de más gente.</p>
<ul>
<li>Salidas de conversión sin colisión MP4/MOV.</li>
<li>Cerrar el API local (Host, token, claves permitidas en config).</li>
<li>No renombrar con «(2)»; reconocer el XML de Sony.</li>
<li>Contención de rutas dentro de la carpeta del cliente.</li>
</ul></div></div>
<h2>Cómo saber si funcionó</h2>
<ul>
<li>Una persona nueva del estudio abre la app con una carpeta ya escaneada y dice en menos de un minuto qué paso toca y qué botón pulsar.</li>
<li>Nadie pregunta «¿está haciendo algo?» durante una conversión: la barra lo dice.</li>
<li>Ningún botón gris sin explicación.</li>
<li>Cero clics en «Más» para hacer el trabajo diario.</li>
</ul>
</div>

270
README.md Archivo normal
Ver fichero

@@ -0,0 +1,270 @@
# Organizador Labstream
App local (Python, sin dependencias) para **inventariar, mapear, ordenar y optimizar** el material de los
clientes de Labstream repartido por los discos del estudio.
```
Iniciar.bat ← doble clic. Abre http://127.0.0.1:8765 en el navegador
organizador.py ← servidor + inventario + plan + cola de conversión
reglas.py ← cómo reconoce cámaras, carpetas, basura y las plantillas de estructura
resolve_bridge.py ← convierte BRAW a H.265 10 bits usando DaVinci Resolve Studio
static/index.html ← la interfaz (Carpeta · Revisar y ordenar · Convertir · Respaldar · Historial · Ajustes)
datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl
```
Nunca borra nada. Todo lo que sobra va a `_Para borrar` dentro de la misma raíz y cada movimiento queda en la
bitácora para poder deshacerlo.
---
## 1. Diagnóstico (9 sep 2026)
| Raíz | Archivos | Peso | BRAW | Stock Envato | Proxies | Basura | Duplicados |
|---|---|---|---|---|---|---|---|
| `Z:\Dermatologas\David Reyes` | 4 667 | 1,36 TB | 723 GB (53 %) | 464 GB | 4,5 GB | 301 archivos | 91 GB |
| `W:\Danney` | 24 967 | 3,14 TB | 1,18 TB (40 %) | — | — | 12 000 autoguardados de AE | — |
| `N:\` (Camilo Ortega) | 24 016 | 3,10 TB | — | — | 150 GB | 1 031 archivos | — |
Lo que se repite en todas:
- **El mismo cliente vive en hasta 12 sitios.** Camilo Ortega aparece en N:, K:, L:, M:, Q: y Z:. David Reyes
en Z:, K:, Q: y V:. Danney en W:, K: y Q:. Cada sitio con un nombre distinto (`David Reyes`, `DR. DAVID REYES`).
- **Dos sistemas de orden a la vez.** Danney tiene `2026/MM- Mes/NN- Pieza` y, al lado, `Nuevo Orden/` por
cámara y año con 1,8 TB. El mismo tipo de material está en los dos.
- **Los meses no se llaman igual:** `08-Agosto`, `09- Septiembre`, `10- octubre`, `02-marzo` (que es marzo
pero numerado 02), `04 -Mayo`, `05- MAYO`.
- **Material suelto en la raíz:** PSD de 2 GB, archivos «Recuperado», capturas de pantalla, un MP4 final.
- **Intermedios enormes que ya no hacen falta:** ProRes HQ vertical de 8 GB, prerenders QuickTime Animation
de 10 GB, proxies de Resolve/Premiere al lado del original.
- **Stock de Envato copiado dentro de cada pieza** (`Visual/Elements`): 464 GB en David Reyes, con
91 GB duplicados exactos.
- **Basura de sistema por todas partes:** `.DS_Store`, `Thumbs.db`, `.tmp` de Photoshop (uno de 1,2 GB),
`~lock~`, carpetas de autoguardado de After Effects.
## 2. Cómo organizarlo
### Cuatro tipos de cliente, cuatro plantillas
| Tipo | Quiénes | Estructura |
|---|---|---|
| **contenido** (mensual) | dermatólogas, marca personal | `AAAA / MM- Mes / NN- Pieza` + `00_Grabacion` por mes |
| **pauta** (anuncios) | Camilo Ortega, Jonathan, Malpraxis | `Campañas / AAAA_Campaña` + `Material / Grabaciones, Celular, IA` |
| **proyecto** (puntual) | Brigard, PepsiCo, Kingspan, Filarmónica… | `00_Brief 01_Material 02_Grabacion 03_Proyecto 04_Exportados` |
| **interno** | recursos de Labstream | `Musica Sonidos Stock LUTs Transiciones` compartidos |
### La carpeta canónica de un cliente de contenido
```
David Reyes/
├── 00_Marca/ logos, manual, fuentes, fondos, transiciones, intros
├── 01_Proyectos/ .drp .prproj .aep .psd de trabajo (Diseño/, Edicion/)
├── 2026/
│ ├── 03- Marzo/
│ │ ├── 00_Grabacion/ originales de cámara del mes, separados por fuente
│ │ │ ├── URSA_BRAW/ ├── SONY/ ├── OSMO/ ├── CELULAR/ └── AUDIO/
│ │ ├── 01- La isotretinoina/ ← una pieza: Guion, Visual, Audio, Portada, Export
│ │ ├── 02- El poder del ácido hialurónico/
│ │ ├── _Exportados/ masters del mes
│ │ └── _Portadas/
│ └── 08- Agosto/
├── Campañas/2026_Garnier/ trabajos de marca (Dove, Loreal, Isdin…)
├── _Apoyos/ tomas de apoyo reutilizables por tema
├── _Recursos/Musica Stock (mejor aún: en la biblioteca compartida del estudio)
├── _Documentos/ propuestas, referentes, brief
├── _Revisar/ lo que la app no supo ubicar
└── _Para borrar/ basura, proxies y originales ya convertidos (la app no borra; lo revisa y lo borra una persona)
```
Reglas que hacen que funcione:
1. **Un cliente, una carpeta, un disco.** Las únicas otras dos casas legítimas son `Entregas_LAB` (K:) y
`Exportados` (Q:), que ya gestiona Labstream OS.
2. **Los archivos no se renombran nunca; solo se mueven carpetas.** Resolve y Premiere re-enlazan por nombre.
3. **Dentro de una pieza no se toca.** El orden es de la raíz hasta el nivel de pieza.
4. **El mes es el de publicación, no el de rodaje.** Una `Grabación` de diciembre que alimenta piezas de marzo
vive en `03- Marzo/00_Grabacion`. Cuando no hay mes, la app usa la fecha del nombre del archivo
(Sony, DJI, Higgsfield, Envato y el MMDD de los clips Blackmagic) y solo al final la de modificación.
5. **Stock y música van a la biblioteca compartida**, no dentro de cada pieza.
6. **Los proxies se regeneran**: a `_Para borrar/proxies`. La basura de sistema, a `_Para borrar/basura`.
7. **Política de frío.** El BRAW de piezas ya publicadas y con más de 3 meses se convierte a H.265 10 bits;
el original va a `_Para borrar/originales`; una persona lo borra cuando comprueba que nadie lo echó de menos.
## 3. Cómo la app aprende
- **Diccionario.** La pestaña lista cada nombre de carpeta que aparece en la raíz («Grabado», «Render»,
«Julio music», «Nuevo Orden»…) con su peso. Le dices qué es (grabación, exportados, portadas, marca…) y
desde entonces esa palabra se reconoce en cualquier disco. Viene sembrado con ~200 palabras vistas en las
carpetas actuales, incluidos los errores de tecleo (`Elemnts`, `Visula`, `Calular pero de`).
- **Reglas exactas.** Si en el plan corriges el destino de una carpeta, se guarda como regla para ese cliente.
- **Alias de clientes.** `DR. DAVID REYES` = `David Reyes` = `Davids`. Con ellos la pestaña Mapa cruza todos
los discos y dice dónde vive cada cliente.
- Todo está en `datos/diccionario.json` y `datos/config.json`: texto plano, se puede editar a mano o versionar.
## 4. Optimizar material viejo (MP4 H.265 10 bits)
| Origen | Motor | Resultado medido |
|---|---|---|
| Sony XAVC-I 4K 60p 4:2:2 10 bits, 228 Mb/s | ffmpeg + NVENC (RTX 5080) | 671 MB → 24,6 MB (8,4 Mb/s) en 30 s, HEVC Main10 |
| Sony XAVC-I 4K 30p, 286 Mb/s | ffmpeg + NVENC | 286 → 9,6 Mb/s, 1,5× tiempo real |
| BRAW URSA 4.6K 30p, 567 Mb/s | DaVinci Resolve Studio (H.265 NVIDIA, Main10) | ≈ 31 Mb/s → unas 18 veces menos |
- **Perfiles:** ahorro / equilibrado / máxima. El bitrate objetivo sale del tamaño del cuadro y los fps
(≈ 0,085 bits por píxel y cuadro en «equilibrado»: 4K30 → 21 Mb/s, 4.6K30 → 31 Mb/s).
- **Croma:** 4:2:0 10 bits (Main10, se reproduce en todo) o 4:2:2 10 bits (Rext; la RTX 5080 lo codifica y
Resolve lo lee, pero muchos reproductores no).
- **BRAW:** pasa por Resolve porque ffmpeg no lo decodifica. El puente abre (o crea) el proyecto
«Labstream Archivo» con gestión de color a Rec.709 Gamma 2.4, importa los clips, monta una línea de
tiempo por resolución y renderiza «clips individuales». Si Resolve no deja cambiar de proyecto, trabaja en
un bin dentro del proyecto abierto y aplica la LUT Gen 5 Film → Video a cada clip. **Resolve debe estar
abierto con un proyecto, no en el Gestor de proyectos, y sin diálogos abiertos.**
- **Flujo:** Analizar (ffprobe) → Ver candidatos → Encolar → Verificar (duración, códec, 10 bits) →
Reemplazar (el original y su `.XML` de Sony van a `_Para borrar/originales`). La salida conserva la fecha
de modificación del original y se llama `nombre_10bit.mp4` junto al original.
- Se excluyen solos: lo que ya está en HEVC/AV1 eficiente (los iPhone en HDR, por ejemplo), 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.
### Stock de Envato en proyectos viejos
Las tomas de Envato se vuelven a descargar con la suscripción: en piezas ya publicadas sobran. En la
pestaña Optimizar, «Buscar stock viejo» lista, agrupado por carpeta, todo lo que la app reconoce como
Envato (nombre `…-2026-01-22-22-34-00-utc` o carpeta `Elements`) en piezas de más de N meses. Se elige
video, música o imágenes. Va a `_Para borrar/stock` (reversible desde Bitácora); la app no lo borra nunca, eso lo hace una persona desde el Explorador. En David Reyes son 464 GB.
### Respaldo compacto (pestaña Respaldo)
Copia a otro disco una versión ligera de cada cliente para tenerla a mano:
- misma estructura de carpetas (`AAAA / MM- Mes / NN- Pieza`), y donde una carpeta mezcla tipos de medio se
separa en `Video`, `Audio`, `Imagenes`, `Diseño`, `Proyectos`, `Documentos`, `Fuentes`;
- los clips de cámara van en su versión **MP4 H.265 10 bits** (verificada) en lugar del original;
el BRAW/RAW que aún no se convirtió no se copia, y la pestaña lo lista para que se convierta antes;
- sin stock de Envato, sin proxies, sin basura, sin `_Para borrar`;
- deja `INDICE.csv` (ruta en el respaldo, ruta original, tipo, bytes, fecha), `_MAPA.md` (resumen por
carpeta y tipo) y `manifest.json` en la raíz del respaldo;
- es incremental: lo que ya está igual (tamaño y fecha) se salta, así se puede detener y retomar.
Orden recomendado por cliente: Ordenar → Optimizar (convertir BRAW e intermedios, verificar, reemplazar)
→ Buscar stock viejo → Respaldo → cuando el respaldo esté comprobado, una persona borra `_Para borrar` a mano.
## 5. Los discos
15 recursos compartidos en tres cajas (NAS Labstream 192.168.0.22, LabTem 192.168.0.40 y el Blackmagic
Cloud Store Mini 192.168.0.3). La pestaña Mapa los lista con su uso y marca en azul las carpetas que
reconoce como clientes. Propuesta de niveles:
| Nivel | Dónde | Qué |
|---|---|---|
| caliente | Cloud Store Mini (10G) | el mes en edición |
| trabajo | NAS Labstream (`labstream`, `Camilo Ortega`, `Medicina1`…) | el año en curso, una carpeta por cliente |
| entregas | `Entregas_LAB` y `Exportados` | lo maneja Labstream OS |
| frío | `temple`, `Datos`, `Nubes` | años anteriores ya optimizados a H.265 10 bits |
| recursos | `Recursos Audiovisuales`, `Sonidos` | música, stock, LUTs, transiciones compartidos |
Consolidar un cliente entre discos es una copia grande; conviene hacerla en el propio NAS
(`rsync` por SSH) y no desde el PC. La app ordena **dentro** de cada raíz; el cruce entre discos lo
muestra en el mapa para decidirlo.
## 6. Uso (interfaz 1.6)
1. `Iniciar.bat` (o `py -3.12 organizador.py`). Requiere Python 3.10+, ffmpeg/ffprobe en el PATH y, para BRAW,
DaVinci Resolve Studio abierto con scripting externo en *Local*.
2. Suelta la carpeta del cliente en la barra lateral (o pega la ruta). Se registra y se escanea sola.
3. La vista **Carpeta** es el recorrido: cinco pasos con estado (hecho, en curso, listo, pendiente, bloqueado)
y un bloque «Siguiente» que dice qué toca y ofrece un solo botón principal. El **Asistente paso a paso**
sigue disponible desde ahí.
- **1 Escanear**: solo lee nombres y tamaños.
- **2 Revisar**: diagnóstico y carpetas que la app no reconoce (se nombran en un panel, al lado del plan).
- **3 Ordenar**: «Calcular» enseña qué va a pasar, agrupado por tipo (basura, grabación, piezas…), con
seguridad *seguro / revisar / dudoso*. «Aplicar» mueve dentro de la misma carpeta. Se deshace desde Historial.
- **4 Convertir a 10 bits**: tres bloques en orden: elegir clips, conversión en curso, verificar y reemplazar.
Abajo, el stock de Envato viejo.
- **5 Respaldar**: destino → Calcular → Copiar. Avisa si hay BRAW sin convertir.
4. **Historial**: cada cambio en palabras («Ordenó carpetas», «Apartó stock viejo») con su Deshacer, y lo que
hay en `_Para borrar`.
5. **Ajustes** (engranaje): carpetas registradas, clientes con alias, plantillas.
Las tareas largas (escaneo, lectura de clips, conversión, asistente, copia) se ven en la **barra inferior** desde
cualquier vista, con progreso y Detener; al terminar aparece un aviso. Un botón deshabilitado siempre dice al
lado por qué. Los errores del programa se muestran como aviso, no rompen la pantalla.
Cambios de seguridad de 1.5.0: el servidor local rechaza peticiones que no vengan de la propia página
(cabeceras Host/Origin y Content-Type), `/api/config` solo acepta ajustes de conversión y respaldo, ningún
destino puede salir de la carpeta del cliente, la carpeta `_Para borrar` ya no se vuelve a planificar como
basura, y no se encola una conversión cuya salida ya exista o esté reclamada por otro clip (MP4 y MOV del
mismo nombre). El plan se invalida al re-escanear.
Movimientos (1.5.0): si en el destino ya existe un archivo con el mismo nombre, **no se renombra con «(2)»**:
se deja donde está y sale en los errores del lote (Resolve y Premiere enlazan por nombre, y el XML de Sony
va emparejado con su clip). Solo dentro de `_Para borrar` y `Duplicados` se numera. Si un archivo está
bloqueado (abierto en un editor) o la copia entre discos queda incompleta, el original no se toca. El sidecar
Sony `C0001M01.XML` se reconoce como cámara.
Actualización automática (1.5.0): sigue funcionando igual para el usuario (aviso en la vista Carpeta →
«Actualizar ahora»), pero antes de instalar se exige que el manifiesto traiga versión con forma `1.2.3`,
nombre de zip simple, sha256 obligatorio y descarga en el mismo servidor https que el manifiesto. No se
actualiza con un escaneo, lectura, conversión, asistente o copia en marcha. El `.bat` espera a que se cierren
todas las ventanas de OrganiLAB, guarda copia de la versión actual en `actualizacion\previa`, instala y, si
la copia falla, restaura la anterior. `datos\` y `ffmpeg\` nunca se tocan.
1.5.1: los hilos de lectura de clips y de la cola de conversión ya no dejan la app «ocupada» si fallan; cada
«Reemplazar» es un lote propio (`optimizar-fecha`) que se deshace solo, y antes de mover el original comprueba
que el MP4 verificado siga ahí; «Deshacer» dice qué archivos no pudo devolver y por qué; una carpeta llamada
solo «3» bajo un año se propone como marzo con confianza media, no alta; el puente con Resolve, cuando no puede
abrir «Labstream Archivo» y usa el proyecto del editor, no toca sus ajustes ni su cola de render ni lo guarda,
y nunca pisa un MP4 que ya exista; las raíces de unidad (`N:\`) ya se consultan bien (mapa y respaldo).
### Publicar una versión
py -3.12 empaquetar.py # construye dist\, los dos zips y manifiesto.json (NOTAS="..." para las notas)
bash publicar.sh # sube los zips al NAS, deja el manifiesto en GitHub y despliega el OS
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\`.
1.5.2: «Juntar repetidos» comprueba el contenido (primer MB, centro y último) antes de apartar una copia
«idéntica»; si difiere, la deja y lo dice. El plan avisa cuando el destino ya tiene un archivo con ese nombre
(baja a «dudoso» y no se marca). El actualizador reintenta solo durante unos minutos si el OS responde 502/503
(pasa mientras se redespliega) en vez de fallar a la primera.
1.5.3: el recorrido de cinco pasos va fijo arriba en todas las vistas (con el paso de la vista actual
resaltado, «Volver a escanear» y el asistente siempre a mano; el paso 1 también re-escanea al hacer clic);
la página aprovecha pantallas anchas (hasta 1720 px); `_Para borrar`, `_Papelera` y `Duplicados` se muestran
como «apartado por la app», no como carpetas sin reconocer.
1.5.4: «Convertir a 10 bits» queda en tres pasos limpios: Buscar clips (lee y lista en un solo botón; los
criterios y la calidad están plegados en «Cambiar criterios»), Convertir (lista compacta con estado, sin
tabla de diez columnas) y Reemplazar. Cada clip se verifica solo al terminar, así que el paso «Verificar»
desaparece. El stock de Envato pasa a «Revisar y ordenar». Ajustes es una sola lista por cliente: tipo,
sus carpetas y otros nombres con que aparece; las plantillas quedan en «Avanzado». Confirmado que ffprobe
sí lee los BRAW (resolución, fps, duración), así que la vía Resolve es alcanzable desde la interfaz.
1.5.5: botón «Buscar actualizaciones» en la barra lateral (junto a la versión y la hora de la última
comprobación). Por comando, en PowerShell hay que dar la ruta completa del .exe con `&` (no está en el PATH):
& "C:\Users\Munka\Downloads\OrganizadorLabstream-portatil-1.4.1\OrganizadorLabstream\OrganizadorLabstream.exe" actualizar --si
(`--si` instala sin preguntar; el nombre de la carpeta portátil no cambia al actualizar, solo su contenido).
1.6.0, rediseño: el recorrido de cinco pasos vive en la barra lateral, colgando de la carpeta seleccionada
(punto de estado, dato corto, clic para ir al paso; el paso 1 re-escanea). Cada vista cabe en una pantalla:
«Revisar y ordenar» tiene pestañas internas (Qué hay · Qué va a pasar · Por nombrar con su número · Stock
viejo); «Convertir» es un solo panel con tres botones (Buscar clips, Convertir N, Reemplazar N) y una lista
única con el estado de cada clip; «Historial» es una línea de tiempo por día con lo apartado en _Para borrar
resumido arriba; «Ajustes» son tarjetas de cliente plegadas que se abren para editar. La ayuda queda en una
línea y un «?» despliega el resto. Texto 13,5 px, un solo botón lleno por pantalla, y la carpeta que está
trabajando parpadea en la barra lateral.
1.6.1: se puede desconectar una carpeta escaneada. Aparece una ✕ al pasar el ratón por la carpeta en la
barra lateral y un enlace «Desconectar» en la cabecera de «Carpeta». Solo la quita de la lista: no borra ni
mueve nada del disco. Una casilla opcional olvida también el inventario escaneado (si no se marca, al volver
a soltar la carpeta el escaneo anterior sigue ahí). No deja desconectar mientras esa carpeta se escanea,
se analiza, se le aplica el plan o tiene conversiones pendientes. Endpoint `POST /api/raiz/quitar
{raiz, datos}`; queda anotado en la bitácora como `raiz_quitada`.
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol
exacto; conversión ffmpeg verificada y reemplazo con original a `_Para borrar`. El puente de Resolve conectó
con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:
queda por probar con un proyecto abierto.

303
actualizador.py Archivo normal
Ver fichero

@@ -0,0 +1,303 @@
# -*- coding: utf-8 -*-
r"""
Actualización automática desde Labstream OS.
El OS publica public/herramientas/organizador.json (versión, archivo del paquete «app», tamaño,
sha256). Este módulo lo consulta, descarga el zip nuevo a BASE\actualizacion\, comprueba el
sha256, lo descomprime y deja un actualizar.bat que, cuando el .exe se cierra, guarda una copia
de la versión actual, copia la nueva encima (sin tocar datos\ ni ffmpeg\), vuelve atrás si algo
falla, y reabre el programa.
Lo que se exige del manifiesto para instalar (si no se cumple, no se instala y se explica por qué):
- versión con forma 1.2.3
- nombre de archivo simple (sin rutas) terminado en .zip
- sha256 de 64 hex, obligatorio
- URL de descarga con el mismo esquema y host que el manifiesto (nada de saltar a otro servidor)
`O` es el módulo organizador (lo fija él al arrancar).
"""
import os
import re
import sys
import json
import time
import shutil
import hashlib
import zipfile
import tempfile
import threading
import subprocess
import urllib.error
import urllib.request
from urllib.parse import urljoin, urlparse
O = None
URL_DEFECTO = "https://os.labstreamsas.com/herramientas/organizador.json"
ESTADO = {"version": "", "comprobado": 0, "disponible": None, "estado": "", "pct": 0, "bytes": 0, "total": 0, "error": "",
"aplicando": False}
_LOCK = threading.Lock()
RE_VERSION = re.compile(r"^\d{1,4}(\.\d{1,5}){0,3}$")
RE_SHA = re.compile(r"^[0-9a-fA-F]{64}$")
def _cfg():
c = O.CONFIG.setdefault("actualizaciones", {})
c.setdefault("url", URL_DEFECTO)
c.setdefault("auto", True)
return c
def _tupla(v):
out = []
for p in str(v or "0").split("."):
num = "".join(ch for ch in p if ch.isdigit())
out.append(int(num) if num else 0)
return tuple(out + [0] * (3 - len(out)))
def _validar(m, url_manifiesto):
"""Devuelve (dict disponible, "") o (None, motivo). Solo acepta manifiestos bien formados."""
app = m.get("app") or {}
version = str(m.get("version") or "")
if not RE_VERSION.match(version):
return None, f"versión con formato raro en el manifiesto: {version!r}"
archivo = str(app.get("archivo") or "")
if not archivo or archivo != os.path.basename(archivo) or not archivo.lower().endswith(".zip") or "/" in archivo or "\\" in archivo:
return None, f"nombre de archivo no válido en el manifiesto: {archivo!r}"
sha = str(app.get("sha256") or "")
if not RE_SHA.match(sha):
return None, "el manifiesto no trae el sha256 del paquete; no se instala nada sin él"
url = urljoin(url_manifiesto, app.get("url") or f"/api/herramientas/{archivo}")
a, b = urlparse(url_manifiesto), urlparse(url)
if (a.scheme, a.netloc.lower()) != (b.scheme, b.netloc.lower()):
return None, f"la descarga apunta a otro servidor ({b.netloc}); solo se acepta {a.netloc}"
if a.scheme != "https" and not a.netloc.split(":")[0] in ("127.0.0.1", "localhost"):
return None, "el manifiesto debe servirse por https"
return {"version": version, "notes": str(m.get("notes", ""))[:2000], "archivo": archivo,
"size": int(app.get("size") or 0), "sha256": sha.lower(), "url": url}, ""
def comprobar(timeout=8):
"""Lee el manifiesto del OS. Devuelve el dict de la versión disponible o None."""
ESTADO["version"] = O.VERSION
url = _cfg()["url"]
try:
req = urllib.request.Request(url, headers={"User-Agent": f"OrganizadorLabstream/{O.VERSION}", "Cache-Control": "no-cache"})
with urllib.request.urlopen(req, timeout=timeout) as r:
m = json.loads(r.read(1 << 20).decode("utf-8"))
ESTADO["error"] = ""
except Exception as ex: # noqa
ESTADO["error"] = f"no se pudo consultar {url}: {ex}"
ESTADO["comprobado"] = time.time()
return None
ESTADO["comprobado"] = time.time()
d, motivo = _validar(m, url)
if d and _tupla(d["version"]) > _tupla(O.VERSION):
ESTADO["disponible"] = d
else:
ESTADO["disponible"] = None
if not d and _tupla(m.get("version")) > _tupla(O.VERSION):
ESTADO["error"] = "hay una versión publicada pero el manifiesto no pasa las comprobaciones: " + motivo
return ESTADO["disponible"]
def comprobar_en_segundo_plano():
threading.Thread(target=comprobar, daemon=True).start()
def _sha256(ruta):
h = hashlib.sha256()
with open(ruta, "rb") as fh:
for b in iter(lambda: fh.read(1 << 20), b""):
h.update(b)
return h.hexdigest()
def tarea_en_marcha():
"""Texto de la tarea que impide actualizar ahora, o '' si se puede."""
if O.COLA.get("activa"):
return "hay una conversión en marcha"
if O.RESPALDO.get("estado") == "corriendo":
return "hay una copia de respaldo en marcha"
if O.asistente.ESTADO.get("activo"):
return "el asistente está trabajando"
if any((p or {}).get("estado") == "corriendo" for p in O.PROGRESO.values()):
return "hay un escaneo en marcha"
if any((a or {}).get("estado") == "corriendo" for a in O.ANALISIS.values()):
return "se están leyendo clips"
return ""
def _bat(s):
"""escapa lo que cmd interpretaría dentro del .bat"""
return str(s).replace("%", "%%")
def descargar_y_aplicar(en_hilo=False, relanzar_args=()):
"""Descarga el paquete, lo verifica, lo descomprime y deja el .bat listo; luego cierra el programa."""
if en_hilo:
if _LOCK.locked():
ESTADO["error"] = "ya hay una actualización en curso"
return False
threading.Thread(target=descargar_y_aplicar, args=(False, relanzar_args), daemon=True).start()
return True
if not _LOCK.acquire(blocking=False):
ESTADO["error"] = "ya hay una actualización en curso"
return False
try:
return _descargar_y_aplicar(relanzar_args)
finally:
if ESTADO["estado"] != "reiniciando":
_LOCK.release()
def _descargar_y_aplicar(relanzar_args):
d = ESTADO.get("disponible") or comprobar()
if not d:
ESTADO["estado"] = "al día"
return False
if not O.CONGELADO:
ESTADO["estado"] = "error"
ESTADO["error"] = "la actualización automática solo funciona con el .exe empaquetado"
return False
ocupado = tarea_en_marcha()
if ocupado:
ESTADO["estado"] = "error"
ESTADO["error"] = f"no se actualiza ahora: {ocupado}. Espera a que termine."
return False
carpeta = os.path.join(O.BASE, "actualizacion")
try:
shutil.rmtree(carpeta, ignore_errors=True)
os.makedirs(carpeta, exist_ok=True)
destino = os.path.join(carpeta, os.path.basename(d["archivo"]))
ESTADO.update(estado="descargando", pct=0, bytes=0, total=d.get("size", 0), error="")
req = urllib.request.Request(d["url"], headers={"User-Agent": f"OrganizadorLabstream/{O.VERSION}"})
tope = max(int(d.get("size") or 0) * 2, 64 << 20) # nunca más del doble de lo anunciado
r = None
for intento in range(1, 7):
try:
r = urllib.request.urlopen(req, timeout=30)
break
except urllib.error.HTTPError as ex:
# 502/503/504: el OS se está redesplegando (pasa unos minutos cada vez que se publica algo)
if ex.code not in (502, 503, 504) or intento == 6:
raise RuntimeError("el servidor de Labstream OS está reiniciándose; prueba de nuevo en un par de minutos" if ex.code in (502, 503, 504) else str(ex))
ESTADO["error"] = f"el servidor está ocupado (HTTP {ex.code}); reintento {intento} de 6 en 20 s…"
time.sleep(20)
except urllib.error.URLError as ex:
if intento == 6:
raise RuntimeError(f"no se pudo conectar con Labstream OS: {ex.reason}")
ESTADO["error"] = f"sin conexión con el servidor; reintento {intento} de 6 en 20 s…"
time.sleep(20)
ESTADO["error"] = ""
with r, open(destino, "wb") as fh:
total = int(r.headers.get("Content-Length") or d.get("size") or 0)
ESTADO["total"] = total
leidos = 0
while True:
b = r.read(1 << 18)
if not b:
break
fh.write(b)
leidos += len(b)
if leidos > tope:
raise RuntimeError("la descarga es mucho más grande de lo anunciado; se cancela")
ESTADO["bytes"] = leidos
ESTADO["pct"] = int(leidos * 100 / total) if total else 0
ESTADO["estado"] = "verificando"
if _sha256(destino).lower() != d["sha256"]:
raise RuntimeError("el archivo descargado no coincide con el sha256 publicado; no se instala")
nueva = os.path.join(carpeta, "nueva")
os.makedirs(nueva)
with zipfile.ZipFile(destino) as z:
nombres = z.namelist()
# el zip lleva la carpeta OrganizadorLabstream/ como raíz: se quita ese primer nivel
raiz = nombres[0].split("/")[0] + "/" if nombres and "/" in nombres[0] and all(n.startswith(nombres[0].split("/")[0] + "/") for n in nombres) else ""
for n in nombres:
rel = n[len(raiz):]
if not rel or rel.endswith("/"):
continue
dest = os.path.normpath(os.path.join(nueva, rel))
if not dest.startswith(os.path.normpath(nueva) + os.sep):
continue
os.makedirs(os.path.dirname(dest), exist_ok=True)
with z.open(n) as src, open(dest, "wb") as out:
shutil.copyfileobj(src, out)
if not os.path.exists(os.path.join(nueva, "OrganizadorLabstream.exe")):
raise RuntimeError("el paquete no trae OrganizadorLabstream.exe")
exe = os.path.join(O.BASE, "OrganizadorLabstream.exe")
previa = os.path.join(carpeta, "previa")
# relanzar_args None = no volver a abrir (modo consola); tupla = abrir con esos argumentos
args = " ".join(f'"{_bat(a)}"' for a in (relanzar_args or ()))
linea_start = f'start "" "{_bat(exe)}" {args}\r\n' if relanzar_args is not None else f"echo Listo: version {d['version']} instalada.\r\n"
# el .bat vive FUERA de la carpeta que va a borrar; UTF-8 con chcp 65001 para rutas con acentos
bat = os.path.join(tempfile.gettempdir(), "organizador_actualizar.bat")
excl = "/XD datos ffmpeg actualizacion"
with open(bat, "w", encoding="utf-8", newline="") as fh:
fh.write("@echo off\r\n"
"chcp 65001 >nul\r\n"
f"title Actualizando OrganiLAB a {d['version']}\r\n"
"echo Esperando a que se cierre OrganiLAB...\r\n"
":espera\r\n"
f"tasklist /FI \"PID eq {os.getpid()}\" 2>nul | find \"{os.getpid()}\" >nul && (timeout /t 1 /nobreak >nul & goto espera)\r\n"
":espera2\r\n"
"tasklist /FI \"IMAGENAME eq OrganizadorLabstream.exe\" 2>nul | find /I \"OrganizadorLabstream.exe\" >nul && (echo Cierra las otras ventanas de OrganiLAB para seguir... & timeout /t 2 /nobreak >nul & goto espera2)\r\n"
"echo Guardando copia de la version actual...\r\n"
f"robocopy \"{_bat(O.BASE)}\" \"{_bat(previa)}\" /E /R:2 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n"
"if %ERRORLEVEL% GEQ 8 (echo ERROR: no se pudo guardar la copia de seguridad. No se cambia nada. & pause & exit /b 1)\r\n"
f"echo Instalando la version {d['version']}...\r\n"
f"robocopy \"{_bat(nueva)}\" \"{_bat(O.BASE)}\" /E /R:5 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n"
"if %ERRORLEVEL% GEQ 8 (\r\n"
" echo ERROR al copiar la version nueva. Volviendo a la anterior...\r\n"
f" robocopy \"{_bat(previa)}\" \"{_bat(O.BASE)}\" /E /R:5 /W:1 {excl} /NFL /NDL /NJH /NJS >nul\r\n"
" echo Restaurada la version anterior. Avisa a Labstream.\r\n"
" pause & exit /b 1\r\n"
")\r\n"
+ linea_start +
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"] = "reiniciando"
ESTADO["aplicando"] = True
# con su PROPIA consola: sin ella, timeout/tasklist se quedan colgados y la copia nunca ocurre
flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
subprocess.Popen(["cmd", "/c", bat], creationflags=flags, close_fds=True, cwd=tempfile.gettempdir())
threading.Timer(1.5, lambda: os._exit(0)).start()
return True
except Exception as ex: # noqa
ESTADO["estado"] = "error"
ESTADO["error"] = str(ex)
ESTADO["aplicando"] = False
return False
def cli(args):
print(f"Versión instalada: {O.VERSION}. Consultando {_cfg()['url']} …")
d = comprobar()
if ESTADO["error"]:
print("No se pudo comprobar:", ESTADO["error"])
return 1
if not d:
print("Ya tienes la última versión.")
return 0
print(f"Hay versión nueva: {d['version']} ({(d.get('size') or 0) / 1e6:.0f} MB)")
if d.get("notes"):
print(" " + d["notes"])
if "--si" not in args and not O._confirmar("¿Descargar e instalar ahora? El programa se cerrará y volverá a abrirse", False):
return 0
hilo = threading.Thread(target=descargar_y_aplicar, args=(False, None), daemon=True)
hilo.start()
ultimo = -1
while hilo.is_alive():
if ESTADO["estado"] == "descargando" and ESTADO["pct"] != ultimo:
ultimo = ESTADO["pct"]
sys.stdout.write(f"\r descargando {ultimo:3d}% {ESTADO['bytes'] / 1e6:.0f}/{ESTADO['total'] / 1e6:.0f} MB")
sys.stdout.flush()
time.sleep(0.2)
print()
if ESTADO["estado"] == "error":
print("ERROR:", ESTADO["error"])
return 1
print("Instalando… se abre una ventana «Actualizando» que termina sola.", flush=True)
time.sleep(3)
return 0

414
asistente.py Archivo normal
Ver fichero

@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
"""
Asistente: al añadir una carpeta la escanea, da un diagnóstico corto por tipo de archivo y
pregunta, paso a paso, qué hacer con cada tipo. Sirve tanto a la consola (arrastrar la carpeta
sobre el .exe) como a la interfaz web (/api/diagnostico, /api/asistente).
`O` es el módulo organizador (lo fija él mismo al arrancar) para compartir CONFIG, base de datos,
Plan, aplicar, cola de conversión, etc. sin importarlo dos veces.
"""
import os
import sys
import time
import threading
O = None
# ---------------------------------------------------------------- diagnóstico
GRUPOS = [
# clave, título corto, qué es (una frase)
("camara_raw", "Clips BRAW (cámara URSA / Blackmagic)", "material original de cámara en 12 bits; lo que más pesa"),
("camara_video", "Clips de cámara MP4/MOV (Sony, Osmo, DJI, Atomos)", "material original de cámara, normalmente 10 bits"),
("audio", "Audio grabado (DJI Mic, Tascam) y música", "pistas de voz, ambiente y música"),
("celular", "Celular y WhatsApp", "videos y fotos del teléfono"),
("fotos", "Fotos de cámara", "JPG/RAW de cámara fotográfica"),
("stock_viejo", "Stock de Envato en proyectos viejos", "tomas descargadas de Envato de piezas ya entregadas"),
("stock_reciente", "Stock de Envato reciente", "tomas de Envato de piezas en curso"),
("proxies", "Proxies", "copias ligeras que Resolve/Premiere regeneran solas"),
("basura", "Basura de sistema y autoguardados", ".DS_Store, Thumbs.db, cachés, autosaves de After Effects"),
("exportados", "Exportados / masters", "videos finales renderizados"),
("video_otro", "Otros videos", "videos que no son de cámara ni exportados (descargas, referencias, IA)"),
("proyectos", "Proyectos de edición y diseño", ".drp, .prproj, .aep, .psd, .ai…"),
("imagenes", "Imágenes, diseño e IA", "PNG/JPG sueltos, capturas, imágenes generadas"),
("documentos", "Documentos", "guiones, PDF, hojas de cálculo"),
("otros", "Otros archivos", "todo lo demás"),
]
TITULO = {k: t for k, t, _ in GRUPOS}
QUE_ES = {k: q for k, _, q in GRUPOS}
def _grupo(f, limite_stock):
if f["es_basura"] or f["rol"] == "basura":
return "basura"
if f["es_proxy"] or f["rol"] == "proxy":
return "proxies"
if f["es_stock"] or f["rol"] == "stock":
return "stock_viejo" if f["mtime"] < limite_stock else "stock_reciente"
rol = f["rol"]
if rol == "camara_raw":
return "camara_raw"
if rol in ("camara_video", "camara_sidecar"):
return "camara_video"
if rol in ("audio", "audio_grabado"):
return "audio"
if rol in ("celular_video", "celular_foto"):
return "celular"
if rol == "foto_camara":
return "fotos"
if rol == "video":
return "exportados" if (f["contexto"] or "") == "EXPORTADOS" else "video_otro"
if rol in ("proyecto", "diseno", "fuente"):
return "proyectos"
if rol in ("imagen", "ia", "captura"):
return "imagenes"
if rol == "documento":
return "documentos"
return "otros"
def diagnostico(raiz):
"""Resumen corto y sencillo de lo que hay en la carpeta, por tipo de archivo."""
raiz = os.path.normpath(raiz)
con = O.db()
filas = con.execute("SELECT rol, bytes, mtime, es_basura, es_proxy, es_stock, contexto, fecha FROM archivos WHERE raiz = ?",
(raiz,)).fetchall()
esc = con.execute("SELECT * FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
con.close()
meses = int(O.CONFIG["optimizar"].get("meses_minimos", 3))
limite = time.time() - meses * 30.4 * 86400
acc = {k: {"clave": k, "titulo": TITULO[k], "que_es": QUE_ES[k], "n": 0, "bytes": 0, "desde": None, "hasta": None} for k, _, _ in GRUPOS}
total_b = 0
for f in filas:
g = acc[_grupo(f, limite)]
g["n"] += 1
g["bytes"] += f["bytes"] or 0
total_b += f["bytes"] or 0
fe = (f["fecha"] or "")[:7]
if fe:
g["desde"] = fe if not g["desde"] or fe < g["desde"] else g["desde"]
g["hasta"] = fe if not g["hasta"] or fe > g["hasta"] else g["hasta"]
grupos = [g for g in acc.values() if g["n"]]
grupos.sort(key=lambda g: -g["bytes"])
for g in grupos:
g["pct"] = round(g["bytes"] * 100 / total_b, 1) if total_b else 0
# frases de diagnóstico (lo importante, en una línea cada una)
frases = []
b = lambda k: acc[k]["bytes"] # noqa
if b("camara_raw") + b("camara_video"):
frases.append(f"El material de cámara ocupa {O._gb(b('camara_raw') + b('camara_video'))}"
f" ({round((b('camara_raw') + b('camara_video')) * 100 / total_b) if total_b else 0} % del total).")
if b("stock_viejo"):
frases.append(f"Hay {O._gb(b('stock_viejo'))} de stock de Envato en proyectos de hace más de {meses} meses: se puede borrar sin perder nada.")
if b("proxies"):
frases.append(f"Los proxies suman {O._gb(b('proxies'))}: se regeneran solos, no hace falta guardarlos.")
if acc["basura"]["n"]:
frases.append(f"{acc['basura']['n']:,} archivos de basura de sistema o autoguardado ({O._gb(b('basura'))}).")
dup = O.duplicados(raiz)
if dup["grupos"]:
frases.append(f"Hay {dup['n']} archivos repetidos ({O._gb(dup['bytes'])}) en {len(dup['grupos'])} grupos: se pueden juntar en subcarpetas Duplicados para revisarlos a mano.")
if not frases:
frases.append("Nada llamativo: la carpeta no tiene grandes bolsas de peso que recortar.")
return {"raiz": raiz, "n": len(filas), "bytes": total_b, "grupos": grupos, "frases": frases, "meses_stock": meses,
"duplicados": {"grupos": len(dup["grupos"]), "n": dup["n"], "bytes": dup["bytes"], "identicos": dup["identicos"], "versiones": dup["versiones"]},
"escaneo": dict(esc) if esc else None}
# ---------------------------------------------------------------- pasos del asistente
def pasos(diag, plan_filas):
"""Los pasos que se le preguntan al usuario, solo los que aplican a esta carpeta."""
g = {x["clave"]: x for x in diag["grupos"]}
por_cubeta = {}
for f in plan_filas:
c = por_cubeta.setdefault(f["cubeta"], {"n": 0, "bytes": 0, "filas": 0})
c["filas"] += 1
c["n"] += f["n"]
c["bytes"] += f["bytes"]
out = []
def paso(clave, titulo, resumen, opciones, defecto):
out.append({"clave": clave, "titulo": titulo, "resumen": resumen, "opciones": opciones, "defecto": defecto})
if "basura" in g or "proxies" in g:
partes = [f"{g[k]['n']:,} {TITULO[k].split(' ')[0].lower()} ({O._gb(g[k]['bytes'])})" for k in ("basura", "proxies") if k in g]
paso("limpieza", "Basura y proxies", " y ".join(partes),
[("papelera", "Apartarlos a _Para borrar (recomendado; nada se borra, se pueden recuperar)"), ("dejar", "Dejarlos donde están")], "papelera")
cam = [k for k in ("camara_raw", "camara_video", "audio", "celular", "fotos") if k in g]
if cam:
resumen = "; ".join(f"{TITULO[k]}: {g[k]['n']:,} archivos, {O._gb(g[k]['bytes'])}" for k in cam)
paso("material", "Material de cámara, audio, celular y fotos", resumen,
[("ordenar", "Ordenarlo por mes y por fuente en 00_Grabacion (recomendado)"), ("dejar", "No tocarlo")], "ordenar")
if "camara_raw" in g or "camara_video" in g:
h = O.herramientas()
aviso = ""
if "camara_raw" in g and not (h.get("resolve") or {}).get("ok"):
aviso = " Los BRAW necesitan DaVinci Resolve Studio abierto en un proyecto."
paso("convertir", "Clips de cámara de proyectos ya entregados",
f"Convertir a MP4 H.265 de 10 bits los clips con más de {diag['meses_stock']} meses: pesan ~10 veces menos y conservan margen de color.{aviso}",
[("no", "Por ahora no (se puede hacer luego desde la pestaña Optimizar)"),
("si", "Sí, convertir y reemplazar los originales (los originales quedan en _Para borrar/originales)")], "no")
dup = diag.get("duplicados") or {}
if dup.get("grupos"):
det = []
if dup.get("identicos"):
det.append(f"{dup['identicos']} idénticos (mismo nombre y peso)")
if dup.get("versiones"):
det.append(f"{dup['versiones']} en dos formatos (MP4 y MOV del mismo clip)")
paso("duplicados", "Material repetido",
f"{dup['n']} copias en {dup['grupos']} grupos ({O._gb(dup['bytes'])}): {' y '.join(det)}. No se borra nada: cada copia se junta en una subcarpeta Duplicados al lado del que se queda, para revisarla a mano.",
[("apartar", "Juntarlas en Duplicados junto al original (recomendado)"), ("dejar", "Dejarlas donde están")], "apartar")
if "stock_viejo" in g:
paso("stock", "Stock de Envato viejo",
f"{g['stock_viejo']['n']:,} tomas de Envato ({O._gb(g['stock_viejo']['bytes'])}) en proyectos de hace más de {diag['meses_stock']} meses",
[("apartar", "Apartarlas a _Para borrar/stock (recomendado; luego una persona las borra a mano)"), ("dejar", "Dejarlas")], "apartar")
carpetas = {c: v for c, v in por_cubeta.items() if c not in ("BASURA", "PROXY", "GRABACION", "CELULAR", "FOTOS")}
if carpetas:
resumen = ", ".join(f"{c.lower()} {v['filas']}" for c, v in sorted(carpetas.items(), key=lambda x: -x[1]["bytes"]))
paso("carpetas", "Carpetas: meses, piezas, marca, exportados, proyectos",
f"{sum(v['filas'] for v in carpetas.values())} movimientos propuestos ({resumen})",
[("seguras", "Aplicar las de confianza alta y media (recomendado)"), ("alta", "Solo las de confianza alta"),
("todas", "Todas, incluidas las dudosas y los renombrados cosméticos"), ("dejar", "Ninguna")], "seguras")
paso("respaldo", "Respaldo compacto en otro disco",
"Copia organizada por carpeta y tipo, con los clips ya convertidos en vez de los originales",
[("no", "Ahora no"), ("si", f"Sí, a {O.CONFIG.get('respaldo', {}).get('destino', 'T:\\Archivo Labstream')}")], "no")
return out
def filas_elegidas(plan_filas, dec):
"""Traduce las decisiones a las filas del plan que se aplican."""
out = []
for f in plan_filas:
c = f["cubeta"]
if c in ("BASURA", "PROXY"):
if dec.get("limpieza", "papelera") == "papelera":
out.append(f)
elif c in ("GRABACION", "CELULAR", "FOTOS"):
if dec.get("material", "ordenar") == "ordenar":
out.append(f)
else:
m = dec.get("carpetas", "seguras")
if m == "todas" or (m == "seguras" and f["marcado"]) or (m == "alta" and f["confianza"] == "alta"):
out.append(f)
return out
# ---------------------------------------------------------------- ejecución con progreso
PLAN_CACHE = {}
ESTADO = {"activo": False, "raiz": "", "paso": "", "pct": 0, "texto": "", "hechas": 0, "total": 0, "resultado": None, "error": ""}
def _prog(paso, hechas, total, texto=""):
ESTADO.update(paso=paso, hechas=hechas, total=total, pct=int(hechas * 100 / total) if total else 0, texto=texto)
def ejecutar(raiz, dec, plan_filas=None, en_hilo=False):
"""Aplica las decisiones. Actualiza ESTADO para las barras de progreso."""
if en_hilo:
threading.Thread(target=ejecutar, args=(raiz, dec, plan_filas, False), daemon=True).start()
return
raiz = os.path.normpath(raiz)
r = O.raiz_cfg(raiz)
ESTADO.update(activo=True, raiz=raiz, resultado=None, error="")
res = {"lotes": [], "movimientos": 0, "errores": [], "stock": 0, "convertidos": 0, "respaldo": None}
try:
if plan_filas is None:
_prog("plan", 0, 0, "pensando el plan…")
plan_filas = O.Plan(r).correr()["filas"]
if dec.get("stock") == "apartar":
# primero el stock viejo, para que en _Para borrar/stock quede con su ruta original
_prog("stock", 0, 0, "apartando stock de Envato viejo")
s = O.stock_viejo(raiz, O.CONFIG["optimizar"]["meses_minimos"], ["Video", "Audio", "Imagenes"])
rutas = [x for g in s["grupos"] for x in g["rutas"]]
if rutas:
st = O.stock_a_papelera(raiz, rutas)
res["lotes"].append(st.get("lote"))
res["stock"] = st.get("movimientos", 0)
plan_filas = [f for f in plan_filas if f["cubeta"] != "STOCK"]
elegidas = filas_elegidas(plan_filas, dec)
if elegidas:
_prog("ordenar", 0, len(elegidas), "moviendo carpetas y archivos")
hilo = threading.Thread(target=lambda: res.update(_ap=O.aplicar(raiz, elegidas)), daemon=True)
hilo.start()
while hilo.is_alive():
a = O.APLICANDO
_prog("ordenar", a["hechas"], a["total"] or len(elegidas), a["actual"])
time.sleep(0.3)
ap = res.pop("_ap", None) or {}
res["lotes"].append(ap.get("lote"))
res["movimientos"] += ap.get("movimientos", 0)
res["errores"] += ap.get("errores", [])
if dec.get("duplicados") == "apartar":
_prog("duplicados", 0, 0, "juntando los duplicados en subcarpetas Duplicados")
dd = O.apartar_duplicados(raiz)
res["lotes"].append(dd.get("lote"))
res["duplicados"] = dd.get("movimientos", 0)
res["movimientos"] += dd.get("movimientos", 0)
res["errores"] += dd.get("errores", [])
if dec.get("convertir") == "si":
o = O.CONFIG["optimizar"]
_prog("analizar", 0, 0, "leyendo los clips con ffprobe")
hilo = threading.Thread(target=O.analizar, args=(raiz, o["min_mb"]), daemon=True)
hilo.start()
while hilo.is_alive():
a = O.ANALISIS.get(raiz) or {}
_prog("analizar", a.get("hechos", 0), a.get("total", 0), "leyendo los clips con ffprobe")
time.sleep(0.5)
cands = [c for c in O.candidatos(raiz, o["meses_minimos"], o["perfil"], o["min_mb"]) if not c["excluido"] and not c["en_cola"]]
ids = O.encolar([c["ruta"] for c in cands], o["perfil"], o["chroma"], o["color"], raiz)
if ids:
O.iniciar_cola()
total = len(ids)
while O.COLA["activa"]:
con = O.db()
hechos = con.execute("SELECT COUNT(*) FROM trabajos WHERE id IN (%s) AND estado NOT IN ('pendiente','corriendo')" % ",".join(map(str, ids))).fetchone()[0]
act = con.execute("SELECT ruta, pct FROM trabajos WHERE estado = 'corriendo' LIMIT 1").fetchone()
con.close()
_prog("convertir", hechos, total, f"{os.path.basename(act['ruta'])} {act['pct'] or 0}%" if act else O.COLA["mensaje"])
time.sleep(1)
vf = O.verificar(ids)
ok = [v["id"] for v in vf if v.get("ok")]
if ok:
O.reemplazar(ok)
res["convertidos"] = len(ok)
res["conversion_fallida"] = len(ids) - len(ok)
if dec.get("respaldo") == "si":
destino = O.CONFIG.get("respaldo", {}).get("destino") or "T:\\Archivo Labstream"
plan = O.plan_respaldo(raiz, destino, True, False, True)
_prog("respaldo", 0, plan["n"], f"copiando a {plan['base']}")
hilo = threading.Thread(target=O.copiar_respaldo, args=(raiz, plan), daemon=True)
hilo.start()
while hilo.is_alive():
rb = O.RESPALDO
_prog("respaldo", rb.get("hechos", 0), rb.get("total", plan["n"]), rb.get("actual", ""))
time.sleep(0.5)
res["respaldo"] = {"base": plan["base"], "copiados": O.RESPALDO.get("copiados", 0), "saltados": O.RESPALDO.get("saltados", 0)}
res["lotes"] = [x for x in res["lotes"] if x]
ESTADO["resultado"] = res
_prog("fin", 1, 1, "listo")
except Exception as ex: # noqa
ESTADO["error"] = str(ex)
ESTADO["resultado"] = res
finally:
ESTADO["activo"] = False
return res
# ---------------------------------------------------------------- consola
ANCHO = 34
def barra(hechas, total, texto="", final=False):
if total:
pct = min(100, int(hechas * 100 / total))
llenos = int(ANCHO * pct / 100)
b = "█" * llenos + "░" * (ANCHO - llenos)
linea = f"\r [{b}] {pct:3d}% {hechas:,}/{total:,} {texto[-40:]:<40}"
else:
giro = "|/-\\"[int(time.time() * 4) % 4]
linea = f"\r [{giro}] {hechas:,} {texto[-52:]:<52}"
sys.stdout.write(linea[:120])
if final:
sys.stdout.write("\n")
sys.stdout.flush()
def escanear_con_barra(raiz):
raiz = os.path.normpath(raiz)
con = O.db()
prev = con.execute("SELECT n FROM escaneos WHERE raiz = ?", (raiz,)).fetchone()
con.close()
estimado = prev["n"] if prev else 0
print(f"\nEscaneando {raiz}")
hilo = threading.Thread(target=O.escanear, args=(raiz,), daemon=True)
hilo.start()
while hilo.is_alive():
p = O.PROGRESO.get(raiz) or {}
barra(p.get("n", 0), estimado if estimado and p.get("n", 0) <= estimado else 0, os.path.basename(p.get("actual") or ""))
time.sleep(0.25)
p = O.PROGRESO[raiz]
if p["estado"] != "listo":
print("\nERROR al escanear:", p.get("error"))
return False
barra(p["n"], p["n"], f"{O._gb(p['bytes'])} en {p.get('fin', time.time()) - p['inicio']:.0f} s", final=True)
return True
def _preguntar(paso, auto):
print(f"\n{paso['titulo']}\n {paso['resumen']}")
for i, (clave, texto) in enumerate(paso["opciones"], 1):
marca = " (Enter)" if clave == paso["defecto"] else ""
print(f" {i}. {texto}{marca}")
if auto:
return paso["defecto"]
while True:
try:
t = input(" Elige: ").strip()
except EOFError:
return paso["defecto"]
if not t:
return paso["defecto"]
if t.isdigit() and 1 <= int(t) <= len(paso["opciones"]):
return paso["opciones"][int(t) - 1][0]
for clave, _ in paso["opciones"]:
if t.lower() == clave:
return clave
print(" Escribe el número de la opción.")
def cli(ruta, args):
r = O.cli_raiz(ruta, args)
if not r:
return 1
raiz = r["ruta"]
if not escanear_con_barra(raiz):
return 1
d = diagnostico(raiz)
print(f"\nDIAGNÓSTICO · {r['cliente']} · {d['n']:,} archivos · {O._gb(d['bytes'])}")
for g in d["grupos"]:
rango = f"{g['desde']} → {g['hasta']}" if g["desde"] and g["desde"] != g["hasta"] else (g["desde"] or "")
print(f" {O._gb(g['bytes']):>9} {g['pct']:5.1f} % {g['n']:>7,} {g['titulo']} {rango}")
for fr in d["frases"]:
print(" •", fr)
print("\nPensando el plan…", end="", flush=True)
plan_filas = O.Plan(r).correr()["filas"]
print(f" {len(plan_filas)} movimientos posibles.")
auto = "--si" in args
dec = {}
for paso in pasos(d, plan_filas):
dec[paso["clave"]] = _preguntar(paso, auto)
elegidas = filas_elegidas(plan_filas, dec)
print(f"\nRESUMEN: se moverán {len(elegidas)} carpetas/grupos ({sum(f['n'] for f in elegidas):,} archivos, {O._gb(sum(f['bytes'] for f in elegidas))})"
+ (", se apartará el stock viejo" if dec.get("stock") == "apartar" else "")
+ (", se convertirán los clips viejos a 10 bits" if dec.get("convertir") == "si" else "")
+ (", y se hará el respaldo" if dec.get("respaldo") == "si" else "") + ".")
if "--simular" in args:
print("Simulación: no se movió nada.")
return 0
if not O._confirmar("¿Empezamos? (nada se borra; lo sobrante va a _Para borrar y todo se puede deshacer)", auto):
print("Cancelado. No se movió nada.")
return 0
hilo = threading.Thread(target=ejecutar, args=(raiz, dec, plan_filas), daemon=True)
hilo.start()
paso_ant = None
while hilo.is_alive():
e = ESTADO
if e["paso"] != paso_ant:
if paso_ant:
barra(e["total"] or 1, e["total"] or 1, "", final=True)
paso_ant = e["paso"]
print({"plan": "Plan", "ordenar": "Ordenando", "stock": "Stock viejo", "analizar": "Analizando clips", "convertir": "Convirtiendo a 10 bits",
"respaldo": "Respaldo", "fin": "Fin"}.get(e["paso"], e["paso"]))
barra(e["hechas"], e["total"], e["texto"])
time.sleep(0.3)
barra(1, 1, "", final=True)
res = ESTADO["resultado"] or {}
if ESTADO["error"]:
print("ERROR:", ESTADO["error"])
print(f"\nHecho: {res.get('movimientos', 0)} movimientos"
+ (f", {res['stock']} tomas de stock apartadas" if res.get("stock") else "")
+ (f", {res['convertidos']} clips convertidos" if res.get("convertidos") else "")
+ (f", respaldo en {res['respaldo']['base']} ({res['respaldo']['copiados']} copiados)" if res.get("respaldo") else "") + ".")
for e in res.get("errores", [])[:20]:
print(" ERROR:", e)
for lote in res.get("lotes", []):
print(f"Para deshacer: OrganizadorLabstream.exe deshacer {lote}")
return 0 if not res.get("errores") else 2

134
empaquetar.py Archivo normal
Ver fichero

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
r"""
Construye la versión portátil y los paquetes de actualización.
Uso: py -3.12 empaquetar.py [ruta\a\ffmpeg\bin]
Resultado:
dist\OrganizadorLabstream\ carpeta completa (con ffmpeg)
OrganizadorLabstream-portatil-<v>.zip primera instalación (con ffmpeg, ~190 MB)
OrganizadorLabstream-app-<v>.zip actualización (sin ffmpeg ni datos, ~15 MB)
..\labstream-os\public\herramientas\organizador.json manifiesto para el OS (versión, tamaños, sha256)
Después: subir los dos zips al NAS (storage/herramientas/) y desplegar el OS.
"""
import os
import re
import sys
import glob
import json
import shutil
import hashlib
import zipfile
import datetime
import subprocess
AQUI = os.path.dirname(os.path.abspath(__file__))
os.chdir(AQUI)
VERSION = re.search(r'^VERSION = "([^"]+)"', open("organizador.py", encoding="utf-8").read(), re.M).group(1)
print("versión", VERSION)
ffbin = sys.argv[1] if len(sys.argv) > 1 else ""
if not ffbin:
for c in glob.glob(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\WinGet\Packages\Gyan.FFmpeg*\ffmpeg-*\bin")):
ffbin = c
if not ffbin and shutil.which("ffmpeg"):
ffbin = os.path.dirname(shutil.which("ffmpeg"))
print("ffmpeg desde:", ffbin or "(no encontrado: el zip irá sin ffmpeg)")
for d in ("build", "dist"):
shutil.rmtree(d, ignore_errors=True)
subprocess.check_call([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", "--onedir", "--windowed",
"--name", "OrganizadorLabstream", "--icon", os.path.join("static", "organilab.ico"),
"--hidden-import", "resolve_bridge", "--hidden-import", "reglas",
"--hidden-import", "asistente", "--hidden-import", "actualizador",
"--collect-all", "webview", "--collect-all", "clr_loader", "--collect-all", "pythonnet",
"--exclude-module", "tkinter", "organizador.py"])
dest = os.path.join("dist", "OrganizadorLabstream")
shutil.copytree("static", os.path.join(dest, "static"))
os.makedirs(os.path.join(dest, "datos"), exist_ok=True)
for f in ("config.json", "diccionario.json", "plantillas.json"):
src = os.path.join("datos", f)
if os.path.exists(src):
shutil.copy(src, os.path.join(dest, "datos", f))
# la config del paquete sale limpia: sin el recorrido ya visto ni raíces de prueba
cfg_p = os.path.join(dest, "datos", "config.json")
if os.path.exists(cfg_p):
cfg = json.load(open(cfg_p, encoding="utf-8"))
cfg.pop("tour_visto", None)
cfg["raices"] = [] # el paquete sale SIN carpetas: cada equipo añade las suyas y se escanean al añadirlas
json.dump(cfg, open(cfg_p, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
shutil.copy("README.md", dest)
BAT = ('@echo off\r\nchcp 65001 >nul\r\ncd /d "%~dp0"\r\nif "%~1"=="" (\r\n echo Arrastra una carpeta de cliente encima de este archivo.\r\n pause\r\n exit /b\r\n)\r\n'
'"%~dp0OrganizadorLabstream.exe" {cmd} "%~1"\r\n')
with open(os.path.join(dest, "Organizar carpeta (arrastra aqui).bat"), "w", encoding="cp1252") as fh:
fh.write(BAT.format(cmd="asistente"))
with open(os.path.join(dest, "Apartar stock Envato viejo (arrastra aqui).bat"), "w", encoding="cp1252") as fh:
fh.write(BAT.format(cmd="stock"))
with open(os.path.join(dest, "LEEME.txt"), "w", encoding="utf-8") as fh:
fh.write(f"OrganiLAB · Organizador Labstream {VERSION} (portátil)\n\n"
"ORDENAR UNA CARPETA: arrastra la carpeta del cliente encima de «Organizar carpeta (arrastra aqui).bat»\n"
"(o del propio .exe). Se escanea con barra de progreso, sale el diagnóstico y el asistente pregunta paso a paso.\n"
"Por consola: OrganizadorLabstream.exe asistente \"D:\\Clientes\\Carpeta\" [--simular] [--si]\n"
"Más comandos: OrganizadorLabstream.exe ayuda\n\n"
"1. Doble clic en OrganizadorLabstream.exe abre OrganiLAB en su propia ventana (sin navegador).\n"
"2. No hace falta instalar nada: Python y ffmpeg van dentro.\n3. Para convertir BRAW hace falta DaVinci Resolve Studio abierto en un proyecto.\n"
"4. La app NUNCA borra: lo que sobra va a _Para borrar y lo repetido a subcarpetas Duplicados, para que una persona lo revise.\n"
"5. Todo lo que aprende y la configuración queda en la carpeta datos\\ (llévala contigo si cambias de PC).\n"
"6. Se actualiza solo desde Labstream OS (os.labstreamsas.com): avisa cuando hay versión nueva.\n"
"Si Windows SmartScreen avisa, «Más información» → «Ejecutar de todas formas».\n")
# ── paquete APP (actualización): todo menos ffmpeg y datos ──
app_zip = f"OrganizadorLabstream-app-{VERSION}.zip"
with zipfile.ZipFile(app_zip, "w", zipfile.ZIP_DEFLATED) as z:
for raiz, dirs, files in os.walk(dest):
rel_raiz = os.path.relpath(raiz, dest)
primero = rel_raiz.split(os.sep)[0]
if primero in ("ffmpeg", "datos"):
continue
for f in files:
p = os.path.join(raiz, f)
z.write(p, "OrganizadorLabstream/" + os.path.relpath(p, dest).replace(os.sep, "/"))
# ── paquete COMPLETO (primera instalación): con ffmpeg ──
if ffbin:
os.makedirs(os.path.join(dest, "ffmpeg"), exist_ok=True)
for exe in ("ffmpeg.exe", "ffprobe.exe"):
shutil.copy(os.path.join(ffbin, exe), os.path.join(dest, "ffmpeg", exe))
lic = os.path.join(os.path.dirname(ffbin), "LICENSE")
if os.path.exists(lic):
shutil.copy(lic, os.path.join(dest, "ffmpeg", "LICENSE.txt"))
for viejo in glob.glob("OrganizadorLabstream-portatil*.zip") + [z for z in glob.glob("OrganizadorLabstream-app-*.zip") if z != app_zip]:
os.remove(viejo)
full_zip = f"OrganizadorLabstream-portatil-{VERSION}.zip"
shutil.make_archive(full_zip[:-4], "zip", "dist", "OrganizadorLabstream")
def sha(p):
h = hashlib.sha256()
with open(p, "rb") as fh:
for b in iter(lambda: fh.read(1 << 20), b""):
h.update(b)
return h.hexdigest()
notas = os.environ.get("NOTAS", "")
manifiesto = {
"nombre": "OrganiLAB",
"version": VERSION,
"builtAt": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"archivo": full_zip, "size": os.path.getsize(full_zip), "sha256": sha(full_zip),
"app": {"archivo": app_zip, "size": os.path.getsize(app_zip), "sha256": sha(app_zip), "url": f"/api/herramientas/{app_zip}"},
"notes": notas,
}
ruta_os = os.path.join(os.path.dirname(AQUI), "labstream-os", "public", "herramientas", "organizador.json")
if os.path.isdir(os.path.dirname(ruta_os)):
previo = {}
try:
previo = json.load(open(ruta_os, encoding="utf-8"))
except Exception: # noqa
pass
if not notas:
manifiesto["notes"] = previo.get("notes", "") if previo.get("version") == VERSION else ""
json.dump(manifiesto, open(ruta_os, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print("manifiesto escrito en", ruta_os)
json.dump(manifiesto, open("manifiesto.json", "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print(f"listo: {full_zip} ({os.path.getsize(full_zip) / 1e6:.0f} MB) · {app_zip} ({os.path.getsize(app_zip) / 1e6:.0f} MB)")
print("Sube los dos zips a storage/herramientas/ del NAS y despliega el OS.")

71
hacer_icono.py Archivo normal
Ver fichero

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""Genera static/organilab.png (256 px) y static/organilab.ico (256/48/32/16) sin dependencias:
el logo OrganiLAB, cuadrado azul redondeado con tres barras blancas."""
import os
import zlib
import struct
AQUI = os.path.dirname(os.path.abspath(__file__))
AZUL = (0x1F, 0x57, 0xD6)
def pinta(n):
"""devuelve filas RGBA del logo a n×n"""
r = n * 7 / 32 # radio de las esquinas (7/32 como el SVG)
barras = [(8, 18, 1.0), (14, 13, .85), (20, 8, .65)] # (y, ancho, opacidad) en unidades de 32
filas = []
for y in range(n):
fila = bytearray()
for x in range(n):
# cuadrado redondeado con antialias sencillo (4 muestras)
dentro = 0
for sx, sy in ((.25, .25), (.75, .25), (.25, .75), (.75, .75)):
px, py = x + sx, y + sy
cx = min(max(px, r), n - r)
cy = min(max(py, r), n - r)
if (px - cx) ** 2 + (py - cy) ** 2 <= r * r:
dentro += 1
a = dentro / 4
if a == 0:
fila += bytes((0, 0, 0, 0))
continue
col = list(AZUL)
for by, bw, op in barras:
y0, y1 = by * n / 32, (by + 4) * n / 32
x0, x1 = 7 * n / 32, (7 + bw) * n / 32
rb = 2 * n / 32
if y0 <= y + .5 < y1 and x0 <= x + .5 < x1:
# esquinas de la barra redondeadas
cx = min(max(x + .5, x0 + rb), x1 - rb)
cy = min(max(y + .5, y0 + rb), y1 - rb)
if (x + .5 - cx) ** 2 + (y + .5 - cy) ** 2 <= rb * rb:
col = [int(c + (255 - c) * op) for c in AZUL]
fila += bytes((col[0], col[1], col[2], int(255 * a)))
filas.append(bytes(fila))
return filas
def png(n):
filas = pinta(n)
raw = b"".join(b"\x00" + f for f in filas)
def chunk(t, d):
return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xffffffff)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", n, n, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b""))
tamanos = (256, 48, 32, 16)
imgs = [png(t) for t in tamanos]
with open(os.path.join(AQUI, "static", "organilab.png"), "wb") as fh:
fh.write(imgs[0])
# ICO con PNG incrustados (válido desde Windows Vista)
cab = struct.pack("<HHH", 0, 1, len(tamanos))
off = 6 + 16 * len(tamanos)
entradas = b""
for t, im in zip(tamanos, imgs):
entradas += struct.pack("<BBBBHHII", t % 256, t % 256, 0, 0, 1, 32, len(im), off)
off += len(im)
with open(os.path.join(AQUI, "static", "organilab.ico"), "wb") as fh:
fh.write(cab + entradas + b"".join(imgs))
print("icono listo:", [len(i) for i in imgs])

15
manifiesto.json Archivo normal
Ver fichero

@@ -0,0 +1,15 @@
{
"nombre": "OrganiLAB",
"version": "1.6.1",
"builtAt": "2026-09-15T19:21:15.000Z",
"archivo": "OrganizadorLabstream-portatil-1.6.1.zip",
"size": 198619195,
"sha256": "913cd3f1a7b04b38606b8b42c6cb6bc1b495b873771212ccbfa407b4fad60d14",
"app": {
"archivo": "OrganizadorLabstream-app-1.6.1.zip",
"size": 14799458,
"sha256": "c722527fb16d6e139d82a268fc6fadf79a14f48eb0a134ac2240086c24f18487",
"url": "/api/herramientas/OrganizadorLabstream-app-1.6.1.zip"
},
"notes": ""
}

2734
organizador.py Archivo normal

La diferencia del archivo ha sido suprimido porque es demasiado grande Cargar Diff

49
publicar.sh Archivo normal
Ver fichero

@@ -0,0 +1,49 @@
#!/bin/bash
# Publica la versión ya empaquetada (empaquetar.py) para que las copias instaladas se actualicen solas:
# 1. sube los dos zips al NAS (storage/herramientas/ del OS)
# 2. deja el manifiesto en GitHub (labstream-os/public/herramientas/organizador.json), en un commit
# que solo toca ese archivo, sin mezclar cambios locales de labstream-os
# 3. lanza el despliegue del OS en el NAS y espera a que publique la versión
# Uso (desde Git Bash): bash publicar.sh
# Requisitos: llave ~/.ssh/editorias_nas con acceso Labstream@192.168.0.22 (sudo sin contraseña),
# y permiso de push a Labstream2026/labstream-os.
set -e
AQUI="$(cd "$(dirname "$0")" && pwd)"
OS="$AQUI/../labstream-os"
NAS="Labstream@192.168.0.22"
SSH="ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o ServerAliveInterval=30 -i $HOME/.ssh/editorias_nas"
DEST=/volume5/contenedores/labstream-os/data/storage/herramientas
V=$(grep -o '"version": "[^"]*"' "$AQUI/manifiesto.json" | head -1 | cut -d'"' -f4)
APP="OrganizadorLabstream-app-$V.zip"; FULL="OrganizadorLabstream-portatil-$V.zip"
[ -f "$AQUI/$APP" ] && [ -f "$AQUI/$FULL" ] || { echo "Faltan los zips de la $V: corre antes py -3.12 empaquetar.py"; exit 1; }
echo "== OrganiLAB $V =="
echo "1/3 subiendo al NAS…"
# el NAS no tiene SFTP: scp en modo antiguo (-O). La carpeta es del usuario del contenedor (1001): se sube a
# la carpeta personal y se mueve con sudo.
scp -O -q -o BatchMode=yes -o StrictHostKeyChecking=no -i "$HOME/.ssh/editorias_nas" "$AQUI/$APP" "$AQUI/$FULL" "$NAS:~/"
$SSH "$NAS" "sudo mv ~/$APP ~/$FULL $DEST/ && sudo chown 1001:1001 $DEST/$APP $DEST/$FULL && cd $DEST && sha256sum $APP $FULL" 2>/dev/null
grep -o '"sha256": "[^"]*"' "$AQUI/manifiesto.json" | sed 's/.*: "//; s/"//' | sed 's/^/local /'
echo "2/3 manifiesto a GitHub…"
cd "$OS"
git fetch -q origin main
export GIT_INDEX_FILE="$(mktemp)"
git read-tree origin/main
blob=$(git hash-object -w public/herramientas/organizador.json)
git update-index --add --cacheinfo "100644,$blob,public/herramientas/organizador.json"
tree=$(git write-tree); unset GIT_INDEX_FILE
if [ "$tree" = "$(git rev-parse origin/main^{tree})" ]; then echo " (el manifiesto ya estaba publicado)"; else
commit=$(git commit-tree "$tree" -p origin/main -m "OrganiLAB $V: paquete y manifiesto")
git push -q origin "$commit:main" && echo " commit $(echo $commit | cut -c1-8) en main"
fi
echo "3/3 desplegando el OS (tarda unos minutos)…"
# por SSH, sudo no lleva /usr/local/bin en el PATH y «docker» no se encuentra: se pasa a mano
$SSH "$NAS" "sudo env PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin bash /var/services/homes/Labstream/claude-job.sh >/dev/null 2>&1; tail -1 /volume5/despliegues/labstream-os-deploy.log" 2>/dev/null
for i in $(seq 1 30); do
pub=$(curl -s -H "Cache-Control: no-cache" https://os.labstreamsas.com/herramientas/organizador.json | grep -o '"version": "[^"]*"' | cut -d'"' -f4)
[ "$pub" = "$V" ] && { echo "Publicado: os.labstreamsas.com sirve la $V. Las copias instaladas avisarán al abrirse."; exit 0; }
sleep 10
done
echo "El OS aún sirve la $pub. Mira /volume5/despliegues/labstream-os-deploy.log en el NAS."; exit 1

559
reglas.py Archivo normal
Ver fichero

@@ -0,0 +1,559 @@
# -*- coding: utf-8 -*-
"""
Reglas de clasificación del material de Labstream.
Aquí vive todo lo que "sabe" el organizador: cómo reconocer de qué cámara
viene un archivo, qué es una carpeta por su nombre, qué es basura y la
estructura canónica a la que se lleva cada cosa según el tipo de cliente.
El diccionario de carpetas se siembra desde aquí la primera vez y después
se guarda en datos/diccionario.json, donde la app va aprendiendo.
"""
import re
import unicodedata
import datetime
# --------------------------------------------------------------------------
# Normalización de nombres
# --------------------------------------------------------------------------
def norm(s: str) -> str:
"""minúsculas, sin acentos, separadores colapsados a un espacio"""
s = unicodedata.normalize("NFKD", s or "")
s = "".join(c for c in s if not unicodedata.combining(c))
s = s.lower()
s = re.sub(r"[\s_\-\.:,;()\[\]{}]+", " ", s)
return s.strip()
MESES = ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
"agosto", "septiembre", "octubre", "noviembre", "diciembre"]
MESES_TITULO = [m.capitalize() for m in MESES]
MESES_ALIAS = {
"setiembre": "septiembre", "septiempre": "septiembre", "sept": "septiembre",
"sep": "septiembre", "ene": "enero", "feb": "febrero", "mar": "marzo",
"abr": "abril", "may": "mayo", "jun": "junio", "jul": "julio", "ago": "agosto",
"oct": "octubre", "nov": "noviembre", "dic": "diciembre",
"january": "enero", "february": "febrero", "march": "marzo", "april": "abril",
"june": "junio", "july": "julio", "august": "agosto", "september": "septiembre",
"october": "octubre", "november": "noviembre", "december": "diciembre",
}
def detectar_anio(nombre: str):
m = re.fullmatch(r"\s*(20\d\d)\s*", nombre or "")
return int(m.group(1)) if m else None
def detectar_mes(nombre: str):
"""Devuelve (num_mes, conflicto) si la carpeta ES un mes ("09- Septiembre",
"02-marzo", "MAYO", "05"). conflicto=True cuando el número no coincide con
el nombre (gana el nombre)."""
n = norm(nombre)
m = re.fullmatch(r"(\d{1,2})?\s*([a-z]*)", n)
if not m:
return None, False
num = int(m.group(1)) if m.group(1) else None
palabra = m.group(2)
palabra = MESES_ALIAS.get(palabra, palabra)
mes_por_nombre = MESES.index(palabra) + 1 if palabra in MESES else None
if mes_por_nombre:
return mes_por_nombre, (num is not None and num != mes_por_nombre)
if num and 1 <= num <= 12 and not palabra:
return num, True # «3» solo puede ser marzo… o la pieza número 3: se propone con confianza media, no alta
return None, False
def nombre_mes(mes: int) -> str:
"""convención de la casa: «MM- Mes» (la forma más usada en las carpetas actuales)"""
return f"{mes:02d}- {MESES_TITULO[mes - 1]}"
def detectar_pieza(nombre: str):
"""'01- Exosomas' -> (1, 'Exosomas'); None si no está numerada."""
if detectar_anio(nombre) or detectar_mes(nombre)[0]:
return None
m = re.match(r"^\s*(\d{1,2})\s*[-_.:]+\s*(.+?)\s*$", nombre or "")
if not m:
return None
num, titulo = int(m.group(1)), m.group(2)
if num == 0:
return None # "00_Grabacion" es estructura, no una pieza
titulo = re.sub(r"\s{2,}", " ", titulo).strip(" -_")
return num, titulo
def nombre_pieza(num: int, titulo: str) -> str:
"""convención de la casa: «NN- Título» (la forma más usada en las carpetas actuales)"""
return f"{num:02d}- {titulo}"
_RX_ENVATO_DIR = re.compile(r"-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-utc$", re.I)
# --------------------------------------------------------------------------
# Firmas de archivos: de qué cámara / herramienta viene
# --------------------------------------------------------------------------
FIRMAS = [
("URSA_BRAW", r"^[A-Z]\d{3}_\d{8}_C\d{3}(_\d+)?\.braw$", "Blackmagic (BRAW)"),
("BM_PROXY", r"^[A-Z]\d{3}_\d{8}_C\d{3}(_\d+)?\.(mp4|mov)$", "Blackmagic proxy H.264 / ProRes"),
("SONY", r"^\d{8}_labstream\d{4}(m01)?\.(mp4|xml)$", "Sony XAVC"),
("SONY", r"^c\d{4}(m01)?\.(mp4|mov|xml)$", "Sony"),
("OSMO", r"^dji_\d{14}_\d{4}_d\.(mp4|mov|lrf)$", "DJI Osmo Pocket"),
("DJI", r"^dji_\d{4}\.(mp4|mov|jpg|dng|lrf|srt)$", "DJI dron / Osmo"),
("DJI_MIC", r"^dji_\d{2}_\d{8}_\d{6}\.wav$", "DJI Mic"),
("TASCAM", r"^tascam_\d{4}\.wav$", "Tascam"),
("ATOMOS", r"^ninjav_", "Atomos Ninja V"),
("CELULAR", r"^img_\d{4}( \(\d+\))?( \d)?(_[a-z0-9]+)?\.(mov|mp4|heic|jpg|jpeg|png)$", "iPhone"),
("CELULAR", r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(mov|heic|jpg|jpeg|mp4|png)$", "iPhone (AirDrop)"),
("CELULAR", r"^(vid|pxl)_\d{8}", "Android"),
("WHATSAPP", r"^whatsapp (image|video|audio) ", "WhatsApp"),
("PANASONIC", r"^_?\d{7}\.(rw2|jpg)$", "Panasonic Lumix"),
("SONY_FOTO", r"^dsc\d{5}\.(arw|jpg)$", "Sony foto"),
("IA_HIGGSFIELD", r"^hf_\d{8}_\d{6}_", "Higgsfield"),
("IA_CHATGPT", r"^chatgpt image ", "ChatGPT"),
("IA_VIDEO", r"^(animate_this_image|video_\d|kling|runway|veo_|sora)", "IA video"),
("IA_VOZ", r"^elevenlabs_", "ElevenLabs"),
("STOCK", r"-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-utc(\b|[ .(])", "Envato Elements"),
("CAPTURA", r"^(captura de pantalla|grabaci[oó]n de pantalla|screenshot|screen recording|grabaci[oó]n \d{4})", "Captura de pantalla"),
]
FIRMAS_RE = [(k, re.compile(p, re.I), d) for k, p, d in FIRMAS]
FUENTE_CARPETA = { # nombre de subcarpeta al separar una sesión de grabación por fuente
"URSA_BRAW": "URSA_BRAW", "BM_PROXY": "BLACKMAGIC_MP4", "SONY": "SONY", "OSMO": "OSMO",
"DJI": "DJI", "DJI_MIC": "AUDIO", "TASCAM": "AUDIO", "ATOMOS": "ATOMOS",
"CELULAR": "CELULAR", "WHATSAPP": "CELULAR", "PANASONIC": "FOTOS", "SONY_FOTO": "FOTOS",
}
def firma_archivo(nombre: str):
for k, rx, desc in FIRMAS_RE:
if rx.search(nombre):
return k
return None
# --------------------------------------------------------------------------
# Extensiones
# --------------------------------------------------------------------------
EXT = {
"video": {".mov", ".mp4", ".m4v", ".mxf", ".mts", ".avi", ".mkv", ".webm", ".braw", ".r3d", ".m2ts"},
"video_raw": {".braw", ".r3d", ".ari"},
"audio": {".wav", ".mp3", ".aac", ".m4a", ".aif", ".aiff", ".flac", ".ogg", ".wma"},
"imagen": {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif", ".tif", ".tiff", ".bmp", ".svg"},
"foto_raw": {".arw", ".rw2", ".dng", ".cr2", ".cr3", ".nef", ".raf", ".xmp"},
"diseno": {".psd", ".psb", ".ai", ".af", ".afphoto", ".afdesign", ".indd", ".xd", ".fig", ".sketch", ".eps"},
"proyecto": {".drp", ".drb", ".drt", ".dra", ".prproj", ".aep", ".prin", ".fcpxml", ".aaf", ".edl", ".otio", ".xml", ".comp", ".setting", ".mogrt", ".drfx"},
"documento": {".pdf", ".docx", ".doc", ".md", ".txt", ".pptx", ".xlsx", ".csv", ".rtf", ".ttml", ".srt", ".vtt", ".pages", ".key", ".numbers"},
"fuente": {".ttf", ".otf", ".woff", ".woff2"},
"comprimido": {".zip", ".rar", ".7z", ".tar", ".gz", ".dmg", ".iso", ".img"},
"lut": {".cube", ".3dl", ".look"},
"programa": {".exe", ".msi", ".pkg", ".app", ".lnk"},
}
BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"}
BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk", ".bak", ".ims", ".idx", ".dat"}
BASURA_DIRS = {"__macosx", "#recycle", ".synologyworkingdirectory", "@eadir", "media cache", "media cache files", "peak files"}
BASURA_DIRS_CONTIENE = ["almacenamiento automatico", "auto save", "autosave", "auto-save", "adobe premiere pro auto"]
PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media", "cache"}
def es_basura_archivo(nombre: str) -> bool:
n = nombre.lower()
if n in BASURA_NOMBRES or n.startswith("._"):
return True
if n.endswith("~lock~") or n.endswith("~"):
return True
ext = n[n.rfind("."):] if "." in n else ""
if ext in BASURA_EXT:
return True
if re.match(r"^ps[a-z0-9]{3,5}\.tmp$", n) or re.match(r"^adobe photoshop \d{10,}$", n):
return True
if re.match(r"^[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}\.tmp$", n):
return True
return False
def es_basura_dir(nombre: str) -> bool:
n = norm(nombre)
if n in BASURA_DIRS or nombre.lower() in BASURA_DIRS:
return True
return any(t in n for t in BASURA_DIRS_CONTIENE)
def es_proxy_dir(nombre: str) -> bool:
return norm(nombre) in PROXY_DIRS or nombre.lower() in {".proxy"}
def rol_archivo(nombre: str, ext: str, firma: str | None) -> str:
"""Qué es el archivo por sí mismo (sin mirar la carpeta)."""
e = ext.lower()
if es_basura_archivo(nombre):
return "basura"
if firma in ("URSA_BRAW",) or e in EXT["video_raw"]:
return "camara_raw"
if firma in ("BM_PROXY", "SONY", "OSMO", "DJI", "ATOMOS") and e in EXT["video"]:
return "camara_video"
if firma == "SONY" and e == ".xml":
return "camara_sidecar"
if firma in ("CELULAR", "WHATSAPP") and e in EXT["video"]:
return "celular_video"
if firma in ("CELULAR", "WHATSAPP") and e in EXT["imagen"]:
return "celular_foto"
if firma in ("DJI_MIC", "TASCAM"):
return "audio_grabado"
if firma in ("PANASONIC", "SONY_FOTO") or e in EXT["foto_raw"]:
return "foto_camara"
if firma and firma.startswith("IA_"):
return "ia"
if firma == "STOCK":
return "stock"
if firma == "CAPTURA":
return "captura"
if e == ".lrf":
return "proxy"
if e in EXT["video"]:
return "video"
if e in EXT["audio"]:
return "audio"
if e in EXT["imagen"]:
return "imagen"
if e in EXT["diseno"]:
return "diseno"
if e in EXT["proyecto"]:
return "proyecto"
if e in EXT["documento"]:
return "documento"
if e in EXT["fuente"]:
return "fuente"
if e in EXT["comprimido"]:
return "comprimido"
if e in EXT["lut"]:
return "lut"
if e in EXT["programa"]:
return "programa"
return "otro"
# --------------------------------------------------------------------------
# Fecha a partir del nombre del archivo
# --------------------------------------------------------------------------
_RX_YMD = re.compile(r"(?<!\d)(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])(?!\d)")
_RX_YMD_HMS = re.compile(r"(?<!\d)(20\d{2})(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{6}(?!\d)")
_RX_Y_M_D = re.compile(r"(?<!\d)(20\d{2})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])(?!\d)")
_RX_ES = re.compile(r"(\d{1,2}) (ene|feb|mar|abr|may|jun|jul|ago|sep|oct|nov|dic)\w*\.? (20\d{2})", re.I)
_RX_BM = re.compile(r"^[A-Z]\d{3}_(\d{2})(\d{2})\d{4}_C\d{3}", re.I)
_RX_WA = re.compile(r"WhatsApp \w+ (20\d{2})-(\d{2})-(\d{2})", re.I)
def fecha_de_nombre(nombre: str, mtime: float):
"""(YYYY-MM-DD, origen) — origen 'nombre' si sale del nombre, 'mtime' si no."""
for rx in (_RX_YMD, _RX_YMD_HMS, _RX_Y_M_D, _RX_WA):
m = rx.search(nombre)
if m:
try:
d = datetime.date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
return d.isoformat(), "nombre"
except ValueError:
pass
m = _RX_ES.search(nombre)
if m:
mes = ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"].index(m.group(2).lower()[:3]) + 1
try:
return datetime.date(int(m.group(3)), mes, int(m.group(1))).isoformat(), "nombre"
except ValueError:
pass
m = _RX_BM.match(nombre)
if m:
mes, dia = int(m.group(1)), int(m.group(2))
if 1 <= mes <= 12 and 1 <= dia <= 31:
mt = datetime.datetime.fromtimestamp(mtime)
anio = mt.year if (mes, dia) <= (mt.month, mt.day) else mt.year - 1
try:
return datetime.date(anio, mes, dia).isoformat(), "nombre+mtime"
except ValueError:
pass
return datetime.date.fromtimestamp(mtime).isoformat(), "mtime"
# --------------------------------------------------------------------------
# Cubetas (buckets): qué es una carpeta
# --------------------------------------------------------------------------
CUBETAS = {
"GRABACION": "Grabación (originales de cámara de una sesión)",
"PIEZA": "Pieza de contenido (un reel, un video)",
"CAMPANA": "Campaña de marca / pauta",
"EXPORTADOS": "Exportados / masters finales",
"PORTADAS": "Portadas y miniaturas",
"APOYOS": "Tomas de apoyo reutilizables (b-roll)",
"MARCA": "Material de marca (logos, fuentes, fondos, transiciones)",
"PROYECTOS": "Proyectos de edición (Resolve, Premiere, AE, Photoshop)",
"MUSICA": "Música",
"SFX": "Efectos de sonido",
"VOZ": "Voces / locuciones",
"IA": "Generado con IA",
"STOCK": "Stock (Envato, etc.)",
"GUION": "Guiones y textos",
"FOTOS": "Fotografías",
"DOCUMENTOS": "Documentos, propuestas, referentes",
"CELULAR": "Material de celular",
"PROXY": "Proxies (se pueden regenerar)",
"BASURA": "Basura de sistema / cachés",
"CONTENEDOR": "Carpeta contenedora: mirar adentro",
"REVISAR": "Revisar a mano",
"IGNORAR": "Dejar como está",
}
# token normalizado -> cubeta. Se siembra en datos/diccionario.json y de ahí aprende.
DICCIONARIO_SEMILLA = {
# grabación
"grabacion": "GRABACION", "grabaciones": "GRABACION", "grabado": "GRABACION", "grabados": "GRABACION",
"tomas": "GRABACION", "rushes": "GRABACION", "brutos": "GRABACION", "raw": "GRABACION",
"ursa": "GRABACION", "braw": "GRABACION", "sony": "GRABACION", "osmo": "GRABACION", "dji": "GRABACION", "blackmagic": "GRABACION",
"blackmagic braw": "GRABACION", "otros blackmagic": "GRABACION", "otro sony": "GRABACION", "dji audio": "GRABACION",
"tascam": "GRABACION", "camara": "GRABACION", "videos originales": "GRABACION", "originales": "GRABACION",
# exportados
"render": "EXPORTADOS", "renders": "EXPORTADOS", "export": "EXPORTADOS", "exportado": "EXPORTADOS",
"exportados": "EXPORTADOS", "exports": "EXPORTADOS", "finales": "EXPORTADOS", "final": "EXPORTADOS",
"videos finales": "EXPORTADOS", "videos finales pantalla": "EXPORTADOS", "finales enviar": "EXPORTADOS",
"entrega": "EXPORTADOS", "entregas": "EXPORTADOS", "master": "EXPORTADOS", "masters": "EXPORTADOS",
"entregable": "EXPORTADOS", "entregables": "EXPORTADOS", "publicados": "EXPORTADOS",
# portadas
"portada": "PORTADAS", "portadas": "PORTADAS", "miniatura": "PORTADAS", "miniaturas": "PORTADAS",
"thumbnails": "PORTADAS", "thumbnail": "PORTADAS", "covers": "PORTADAS", "cover": "PORTADAS",
# marca
"material de marca": "MARCA", "marca": "MARCA", "manual de marca": "MARCA", "branding": "MARCA",
"logos": "MARCA", "logo": "MARCA", "logos png": "MARCA", "fuente": "MARCA", "fuentes": "MARCA",
"tipografia": "MARCA", "tipografias": "MARCA", "fondo": "MARCA", "fondos": "MARCA",
"transitions": "MARCA", "transiciones": "MARCA", "efectos": "MARCA", "texto": "MARCA", "textos": "MARCA",
"intro": "MARCA", "intros": "MARCA", "plantillas": "MARCA", "luts": "MARCA",
# proyectos
"adobe": "PROYECTOS", "proyecto": "PROYECTOS", "proyectos": "PROYECTOS", "premiere": "PROYECTOS",
"resolve": "PROYECTOS", "after": "PROYECTOS", "after effects": "PROYECTOS", "photoshop": "PROYECTOS",
"animaciones": "PROYECTOS", "prerenders": "PROYECTOS", "socalo": "PROYECTOS",
# música / sonido / voz
"musica": "MUSICA", "music": "MUSICA", "canciones": "MUSICA", "soundtrack": "MUSICA", "julio music": "MUSICA",
"sonidos": "SFX", "sfx": "SFX", "swoosh": "SFX", "whoosh": "SFX", "efectos de sonido": "SFX",
"voces": "VOZ", "voz": "VOZ", "elevenlabs": "VOZ", "locucion": "VOZ", "locuciones": "VOZ", "narracion": "VOZ",
"audio": "VOZ", "audios": "VOZ",
# ia
"ia": "IA", "ai": "IA", "imagenes ia": "IA", "imagenes ia importante": "IA", "avatars": "IA", "avatares": "IA",
"higgsfield": "IA", "chatgpt": "IA", "generado": "IA",
# stock
"stock": "STOCK", "envato": "STOCK", "recursos": "STOCK", "elements": "STOCK", "recursos audiovisuales": "STOCK",
# guion / documentos
"guion": "GUION", "guiones": "GUION", "script": "GUION", "scripts": "GUION",
"propuesta": "DOCUMENTOS", "propuestas": "DOCUMENTOS", "referentes": "DOCUMENTOS", "referencia": "DOCUMENTOS",
"referencias": "DOCUMENTOS", "imagen referencia": "DOCUMENTOS", "brief": "DOCUMENTOS", "documentos": "DOCUMENTOS",
"claude": "DOCUMENTOS", "conferencia": "DOCUMENTOS",
# fotos
"fotos": "FOTOS", "fotografias": "FOTOS", "fotografia": "FOTOS", "jpeg": "FOTOS", "imagenes": "FOTOS", "imagen": "FOTOS",
# apoyos
"tomas de apoyo": "APOYOS", "tomas apoyos": "APOYOS", "apoyos": "APOYOS", "apoyo": "APOYOS", "b roll": "APOYOS",
"broll": "APOYOS", "rellenos": "APOYOS",
# campañas de marca
"dove": "CAMPANA", "loreal": "CAMPANA", "garnier": "CAMPANA", "isdin": "CAMPANA", "cetaphil": "CAMPANA",
"artemis": "CAMPANA", "vinicius": "CAMPANA", "the organic pharmacy": "CAMPANA", "nixoderm": "CAMPANA",
"publicidad": "CAMPANA", "pautas facebook": "CAMPANA", "pauta": "CAMPANA", "campana": "CAMPANA",
"campanas": "CAMPANA", "anuncio": "CAMPANA", "ads": "CAMPANA", "vsl": "CAMPANA",
"vsls": "CAMPANA", "lanzamientos": "CAMPANA", "tanda": "CAMPANA",
# celular
"celular": "CELULAR", "material celular": "CELULAR", "iphone": "CELULAR", "calular pero de": "CELULAR",
# proxies y basura
"proxy": "PROXY", "proxies": "PROXY", "macosx": "BASURA", "recycle": "BASURA",
# contenedores: hay que mirar adentro
"nuevo": "CONTENEDOR", "nuevo orden": "CONTENEDOR", "nuevo 2026": "CONTENEDOR", "anuncios": "CONTENEDOR",
"material": "IGNORAR", # "Material" es estructura (de una campaña o de la raíz de pauta): no se mueve
"otros videos": "CONTENEDOR", "otros": "CONTENEDOR", "varios": "CONTENEDOR", "sin fecha": "CONTENEDOR",
"temporal": "CONTENEDOR", "temp": "CONTENEDOR", "desordenado": "CONTENEDOR", "desordenado revisar": "CONTENEDOR",
"sacado de nubes": "CONTENEDOR", "cambio de proyecto": "CONTENEDOR", "nueva carpeta": "CONTENEDOR",
"repetidos": "IGNORAR", "duplicados": "IGNORAR", "backup": "REVISAR", "respaldo": "REVISAR",
# cosas del cliente que son piezas o series
"reels": "PIEZA", "reel": "PIEZA", "historia": "PIEZA", "historias": "PIEZA", "carrusel": "PIEZA",
"podcast": "PIEZA", "consultorio": "PIEZA", "testimonios": "PIEZA", "clases": "PIEZA",
# subcarpetas normales DENTRO de una pieza: se dejan como están
"visual": "IGNORAR", "visula": "IGNORAR", "01 visual": "IGNORAR", "chroma key": "IGNORAR",
"prerenders": "IGNORAR", "elements": "STOCK", "elemnts": "STOCK", "stems": "MUSICA", "loops": "MUSICA",
"shorts": "MUSICA", "full": "MUSICA", "exportada": "EXPORTADOS", "higgsfiel": "IA",
}
# sub-carpeta canónica dentro de 00_Marca
MARCA_SUB = {
"fuente": "Fuentes", "fuentes": "Fuentes", "tipografia": "Fuentes", "tipografias": "Fuentes",
"fondo": "Fondos", "fondos": "Fondos", "transitions": "Transiciones", "transiciones": "Transiciones",
"efectos": "Transiciones", "sonidos": "Sonidos", "texto": "Textos", "textos": "Textos",
"logos": "Logos", "logo": "Logos", "logos png": "Logos", "manual de marca": "Manual", "intro": "Intros",
"intros": "Intros", "luts": "LUTs", "plantillas": "Plantillas",
}
def cubeta_carpeta(nombre: str, diccionario: dict):
"""(cubeta, motivo) de una carpeta por su nombre; None si no se reconoce."""
if es_basura_dir(nombre):
return "BASURA", "basura de sistema"
if es_proxy_dir(nombre):
return "PROXY", "carpeta de proxies"
if _RX_ENVATO_DIR.search(nombre.strip()):
return "STOCK", "nombre de paquete de Envato"
n = norm(nombre)
if n in diccionario:
return diccionario[n], f"diccionario: «{n}»"
# tokens de varias palabras primero, luego palabras sueltas
# (CONTENEDOR solo por coincidencia exacta: "Material juan manuel" no es un cajón de sastre)
claves = sorted(diccionario.keys(), key=lambda k: -len(k))
for k in claves:
if " " in k and diccionario[k] != "CONTENEDOR" and re.search(r"(^| )" + re.escape(k) + r"( |$)", n):
return diccionario[k], f"diccionario: contiene «{k}»"
palabras = n.split()
for k in claves:
if " " not in k and diccionario[k] != "CONTENEDOR" and k in palabras:
return diccionario[k], f"diccionario: palabra «{k}»"
return None, ""
# palabras que solo dicen "de qué cámara": no aportan etiqueta a una sesión de grabación
FUENTE_PALABRAS = {"grabacion", "grabaciones", "grabado", "grabados", "ursa", "sony", "osmo", "dji", "blackmagic",
"braw", "tascam", "audio", "otro", "otros", "a", "c", "camara", "material", "video", "videos",
"mp4", "mov", "de", "la", "el", "pocket", "drone", "dron", "mic", "celular"}
def etiqueta_grabacion(nombre: str) -> str:
"""'Grabación Loreal Peeling' -> 'Loreal Peeling'; 'Grabación URSA' -> ''"""
palabras = [p for p in re.sub(r"\s{2,}", " ", nombre).strip(" -_").split(" ") if p]
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)]
if not utiles:
return ""
return " ".join(utiles)
# --------------------------------------------------------------------------
# Plantillas de estructura por tipo de cliente
# --------------------------------------------------------------------------
TIPOS_CLIENTE = {
"contenido": "Contenido mensual (dermatólogas, marca personal): año / mes / pieza",
"pauta": "Pauta y anuncios (campañas por tandas, ganchos, VSL)",
"proyecto": "Proyecto puntual corporativo (un trabajo, una entrega)",
"interno": "Interno de Labstream (recursos compartidos)",
}
# Destinos relativos a la raíz del cliente. {anio} {mes} = "08-Agosto" {nombre} {fuente}
PLANTILLAS = {
"contenido": {
"MARCA": "00_Marca/{sub}",
"PROYECTOS": "01_Proyectos/{nombre}",
"GRABACION": "{anio}/{mes}/00_Grabacion/{sub}",
"PIEZA": "{anio}/{mes}/{nombre}",
"CAMPANA": "Campañas/{anio}_{nombre}",
"EXPORTADOS": "{anio}/{mes}/_Exportados/{sub}",
"PORTADAS": "{anio}/{mes}/_Portadas/{sub}",
"APOYOS": "_Apoyos/{sub}",
"MUSICA": "_Recursos/Musica/{sub}",
"SFX": "_Recursos/Sonidos/{sub}",
"VOZ": "{anio}/{mes}/_Voces/{sub}",
"IA": "{anio}/{mes}/_IA/{sub}",
"STOCK": "_Recursos/Stock/{sub}",
"GUION": "{anio}/{mes}/_Guiones/{sub}",
"FOTOS": "{anio}/{mes}/_Fotos/{sub}",
"DOCUMENTOS": "_Documentos/{sub}",
"CELULAR": "{anio}/{mes}/00_Grabacion/CELULAR/{sub}",
"PROXY": "_Para borrar/proxies/{rel}",
"BASURA": "_Para borrar/basura/{rel}",
"REVISAR": "_Revisar/{nombre}",
},
"pauta": {
"MARCA": "00_Marca/{sub}",
"PROYECTOS": "01_Proyectos/{nombre}",
"GRABACION": "Material/Grabaciones/{anio}/{mes}/{sub}",
"PIEZA": "Campañas/{anio}_{nombre}",
"CAMPANA": "Campañas/{anio}_{nombre}",
"EXPORTADOS": "_Exportados/{anio}/{sub}",
"PORTADAS": "_Portadas/{sub}",
"APOYOS": "Material/Apoyos/{sub}",
"MUSICA": "_Recursos/Musica/{sub}",
"SFX": "_Recursos/Sonidos/{sub}",
"VOZ": "Material/Voces/{sub}",
"IA": "Material/IA/{sub}",
"STOCK": "_Recursos/Stock/{sub}",
"GUION": "_Guiones/{sub}",
"FOTOS": "Material/Fotos/{sub}",
"DOCUMENTOS": "_Documentos/{sub}",
"CELULAR": "Material/Celular/{sub}",
"PROXY": "_Para borrar/proxies/{rel}",
"BASURA": "_Para borrar/basura/{rel}",
"REVISAR": "_Revisar/{nombre}",
},
"proyecto": {
"MARCA": "00_Marca/{sub}",
"PROYECTOS": "03_Proyecto/{nombre}",
"GRABACION": "02_Grabacion/{anio}/{mes}/{sub}",
"PIEZA": "04_Exportados/{nombre}",
"CAMPANA": "{anio}_{nombre}",
"EXPORTADOS": "04_Exportados/{sub}",
"PORTADAS": "04_Exportados/Portadas/{sub}",
"APOYOS": "01_Material/Apoyos/{sub}",
"MUSICA": "01_Material/Musica/{sub}",
"SFX": "01_Material/Sonidos/{sub}",
"VOZ": "01_Material/Voces/{sub}",
"IA": "01_Material/IA/{sub}",
"STOCK": "01_Material/Stock/{sub}",
"GUION": "00_Brief/{sub}",
"FOTOS": "01_Material/Fotos/{sub}",
"DOCUMENTOS": "00_Brief/{sub}",
"CELULAR": "01_Material/Celular/{sub}",
"PROXY": "_Para borrar/proxies/{rel}",
"BASURA": "_Para borrar/basura/{rel}",
"REVISAR": "_Revisar/{nombre}",
},
"interno": {
"MARCA": "Marca/{sub}",
"PROYECTOS": "Proyectos/{nombre}",
"GRABACION": "Grabaciones/{anio}/{mes}/{sub}",
"PIEZA": "Piezas/{anio}/{nombre}",
"CAMPANA": "Campañas/{anio}_{nombre}",
"EXPORTADOS": "Exportados/{anio}/{sub}",
"PORTADAS": "Portadas/{sub}",
"APOYOS": "Apoyos/{sub}",
"MUSICA": "Musica/{sub}",
"SFX": "Sonidos/{sub}",
"VOZ": "Voces/{sub}",
"IA": "IA/{sub}",
"STOCK": "Stock/{sub}",
"GUION": "Guiones/{sub}",
"FOTOS": "Fotos/{sub}",
"DOCUMENTOS": "Documentos/{sub}",
"CELULAR": "Celular/{sub}",
"PROXY": "_Para borrar/proxies/{rel}",
"BASURA": "_Para borrar/basura/{rel}",
"REVISAR": "_Revisar/{nombre}",
},
}
# La app NUNCA borra: lo que sobra va a esta carpeta dentro de la misma raíz y una persona la revisa y la borra a mano.
PAPELERA = "_Para borrar"
PAPELERAS = ("_Para borrar", "_Papelera") # el nombre viejo se sigue reconociendo en carpetas ya ordenadas
PAPELERAS_NORM = {norm(x) for x in PAPELERAS}
# Los duplicados se juntan en esta subcarpeta AL LADO del archivo que se queda, para revisarlos a mano.
DUPLICADOS = "Duplicados"
# Carpetas que la app crea y que no debe volver a "ordenar" (se comparan ya normalizadas)
CARPETAS_PROPIAS = {norm(x) for x in (
"_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "_Apoyos", "_Documentos", "00_Marca", "01_Proyectos", "Campañas",
"_Exportados", "_Portadas", "00_Grabacion", "_Voces", "_IA", "_Guiones", "_Fotos", "_Musica", "_Archivo",
"00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados")}
# Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio)
ROL_A_CUBETA = {
"camara_raw": "GRABACION", "camara_video": "GRABACION", "camara_sidecar": "GRABACION",
"audio_grabado": "GRABACION", "celular_video": "CELULAR", "celular_foto": "CELULAR",
"foto_camara": "FOTOS", "ia": "IA", "stock": "STOCK", "captura": "REVISAR", "proxy": "PROXY",
"video": "EXPORTADOS", "audio": "MUSICA", "imagen": "PORTADAS", "diseno": "PROYECTOS",
"proyecto": "PROYECTOS", "documento": "DOCUMENTOS", "fuente": "MARCA", "comprimido": "REVISAR",
"lut": "MARCA", "programa": "REVISAR", "otro": "REVISAR", "basura": "BASURA",
}

285
resolve_bridge.py Archivo normal
Ver fichero

@@ -0,0 +1,285 @@
# -*- coding: utf-8 -*-
"""
Puente con DaVinci Resolve Studio: convierte clips BRAW a MP4 H.265 10 bits
usando el codificador NVIDIA de Resolve.
Se ejecuta como proceso aparte: python resolve_bridge.py lote.json
El lote es una lista de trabajos {id, src, dst, w, h, fps, kbps, color}.
Reporta por stdout una línea JSON por evento: {"id":..,"pct":..,"estado":..}
Requisitos: Resolve abierto, con "Scripting externo" en Local
(Preferencias > Sistema > General). Usa el proyecto "Labstream Archivo" de la
base de datos actual (lo crea si no existe) y al terminar vuelve al proyecto
que estaba abierto.
"""
import os
import sys
import json
import time
import shutil
from collections import defaultdict
sys.path.append(r"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\Developer\Scripting\Modules")
os.environ.setdefault("RESOLVE_SCRIPT_API", r"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\Developer\Scripting")
os.environ.setdefault("RESOLVE_SCRIPT_LIB", r"C:\Program Files\Blackmagic Design\DaVinci Resolve\fusionscript.dll")
PROYECTO = "Labstream Archivo"
def emitir(**kw):
print(json.dumps(kw, ensure_ascii=False), flush=True)
def fps_texto(fps):
if not fps:
return "30"
for candidato in (23.976, 24, 25, 29.97, 30, 47.952, 48, 50, 59.94, 60, 119.88, 120):
if abs(fps - candidato) < 0.02:
return f"{candidato:g}"
return f"{fps:g}"
def main(ruta_lote=None):
ruta_lote = ruta_lote or (sys.argv[1] if len(sys.argv) > 1 else None)
if not ruta_lote:
emitir(msg="uso: resolve_bridge.py lote.json")
return 2
with open(ruta_lote, "r", encoding="utf-8") as f:
trabajos = json.load(f)
try:
import DaVinciResolveScript as dvr
resolve = dvr.scriptapp("Resolve")
except Exception as ex: # noqa
for t in trabajos:
emitir(id=t["id"], estado="error", log=f"no se pudo conectar con Resolve: {ex!r}")
return 1
if not resolve:
for t in trabajos:
emitir(id=t["id"], estado="error", log="Resolve no responde. ¿Está abierto y con scripting externo en Local?")
return 1
emitir(msg=f"conectado a {resolve.GetProductName()} {resolve.GetVersionString()}")
if resolve.GetCurrentPage() is None:
for t in trabajos:
emitir(id=t["id"], estado="error",
log="Resolve está en la ventana del Gestor de proyectos (o con un diálogo abierto): "
"abre un proyecto, cierra el diálogo y pulsa «Reintentar errores»")
return 1
pm = resolve.GetProjectManager()
anterior = pm.GetCurrentProject()
nombre_anterior = anterior.GetName() if anterior else None
if anterior:
try:
pm.SaveProject()
except Exception: # noqa
pass
color = (trabajos[0].get("color") or "rec709") if trabajos else "rec709"
color = color if color in ("rec709", "log") else "rec709"
# Plan A: proyecto propio «Labstream Archivo» con gestión de color a Rec.709.
proj = None
try:
pm.GotoRootFolder()
proj = pm.LoadProject(PROYECTO) or pm.CreateProject(PROYECTO)
except Exception: # noqa
proj = None
lut_por_clip = None
plan_b = False
if proj:
emitir(msg=f"proyecto «{PROYECTO}» abierto (antes estaba «{nombre_anterior}»)")
ajustes = {
"rec709": {
"colorScienceMode": "davinciYRGBColorManagedv2",
"rcmPresetMode": "Custom",
"separateColorSpaceAndGamma": "0",
"colorSpaceTimeline": "DaVinci WG/Intermediate",
"colorSpaceOutput": "Rec.709 Gamma 2.4",
"inputDRT": "DaVinci",
"outputDRT": "DaVinci",
},
"log": {"colorScienceMode": "davinciYRGB"},
}[color]
for k, v in ajustes.items():
try:
proj.SetSetting(k, v)
except Exception: # noqa
pass
else:
# Plan B: Resolve no deja cambiar de proyecto (biblioteca colaborativa, etc.):
# se trabaja dentro del proyecto abierto, en un bin propio, sin tocar sus ajustes.
proj = anterior
plan_b = True
if not proj:
for t in trabajos:
emitir(id=t["id"], estado="error", log="no hay ningún proyecto abierto en Resolve y no se pudo crear «Labstream Archivo»")
return 1
gestionado = "colormanaged" in (proj.GetSetting("colorScienceMode") or "").lower()
emitir(msg=f"no se pudo abrir «{PROYECTO}»: se usa el proyecto actual «{nombre_anterior}» (color {'gestionado' if gestionado else 'YRGB'})")
if color == "rec709" and not gestionado:
for nombre_lut in ("Blackmagic Gen 5 Film to Extended Video.cube", "Blackmagic Design Film Gen 5 to Extended Video.cube"):
cand = os.path.join(r"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\LUT\Blackmagic Design", nombre_lut)
if os.path.exists(cand):
lut_por_clip = cand
break
emitir(msg="a cada clip BRAW se le aplica la LUT Gen 5 Film → Video para que el archivo se vea bien" if lut_por_clip else "aviso: sin LUT Gen 5 disponible, el archivo quedará en log")
mp = proj.GetMediaPool()
raiz = mp.GetRootFolder()
bin_archivo = None
for sf in raiz.GetSubFolderList() or []:
if sf.GetName() == PROYECTO:
bin_archivo = sf
break
if not bin_archivo:
bin_archivo = mp.AddSubFolder(raiz, PROYECTO)
lote_bin = mp.AddSubFolder(bin_archivo or raiz, time.strftime("lote %Y-%m-%d %H%M%S"))
if lote_bin:
mp.SetCurrentFolder(lote_bin)
codecs = proj.GetRenderCodecs("mp4") or {}
codec = "H265_NVIDIA" if "H265_NVIDIA" in codecs.values() else ("H265" if "H265" in codecs.values() else None)
if not codec:
for t in trabajos:
emitir(id=t["id"], estado="error", log=f"Resolve no ofrece H.265 en MP4 (códecs: {list(codecs.values())})")
return 1
# agrupar por carpeta de salida + formato: cada grupo es una línea de tiempo
grupos = defaultdict(list)
for t in trabajos:
grupos[(os.path.dirname(t["dst"]), t["w"], t["h"], fps_texto(t["fps"]), t["kbps"])].append(t)
salida_ok = 0
for (dst_dir, w, h, fps, kbps), ts in grupos.items():
ids = [t["id"] for t in ts]
tmp_dir = os.path.join(dst_dir, f"_render_{int(time.time())}")
try:
os.makedirs(tmp_dir, exist_ok=True)
# ajustes de proyecto (sirven de base para la nueva línea de tiempo)
# en el proyecto propio se ajusta la base; en el del editor (plan B) NO se toca ningún ajuste
for k, v in (() if plan_b else (("timelineResolutionWidth", str(w)), ("timelineResolutionHeight", str(h)), ("timelineFrameRate", fps))):
try:
proj.SetSetting(k, v)
except Exception: # noqa
pass
items = mp.ImportMedia([t["src"] for t in ts]) or []
if len(items) != len(ts):
emitir(msg=f"aviso: se importaron {len(items)} de {len(ts)} clips")
if not items:
for i in ids:
emitir(id=i, estado="error", log="Resolve no pudo importar los clips")
continue
tl = mp.CreateEmptyTimeline(f"archivo {w}x{h} {fps} {int(time.time())}")
if not tl:
for i in ids:
emitir(id=i, estado="error", log="no se pudo crear la línea de tiempo")
continue
for k, v in (("useCustomSettings", "1"), ("timelineResolutionWidth", str(w)), ("timelineResolutionHeight", str(h)), ("timelineFrameRate", fps)):
try:
tl.SetSetting(k, v)
except Exception: # noqa
pass
mp.AppendToTimeline(items)
proj.SetCurrentTimeline(tl)
if lut_por_clip:
try:
for it in tl.GetItemListInTrack("video", 1) or []:
it.SetLUT(1, lut_por_clip)
except Exception as ex: # noqa
emitir(msg=f"aviso: no se pudo aplicar la LUT: {ex!r}")
if not plan_b:
proj.DeleteAllRenderJobs() # solo en el proyecto propio: en el del editor su cola de render es suya
proj.SetCurrentRenderMode(0) # 0 = clips individuales
proj.SetCurrentRenderFormatAndCodec("mp4", codec)
ok = proj.SetRenderSettings({
"SelectAllFrames": True,
"TargetDir": tmp_dir,
"CustomName": "",
"UniqueFilenameStyle": 1,
"ExportVideo": True,
"ExportAudio": True,
"FormatWidth": int(w),
"FormatHeight": int(h),
"FrameRate": float(fps),
"VideoQuality": int(kbps),
"EncodingProfile": "Main10",
"MultiPassEncode": True,
"AudioCodec": "aac",
"AudioBitDepth": 24,
"AudioSampleRate": 48000,
"NetworkOptimization": True,
})
if not ok:
emitir(msg="aviso: Resolve rechazó parte de los ajustes de render; sigue con los que aceptó")
job = proj.AddRenderJob()
if not job:
for i in ids:
emitir(id=i, estado="error", log="no se pudo crear el trabajo de render")
continue
proj.StartRendering([job], False)
for i in ids:
emitir(id=i, estado="corriendo", pct=0)
while proj.IsRenderingInProgress():
st = proj.GetRenderJobStatus(job) or {}
pct = st.get("CompletionPercentage", 0) or 0
for i in ids:
emitir(id=i, pct=min(99, pct))
time.sleep(2)
st = proj.GetRenderJobStatus(job) or {}
try:
proj.DeleteRenderJob(job) # el trabajo ya rindió: no se deja en la cola del editor
except Exception: # noqa
pass
if st.get("JobStatus") not in ("Complete",):
for i in ids:
emitir(id=i, estado="error", log=f"Resolve terminó con estado {st.get('JobStatus')}: {st.get('Error', '')}")
continue
# mover los renders a su nombre final
producidos = {os.path.splitext(n)[0].lower(): os.path.join(tmp_dir, n) for n in os.listdir(tmp_dir) if n.lower().endswith(".mp4")}
for t in ts:
base = os.path.splitext(os.path.basename(t["src"]))[0].lower()
src_render = producidos.get(base)
if not src_render:
# Resolve puede añadir sufijos: buscar por prefijo
cands = [p for b, p in producidos.items() if b.startswith(base)]
src_render = cands[0] if cands else None
if not src_render or not os.path.exists(src_render):
emitir(id=t["id"], estado="error", log=f"no apareció el render de {os.path.basename(t['src'])} en {tmp_dir}")
continue
os.makedirs(os.path.dirname(t["dst"]), exist_ok=True)
if os.path.exists(t["dst"]):
# nunca se pisa un archivo existente (podría ser la salida de otro clip con el mismo nombre)
emitir(id=t["id"], estado="error", log=f"ya existe {os.path.basename(t['dst'])}; el render queda en {src_render}")
continue
shutil.move(src_render, t["dst"])
try:
stt = os.stat(t["src"])
os.utime(t["dst"], (stt.st_atime, stt.st_mtime))
except OSError:
pass
salida_ok += 1
emitir(id=t["id"], estado="listo", pct=100, salida=t["dst"])
try:
if not os.listdir(tmp_dir):
os.rmdir(tmp_dir)
except OSError:
pass
except Exception as ex: # noqa
for i in ids:
emitir(id=i, estado="error", log=f"error en el puente: {ex!r}")
if not plan_b:
try:
pm.SaveProject()
except Exception: # noqa
pass
if nombre_anterior and nombre_anterior != PROYECTO and proj.GetName() == PROYECTO:
try:
pm.LoadProject(nombre_anterior)
emitir(msg=f"de vuelta en «{nombre_anterior}»")
except Exception: # noqa
emitir(msg=f"no pude volver a «{nombre_anterior}»; ábrelo a mano")
emitir(msg=f"lote terminado: {salida_ok}/{len(trabajos)} clips")
return 0
if __name__ == "__main__":
sys.exit(main())

1075
static/index.html Archivo normal

La diferencia del archivo ha sido suprimido porque es demasiado grande Cargar Diff

BIN
static/labstream-blanco.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 37 KiB

BIN
static/labstream.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 36 KiB

BIN
static/organilab.ico Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.9 KiB

BIN
static/organilab.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.3 KiB