Files
labstream-organizador/reglas.py
Labstream 1ae7a1bb38 2.4.0: el mapa se vuelve interactivo, con colores en Windows
- Cada carpeta lleva el color de lo que guarda (R.COLOR_CUBETA). Lo que
  cuelga de una carpeta con color hereda el suyo; una que mezcla tipos se
  queda neutra.
- «¿Dónde va a parar este archivo?»: donde_iria() resuelve cualquier nombre
  con el mismo clasificador del escaneo y el árbol enciende el destino.
  Debajo, nombres de archivo de verdad de la carpeta conectada.
- Renombrar una carpeta del árbol cambia TODAS las plantillas que pasan por
  ella (00_Grabacion arrastra a CELULAR) y, si se pide, las carpetas que ya
  existen en el disco; queda en la bitácora y se deshace.
- {anio} y {mes} dejan de encajar con cualquier carpeta: solo con algo que
  parezca un año o un mes.
- Colores en el Explorador: .ico generados sin dependencias y asignados con
  SHGetSetFolderCustomSettings, la misma API de «Cambiar icono». Escribir el
  desktop.ini a mano NO basta, y SHGetFileInfo no sirve para comprobarlo:
  no informa de la personalización ni cuando la puso el propio Explorador.
  Ruta de icono relativa, así que los colores viajan con el disco. Si el
  disco no deja marcar la carpeta, se dice y no se deja nada escrito.
- .organilab no se escanea y los desktop.ini de la app no son basura.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:52:05 -05:00

670 líneas
32 KiB
Python

