3.2.0: el orden mira la forma de las carpetas, no solo su nombre

Medido sobre una carpeta de cliente real (8.207 archivos, 920 GB) antes de tocar nada:

- «Por nombrar» solo pregunta por lo que el plan llega a ver y no sabe resolver; ya no por las
  carpetas de la app ni por lo de dentro de la papelera. Lo aprendido vale por el nombre entero.
- Una carpeta es una grabación por lo que tiene dentro: una pieza montada no se desarma, y lo
  que no es rodaje no acaba en 00_Grabacion\OTROS. Se conserva el nombre de cada sesión.
- Una carpeta que guarda meses o años dentro se abre y cada cosa va a su mes; si la pieza ya
  existe allí, se junta con ella.
- Repaso de lo ya ordenado: lo que está en un mes que no es el de su rodaje vuelve al suyo.
- El año de un clip de Blackmagic sale de los demás clips de su tarjeta y día; un rodaje que
  pasa de medianoche cuenta como uno.
- Un clip convertido sigue siendo de su cámara, y el convertidor no repite lo que ya convirtió.
- «Exportados» junto a «_Exportados» y «_Papelera» junto a «_Para borrar» se juntan.
- «Hacer de una vez» solo mueve lo marcado; lo dudoso que queda no cuenta como pendiente.
- El inventario lleva la versión de las reglas: uno anterior pide escanear otra vez.

Pruebas: prueba_orden.py (nueva, 19 casos), probar.py 19, prueba_funcional.py 26,
prueba_ventana.py 18 desde el código y 17 con el paquete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Este commit está contenido en:
Labstream
2026-10-04 16:33:45 -05:00
padre 45ece8f2e7
commit c419d5d943
Se han modificado 8 ficheros con 1334 adiciones y 184 borrados

Ver fichero

