Tres cosas para la reorganización, pedidas juntas.
Proyectos por programa: la plantilla pasa de 01_Proyectos/{nombre} a
01_Proyectos/{app}/{nombre}, con {app} = el programa que abre el archivo
(R.APPS_PROYECTO, R.app_proyecto). Lo que ya viene en su propia carpeta se
queda con el nombre de esa carpeta; lo que se separa es lo suelto. Las
plantillas guardadas se migran solas si nadie las tocó. Entran extensiones
que caían en «otro»: .sesx, .c4d, .blend, .motn, .aepx, .aet, .prel, .idml,
.psdt, .afpub.
Carpetas vacías: carpetas_vacias() las encuentra desde el inventario y las
comprueba en el disco; borrar_vacias() las quita. Es lo único que la app
borra, y solo porque no hay nada dentro: cada una queda en la bitácora como
rmdir y Deshacer las vuelve a crear con su nombre y en el inventario. No
entra en «Hacer las N de una vez». 29 huecos en David Reyes, 40 en N:.
Mapa: pestaña nueva con el árbol que producen las plantillas de verdad
(mapa_estructura, GET /api/estructura). Al tocar una carpeta dice qué guarda
—derivado de ROL_A_CUBETA y ROL_QUE—, cuánto hay hoy, y deja cambiar el
destino ahí mismo. POST /api/plantillas pasa a fusionar cubeta a cubeta:
guardar una sola plantilla borraba las otras diecinueve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
656 líneas
32 KiB
Python
656 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")}
|
|
|
|
# Rol de archivo suelto -> cubeta (para archivos que están fuera de sitio)
|
|
# 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",
|
|
}
|