# -*- 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 os
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]
# abreviaturas: se entienden, pero nunca con confianza alta (ver MESES_DUDOSOS)
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",
}
# «Mar», «May» o «Ago» pueden ser un nombre propio, no un mes: se proponen con confianza media
MESES_DUDOSOS = {"sep", "ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "oct", "nov", "dic"}
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)
abreviada = palabra in MESES_DUDOSOS
palabra = MESES_ALIAS.get(palabra, palabra)
mes_por_nombre = MESES.index(palabra) + 1 if palabra in MESES else None
if mes_por_nombre:
# abreviada sin número («Mar») o número que no cuadra con el nombre: confianza media
return mes_por_nombre, (num is not None and num != mes_por_nombre) or (abreviada and num is None)
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", ".psdt", ".ai", ".af", ".afphoto", ".afdesign", ".afpub", ".indd", ".idml",
".xd", ".fig", ".sketch", ".eps"},
"proyecto": {".drp", ".drb", ".drt", ".dra", ".prproj", ".prel", ".ppj", ".prtl", ".aep", ".aepx", ".aet",
".prin", ".fcpxml", ".aaf", ".edl", ".otio", ".xml", ".comp", ".setting", ".mogrt", ".drfx",
".sesx", ".c4d", ".blend", ".motn"},
"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"},
}
# Qué programa abre cada proyecto. «01_Proyectos» se separa por programa porque en la carpeta de un
# cliente conviven .prproj, .aep, .drp y .psd, y lo que cuesta es encontrarlos mezclados. El nombre de
# la carpeta es el del programa tal como se dice en el estudio.
APPS_PROYECTO = {
"Premiere": (".prproj", ".prel", ".ppj", ".prtl", ".prin"),
"After Effects": (".aep", ".aepx", ".aet"),
"DaVinci Resolve": (".drp", ".drb", ".drt", ".dra", ".drfx", ".setting", ".comp"),
"Photoshop": (".psd", ".psb", ".psdt"),
"Illustrator": (".ai", ".eps"),
"InDesign": (".indd", ".idml"),
"Audition": (".sesx",),
"Affinity": (".af", ".afphoto", ".afdesign", ".afpub"),
"Figma y XD": (".fig", ".xd", ".sketch"),
"Cinema 4D": (".c4d",),
"Blender": (".blend",),
"Motion": (".motn",),
"Plantillas": (".mogrt",),
"Intercambio": (".fcpxml", ".aaf", ".edl", ".otio", ".xml"),
}
EXT_A_APP = {e: a for a, exts in APPS_PROYECTO.items() for e in exts}
def app_proyecto(ext: str):
"""«.prproj» -> «Premiere». None si la extensión no es de un proyecto de edición o diseño."""
return EXT_A_APP.get((ext or "").lower())
BASURA_NOMBRES = {".ds_store", "thumbs.db", "desktop.ini", ".localized", ".bridgesort", ".bridgelabelsandratings"}
# solo temporales y cachés que un programa regenera solo. NO se ponen aquí extensiones de datos
# (.dat, .idx, .bak): son de plugins, subtítulos o copias que la gente guarda a propósito.
BASURA_EXT = {".tmp", ".crdownload", ".part", ".pek", ".cfa", ".sfk"}
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"]
# «cache» a secas no entra: es un nombre demasiado genérico para mandar la carpeta entera a _Para borrar
PROXY_DIRS = {"proxy", "proxies", "proxy media", "proxytest", "_proxytest", "optimized media",
"cache files", "render 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)
# sufijos que la propia app (o el editor) le pone a un archivo derivado de otro: el derivado se
# genera mucho después, pero la fecha que importa es la del material original
SUFIJOS_DERIVADO = ("_10bit", "_ligero", "_h264", "_proxy", "_lt", "_hq", "_prores", "_convertido",
"_export", "_render", "_master", "_final")
def base_derivado(nombre: str):
"""Nombre del archivo del que este parece salir, o None. «C0001_10bit.mp4» -> «C0001»."""
base = os.path.splitext(nombre)[0]
b = base.lower()
for suf in SUFIJOS_DERIVADO:
if b.endswith(suf) and len(base) > len(suf):
return base[:-len(suf)]
return None
def fecha_real(st):
"""El instante más antiguo que el disco sabe de este archivo.
Al copiar en Windows, la fecha de creación pasa a ser la de la copia y la de modificación se
conserva; al renderizar o reexportar, las dos son nuevas. Quedarse con la más antigua de las dos
da siempre la mejor estimación de cuándo se grabó, sin tener que elegir una de antemano."""
c = getattr(st, "st_birthtime", None) or st.st_ctime
return min(st.st_mtime, c) if c and c > 0 else st.st_mtime
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, separados por programa (Premiere, After Effects, Resolve, 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": "Le hago piezas mes a mes · se ordena por año y mes",
"pauta": "Le hago anuncios y campañas · se ordena por campaña",
"proyecto": "Un trabajo puntual con su entrega · se ordena por fases",
"interno": "Es material de Labstream · se ordena por tipo de recurso",
}
# Destinos relativos a la raíz del cliente. Variables: {anio} {mes} ("08- Agosto") {nombre} {sub}
# {fuente} (la cámara) {app} (el programa del proyecto) {rel} (la ruta tal cual venía).
# Una variable vacía se lleva consigo su segmento: «01_Proyectos/{app}/{nombre}» sin programa queda
# en «01_Proyectos/Reel botox».
PLANTILLAS = {
"contenido": {
"MARCA": "00_Marca/{sub}",
"PROYECTOS": "01_Proyectos/{app}/{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/{app}/{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/{app}/{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/{app}/{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", ".organilab")}
# 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
# propio Explorador de Windows, poniéndole a cada carpeta un icono de ese color.
COLOR_CUBETA = {
"ANIO": "#4f8cff", "GRABACION": "#e0574f", "PIEZA": "#7c5cff", "CAMPANA": "#ff8c42",
"EXPORTADOS": "#3ecf8e", "PORTADAS": "#f5b84b", "APOYOS": "#d96fd9", "MARCA": "#39b8c9",
"PROYECTOS": "#8fa1b8", "MUSICA": "#c9b339", "SFX": "#c9b339", "VOZ": "#c9b339",
"IA": "#a78bfa", "STOCK": "#5aa8e0", "GUION": "#9aa3ad", "FOTOS": "#f5b84b",
"DOCUMENTOS": "#9aa3ad", "CELULAR": "#e0574f", "CONTENEDOR": "#6b7280", "REVISAR": "#ff6b6b",
"PROXY": "#555c6b", "BASURA": "#3a3f4a", "APARTADO": "#9aa3ad", "": "#4b5563",
}
# Todo lo de la app en la carpeta del cliente vive aquí: los iconos de color, y lo que venga.
CARPETA_APP = ".organilab"
# Qué es cada rol dicho en llano, para que el mapa pueda explicar qué cae en cada carpeta.
ROL_QUE = {
"camara_raw": "originales RAW de cámara (.braw, .r3d, .ari)",
"camara_video": "clips de cámara (URSA, Sony, DJI, Osmo, Atomos)",
"camara_sidecar": "los .xml que Sony deja junto a cada clip",
"audio_grabado": "audio de grabadora o micro (DJI Mic, Tascam)",
"celular_video": "video de celular y de WhatsApp",
"celular_foto": "fotos de celular y de WhatsApp",
"foto_camara": "fotos de cámara (.arw, .cr3, .nef, .dng)",
"ia": "material generado con IA",
"stock": "tomas de stock (Envato y similares)",
"captura": "capturas de pantalla",
"proxy": "proxies (.lrf y los que crean Resolve y Premiere)",
"video": "video que no viene de cámara: exportados y masters",
"audio": "música y audio (.wav, .mp3, .aac)",
"imagen": "imágenes (.jpg, .png, .tif)",
"diseno": "diseño (.psd, .ai, .indd, Affinity)",
"proyecto": "proyectos de edición (.prproj, .aep, .drp)",
"documento": "documentos, guiones y subtítulos (.pdf, .docx, .srt)",
"fuente": "tipografías (.ttf, .otf)",
"comprimido": "comprimidos (.zip, .rar)",
"lut": "LUTs (.cube, .3dl)",
"programa": "instaladores y ejecutables",
"otro": "lo que no encaja en ninguna otra cosa",
"basura": "basura de sistema y cachés",
}
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",
}