@@ -17,7 +17,7 @@ static/manrope.woff2 ← la tipografía, para no pedirle nada a nadie fuera de
WebView2Loader.dll ← la entrada a WebView2 (Microsoft, firmada); lo único que viaja y no es de Python WebView2Loader.dll ← la entrada a WebView2 (Microsoft, firmada); lo único que viaja y no es de Python
datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl datos/ ← inventario.sqlite, config.json, diccionario.json (lo aprendido), bitacora.jsonl
empaquetar.py ← arma el paquete: dist\OrganizadorLabstream\ con OrganizadorLabstream.exe y los dos zips empaquetar.py ← arma el paquete: dist\OrganizadorLabstream\ con OrganizadorLabstream.exe y los dos zips
probar.py ← 19 pruebas sin abrir nada · prueba_funcional.py: las 26 funciones · prueba_ventana.py: la ventana de verdad probar.py ← 19 pruebas sin abrir nada · prueba_orden.py: el motor de orden, caso a caso · prueba_funcional.py: las 26 funciones · prueba_ventana.py: la ventana de verdad
Iniciar.bat ← desde el código, con consola (para ver lo que imprime) Iniciar.bat ← desde el código, con consola (para ver lo que imprime)
``` ```
@@ -229,7 +229,7 @@ y nunca pisa un MP4 que ya exista; las raíces de unidad (`N:\`) ya se consultan
### Publicar una versión ### Publicar una versión
py -3.12 probar.py && py -3.12 prueba_funcional.py && py -3.12 prueba_ventana.py py -3.12 probar.py && py -3.12 prueba_orden.py && py -3.12 prueba_funcional.py && py -3.12 prueba_ventana.py
py -3.12 empaquetar.py # construye dist\, los dos zips y manifiesto.json (NOTAS="..." para las notas) py -3.12 empaquetar.py # construye dist\, los dos zips y manifiesto.json (NOTAS="..." para las notas)
py -3.12 prueba_ventana.py --paquete dist\OrganizadorLabstream # el paquete, arrancado por su .exe py -3.12 prueba_ventana.py --paquete dist\OrganizadorLabstream # el paquete, arrancado por su .exe
bash publicar.sh # sube los zips al NAS, deja el manifiesto en GitHub y despliega el OS bash publicar.sh # sube los zips al NAS, deja el manifiesto en GitHub y despliega el OS
@@ -757,6 +757,66 @@ son los de verdad. Consecuencias, vistas el 4 oct 2026:
- Las pruebas que dan de alta en el registro (`probar.py`, `prueba_ventana.py`) no se ven afectadas: escriben - Las pruebas que dan de alta en el registro (`probar.py`, `prueba_ventana.py`) no se ven afectadas: escriben
y leen en la misma copia, y además usan una clave de ensayo. y leen en la misma copia, y además usan una clave de ensayo.
3.2.0, el orden mira la forma de las carpetas, no solo su nombre. Munka pidió pulir la organización de las
carpetas. Antes de tocar nada se midió sobre su carpeta de Andrea Galeano (`V:\Galeano`, 8.207 archivos,
920 GB), el 4 oct 2026, y de ahí sale cada cambio:
- **«Por nombrar» preguntaba lo que no debía, y contestar hacía daño.** Salían todas las carpetas del
inventario, también las de la propia app (`_Para borrar\basura`, las etiquetas que ella misma crea dentro
de `00_Grabacion`) y las que solo existían ya dentro de la papelera. De las 6 preguntas que tenía esa
carpeta, sobraban las 6; a todas proponía «grabación» porque medía por peso, y los clips pesan cien veces
más que lo demás. Aceptarlas enseñó al diccionario que «orden», «videos» y «basura» eran grabaciones, y
como lo aprendido valía también como palabra suelta dentro de otros nombres, «videos ia» pasó a ser
grabación y el plan se dispuso a desarmar «01- Orden», 533 archivos y 65 GB con proyectos dentro.
Ahora `vocabulario` recorre el inventario igual que el plan recorre el disco y solo pregunta por lo que el
plan llega a ver y no sabe resolver; la propuesta sale de la forma de la carpeta y del número de archivos,
y nunca es «grabación» (una grabación de verdad el plan la reconoce solo). Lo aprendido vale por el nombre
entero; solo las palabras de fábrica valen dentro de otros nombres. Los nombres de la app no se pueden
enseñar, y los que se habían colado se olvidan al arrancar. Sobre la misma carpeta: de 6 preguntas a 0.
- **Una carpeta es una grabación por lo que tiene dentro** (`Estructura.es_rodaje`): casi todo clips de
cámara, y sin las carpetas de una pieza montada (exportados, portada, stock, música, proyecto). Si el
nombre lo dice con una palabra de fábrica basta con que casi todo sea material; si lo dijo alguien en
«Por nombrar», se exige cámara reconocida. Una pieza a la que se llamó grabación ya no se desarma.
Dentro de una grabación, lo que no es rodaje (un proyecto, un guion, una imagen de IA) ya no acaba en
`00_Grabacion\OTROS`: va donde iría suelto; y una subcarpeta de exportados o de música va a lo suyo. Se
conserva el nombre de cada sesión («Tomas consultorio») y la subcarpeta `Duplicados`.
- **Cajones.** Una carpeta que guarda meses o años dentro («Orden», «Proyectos\Mayo», «Edición\11-Noviembre»)
no es una cosa que mover: se abre, y lo de dentro se fecha solo, sin heredar el mes donde el cajón esté
metido (`Plan.abrir`). Vale también para una carpeta numerada como pieza cuando alguien confirma que es un
cajón. Solo en clientes cuyas piezas van por mes: una campaña con sus fechas se sigue moviendo entera.
Si la pieza que llega ya existe en ese mes, se junta con ella en vez de dejar una gemela al lado.
- **Repaso de lo ya ordenado** (`Plan.plan_auditoria`). Las carpetas de la estructura no se volvían a
mirar, así que lo que una versión anterior dejó en el mes equivocado se quedaba ahí. En esa carpeta, 385
clips (464,6 GB) estaban en un mes que no era el de su rodaje: los de diciembre de 2025, en marzo. Se
proponen a su mes los que llevan la fecha en el nombre, y con ellos lo que se grabó ese mismo día.
- **El año de un clip de Blackmagic.** El nombre trae tarjeta, mes, día y hora, pero no año. Antes salía de
la fecha del archivo (que una conversión cambia) o del año de la carpeta (que podía estar mal: mandaba a
diciembre de 2024 lo de diciembre de 2025). Ahora todos los clips de la misma tarjeta y día comparten
año (`_fechas_de_rodaje`): el del que conserve su fecha de cámara, o el más antiguo. Y un rodaje que pasa
de medianoche es uno solo: el del 31 de mayo a las 21:07 seguía a las 04:00 del 1 de junio y salía partido
en dos meses.
- **Un clip convertido sigue siendo de su cámara.** 166 clips que ya habían pasado por el convertidor
(«C0001_10bit.mp4») habían perdido su cámara: contaban como «video que no viene de cámara» e iban a OTROS
o a exportados. Y el convertidor no reconocía su propia salida: 22 estaban convertidos dos veces y algunos
tres («…_10bit_10bit_10bit.mp4»). `firma_archivo` mira el nombre sin el sufijo del convertidor (ni el
« (2)» de una copia), y `candidatos` no vuelve a ofrecer lo que ya convirtió.
- **Carpetas gemelas.** `CARPETAS_PROPIAS` comparaba sin el guion bajo, así que una «Exportados» hecha a
mano pasaba por la `_Exportados` de la app y no se tocaba: convivían las dos en el mismo mes. Ahora las
propias salen de las plantillas del tipo de cliente, y la gemela se junta con la buena. Lo mismo con la
papelera: `_Papelera`, el nombre de antes, se junta con `_Para borrar`.
- **«Hacer las N de una vez» solo hace lo marcado.** El material de cámara entraba entero aunque el plan
lo diera por dudoso. La fecha de un original de cámara sí cuenta como probable (es la del rodaje). Lo
dudoso que queda después ya no cuenta como tarea pendiente, y el stock viejo que la lista contaba ahora
se aparta entero (la licencia y el «léeme» de un paquete de Envato se quedaban atrás para siempre).
- **Inventario con versión** (`R.VERSION_REGLAS`). Cambió lo que el escaneo apunta de cada archivo, así
que un inventario hecho con una versión anterior pide «Escanear otra vez» antes de planear nada.
Comprobado sobre una réplica local de esa carpeta (mismos nombres, carpetas y fechas; archivos de 64 bytes;
el NAS no se tocó): «hacer de una vez» hizo 803 movimientos sin errores ni pérdidas, «01- Orden» quedó vacía
y repartida en sus meses, y una segunda pasada no propuso nada marcado. Pruebas: `prueba_orden.py`, nueva,
19 casos que reproducen cada uno de estos puntos en pequeño; `probar.py` 19 de 19, `prueba_funcional.py` 26
de 26 y `prueba_ventana.py` 18.
Probado el 9 sep 2026 sobre una copia de ensayo: plan → aplicar → deshacer → volver a aplicar deja el árbol 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ó 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: con Resolve Studio 21.1 pero no pudo abrir proyecto porque Resolve estaba en el Gestor de proyectos:

Ver fichero

@@ -182,7 +182,10 @@ def filas_elegidas(plan_filas, dec):
if dec.get("limpieza", "papelera") == "papelera": if dec.get("limpieza", "papelera") == "papelera":
out.append(f) out.append(f)
elif c in ("GRABACION", "CELULAR", "FOTOS"): elif c in ("GRABACION", "CELULAR", "FOTOS"):
if dec.get("material", "ordenar") == "ordenar": # solo lo marcado: antes entraba todo el material aunque el plan lo diera por dudoso (el mes
# sacado de la fecha de modificación de un archivo que no es de cámara), y «hacer lo seguro»
# movía justo lo que el plan decía que había que mirar
if dec.get("material", "ordenar") == "ordenar" and f["marcado"]:
out.append(f) out.append(f)
else: else:
m = dec.get("carpetas", "seguras") m = dec.get("carpetas", "seguras")
@@ -222,7 +225,10 @@ def ejecutar(raiz, dec, plan_filas=None, en_hilo=False):
st = O.stock_a_papelera(raiz, rutas) st = O.stock_a_papelera(raiz, rutas)
res["lotes"].append(st.get("lote")) res["lotes"].append(st.get("lote"))
res["stock"] = st.get("movimientos", 0) res["stock"] = st.get("movimientos", 0)
plan_filas = [f for f in plan_filas if f["cubeta"] != "STOCK"] # las filas de stock cuyo material acaba de irse entero ya no tienen qué mover; las demás
# siguen valiendo (antes se quitaban todas, y una carpeta «Recursos» que no era de Envato
# se quedaba sin ordenar hasta la siguiente pasada)
plan_filas = [f for f in plan_filas if f["cubeta"] != "STOCK" or os.path.exists(f["origen"])]
elegidas = filas_elegidas(plan_filas, dec) elegidas = filas_elegidas(plan_filas, dec)
if elegidas: if elegidas:
_prog("ordenar", 0, len(elegidas), "moviendo carpetas y archivos") _prog("ordenar", 0, len(elegidas), "moviendo carpetas y archivos")

Ver fichero

@@ -1,19 +1,19 @@
{ {
"nombre": "OrganiLAB", "nombre": "OrganiLAB",
"version": "3.1.1", "version": "3.2.0",
"builtAt": "2026-10-04T17:34:05.000Z", "builtAt": "2026-10-04T21:33:00.000Z",
"archivo": "OrganizadorLabstream-portatil-3.1.1.zip", "archivo": "OrganizadorLabstream-portatil-3.2.0.zip",
"size": 195387259, "size": 195403245,
"sha256": "117da3af85e14222893a34c7f992cd7853536bdef81f634b8453742e33aebdde", "sha256": "e029a6010a72817b719fdefc66a9669f11e9cd037a48e8c76f5f91c99f82ee58",
"app": { "app": {
"archivo": "OrganizadorLabstream-app-3.1.1.zip", "archivo": "OrganizadorLabstream-app-3.2.0.zip",
"size": 11574680, "size": 11590666,
"sha256": "05b9e0f5d35ac1244d8b1b0eb6aa7caefc9799b8d910f3d3ad42d8e22e3c3a2e", "sha256": "dcef264edc282a83f00073c6d7bec8aa6a799996e49a255483c1ea6187c6720e",
"url": "/api/herramientas/OrganizadorLabstream-app-3.1.1.zip" "url": "/api/herramientas/OrganizadorLabstream-app-3.2.0.zip"
}, },
"notes": "El programa se instala solo en su sitio definitivo (tu carpeta de programas) y trae un manual dentro, en «Manual». Dos copias ya no pueden compartir puerto, y tras actualizarse Windows enseña siempre la versión nueva.", "notes": "El orden mira la forma de las carpetas y no solo su nombre: «Por nombrar» pregunta solo lo que importa, una pieza o un cajón con meses dentro ya no se desarman, se repasa lo ya ordenado, y el convertidor no repite lo convertido. Pide escanear otra vez cada carpeta.",
"firma": { "firma": {
"alg": "rsa2048-pkcs1v15-sha256", "alg": "rsa2048-pkcs1v15-sha256",
"valor": "09ce52a3885faebe34bbb4a8210f460cfa345da578fcb9e73316aec5bdb55ec9977f6cbb9f3d3f8e8925755d6dce94310219d046210fcbcba082134fd088208a9b830086c3bbcbff6acd04259348b70871c935fceb457362619a406f99da2b8abafd69dba7e1400020b90fd7a211d42e72cd49cd0294fbc8fc30e8fbae0ba431e5881f68a6607de7ae44ceadb5b22b007c7a4e5a95347ebdab5b1a645c4814b51a01f95c57e2c0caf9a5a0ccf35ca2f9f53117788b509ff42f4fd8800fe9f38eecb5ee421f3e6dc366b48d344291b21c1b50677fb50e8621733b30d932e0f055b07571b74a0273e528797ae3853851ce35e0eb9a42fe14ae34ca6ea7a6ff2029" "valor": "2d0254434190db0e98469d7988923419aff8364bf0b72378aa528dfa0ef3fb5abf9c1620b11ebfa7ba679f9e7863c81edab027c0ce783f31fb61032b938ecee86e0f3bdf9a8d999cebe1701668983c4b3df48db2061ff011ab1a9b026ca2d6b124fdf92b6ea6df0d75d69c41d7e6ac3235bd9b8a058cf20b98bed026d2ff574802314b6542eeed1f89df914f777c3d06c0aaa9c656b64972edd274cb1b41c2baf94c9d4d05381241c56e4b621681ec8eafb29f88b7c64a111665c47247b05e584f19f4e70ee06ee3d163600dac3f70515f8fec4d09dfc1c151337058189bf02d5f5064112ee4d0989c2118d7f5e981a9fa77938d5aa8e839c58092250a1f29ac"
} }
} }

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

Ver fichero

@@ -189,7 +189,10 @@ def p_nombrar():
v2 = get('/api/vocabulario' + q(raiz=RAIZ)) v2 = get('/api/vocabulario' + q(raiz=RAIZ))
assert {t['token']: t for t in v2['tokens']}['raro'].get('cubeta') == 'DOCUMENTOS' assert {t['token']: t for t in v2['tokens']}['raro'].get('cubeta') == 'DOCUMENTOS'
c, r = post('/api/diccionario', {'token': 'raro', 'cubeta': None}) # y se olvida c, r = post('/api/diccionario', {'token': 'raro', 'cubeta': None}) # y se olvida
return 'reconoce, aprende y olvida un nombre de carpeta' # las carpetas de la propia app no se enseñan: «basura» es su _Para borrar\basura, no una del cliente
c, r = post('/api/diccionario', {'token': 'Basura', 'cubeta': 'GRABACION'})
assert c == 400 and 'propia app' in r.get('error', ''), (c, r)
return 'reconoce, aprende y olvida un nombre de carpeta; no deja enseñar las de la app'
PLAN = {} PLAN = {}

389
prueba_orden.py Archivo normal
Ver fichero

@@ -0,0 +1,389 @@
# -*- coding: utf-8 -*-
"""Pruebas del motor de orden. Cada caso reproduce en pequeño algo medido en una carpeta de verdad (la
de Andrea Galeano, el 4-oct-2026): monta un cliente de ensayo en una carpeta temporal, lo escanea, pide
el plan y, donde importa, lo aplica y mira cómo queda el disco.
py -3.12 prueba_orden.py
"""
import os
import sys
import json
import time
import shutil
import tempfile
import unittest
AQUI = os.path.dirname(os.path.abspath(__file__))
TMP = tempfile.mkdtemp(prefix="organilab-motor-")
APP = os.path.join(TMP, "app")
os.makedirs(os.path.join(APP, "datos"))
for _f in ("organizador.py", "reglas.py", "asistente.py", "actualizador.py", "firma.py", "instalar.py", "ventana.py",
"resolve_bridge.py"):
shutil.copy(os.path.join(AQUI, _f), APP)
# un diccionario como el que dejaba la 3.1.1: con una carpeta de la propia app aprendida como grabación
with open(os.path.join(APP, "datos", "diccionario.json"), "w", encoding="utf-8") as _fh:
json.dump({"carpetas": {"basura": "GRABACION", "mi carpeta rara": "FOTOS"}, "reglas": [], "aprendido": []}, _fh)
sys.path.insert(0, APP)
import organizador as O # noqa: E402 (tiene que importarse desde la copia: sus datos van junto a él)
import asistente # noqa: E402
R = O.R
S = os.sep
def cliente(nombre, tipo="contenido"):
raiz = os.path.join(TMP, nombre)
os.makedirs(raiz)
O.CONFIG["raices"].append({"ruta": raiz, "cliente": nombre, "tipo": tipo})
return raiz
def mk(raiz, rel, y, m, d, tam=2048):
"""Crea un archivo con esa fecha de modificación (a mediodía)."""
p = os.path.join(raiz, rel.replace("/", S))
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "wb") as fh:
fh.write(os.urandom(tam))
t = time.mktime(time.struct_time((y, m, d, 12, 0, 0, 0, 0, -1)))
os.utime(p, (t, t))
return p
def plan(raiz):
O.escanear(raiz)
prog = O.PROGRESO[os.path.normpath(raiz)]
assert prog["estado"] == "listo", prog
return O.Plan(O.raiz_cfg(raiz)).correr()
def aplicar(raiz, p):
r = O.aplicar(raiz, [f for f in p["filas"] if f["marcado"]])
assert not r["errores"], r["errores"]
return r
def arbol(raiz):
return sorted(os.path.relpath(os.path.join(b, f), raiz) for b, _, fs in os.walk(raiz) for f in fs)
def nombres(raiz):
return sorted(os.path.basename(x) for x in arbol(raiz))
def rel(*partes):
return S.join(partes)
class Reglas(unittest.TestCase):
def test_un_clip_convertido_sigue_siendo_de_su_camara(self):
"""166 clips ya convertidos habían perdido su cámara: pasaban por exportados."""
self.assertEqual(R.firma_archivo("C0001_10bit.mp4"), "SONY")
self.assertEqual(R.firma_archivo("20251111_Labstream5320_10bit.mp4"), "SONY")
self.assertEqual(R.firma_archivo("A002_03110416_C042_10bit_10bit.mp4"), "BM_PROXY")
self.assertEqual(R.firma_archivo("A006_06010242_C114 (2)_10bit.mp4"), "BM_PROXY")
self.assertIsNone(R.firma_archivo("INMODE_10bit.mp4")) # no era de cámara: sigue sin serlo
self.assertEqual(R.rol_archivo("C0001_10bit.mp4", ".mp4", R.firma_archivo("C0001_10bit.mp4")), "camara_video")
self.assertTrue(R.es_conversion("C0001_10bit.mp4"))
self.assertTrue(R.es_conversion("C0001_10bit_10bit.mp4"))
self.assertFalse(R.es_conversion("C0001.MP4"))
self.assertEqual(R.sin_conversion("C0001_10bit_10bit.mp4"), "C0001.mp4")
self.assertEqual(R.raiz_derivado("C0001_10bit_10bit.mp4"), "C0001")
self.assertIsNone(R.raiz_derivado("C0001.MP4"))
def test_lo_aprendido_vale_por_el_nombre_entero(self):
"""Confirmar que «Videos» era una grabación convertía en grabación a «videos ia» y a «01- Orden»."""
d = dict(R.DICCIONARIO_SEMILLA, videos="GRABACION", orden="GRABACION")
self.assertEqual(R.cubeta_carpeta("Videos", d)[0], "GRABACION")
self.assertEqual(R.cubeta_carpeta("videos ia", d)[0], "IA") # la palabra de fábrica sí vale
self.assertIsNone(R.cubeta_carpeta("01- Orden", d)[0])
self.assertIsNone(R.cubeta_carpeta("Orden nuevo de cosas", d)[0])
self.assertEqual(R.cubeta_carpeta("04_Revisar-Borrar", R.DICCIONARIO_SEMILLA)[0], "REVISAR")
def test_fecha_de_blackmagic(self):
"""El nombre trae mes y día. Si el archivo no se ha tocado desde el rodaje, el año es seguro."""
intacto = time.mktime(time.struct_time((2025, 12, 9, 18, 30, 0, 0, 0, -1)))
reescrito = time.mktime(time.struct_time((2026, 7, 1, 10, 0, 0, 0, 0, -1)))
self.assertEqual(R.fecha_de_nombre("A014_12091814_C001.braw", intacto), ("2025-12-09", "nombre"))
self.assertEqual(R.fecha_de_nombre("A014_12091814_C001.mp4", reescrito), ("2025-12-09", "nombre+mtime"))
self.assertEqual(R.clave_rodaje("A014_12091814_C001_10bit.mp4"), "A014_1209")
self.assertIsNone(R.clave_rodaje("C0001.MP4"))
def test_carpetas_de_tarjeta(self):
for n in ("DCIM", "100MEDIA", "PRIVATE", "M4ROOT", "CLIP", "A001_0305XY", "100MSDCF"):
self.assertTrue(R.es_carpeta_de_tarjeta(n), n)
for n in ("Tomas consultorio", "Entrevista", "Maquinas"):
self.assertFalse(R.es_carpeta_de_tarjeta(n), n)
def test_rodaje_de_una_misma_tarjeta(self):
"""Los clips de la misma tarjeta y día comparten año, y la madrugada cuenta como la noche anterior."""
def fila(nombre, fecha, origen):
return ("x" + S + nombre, "x", "x", nombre, ".mp4", 1, 0.0, "camara_video", "BM_PROXY", "", None, None,
fecha, origen, 0, 0, 0)
filas = [fila("A002_03020727_C001.mp4", "2025-03-02", "nombre+mtime"), # reescrito en dic-2025
fila("A002_03020727_C002.mp4", "2026-03-02", "nombre+mtime"), # reescrito en jul-2026
fila("A014_12091814_C001.braw", "2025-12-09", "nombre"), # intacto
fila("A014_12091814_C001.mp4", "2025-12-09", "nombre+mtime"),
fila("A006_05312107_C001.mp4", "2025-05-31", "nombre+mtime"), # empieza de noche…
fila("A006_06010000_C002.mp4", "2025-06-01", "nombre+mtime"), # …y sigue de madrugada
fila("A003_06010400_C019.mp4", "2025-06-01", "nombre+mtime"), # otra tarjeta, misma madrugada
fila("A009_06011500_C001.mp4", "2025-06-01", "nombre+mtime")] # esa tarde: otro rodaje
O._fechas_de_rodaje(filas)
por = {f[3]: (f[12], f[13]) for f in filas}
self.assertEqual(por["A002_03020727_C002.mp4"], ("2025-03-02", "nombre+mtime")) # el año más antiguo del grupo
self.assertEqual(por["A014_12091814_C001.mp4"], ("2025-12-09", "nombre")) # el año seguro se contagia
self.assertEqual(por["A006_06010000_C002.mp4"][0], "2025-05-31")
self.assertEqual(por["A003_06010400_C019.mp4"][0], "2025-05-31")
self.assertEqual(por["A009_06011500_C001.mp4"][0], "2025-06-01")
class Arranque(unittest.TestCase):
def test_se_olvida_lo_que_era_de_la_app(self):
"""«basura» se aprendió como grabación porque «Por nombrar» preguntaba por _Para borrar\\basura."""
self.assertNotIn("basura", O.DICC["carpetas"])
self.assertEqual(O.DICC["carpetas"].get("mi carpeta rara"), "FOTOS") # lo del cliente se conserva
with open(O.DICC_PATH, encoding="utf-8") as fh:
self.assertNotIn("basura", json.load(fh)["carpetas"])
class Motor(unittest.TestCase):
def aprende(self, token, cubeta):
O.DICC["carpetas"][token] = cubeta
self.addCleanup(lambda: O.DICC["carpetas"].pop(token, None))
def test_un_cajon_no_se_desarma(self):
"""«Orden» (una organización anterior entera) se aprendió como grabación y el plan la iba a repartir
por cámara y por mes, proyectos incluidos. Ahora se abre, y cada cosa va a su sitio."""
raiz = cliente("Cajon")
mk(raiz, "2026/05- Mayo/03- El cuello/portada vieja.png", 2026, 5, 10)
mk(raiz, "Orden/01_Grabaciones/Tomas consultorio/20251111_Labstream5320.MP4", 2025, 11, 11)
mk(raiz, "Orden/01_Grabaciones/Tomas consultorio/20251111_Labstream5321_10bit.mp4", 2026, 1, 10)
mk(raiz, "Orden/01_Grabaciones/Tomas consultorio/Duplicados/20251111_Labstream5322.MP4", 2025, 11, 11)
mk(raiz, "Orden/01_Grabaciones/Videos Maquinas/A006_05312107_C001.mp4", 2026, 1, 10)
mk(raiz, "Orden/01_Grabaciones/Videos Maquinas/A006_06010000_C002.mp4", 2026, 1, 10)
mk(raiz, "Orden/01_Grabaciones/Videos Maquinas/A006_06010030_C003.braw", 2025, 6, 1)
mk(raiz, "Orden/02_Proyectos/Mayo/03 - El cuello/Exportado/final.mp4", 2026, 5, 20)
mk(raiz, "Orden/02_Proyectos/Mayo/03 - El cuello/Portada/p.png", 2026, 5, 20)
mk(raiz, "Orden/02_Proyectos/Mayo/03 - El cuello/proyecto.prproj", 2026, 5, 20)
mk(raiz, "Orden/03_Recursos/logo.png", 2026, 5, 20)
self.aprende("orden", "GRABACION")
antes = nombres(raiz)
p = plan(raiz)
self.assertFalse([f for f in p["filas"] if f["origen_rel"] == "Orden"], "sigue queriendo mover «Orden» entera")
self.assertTrue(any("guarda dentro carpetas de otras fechas" in a for a in p["avisos"]), p["avisos"])
destinos = {f["destino_rel"] for f in p["filas"]}
self.assertIn(rel("2025", "11- Noviembre", "00_Grabacion", "Tomas consultorio", "SONY"), destinos)
self.assertIn(rel("2025", "11- Noviembre", "00_Grabacion", "Tomas consultorio", "SONY", "Duplicados"), destinos)
# el rodaje del 31 de mayo siguió de madrugada: todo a mayo de 2025, el año que da el .braw intacto
self.assertIn(rel("2025", "05- Mayo", "00_Grabacion", "Maquinas", "BLACKMAGIC_MP4"), destinos)
self.assertIn(rel("2025", "05- Mayo", "00_Grabacion", "Maquinas", "URSA_BRAW"), destinos)
self.assertFalse([d for d in destinos if "06- Junio" in d], destinos)
junta = [f for f in p["filas"] if f["accion"] == "renombrar" and f["origen_rel"].endswith("03 - El cuello")]
self.assertTrue(junta and junta[0]["marcado"] and "se junta" in junta[0]["motivo"], junta)
aplicar(raiz, p)
self.assertEqual(nombres(raiz), antes, "se perdió o se duplicó algún archivo")
for esperado in (rel("2025", "11- Noviembre", "00_Grabacion", "Tomas consultorio", "SONY", "20251111_Labstream5320.MP4"),
rel("2025", "11- Noviembre", "00_Grabacion", "Tomas consultorio", "SONY", "20251111_Labstream5321_10bit.mp4"),
rel("2025", "05- Mayo", "00_Grabacion", "Maquinas", "BLACKMAGIC_MP4", "A006_06010000_C002.mp4"),
rel("2025", "05- Mayo", "00_Grabacion", "Maquinas", "URSA_BRAW", "A006_06010030_C003.braw"),
rel("2026", "05- Mayo", "03- El cuello", "Exportado", "final.mp4"),
rel("2026", "05- Mayo", "03- El cuello", "proyecto.prproj"),
rel("2026", "05- Mayo", "03- El cuello", "portada vieja.png")):
self.assertTrue(os.path.isfile(os.path.join(raiz, esperado)), "falta " + esperado + "\n" + "\n".join(arbol(raiz)))
self.assertFalse(os.path.isdir(os.path.join(raiz, "2026", "05- Mayo", "03 - El cuello")), "quedó la pieza gemela")
# y con todo en su sitio, no queda nada que proponer sobre lo mismo
self.assertFalse([f for f in O.Plan(O.raiz_cfg(raiz)).correr()["filas"] if f["marcado"]])
def test_una_pieza_no_es_una_grabacion(self):
"""Una pieza numerada cuyo título alguien confirmó como «grabación» se quedaba sin sus archivos."""
raiz = cliente("Pieza")
for n in ("final VR1.mp4", "toma 2.mp4", "Musica/tema.wav", "Portada/p.png"):
mk(raiz, "2026/02- Febrero/03- Se me cayó el parpado/" + n, 2026, 2, 10)
mk(raiz, "2026/08- Agosto/03- Grabacion Agosto/C0001.MP4", 2026, 8, 5)
self.aprende("se me cayo el parpado", "GRABACION")
p = plan(raiz)
self.assertFalse([f for f in p["filas"] if "parpado" in f["origen_rel"]], "desarma la pieza")
# la sesión del mes, con nombre de fábrica y clips de cámara, sí se reparte
self.assertIn(rel("2026", "08- Agosto", "00_Grabacion", "SONY"), {f["destino_rel"] for f in p["filas"]})
def test_lo_que_no_es_rodaje_no_va_a_otros(self):
"""Dentro de una carpeta de grabación había proyectos e imágenes de IA que acababan en 00_Grabacion\\OTROS."""
raiz = cliente("Mezcla")
for n in ("C0001.MP4", "C0002.MP4", "C0003.MP4"):
mk(raiz, "Grabación evento/" + n, 2026, 4, 10)
mk(raiz, "Grabación evento/guion.docx", 2026, 4, 10)
mk(raiz, "Grabación evento/proyecto.drp", 2026, 4, 10)
mk(raiz, "Grabación evento/Export/final.mp4", 2026, 4, 12)
p = plan(raiz)
por_archivo = {os.path.basename(a): f for f in p["filas"] for a in (f["archivos"] or [])}
self.assertEqual(por_archivo["C0001.MP4"]["destino_rel"], rel("2026", "04- Abril", "00_Grabacion", "evento", "SONY"))
self.assertTrue(por_archivo["C0001.MP4"]["marcado"], "un original de cámara fechado por su archivo debe ir marcado")
self.assertEqual(por_archivo["proyecto.drp"]["destino_rel"], rel("01_Proyectos", "DaVinci Resolve"))
self.assertNotIn("00_Grabacion", por_archivo["guion.docx"]["destino_rel"])
self.assertNotIn("final.mp4", por_archivo) # va con su carpeta, no suelto
export = [f for f in p["filas"] if f["origen_rel"] == rel("Grabación evento", "Export")]
self.assertTrue(export and export[0]["destino_rel"] == rel("2026", "04- Abril", "_Exportados"), export)
self.assertFalse([f for f in p["filas"] if f["destino_rel"].endswith("OTROS")])
def test_repaso_de_lo_ya_ordenado(self):
"""160 clips de diciembre de 2025 vivían en la 00_Grabacion de marzo, y nada los volvía a mirar."""
raiz = cliente("Repaso")
g = "2025/03- Marzo/00_Grabacion/"
mk(raiz, g + "URSA_BRAW/A014_12091814_C001.braw", 2025, 12, 9)
mk(raiz, g + "URSA_BRAW/A014_12100130_C002.braw", 2025, 12, 10) # la misma noche, ya de madrugada
mk(raiz, g + "URSA_BRAW/Duplicados/A014_12091814_C001.mp4", 2026, 7, 1) # reescrito después
mk(raiz, g + "SONY/C0001.MP4", 2025, 12, 9) # sin fecha en el nombre, mismo día
mk(raiz, g + "SONY/C0002.MP4", 2025, 3, 5) # este sí es de marzo
mk(raiz, g + "URSA_BRAW/A002_03110342_C036.braw", 2025, 3, 11) # y este
antes = nombres(raiz)
p = plan(raiz)
filas = [f for f in p["filas"] if "ya estaba ordenado" in f["motivo"]]
movidos = sorted(os.path.basename(a) for f in filas for a in f["archivos"])
self.assertEqual(movidos, ["A014_12091814_C001.braw", "A014_12091814_C001.mp4", "A014_12100130_C002.braw", "C0001.MP4"])
self.assertTrue(all(f["marcado"] for f in filas), filas)
self.assertTrue(any("en un mes que no es el de su rodaje" in a for a in p["avisos"]), p["avisos"])
aplicar(raiz, p)
self.assertEqual(nombres(raiz), antes)
dic = os.path.join(raiz, "2025", "12- Diciembre", "00_Grabacion")
for esperado in ("URSA_BRAW" + S + "A014_12091814_C001.braw", "URSA_BRAW" + S + "A014_12100130_C002.braw",
"URSA_BRAW" + S + "Duplicados" + S + "A014_12091814_C001.mp4", "SONY" + S + "C0001.MP4"):
self.assertTrue(os.path.isfile(os.path.join(dic, esperado)), "falta " + esperado + "\n" + "\n".join(arbol(raiz)))
mar = os.path.join(raiz, "2025", "03- Marzo", "00_Grabacion")
self.assertTrue(os.path.isfile(os.path.join(mar, "SONY", "C0002.MP4")))
self.assertTrue(os.path.isfile(os.path.join(mar, "URSA_BRAW", "A002_03110342_C036.braw")))
self.assertFalse([f for f in O.Plan(O.raiz_cfg(raiz)).correr()["filas"] if f["marcado"]], "el repaso no termina")
def test_carpetas_gemelas_y_papelera_vieja(self):
"""«Exportados» y «_Exportados» convivían en el mismo mes, y lo apartado, en dos papeleras."""
raiz = cliente("Gemelas")
mk(raiz, "2026/03- Marzo/Exportados/a.mp4", 2026, 3, 10)
mk(raiz, "2026/03- Marzo/_Exportados/b.mp4", 2026, 3, 11)
mk(raiz, "2026/05- Mayo/EXPORTADOS/c.mp4", 2026, 5, 11)
mk(raiz, "_Papelera/basura/x.tmp", 2026, 1, 1)
mk(raiz, "_Para borrar/basura/y.tmp", 2026, 1, 1)
p = plan(raiz)
gemela = [f for f in p["filas"] if f["origen_rel"] == rel("2026", "03- Marzo", "Exportados")]
self.assertTrue(gemela and gemela[0]["destino_rel"] == rel("2026", "03- Marzo", "_Exportados") and gemela[0]["marcado"], gemela)
vieja = [f for f in p["filas"] if f["origen_rel"] == "_Papelera"]
self.assertTrue(vieja and vieja[0]["destino_rel"] == "_Para borrar" and vieja[0]["paso"] == "limpiar", vieja)
self.assertEqual([a["titulo"] for a in O.acciones(raiz)["acciones"] if a["clave"] == "limpiar"], ["Juntar las dos papeleras"])
aplicar(raiz, p)
self.assertEqual(arbol(raiz), sorted([rel("2026", "03- Marzo", "_Exportados", "a.mp4"), rel("2026", "03- Marzo", "_Exportados", "b.mp4"),
rel("2026", "05- Mayo", "_Exportados", "c.mp4"),
rel("_Para borrar", "basura", "x.tmp"), rel("_Para borrar", "basura", "y.tmp")]))
def test_por_nombrar_solo_pregunta_lo_que_importa(self):
"""De 6 preguntas, 6 sobraban: carpetas de la app, de dentro de la papelera o de dentro de una pieza."""
raiz = cliente("Nombrar")
mk(raiz, "_Para borrar/basura/Videos/c.mp4", 2026, 1, 1) # dentro de la papelera
mk(raiz, "2026/01- Enero/00_Grabacion/Orden/SONY/C0001.MP4", 2026, 1, 5) # una etiqueta de la propia app
mk(raiz, "2026/01- Enero/01- Pieza/Rara/x.png", 2026, 1, 5) # dentro de una pieza
mk(raiz, "2026/01- Enero/Sin numero/x.png", 2026, 1, 6) # en un mes: será una pieza
for n in ("Exportado/f.mp4", "Portada/p.png", "Musica/m.wav", "r.prproj"):
mk(raiz, "Botox labios/" + n, 2026, 2, 2) # una pieza montada, suelta en la raíz
for n in ("a.xyz", "b.xyz", "c.xyz"):
mk(raiz, "Cosas/" + n, 2026, 2, 2)
for i in range(5):
mk(raiz, "Tarjeta/C%04d.MP4" % i, 2026, 2, 3) # llena de clips: se reparte sola
os.makedirs(os.path.join(raiz, "Vacia", "dentro"))
plan(raiz)
voc = {t["token"]: t for t in O.vocabulario(raiz)}
self.assertEqual(sorted(k for k, t in voc.items() if not t["cubeta"]), ["botox labios", "cosas"], sorted(voc))
for sobra in ("videos", "orden", "rara", "sin numero", "tarjeta", "vacia", "basura", "sony"):
self.assertNotIn(sobra, voc)
t = voc["botox labios"]
self.assertEqual((t["sugerencia"], t["sug_conf"]), ("PIEZA", "alta"), t)
self.assertNotEqual(voc["cosas"].get("sugerencia"), "GRABACION")
nombrar = [a for a in O.acciones(raiz)["acciones"] if a["clave"] == "nombrar"][0]
self.assertIn("2 carpetas", nombrar["cifra"])
def test_piezas_gemelas_en_el_mismo_mes(self):
"""La misma pieza escrita de dos formas en el mismo mes: se juntan, y solo se mueve una de las dos."""
raiz = cliente("Gemelas de pieza")
mk(raiz, "2026/05- Mayo/03 - El cuello/a.mp4", 2026, 5, 10)
mk(raiz, "2026/05- Mayo/03- El cuello/b.mp4", 2026, 5, 10)
mk(raiz, "2026/06- Junio/Botox/c.mp4", 2026, 6, 10)
mk(raiz, "2026/06- Junio/02- Botox/d.mp4", 2026, 6, 10)
p = plan(raiz)
self.assertEqual(sorted((f["origen_rel"], f["destino_rel"], f["marcado"]) for f in p["filas"]),
[(rel("2026", "05- Mayo", "03 - El cuello"), rel("2026", "05- Mayo", "03- El cuello"), True),
(rel("2026", "06- Junio", "Botox"), rel("2026", "06- Junio", "02- Botox"), True)])
aplicar(raiz, p)
self.assertEqual(arbol(raiz), [rel("2026", "05- Mayo", "03- El cuello", "a.mp4"), rel("2026", "05- Mayo", "03- El cuello", "b.mp4"),
rel("2026", "06- Junio", "02- Botox", "c.mp4"), rel("2026", "06- Junio", "02- Botox", "d.mp4")])
def test_una_campana_con_fechas_no_es_un_cajon(self):
"""Abrir lo que guarda meses dentro vale donde las piezas van por mes; una campaña se mueve entera."""
raiz = cliente("Anuncios", "pauta")
mk(raiz, "Campaña Navidad/2025/Noviembre/anuncio.mp4", 2025, 11, 20)
mk(raiz, "Campaña Navidad/2025/Noviembre/guion.docx", 2025, 11, 20)
p = plan(raiz)
self.assertFalse([a for a in p["avisos"] if "guarda dentro" in a], p["avisos"])
self.assertEqual([(f["origen_rel"], f["accion"]) for f in p["filas"]], [("Campaña Navidad", "mover carpeta")])
def test_lo_dudoso_no_cuenta_como_pendiente(self):
"""Con lo seguro hecho, la carpeta está al día aunque quede algo que la app no mueve sola."""
raiz = cliente("Dudas")
mk(raiz, "2026/03- Marzo/01 - Botox/final.mp4", 2026, 3, 10) # solo falta un retoque de nombre, sin marcar
p = plan(raiz)
self.assertEqual([(f["accion"], f["marcado"]) for f in p["filas"]], [("renombrar", False)])
O.PLANES[os.path.normpath(raiz)] = p
a = O.acciones(raiz)
fechas = [x for x in a["acciones"] if x["clave"] == "fechas"][0]
self.assertEqual((fechas["estado"], fechas["cifra"]), ("espera", "1 dudoso"))
self.assertNotIn("fechas", a["automatico"])
def test_hacer_lo_seguro_no_mueve_lo_dudoso(self):
filas = [{"cubeta": "GRABACION", "marcado": False, "confianza": "baja"},
{"cubeta": "GRABACION", "marcado": True, "confianza": "media"},
{"cubeta": "BASURA", "marcado": True, "confianza": "alta"},
{"cubeta": "EXPORTADOS", "marcado": False, "confianza": "baja"}]
elegidas = asistente.filas_elegidas(filas, {"carpetas": "seguras"})
self.assertEqual([(f["cubeta"], f["marcado"]) for f in elegidas], [("GRABACION", True), ("BASURA", True)])
def test_el_convertidor_no_repite(self):
"""22 clips se habían convertido dos veces y algunos tres («…_10bit_10bit_10bit.mp4»)."""
raiz = cliente("Convertidos")
mk(raiz, "2026/04- Abril/00_Grabacion/SONY/C0001.MP4", 2025, 4, 10)
mk(raiz, "2026/04- Abril/00_Grabacion/SONY/C0002_10bit.mp4", 2025, 4, 10)
plan(raiz)
con = O.db()
with con:
con.execute("UPDATE archivos SET analizado = 1, codec = 'hevc', ancho = 1920, alto = 1080, fps = 25, dur = 10, "
"kbps = 90000 WHERE raiz = ?", (os.path.normpath(raiz),))
roles = {r["nombre"]: r["rol"] for r in con.execute("SELECT nombre, rol FROM archivos WHERE raiz = ?", (os.path.normpath(raiz),))}
con.close()
self.assertEqual(roles, {"C0001.MP4": "camara_video", "C0002_10bit.mp4": "camara_video"})
cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(os.path.normpath(raiz), 0, "equilibrado", 0)}
self.assertEqual(cs["C0001.MP4"], "")
self.assertIn("ya es una conversión", cs["C0002_10bit.mp4"])
def test_el_sufijo_del_convertidor_se_puede_cambiar(self):
"""El sufijo que pone el convertidor es un ajuste: si se cambia, las reglas tienen que enterarse."""
antes = O.CONFIG["optimizar"].get("sufijo")
self.addCleanup(lambda: O.CONFIG["optimizar"].__setitem__("sufijo", antes))
self.assertFalse(R.es_conversion("C0001_arch.mp4"))
O.CONFIG["optimizar"]["sufijo"] = "_arch"
O._sufijos_conversion()
self.assertTrue(R.es_conversion("C0001_arch.mp4"))
self.assertEqual(R.firma_archivo("C0001_arch.mp4"), "SONY")
self.assertTrue(R.es_conversion("C0001_10bit.mp4")) # y los de siempre siguen valiendo
def test_inventario_de_otra_version(self):
"""Lo que apunta el escaneo cambió: un inventario viejo se relee antes de planear nada."""
raiz = cliente("Viejo")
mk(raiz, "Grabacion/C0001.MP4", 2026, 4, 10)
plan(raiz)
self.assertNotEqual(O.acciones(raiz)["acciones"][0]["clave"], "escanear")
con = O.db()
with con:
con.execute("UPDATE escaneos SET reglas = 0 WHERE raiz = ?", (os.path.normpath(raiz),))
con.close()
a = O.acciones(raiz)
self.assertEqual((a["acciones"][0]["clave"], a["acciones"][0]["estado"], a["automatico"]), ("escanear", "toca", []))
self.assertTrue(any("versión anterior" in x for x in O.Plan(O.raiz_cfg(raiz)).correr()["avisos"]))
if __name__ == "__main__":
try:
unittest.main(verbosity=2, exit=False)
finally:
shutil.rmtree(TMP, ignore_errors=True)

125
reglas.py
Ver fichero

@@ -147,9 +147,44 @@ FUENTE_CARPETA = { # nombre de subcarpeta al separar una sesión de grabación
} }
# Sufijos que pone el convertidor de la propia app. «C0001_10bit.mp4» sigue siendo el clip C0001 de la
# Sony: medido el 4-oct-2026 sobre el material real, 166 clips ya convertidos habían perdido su cámara
# (pasaban por «video que no viene de cámara» y el plan los mandaba a OTROS o a exportados), y 22 se
# habían convertido dos y hasta tres veces porque el convertidor no reconocía su propia salida.
SUFIJOS_CONVERSION = ("_10bit", "_ligero", "_h264", "_proxy", "_lt", "_hq")
def sin_conversion(nombre: str) -> str:
"""«C0001_10bit_10bit.mp4» -> «C0001.mp4»: el nombre sin los sufijos del convertidor."""
base, ext = os.path.splitext(nombre or "")
sigue = True
while sigue:
sigue = False
b = base.lower()
for suf in SUFIJOS_CONVERSION:
if suf and b.endswith(suf) and len(base) > len(suf):
base = base[:-len(suf)]
sigue = True
break
return base + ext
def es_conversion(nombre: str) -> bool:
"""True si el archivo es salida del convertidor (lleva su sufijo): no se convierte otra vez."""
return sin_conversion(nombre) != (nombre or "")
_RX_COPIA = re.compile(r"( \(\d+\)|-\d{3}| - copia)(?=\.[^.]+$)", re.I)
def firma_archivo(nombre: str): def firma_archivo(nombre: str):
# se prueba el nombre tal cual, luego sin los sufijos del convertidor y sin el « (2)» que pone
# Windows al copiar: «A006_06010242_C114 (2)_10bit.mp4» sigue siendo un clip de la Blackmagic
limpio = sin_conversion(nombre)
limpio = _RX_COPIA.sub("", limpio)
for intento in ((nombre,) if limpio == nombre else (nombre, limpio)):
for k, rx, desc in FIRMAS_RE: for k, rx, desc in FIRMAS_RE:
if rx.search(nombre): if rx.search(intento):
return k return k
return None return None
@@ -318,6 +353,29 @@ def base_derivado(nombre: str):
return None return None
def raiz_derivado(nombre: str):
"""El original del todo, quitando todos los sufijos: «C0001_10bit_10bit.mp4» -> «C0001». None si el
nombre no lleva ninguno."""
base = os.path.splitext(nombre)[0]
visto = False
while True:
b = base.lower()
for suf in SUFIJOS_DERIVADO:
if b.endswith(suf) and len(base) > len(suf):
base = base[:-len(suf)]
visto = True
break
else:
break
return base if visto else None
def clave_rodaje(nombre: str):
"""«A014_12091814_C001.braw» -> «A014_1209»: la tarjeta y el día. Todos los clips que la comparten
salieron del mismo rodaje, así que comparten año aunque a alguno se le haya cambiado la fecha."""
return nombre[:9].upper() if _RX_BM.match(nombre or "") else None
def fecha_real(st): def fecha_real(st):
"""El instante más antiguo que el disco sabe de este archivo. """El instante más antiguo que el disco sabe de este archivo.
@@ -352,7 +410,12 @@ def fecha_de_nombre(nombre: str, mtime: float):
mt = datetime.datetime.fromtimestamp(mtime) mt = datetime.datetime.fromtimestamp(mtime)
anio = mt.year if (mes, dia) <= (mt.month, mt.day) else mt.year - 1 anio = mt.year if (mes, dia) <= (mt.month, mt.day) else mt.year - 1
try: try:
return datetime.date(anio, mes, dia).isoformat(), "nombre+mtime" d = datetime.date(anio, mes, dia)
# el nombre de Blackmagic trae mes y día, no año. Si la fecha del archivo cae ese mismo
# día, el archivo no se ha tocado desde el rodaje y el año es seguro; si cae después
# (lo reescribió una conversión o una copia), el año es solo «el más reciente posible».
intacto = abs((mt.date() - d).days) <= 1
return d.isoformat(), "nombre" if intacto else "nombre+mtime"
except ValueError: except ValueError:
pass pass
return datetime.date.fromtimestamp(mtime).isoformat(), "mtime" return datetime.date.fromtimestamp(mtime).isoformat(), "mtime"
@@ -395,6 +458,7 @@ DICCIONARIO_SEMILLA = {
"ursa": "GRABACION", "braw": "GRABACION", "sony": "GRABACION", "osmo": "GRABACION", "dji": "GRABACION", "blackmagic": "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", "blackmagic braw": "GRABACION", "otros blackmagic": "GRABACION", "otro sony": "GRABACION", "dji audio": "GRABACION",
"tascam": "GRABACION", "camara": "GRABACION", "videos originales": "GRABACION", "originales": "GRABACION", "tascam": "GRABACION", "camara": "GRABACION", "videos originales": "GRABACION", "originales": "GRABACION",
"atomos": "GRABACION",
# exportados # exportados
"render": "EXPORTADOS", "renders": "EXPORTADOS", "export": "EXPORTADOS", "exportado": "EXPORTADOS", "render": "EXPORTADOS", "renders": "EXPORTADOS", "export": "EXPORTADOS", "exportado": "EXPORTADOS",
"exportados": "EXPORTADOS", "exports": "EXPORTADOS", "finales": "EXPORTADOS", "final": "EXPORTADOS", "exportados": "EXPORTADOS", "exports": "EXPORTADOS", "finales": "EXPORTADOS", "final": "EXPORTADOS",
@@ -451,6 +515,7 @@ DICCIONARIO_SEMILLA = {
"temporal": "CONTENEDOR", "temp": "CONTENEDOR", "desordenado": "CONTENEDOR", "desordenado revisar": "CONTENEDOR", "temporal": "CONTENEDOR", "temp": "CONTENEDOR", "desordenado": "CONTENEDOR", "desordenado revisar": "CONTENEDOR",
"sacado de nubes": "CONTENEDOR", "cambio de proyecto": "CONTENEDOR", "nueva carpeta": "CONTENEDOR", "sacado de nubes": "CONTENEDOR", "cambio de proyecto": "CONTENEDOR", "nueva carpeta": "CONTENEDOR",
"repetidos": "IGNORAR", "duplicados": "IGNORAR", "backup": "REVISAR", "respaldo": "REVISAR", "repetidos": "IGNORAR", "duplicados": "IGNORAR", "backup": "REVISAR", "respaldo": "REVISAR",
"revisar": "REVISAR", "por revisar": "REVISAR", "borrar": "REVISAR", "eliminar": "REVISAR",
# cosas del cliente que son piezas o series # cosas del cliente que son piezas o series
"reels": "PIEZA", "reel": "PIEZA", "historia": "PIEZA", "historias": "PIEZA", "carrusel": "PIEZA", "reels": "PIEZA", "reel": "PIEZA", "historia": "PIEZA", "historias": "PIEZA", "carrusel": "PIEZA",
"podcast": "PIEZA", "consultorio": "PIEZA", "testimonios": "PIEZA", "clases": "PIEZA", "podcast": "PIEZA", "consultorio": "PIEZA", "testimonios": "PIEZA", "clases": "PIEZA",
@@ -470,6 +535,9 @@ MARCA_SUB = {
} }
_SEMILLA_POR_LARGO = sorted(DICCIONARIO_SEMILLA, key=lambda k: -len(k))
def cubeta_carpeta(nombre: str, diccionario: dict): def cubeta_carpeta(nombre: str, diccionario: dict):
"""(cubeta, motivo) de una carpeta por su nombre; None si no se reconoce.""" """(cubeta, motivo) de una carpeta por su nombre; None si no se reconoce."""
if es_basura_dir(nombre): if es_basura_dir(nombre):
@@ -483,7 +551,12 @@ def cubeta_carpeta(nombre: str, diccionario: dict):
return diccionario[n], f"diccionario: «{n}»" return diccionario[n], f"diccionario: «{n}»"
# tokens de varias palabras primero, luego palabras sueltas # tokens de varias palabras primero, luego palabras sueltas
# (CONTENEDOR solo por coincidencia exacta: "Material juan manuel" no es un cajón de sastre) # (CONTENEDOR solo por coincidencia exacta: "Material juan manuel" no es un cajón de sastre)
claves = sorted(diccionario.keys(), key=lambda k: -len(k)) #
# Solo las palabras DE FÁBRICA valen como parte de otro nombre. Lo que alguien confirma en «Por
# nombrar» es «esta carpeta es tal cosa», no «toda carpeta que lleve esta palabra lo es»: medido el
# 4-oct-2026, aceptar que una carpeta «Videos» era una grabación convirtió en grabación a «videos ia»,
# y «orden» a «01- Orden», una carpeta de 65 GB con proyectos dentro.
claves = [k for k in _SEMILLA_POR_LARGO if k in diccionario]
for k in claves: for k in claves:
if " " in k and diccionario[k] != "CONTENEDOR" and re.search(r"(^| )" + re.escape(k) + r"( |$)", n): if " " in k and diccionario[k] != "CONTENEDOR" and re.search(r"(^| )" + re.escape(k) + r"( |$)", n):
return diccionario[k], f"diccionario: contiene «{k}»" return diccionario[k], f"diccionario: contiene «{k}»"
@@ -497,7 +570,18 @@ def cubeta_carpeta(nombre: str, diccionario: dict):
# palabras que solo dicen "de qué cámara": no aportan etiqueta a una sesión de grabación # 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", FUENTE_PALABRAS = {"grabacion", "grabaciones", "grabado", "grabados", "ursa", "sony", "osmo", "dji", "blackmagic",
"braw", "tascam", "audio", "otro", "otros", "a", "c", "camara", "material", "video", "videos", "braw", "tascam", "audio", "otro", "otros", "a", "c", "camara", "material", "video", "videos",
"mp4", "mov", "de", "la", "el", "pocket", "drone", "dron", "mic", "celular"} "mp4", "mov", "de", "la", "el", "pocket", "drone", "dron", "mic", "celular",
"fotos", "foto", "atomos"}
# carpetas que crea la propia tarjeta de la cámara: no son el nombre de nada
_TARJETA = {"dcim", "private", "m4root", "clip", "sub", "thmbnl", "general", "avchd", "bdmv", "stream",
"takedir", "xdroot", "misc", "panasonic", "avf info", "mp root"}
_RX_TARJETA = re.compile(r"^(\d{3}[a-z_]{3,8}|[a-z]\d{3}(_[a-z0-9]+)?|\d{3}_\d{4})$", re.I)
def es_carpeta_de_tarjeta(nombre: str) -> bool:
"""«DCIM», «100MEDIA», «PRIVATE», «A001_0305XY»: la estructura que deja la cámara en la tarjeta."""
return norm(nombre) in _TARJETA or bool(_RX_TARJETA.match((nombre or "").strip()))
def etiqueta_grabacion(nombre: str) -> str: def etiqueta_grabacion(nombre: str) -> str:
@@ -627,11 +711,36 @@ 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. # Los duplicados se juntan en esta subcarpeta AL LADO del archivo que se queda, para revisarlos a mano.
DUPLICADOS = "Duplicados" DUPLICADOS = "Duplicados"
# Carpetas que la app crea y que no debe volver a "ordenar" (se comparan ya normalizadas) # Carpetas que la app crea y que no debe volver a "ordenar" (se comparan ya normalizadas).
#
# Aquí solo están las que no tienen doble: las que empiezan por «_» ya se reconocen por el guion. Antes
# estaban todas, y como al normalizar se pierde el guion, una carpeta «Exportados» hecha a mano pasaba
# por ser la «_Exportados» de la app y no se tocaba nunca: medido el 4-oct-2026, en un mismo mes
# convivían «Exportados» y «_Exportados», y «EXPORTADOS», «Portadas» y «Fotos» sueltas en otros.
CARPETAS_PROPIAS = {norm(x) for x in ( CARPETAS_PROPIAS = {norm(x) for x in (
"_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "_Apoyos", "_Documentos", "00_Marca", "01_Proyectos", "Campañas", "_Para borrar", "_Papelera", "Duplicados", "_Revisar", "_Recursos", "00_Marca", "01_Proyectos", "Campañas",
"_Exportados", "_Portadas", "00_Grabacion", "_Voces", "_IA", "_Guiones", "_Fotos", "_Musica", "_Archivo", "00_Grabacion", "_Archivo", "00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados",
"00_Brief", "01_Material", "02_Grabacion", "03_Proyecto", "04_Exportados", ".organilab")} ".organilab")}
# Nombres que son de la app o de su estructura: no se pueden enseñar como si fueran una carpeta del
# cliente. «Por nombrar» llegó a preguntar qué era «basura» (su propia _Para borrar\basura) y a proponer
# «grabación» porque dentro había clips.
NOMBRES_DE_LA_APP = {norm(x) for x in (
"_Para borrar", "_Papelera", "_Archivo", ".organilab", "basura", "proxies", "00_Grabacion")}
# Lo que cuenta como material de un rodaje dentro de una carpeta de grabación. Lo demás (un proyecto, un
# guion, una imagen de IA) estaba ahí de paso y no se arrastra a 00_Grabacion\OTROS.
ROLES_RODAJE = {"camara_raw", "camara_video", "camara_sidecar", "audio_grabado", "celular_video", "celular_foto",
"foto_camara", "video", "audio", "imagen"}
ROLES_CAMARA = {"camara_raw", "camara_video", "camara_sidecar", "audio_grabado"}
# Subcarpetas que delatan una pieza montada (o un proyecto entero), no una tarjeta volcada: una grabación
# no trae dentro su carpeta de exportados, de portada y de música.
PARTES_PIEZA = {"EXPORTADOS", "PORTADAS", "STOCK", "IA", "GUION", "PROYECTOS", "MUSICA", "SFX"}
# Sube cuando cambia lo que el escaneo apunta de cada archivo (cámara, rol, fecha): un inventario hecho
# con una versión anterior se vuelve a leer antes de planear nada.
VERSION_REGLAS = 2
# Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio) # Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio)
# Un color por tipo de material. Se usa en el mapa, en el gráfico de peso y —si se quiere— en el # Un color por tipo de material. Se usa en el mapa, en el gráfico de peso y —si se quiere— en el

Ver fichero

@@ -264,6 +264,7 @@
.nombrar li .r{display:block} .nombrar li .r{display:block}
.nombrar li .sg{display:block;font-size:12px;color:var(--acc);margin-top:3px;white-space:normal} .nombrar li .sg{display:block;font-size:12px;color:var(--acc);margin-top:3px;white-space:normal}
.nombrar li .sg b{display:inline} .nombrar li .sg b{display:inline}
.nombrar li .nt{display:block;font-size:12px;color:var(--mut);margin-top:3px;white-space:normal}
.nombrar li .acts button{white-space:nowrap} .nombrar li .acts button{white-space:nowrap}
.nombrar li .acts{display:flex;gap:6px;align-items:center;justify-content:flex-end} .nombrar li .acts{display:flex;gap:6px;align-items:center;justify-content:flex-end}
.nombrar li .acts select{max-width:230px} .nombrar li .acts select{max-width:230px}
@@ -425,8 +426,8 @@
</div> </div>
<div class="sub" id="s-nombrar"> <div class="sub" id="s-nombrar">
<p class="ayuda">La app mira qué hay dentro de cada carpeta y propone qué es. Confirma con un clic o elige otra cosa; se lo aprende para todos los discos. <span class="q">?</span><span class="mas">Solo se ordenan solas las carpetas reconocidas. Las que dejes sin nombrar no se mueven. Una sugerencia «segura» sale cuando casi todo el contenido es de un mismo tipo (por ejemplo, 90 % clips de cámara).</span></p> <p class="ayuda">Aquí solo salen las carpetas sobre las que hay algo que decidir: las que la app no sabe qué son, y que no va a mover hasta que se lo digas. Confirma la propuesta con un clic o elige otra cosa. <span class="q">?</span><span class="mas">No salen las carpetas de la propia app, ni lo de dentro de las piezas, ni lo que ya se ordena solo: una carpeta llena de clips de cámara se reparte sin preguntar, y un cajón con meses dentro se abre. La propuesta sale de la forma de la carpeta (si trae sus exportados, su portada y su música es una pieza; si guarda meses, es un cajón) y de lo que tiene dentro contando archivos, no peso. Lo que confirmes vale para las carpetas que se llamen exactamente igual, en cualquier disco. Quita «solo sin reconocer» para ver y corregir lo que ya reconoce.</span></p>
<div class="toolbar"><button id="btnAceptarSug" class="p" disabled>Aceptar las sugerencias seguras</button><input type="text" id="filtroVoc" placeholder="buscar carpeta…" style="min-width:200px"><label><input type="checkbox" id="soloSin" checked> solo sin reconocer</label><label><input type="checkbox" id="sinPiezas" checked> ocultar lo de dentro de piezas</label><span class="sep"></span><span id="vocResumen" class="mut"></span></div> <div class="toolbar"><button id="btnAceptarSug" class="p" disabled>Aceptar las sugerencias seguras</button><input type="text" id="filtroVoc" placeholder="buscar carpeta…" style="min-width:200px"><label><input type="checkbox" id="soloSin" checked> solo sin reconocer</label><span class="sep"></span><span id="vocResumen" class="mut"></span></div>
<div class="box"><ul class="nombrar" id="nombrar"><li class="mut">cargando…</li></ul></div> <div class="box"><ul class="nombrar" id="nombrar"><li class="mut">cargando…</li></ul></div>
<details class="mas"><summary>Reglas exactas aprendidas al editar un destino en el plan</summary><div id="reglas" class="mut" style="margin-top:8px">ninguna todavía</div></details> <details class="mas"><summary>Reglas exactas aprendidas al editar un destino en el plan</summary><div id="reglas" class="mut" style="margin-top:8px">ninguna todavía</div></details>
</div> </div>
@@ -565,15 +566,18 @@
<dl> <dl>
<dt>Escanear</dt><dd>Lee nombres, tamaños y fechas de todo lo que hay dentro. <i>Cómo: recorre la carpeta sin abrir los archivos, guarda un inventario y clasifica cada cosa por su extensión, su nombre y la carpeta donde está: cámara, proyecto, exportado, stock, proxy, basura…</i></dd> <dt>Escanear</dt><dd>Lee nombres, tamaños y fechas de todo lo que hay dentro. <i>Cómo: recorre la carpeta sin abrir los archivos, guarda un inventario y clasifica cada cosa por su extensión, su nombre y la carpeta donde está: cámara, proyecto, exportado, stock, proxy, basura…</i></dd>
<dt>Lista de acciones</dt><dd>Todo lo que se puede hacer ahora en esa carpeta, en orden, cada cosa con su cifra y su botón; lo ya hecho sale tachado. <i>Cómo: son consultas al inventario, por eso es instantánea.</i></dd> <dt>Lista de acciones</dt><dd>Todo lo que se puede hacer ahora en esa carpeta, en orden, cada cosa con su cifra y su botón; lo ya hecho sale tachado. <i>Cómo: son consultas al inventario, por eso es instantánea.</i></dd>
<dt>Hacer las N de una vez</dt><dd>Ejecuta seguidas las acciones que no necesitan que elijas nada: apartar basura y proxies, ordenar por fecha, ordenar lo demás, apartar stock viejo y juntar repetidos. <i>Cómo: lo dudoso no entra nunca; convertir, respaldar y borrar carpetas vacías tienen su propio botón.</i></dd> <dt>Hacer las N de una vez</dt><dd>Ejecuta seguidas las acciones que no necesitan que elijas nada: apartar basura y proxies, ordenar por fecha, ordenar lo demás, apartar stock viejo y juntar repetidos. <i>Cómo: solo entra lo que el plan da por seguro o probable; lo dudoso no entra nunca, tampoco si es material de cámara. Convertir, respaldar y borrar carpetas vacías tienen su propio botón.</i></dd>
<dt>Quitar de la lista</dt><dd>La carpeta deja de aparecer en el programa y, si quieres, se olvida su inventario. En el disco no se toca nada.</dd> <dt>Quitar de la lista</dt><dd>La carpeta deja de aparecer en el programa y, si quieres, se olvida su inventario. En el disco no se toca nada.</dd>
</dl> </dl>
<h3>Revisar y ordenar</h3> <h3>Revisar y ordenar</h3>
<dl> <dl>
<dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd> <dt>Qué hay</dt><dd>Qué pesa y de qué clase es, por cámara, por mes y por tipo de archivo, con avisos de lo raro: material suelto, meses mal escritos, dos sistemas de orden a la vez.</dd>
<dt>Por nombrar</dt><dd>Las carpetas cuyo nombre no reconoce. Propone qué son y tú lo confirmas. <i>Cómo: mira qué tienen dentro; lo confirmado se guarda en un diccionario y vale para todas las carpetas y discos.</i></dd> <dt>Por nombrar</dt><dd>Las carpetas que no sabe qué son y que no moverá hasta que se lo digas. Propone qué es cada una y tú lo confirmas. No pregunta por las carpetas de la propia app, ni por lo de dentro de las piezas, ni por lo que ya ordena solo. <i>Cómo: mira la forma (si trae sus exportados, su portada y su música es una pieza; si guarda meses dentro, un cajón) y lo que tiene dentro contando archivos, no peso. Lo confirmado se guarda en un diccionario y vale para las carpetas que se llamen exactamente igual, en cualquier disco.</i></dd>
<dt>Qué va a pasar</dt><dd>El plan: a dónde iría cada cosa, sin mover nada. Va en tres pasos que se aplican por separado: apartar basura y proxies, ordenar por año y mes (y por día de rodaje si ese mes hubo más de uno), y ordenar lo demás (proyectos por programa, exportados, música…). <i>Cómo: usa la plantilla de la clase de trabajo y la fecha de cada archivo, la más antigua entre creación y modificación; un render hereda la fecha de su original. Cada fila va marcada como segura, por revisar o dudosa.</i></dd> <dt>Qué va a pasar</dt><dd>El plan: a dónde iría cada cosa, sin mover nada. Va en tres pasos que se aplican por separado: apartar basura y proxies, ordenar por año y mes (y por día de rodaje si ese mes hubo más de uno), y ordenar lo demás (proyectos por programa, exportados, música…). <i>Cómo: usa la plantilla de la clase de trabajo y la fecha de cada archivo, la más antigua entre creación y modificación; un render hereda la fecha de su original. Cada fila va marcada como segura, por revisar o dudosa.</i></dd>
<dt>Grabaciones</dt><dd>Una carpeta de grabación se reparte por mes, día y cámara, y conserva el nombre de cada sesión («Tomas consultorio»). Un clip ya convertido sigue contando como de su cámara. <i>Cómo: solo se reparte lo que de verdad es una tarjeta volcada; si dentro hay una pieza montada (exportados, portada, proyecto), no se desarma. El año de un clip de Blackmagic, que el nombre no trae, sale del resto de clips de la misma tarjeta y día, y un rodaje que pasa de medianoche cuenta como uno solo.</i></dd>
<dt>Cajones</dt><dd>Una carpeta que guarda meses o años dentro («Orden», «Proyectos\Mayo») no se mueve entera: se abre, y cada cosa va a su mes. Si una pieza ya existe en ese mes, se junta con ella en vez de quedar una carpeta gemela al lado.</dd>
<dt>Repaso de lo ya ordenado</dt><dd>El plan también mira lo que ya está en <span class="path">00_Grabacion</span>: si un clip lleva en el nombre una fecha que no es la del mes donde está, propone llevarlo al suyo. <i>Cómo: solo con fechas escritas por la cámara; la fecha de modificación no cuenta, salvo para lo que se grabó ese mismo día.</i></dd>
<dt>Aplicar</dt><dd>Hace los movimientos marcados, siempre dentro de la carpeta del cliente. <i>Cómo: no pisa nada; si en el destino ya hay un archivo con ese nombre, lo deja donde está y lo avisa. Cada movimiento queda apuntado para poder deshacerlo.</i></dd> <dt>Aplicar</dt><dd>Hace los movimientos marcados, siempre dentro de la carpeta del cliente. <i>Cómo: no pisa nada; si en el destino ya hay un archivo con ese nombre, lo deja donde está y lo avisa. Cada movimiento queda apuntado para poder deshacerlo.</i></dd>
<dt>Stock viejo</dt><dd>Aparta a <span class="path">_Para borrar\stock</span> las tomas de Envato de piezas de más de 1, 2, 3, 6 o 12 meses, que se vuelven a descargar con la suscripción. <i>Cómo: las reconoce por el nombre (terminan en «-utc») o por estar en una carpeta «Elements».</i></dd> <dt>Stock viejo</dt><dd>Aparta a <span class="path">_Para borrar\stock</span> las tomas de Envato de piezas de más de 1, 2, 3, 6 o 12 meses, que se vuelven a descargar con la suscripción. <i>Cómo: las reconoce por el nombre (terminan en «-utc») o por estar en una carpeta «Elements».</i></dd>
<dt>Repetidos</dt><dd>Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta <span class="path">Duplicados</span>. <i>Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca.</i></dd> <dt>Repetidos</dt><dd>Cuando el mismo archivo está en varios sitios, deja uno y junta los demás en una subcarpeta <span class="path">Duplicados</span>. <i>Cómo: mismo nombre y mismo peso, y antes de apartar compara el contenido (principio, centro y final); si difiere, no lo toca.</i></dd>
@@ -591,7 +595,7 @@
<h3>Convertidor</h3> <h3>Convertidor</h3>
<dl> <dl>
<dt>Buscar clips</dt><dd>Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. <i>Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero y los exportados.</i></dd> <dt>Buscar clips</dt><dd>Encuentra los clips de cámara viejos y pesados (de fábrica, de más de 3 meses y 300 MB) y calcula cuánto pesarían convertidos. <i>Cómo: lee códec, duración y resolución con ffprobe; se salta lo que ya es ligero, los exportados y lo que ya convirtió este programa, para no pasar un clip dos veces.</i></dd>
<dt>Formato y calidad</dt><dd>H.265 10 bits (el recomendado, unas diez veces más ligero), H.265 ligero, H.264 o ProRes proxy, LT y HQ; calidad de ahorro, equilibrada o máxima. «Motores» te dice qué acelera tu tarjeta gráfica.</dd> <dt>Formato y calidad</dt><dd>H.265 10 bits (el recomendado, unas diez veces más ligero), H.265 ligero, H.264 o ProRes proxy, LT y HQ; calidad de ahorro, equilibrada o máxima. «Motores» te dice qué acelera tu tarjeta gráfica.</dd>
<dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW pasan por DaVinci Resolve Studio, que tiene que estar abierto en un proyecto.</i></dd> <dt>Convertir</dt><dd>Mete los clips en una cola que va uno tras otro en segundo plano. Se puede detener, reanudar, reintentar y vaciar. <i>Cómo: con ffmpeg, por tarjeta gráfica si la hay y si no por procesador; los BRAW pasan por DaVinci Resolve Studio, que tiene que estar abierto en un proyecto.</i></dd>
<dt>Verificar</dt><dd>Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.</dd> <dt>Verificar</dt><dd>Cada clip se comprueba solo al terminar: duración, códec y los 10 bits. Lo que no cuadra no se da por bueno.</dd>
@@ -1088,16 +1092,17 @@ async function cargarVoc(){
const d = await api('/api/vocabulario?raiz=' + encodeURIComponent(raizActual())); const d = await api('/api/vocabulario?raiz=' + encodeURIComponent(raizActual()));
VOC = d.tokens; pintarNombrar(); pintarReglas(d.diccionario.reglas); VOC = d.tokens; pintarNombrar(); pintarReglas(d.diccionario.reglas);
} }
const PARECE = {CONTENEDOR: 'un cajón con cosas de varias fechas', PIEZA: 'una pieza montada'};
const opcionesCubeta = c =>`<option value="">— no sé / dejar como está —</option>` + Object.entries(ESTADO.cubetas).map(([k,v]) => `<option value="${k}" ${k===c?'selected':''}>${esc(NOMBRE_CUBETA[k] || k)} · ${esc(v)}</option>`).join(''); const opcionesCubeta = c =>`<option value="">— no sé / dejar como está —</option>` + Object.entries(ESTADO.cubetas).map(([k,v]) => `<option value="${k}" ${k===c?'selected':''}>${esc(NOMBRE_CUBETA[k] || k)} · ${esc(v)}</option>`).join('');
function pintarNombrar(){ function pintarNombrar(){
const f = ($('#filtroVoc').value || '').toLowerCase(); const solo = $('#soloSin').checked; const sinP = $('#sinPiezas').checked; const f = ($('#filtroVoc').value || '').toLowerCase(); const solo = $('#soloSin').checked;
const rows = VOC.filter(t => (!f || t.token.includes(f) || (t.rutas || []).join(' ').toLowerCase().includes(f)) && (!solo || !t.cubeta) && (!sinP || !t.dentro_pieza)).sort((a, b) => (!!a.cubeta - !!b.cubeta) || ((b.sug_conf === 'alta') - (a.sug_conf === 'alta')) || b.bytes - a.bytes); const rows = VOC.filter(t => (!f || t.token.includes(f) || (t.rutas || []).join(' ').toLowerCase().includes(f)) && (!solo || !t.cubeta)).sort((a, b) => (!!a.cubeta - !!b.cubeta) || ((b.sug_conf === 'alta') - (a.sug_conf === 'alta')) || b.bytes - a.bytes);
const sin = VOC.filter(t => !t.cubeta && !t.dentro_pieza).length; const sin = VOC.filter(t => !t.cubeta && !t.dentro_pieza).length;
const seguras = VOC.filter(t => !t.cubeta && !t.dentro_pieza && t.sugerencia && t.sug_conf === 'alta'); const seguras = VOC.filter(t => !t.cubeta && !t.dentro_pieza && t.sugerencia && t.sug_conf === 'alta');
$('#vocResumen').textContent = `${rows.length} carpetas · ${sin} sin reconocer${seguras.length ? ` · ${seguras.length} con sugerencia segura` : ''}`; $('#vocResumen').textContent = `${rows.length} carpetas · ${sin} sin reconocer${seguras.length ? ` · ${seguras.length} con sugerencia segura` : ''}`;
const bd = $('#badgeNombrar'); bd.textContent = sin; bd.classList.toggle('cero', !sin); const bd = $('#badgeNombrar'); bd.textContent = sin; bd.classList.toggle('cero', !sin);
bloquear('btnAceptarSug', seguras.length ? '' : 'x'); $('#btnAceptarSug').textContent = seguras.length ? `Aceptar ${seguras.length} sugerencia${seguras.length > 1 ? 's' : ''} segura${seguras.length > 1 ? 's' : ''}` : 'Aceptar las sugerencias seguras'; bloquear('btnAceptarSug', seguras.length ? '' : 'x'); $('#btnAceptarSug').textContent = seguras.length ? `Aceptar ${seguras.length} sugerencia${seguras.length > 1 ? 's' : ''} segura${seguras.length > 1 ? 's' : ''}` : 'Aceptar las sugerencias seguras';
$('#nombrar').innerHTML = rows.slice(0, 300).map(t => `<li><span><b title="${esc((t.rutas || []).join('\n'))}">${esc(t.ejemplo)}${t.aprendido ? ' <span class="tag alta">aprendido</span>' : ''}${t.n > 1 ? ` <span class="mut" style="font-weight:500">· ${t.n} carpetas con este nombre</span>` : ''}</b><span class="r">${esc((t.rutas || []).slice(0, 2).join(' · '))}</span>${t.contenido ? `<span class="r">Dentro: ${esc(t.contenido)}</span>` : ''}${!t.cubeta && t.sugerencia ? `<span class="sg">Parece <b>${esc(NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)}</b> (${SEG_TXT[t.sug_conf] || t.sug_conf}): ${esc(t.por_que)}</span>` : !t.cubeta ? `<span class="sg" style="color:var(--mut)">Sin pista: elige qué es, o déjala y no se moverá.</span>` : ''}</span><span class="acts">${!t.cubeta && t.sugerencia ? `<button class="p" data-si="${esc(t.token)}" data-cub="${esc(t.sugerencia)}">Sí, es ${esc(NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)}</button>` : ''}<select data-tok="${esc(t.token)}" aria-label="qué es">${opcionesCubeta(t.cubeta)}</select></span><small>${gb(t.bytes)}</small></li>`).join('') + (rows.length > 300 ? `<li class="mut">… y ${rows.length - 300} más: usa el buscador</li>` : '') || (sin ? '<li class="mut">Nada con ese filtro.</li>' : '<li class="ok">Todas las carpetas están reconocidas.</li>'); $('#nombrar').innerHTML = rows.slice(0, 300).map(t => `<li><span><b title="${esc((t.rutas || []).join('\n'))}">${esc(t.ejemplo)}${t.aprendido ? ' <span class="tag alta">aprendido</span>' : ''}${t.n > 1 ? ` <span class="mut" style="font-weight:500">· ${t.n} carpetas con este nombre</span>` : ''}</b><span class="r">${esc((t.rutas || []).slice(0, 2).join(' · '))}</span>${t.contenido ? `<span class="r">Dentro: ${esc(t.contenido)}</span>` : ''}${t.nota ? `<span class="nt">Ojo: ${esc(t.nota)}.</span>` : ''}${!t.cubeta && t.sugerencia ? `<span class="sg">Parece <b>${esc(PARECE[t.sugerencia] || NOMBRE_CUBETA[t.sugerencia] || t.sugerencia)}</b> (${SEG_TXT[t.sug_conf] || t.sug_conf}): ${esc(t.por_que)}</span>` : !t.cubeta ? `<span class="sg" style="color:var(--mut)">Sin pista: elige qué es, o déjala y no se moverá.</span>` : ''}</span><span class="acts">${!t.cubeta && t.sugerencia ? `<button class="p" data-si="${esc(t.token)}" data-cub="${esc(t.sugerencia)}" title="${t.sugerencia === 'CONTENEDOR' ? 'La app la abre y ordena lo de dentro, cada cosa a su mes' : ''}">${esc(t.sugerencia === 'CONTENEDOR' ? 'Sí, abrirla' : 'Sí, es ' + (NOMBRE_CUBETA[t.sugerencia] || t.sugerencia))}</button>` : ''}<select data-tok="${esc(t.token)}" aria-label="qué es">${opcionesCubeta(t.cubeta)}</select></span><small>${gb(t.bytes)}</small></li>`).join('') + (rows.length > 300 ? `<li class="mut">… y ${rows.length - 300} más: usa el buscador</li>` : '') || (sin ? '<li class="mut">Nada con ese filtro.</li>' : '<li class="ok">Todas las carpetas están reconocidas.</li>');
$$('#nombrar select').forEach(s => s.onchange = () => nombrarToken(s.dataset.tok, s.value)); $$('#nombrar select').forEach(s => s.onchange = () => nombrarToken(s.dataset.tok, s.value));
$$('#nombrar [data-si]').forEach(b => b.onclick = () => nombrarToken(b.dataset.si, b.dataset.cub)); $$('#nombrar [data-si]').forEach(b => b.onclick = () => nombrarToken(b.dataset.si, b.dataset.cub));
} }
@@ -1114,7 +1119,7 @@ const nombrarToken = seguro(async (tok, cub) => {
pintarNombrar(); pintarNombrar();
if (cub) aviso(`«${t ? t.ejemplo : tok}» ahora es ${NOMBRE_CUBETA[cub] || cub}. Calcula el plan de nuevo para verlo.`, 'ok'); if (cub) aviso(`«${t ? t.ejemplo : tok}» ahora es ${NOMBRE_CUBETA[cub] || cub}. Calcula el plan de nuevo para verlo.`, 'ok');
}); });
$('#filtroVoc').oninput = pintarNombrar; $('#soloSin').onchange = pintarNombrar; $('#sinPiezas').onchange = pintarNombrar; $('#filtroVoc').oninput = pintarNombrar; $('#soloSin').onchange = pintarNombrar;
function pintarReglas(reglas){ function pintarReglas(reglas){
$('#reglas').innerHTML = reglas.length ? `<table>${reglas.map((r,i) => `<tr><td class="mut">${esc(r.cliente||'todos')}</td><td class="path">${esc(r.origen)}</td><td class="arrow">→</td><td class="path">${esc(r.destino||'(ignorar)')}</td><td><button class="bad" data-i="${i}">quitar</button></td></tr>`).join('')}</table>` : 'ninguna todavía'; $('#reglas').innerHTML = reglas.length ? `<table>${reglas.map((r,i) => `<tr><td class="mut">${esc(r.cliente||'todos')}</td><td class="path">${esc(r.origen)}</td><td class="arrow">→</td><td class="path">${esc(r.destino||'(ignorar)')}</td><td><button class="bad" data-i="${i}">quitar</button></td></tr>`).join('')}</table>` : 'ninguna todavía';
$$('#reglas button').forEach(b => b.onclick = () => accion(b, async () => { const r = reglas[+b.dataset.i]; const d = await api('/api/regla', {cliente: r.cliente, origen: r.origen, destino: null}); pintarReglas(d.reglas); PLAN = null; estadoPlan(); })); $$('#reglas button').forEach(b => b.onclick = () => accion(b, async () => { const r = reglas[+b.dataset.i]; const d = await api('/api/regla', {cliente: r.cliente, origen: r.origen, destino: null}); pintarReglas(d.reglas); PLAN = null; estadoPlan(); }));
@@ -1123,7 +1128,7 @@ function pintarReglas(reglas){
// ---------------- revisar: plan (paso a paso, en frases) ---------------- // ---------------- revisar: plan (paso a paso, en frases) ----------------
const PASOS_PLAN = [ const PASOS_PLAN = [
{k: 'limpiar', t: 'Apartar basura y proxies', q: 'Archivos de sistema (.DS_Store, Thumbs.db, cachés) y proxies se apartan en «_Para borrar». Los proxies se regeneran desde Resolve o Premiere. Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'}, {k: 'limpiar', t: 'Apartar basura y proxies', q: 'Archivos de sistema (.DS_Store, Thumbs.db, cachés) y proxies se apartan en «_Para borrar». Los proxies se regeneran desde Resolve o Premiere. Nada se borra.', vacio: 'No hay basura ni proxies que apartar.'},
{k: 'fechas', t: 'Ordenar por año y mes', q: 'Los meses toman el nombre «03- Marzo», las grabaciones se reparten por mes y por cámara, y las piezas quedan numeradas dentro de su mes.', vacio: 'Las fechas ya están en orden.'}, {k: 'fechas', t: 'Ordenar por año y mes', q: 'Los meses toman el nombre «03- Marzo», las grabaciones se reparten por mes y por cámara, y las piezas quedan numeradas dentro de su mes. Lo que ya estaba ordenado en un mes que no es el de su rodaje vuelve al suyo.', vacio: 'Las fechas ya están en orden.'},
{k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música, marca y demás van a su carpeta. Lo dudoso queda sin marcar: la app no lo mueve salvo que lo marques tú.', vacio: 'Nada más que ordenar.'}, {k: 'resto', t: 'Ordenar lo demás', q: 'Exportados, proyectos, música, marca y demás van a su carpeta. Lo dudoso queda sin marcar: la app no lo mueve salvo que lo marques tú.', vacio: 'Nada más que ordenar.'},
]; ];
const SEG_TXT = {alta: 'seguro', media: 'probable', baja: 'dudoso'}; const SEG_TXT = {alta: 'seguro', media: 'probable', baja: 'dudoso'};