Munka: «que al cerrar cierre todos los procesos… que al continuar la cola siga por donde iba y vuelva a generar los videos que hagan falta… que identifique bien el material… y convertir antes de mover». Cerrar con trabajo avisa y lo para todo (cola, lectura, respaldo, traer), cierra lo que quede con lo que lanzó y borra lo que quedó a medias. Lo cortado vuelve a esperar en su sitio (no «cancelado») y al continuar se hace entero; también vuelven los fallos por un corte y los convertidos que ya no están. Actualizar con la cola en marcha la detiene y sigue sola al volver. El Convertidor enseña lo grande de la carpeta y qué pasa con cada parte (exportados sin incluir, con un botón). Convertir va antes de ordenar en los pasos y en Inicio; lo que se está convirtiendo no se mueve, y una carpeta con algo abierto ya no se copia entera. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1668 líneas
106 KiB
Python
1668 líneas
106 KiB
Python
# -*- 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 threading
|
||
import subprocess
|
||
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", "braw.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", "11- Martes", "Tomas consultorio", "SONY"), destinos)
|
||
self.assertIn(rel("2025", "11- Noviembre", "00_Grabacion", "11- Martes", "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", "31- Sábado", "Maquinas", "BLACKMAGIC_MP4"), destinos)
|
||
self.assertIn(rel("2025", "05- Mayo", "00_Grabacion", "31- Sábado", "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", "11- Martes", "Tomas consultorio", "SONY", "20251111_Labstream5320.MP4"),
|
||
rel("2025", "11- Noviembre", "00_Grabacion", "11- Martes", "Tomas consultorio", "SONY", "20251111_Labstream5321_10bit.mp4"),
|
||
rel("2025", "05- Mayo", "00_Grabacion", "31- Sábado", "Maquinas", "BLACKMAGIC_MP4", "A006_06010000_C002.mp4"),
|
||
rel("2025", "05- Mayo", "00_Grabacion", "31- Sábado", "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", "05- Miércoles", "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", "10- Viernes", "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 ("09- Martes" + S + "URSA_BRAW" + S + "A014_12091814_C001.braw", "09- Martes" + S + "URSA_BRAW" + S + "A014_12100130_C002.braw", # la madrugada sigue siendo la noche del 9
|
||
"09- Martes" + S + "URSA_BRAW" + S + "Duplicados" + S + "A014_12091814_C001.mp4", "09- Martes" + S + "SONY" + S + "C0001.MP4"):
|
||
self.assertTrue(os.path.isfile(os.path.join(dic, esperado)), "falta " + esperado + "\n" + "\n".join(arbol(raiz)))
|
||
# y lo que sí era de marzo, pero estaba sin carpeta de día, entra en la suya (3.6.0: el día va siempre)
|
||
mar = os.path.join(raiz, "2025", "03- Marzo", "00_Grabacion")
|
||
self.assertTrue(os.path.isfile(os.path.join(mar, "05- Miércoles", "SONY", "C0002.MP4")), "\n".join(arbol(raiz)))
|
||
self.assertTrue(os.path.isfile(os.path.join(mar, "11- Martes", "URSA_BRAW", "A002_03110342_C036.braw")), "\n".join(arbol(raiz)))
|
||
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": "PROXY", "marcado": False, "confianza": "baja"}, # sin su original, o dentro de «No tocar»
|
||
{"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_banco_de_material(self):
|
||
"""Vía Sumapaz: 3,6 TB en «Material Vía (No tocar)», por temas, que el plan dejaba fuera por la palabra
|
||
«material». Los temas se quedan; dentro, las grabaciones por fecha y cámara; todo sin marcar."""
|
||
raiz = cliente("Banco")
|
||
b = "Material Vía (No tocar)/"
|
||
mk(raiz, b + "Lugares/Peajes/Drone/DJI_20240312101500_0001_D.MP4", 2024, 3, 12)
|
||
mk(raiz, b + "Lugares/Peajes/URSA/A001_03121015_C001.mp4", 2024, 3, 12)
|
||
mk(raiz, b + "Lugares/Peajes/URSA/Proxy/A001_03121015_C001.mov", 2024, 3, 12) # proxy con su original
|
||
mk(raiz, b + "Lugares/Peajes/Final peajes.mp4", 2024, 4, 2) # montado: no se toca
|
||
mk(raiz, b + "Material Importante/Social/Mercados/C0001.MP4", 2025, 5, 2)
|
||
mk(raiz, b + "Material Importante/UF9/Drone/DJI_0001.MP4", 2009, 1, 19) # reloj sin poner
|
||
mk(raiz, "2026/03- Marzo/Grabacion/C0010.MP4", 2026, 3, 12)
|
||
mk(raiz, "2026/03- Marzo/Grabacion/Proxy/C0010.MOV", 2026, 3, 12) # proxy con original
|
||
mk(raiz, "2026/04- Abril/01- Viejo/Proxy/C0099.MOV", 2026, 4, 2) # el único que queda
|
||
dicc = O.DICC["carpetas"]
|
||
self.assertEqual(O.R.cubeta_carpeta("Material Vía (No tocar)", dicc)[0], "BANCO")
|
||
self.assertIsNone(O.R.cubeta_carpeta("Material juan", dicc)[0], "«material» suelto ya no ignora la carpeta")
|
||
self.assertEqual(O.R.cubeta_carpeta("Material", dicc)[0], "IGNORAR")
|
||
antes = nombres(raiz)
|
||
p = plan(raiz)
|
||
banco = [f for f in p["filas"] if f["paso"] == "banco"]
|
||
destinos = {f["destino_rel"] for f in banco}
|
||
B = "Material Vía (No tocar)"
|
||
self.assertIn(rel(B, "Lugares", "Peajes", "2024-03-12", "OSMO"), destinos)
|
||
self.assertIn(rel(B, "Lugares", "Peajes", "2024-03-12", "BLACKMAGIC_MP4"), destinos)
|
||
self.assertIn(rel(B, "Material Importante", "Social", "Mercados", "2025-05-02", "SONY"), destinos)
|
||
self.assertFalse([f for f in banco if f["marcado"]], "en «No tocar» nada va marcado")
|
||
self.assertFalse([f for f in p["filas"] if any("Final peajes" in a for a in (f["archivos"] or []))], "movió lo montado")
|
||
reloj = [f for f in banco if "reloj sin poner" in f["motivo"]]
|
||
self.assertTrue(reloj and reloj[0]["confianza"] == "baja", [f["motivo"] for f in banco])
|
||
prox = [f for f in p["filas"] if f["cubeta"] == "PROXY"]
|
||
dentro = [f for f in prox if "Peajes" in f["origen_rel"]]
|
||
self.assertTrue(dentro and not dentro[0]["marcado"] and "No tocar" in dentro[0]["motivo"], dentro)
|
||
sin = [f for f in prox if "01- Viejo" in f["origen_rel"]]
|
||
self.assertTrue(sin and not sin[0]["marcado"] and "OJO" in sin[0]["motivo"], sin)
|
||
con = [f for f in prox if "03- Marzo" in f["origen_rel"]]
|
||
self.assertTrue(con and con[0]["marcado"], con)
|
||
# «hacer de una vez» no toca nada de lo que va sin marcar
|
||
self.assertFalse([f for f in asistente.filas_elegidas(p["filas"], {"carpetas": "seguras"}) if not f["marcado"]])
|
||
dentro_antes = [x for x in arbol(raiz) if x.startswith(B)]
|
||
aplicar(raiz, p)
|
||
self.assertEqual([x for x in arbol(raiz) if x.startswith(B)], dentro_antes, "tocó la carpeta «No tocar» sin que nadie lo marcara")
|
||
self.assertTrue(os.path.isfile(os.path.join(raiz, "2026", "04- Abril", "01- Viejo", "Proxy", "C0099.MOV")), "apartó el proxy sin original")
|
||
# si alguien lo marca, queda donde dice
|
||
p2 = O.Plan(O.raiz_cfg(raiz)).correr()
|
||
for f in p2["filas"]:
|
||
f["marcado"] = f["paso"] == "banco"
|
||
aplicar(raiz, p2)
|
||
self.assertEqual(nombres(raiz), antes, "se perdió o se duplicó algún archivo")
|
||
for esperado in (rel(B, "Lugares", "Peajes", "2024-03-12", "OSMO", "DJI_20240312101500_0001_D.MP4"),
|
||
rel(B, "Lugares", "Peajes", "2024-03-12", "BLACKMAGIC_MP4", "A001_03121015_C001.mp4"),
|
||
rel(B, "Lugares", "Peajes", "Final peajes.mp4"),
|
||
rel(B, "Material Importante", "Social", "Mercados", "2025-05-02", "SONY", "C0001.MP4")):
|
||
self.assertTrue(os.path.isfile(os.path.join(raiz, esperado)), "falta " + esperado + "\n" + "\n".join(arbol(raiz)))
|
||
# y «Qué pesa» entra hasta el fondo y dice cómo la trata la app
|
||
d = O.carpeta_detalle(raiz, os.path.join(raiz, B))
|
||
self.assertEqual(d["trato"]["cubeta"], "BANCO")
|
||
self.assertTrue(d["niveles"] >= 3 and "Lugares" in [h["nombre"] for h in d["hijos"]], d)
|
||
self.assertIn("banco", [a["clave"] for a in O.acciones(raiz)["acciones"]])
|
||
|
||
def test_alta_y_baja_no_chocan(self):
|
||
"""Vía Sumapaz: cada evento guardaba la misma foto en «Alta» y en «Baja»; el plan las juntaba en una
|
||
sola carpeta y 753 no se movieron. Las subcarpetas con sentido se conservan."""
|
||
raiz = cliente("AltaBaja")
|
||
for sub, tam in (("Alta", 8192), ("Baja", 2048)):
|
||
for n in ("IMG_5154.jpg", "IMG_5155.jpg"):
|
||
mk(raiz, f"Fotos varias/2022/Enero/Evento recaudadoras/{sub}/{n}", 2022, 1, 2, tam)
|
||
mk(raiz, "Fotos varias/2022/Diciembre/Fiesta 6 Años/IMG_3135.jpg", 2022, 12, 14)
|
||
antes = nombres(raiz)
|
||
p = plan(raiz)
|
||
self.assertFalse([f for f in p["filas"] if "OJO" in f["motivo"]], [f["motivo"] for f in p["filas"]])
|
||
por = {}
|
||
for f in p["filas"]:
|
||
for a in f["archivos"] or []:
|
||
por[a] = f["destino_rel"]
|
||
alta = [d for a, d in por.items() if os.sep + "Alta" + os.sep in a]
|
||
baja = [d for a, d in por.items() if os.sep + "Baja" + os.sep in a]
|
||
self.assertTrue(alta and baja and all(d.endswith("Alta") for d in alta) and all(d.endswith("Baja") for d in baja), por)
|
||
self.assertTrue(any("Fiesta 6 Años" in d for d in por.values()), por)
|
||
aplicar(raiz, p) # sin errores: ninguna foto choca con su gemela
|
||
self.assertEqual(nombres(raiz), antes)
|
||
|
||
def test_reloj_de_una_camara(self):
|
||
"""Una cámara con el reloj sin poner: se corrige con la fecha real del primer clip, sigue corregida al
|
||
volver a escanear, el plan la lleva a su mes de verdad, y se deshace."""
|
||
raiz = cliente("Reloj")
|
||
mk(raiz, "Vía Mujeres/Drone/DJI_0001.MP4", 2009, 1, 19, 4096)
|
||
mk(raiz, "Vía Mujeres/Drone/DJI_0002.MP4", 2009, 1, 20, 4096)
|
||
plan(raiz) # escanea
|
||
r = O.relojes(raiz)
|
||
self.assertEqual([(g["fuente"], g["n"], g["desde"]) for g in r["grupos"]], [("DJI", 2, "2009-01-19")], r)
|
||
c = O.corregir_reloj(raiz, r["grupos"][0]["dir"], "DJI", "2024-03-12")
|
||
self.assertTrue(c.get("ok") and c["n"] == 2, c)
|
||
con = O.db()
|
||
fechas = sorted((x["fecha"][:10], x["fecha_origen"]) for x in con.execute("SELECT fecha, fecha_origen FROM archivos WHERE raiz = ? AND nombre LIKE 'DJI_%'", (raiz,)))
|
||
con.close()
|
||
self.assertEqual(fechas, [("2024-03-12", "corregida"), ("2024-03-13", "corregida")])
|
||
p = plan(raiz) # vuelve a escanear: la corrección se mantiene
|
||
self.assertFalse(O.relojes(raiz)["grupos"])
|
||
self.assertTrue(any("2024" in f["destino_rel"] and "03- Marzo" in f["destino_rel"] for f in p["filas"]), [f["destino_rel"] for f in p["filas"]])
|
||
d = O.deshacer_reloj(raiz, O.relojes(raiz)["corregidos"][0]["grupo"])
|
||
self.assertTrue(d.get("ok"), d)
|
||
self.assertEqual(O.relojes(raiz)["grupos"][0]["desde"], "2009-01-19")
|
||
# y buscar la encuentra, con tilde o sin ella
|
||
b = O.buscar("via mujeres dji_0002")
|
||
self.assertEqual([x["nombre"] for x in b["resultados"]], ["DJI_0002.MP4"], b)
|
||
self.assertEqual(O.buscar("x").get("error"), "escribe al menos dos letras")
|
||
|
||
def test_banco_por_fecha(self):
|
||
"""El banco también se puede ordenar por fecha: a la 00_Grabacion de su mes, con el tema de etiqueta."""
|
||
raiz = cliente("BancoFecha")
|
||
mk(raiz, "Archivo (No tocar)/Lugares/Peajes/Drone/DJI_20240312101500_0001_D.MP4", 2024, 3, 12)
|
||
mk(raiz, "Archivo (No tocar)/Lugares/Peajes/Final.mp4", 2024, 4, 2)
|
||
O.CONFIG.setdefault("bancos", {})[os.path.normpath(raiz).lower()] = {O.R.norm("Archivo (No tocar)"): "fecha"}
|
||
self.addCleanup(lambda: O.CONFIG["bancos"].pop(os.path.normpath(raiz).lower(), None))
|
||
p = plan(raiz)
|
||
self.assertEqual([b["modo"] for b in p["bancos"]], ["fecha"])
|
||
banco = [f for f in p["filas"] if f["paso"] == "banco"]
|
||
self.assertEqual([f["destino_rel"] for f in banco], [rel("2024", "03- Marzo", "00_Grabacion", "12- Martes", "Peajes", "OSMO")])
|
||
self.assertFalse(banco[0]["marcado"])
|
||
|
||
def test_otros_discos(self):
|
||
"""El mismo cliente en otros discos: se ofrece traerlo, sin contar las carpetas ya registradas."""
|
||
raiz = cliente("Repartido")
|
||
previo = dict(O._DISCOS_CACHE)
|
||
self.addCleanup(lambda: (O._DISCOS_CACHE.clear(), O._DISCOS_CACHE.update(previo)))
|
||
O._DISCOS_CACHE.update(ts=time.time(), datos={"discos": [{"letra": "K:", "unc": r"\\nas\Entregas", "nombre": ""}],
|
||
"clientes": {"Repartido": [r"K:\Repartido viejo", raiz]}})
|
||
s = O.otros_sitios(raiz)
|
||
self.assertEqual([(x["ruta"], x["unc"]) for x in s], [(r"K:\Repartido viejo", r"\\nas\Entregas")])
|
||
|
||
def test_el_convertidor_no_repite(self):
|
||
"""22 clips se habían convertido dos veces y algunos tres («…_10bit_10bit_10bit.mp4»)."""
|
||
raiz = cliente("Convertidos")
|
||
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_el_inventario_escribe_la_carpeta_como_el_disco(self):
|
||
"""Vía Sumapaz, 14-oct-2026: el plan movió fotos a «Altas Print» y «Altas print», que en el disco son la misma
|
||
«ALTAS PRINT»; el inventario apuntó tres carpetas distintas con 1.039 fotos mal escritas."""
|
||
raiz = cliente("Mayusculas")
|
||
nr = os.path.normpath(raiz)
|
||
a = mk(raiz, "Sueltas/foto1.jpg", 2024, 5, 3)
|
||
b = mk(raiz, "Sueltas/foto2.jpg", 2024, 5, 3)
|
||
mk(raiz, "2024/05- Mayo/_Fotos/ALTAS PRINT/foto0.jpg", 2024, 5, 3)
|
||
O.escanear(raiz)
|
||
movidos = []
|
||
O.mover(a, os.path.join(nr, "2024", "05- Mayo", "_Fotos", "Altas Print", "foto1.jpg"), "prueba", movidos)
|
||
O.mover(b, os.path.join(nr, "2024", "05- mayo", "_fotos", "altas print", "foto2.jpg"), "prueba", movidos)
|
||
bien = os.path.join(nr, "2024", "05- Mayo", "_Fotos", "ALTAS PRINT")
|
||
self.assertEqual([d for _, d in movidos], [os.path.join(bien, "foto1.jpg"), os.path.join(bien, "foto2.jpg")])
|
||
O.actualizar_inventario(movidos)
|
||
con = O.db()
|
||
rutas = {r[0] for r in con.execute("SELECT ruta FROM archivos WHERE raiz = ?", (nr,))}
|
||
carpetas = [r[0] for r in con.execute("SELECT ruta FROM carpetas WHERE raiz = ? AND lower(ruta) = lower(?)", (nr, bien))]
|
||
con.close()
|
||
self.assertTrue({os.path.join(bien, n) for n in ("foto0.jpg", "foto1.jpg", "foto2.jpg")} <= rutas, rutas)
|
||
self.assertEqual(carpetas, [bien])
|
||
# una carpeta que no existía se crea como la escribe el plan, y las siguientes la siguen igual
|
||
c = mk(raiz, "Sueltas/foto3.jpg", 2024, 5, 3)
|
||
d = mk(raiz, "Sueltas/foto4.jpg", 2024, 5, 3)
|
||
movidos = []
|
||
O.mover(c, os.path.join(nr, "Nueva Carpeta", "foto3.jpg"), "prueba", movidos)
|
||
O.mover(d, os.path.join(nr, "NUEVA CARPETA", "foto4.jpg"), "prueba", movidos)
|
||
self.assertEqual({os.path.dirname(x) for _, x in movidos}, {os.path.join(nr, "Nueva Carpeta")})
|
||
|
||
def test_escaneo_con_movimientos_a_la_vez(self):
|
||
"""Vía Sumapaz, 14-oct-2026: un proxy apartado a _Para borrar en pleno escaneo quedó dos veces en el
|
||
inventario (visto en su sitio y luego en la papelera). Si la app mueve algo mientras se recorre, se recorre otra vez."""
|
||
raiz = cliente("EnMarcha")
|
||
nr = os.path.normpath(raiz)
|
||
p = mk(raiz, "2024/09- Septiembre/03- Botellitas/Proxy/Botellas Vr2.mov", 2024, 9, 3)
|
||
mk(raiz, "2024/09- Septiembre/03- Botellitas/Botellas Vr2.mov", 2024, 9, 3, tam=4096)
|
||
real = O._fechas_de_rodaje
|
||
llamadas = []
|
||
|
||
def a_la_vez(filas):
|
||
llamadas.append(len(filas))
|
||
if len(llamadas) == 1: # después de recorrer, antes de guardar: la app aparta el proxy
|
||
O.mover(p, os.path.join(nr, R.PAPELERA, "proxies", os.path.relpath(p, nr)), "aparte", [])
|
||
return real(filas)
|
||
O._fechas_de_rodaje = a_la_vez
|
||
self.addCleanup(setattr, O, "_fechas_de_rodaje", real)
|
||
O.escanear(raiz)
|
||
self.assertEqual(len(llamadas), 2)
|
||
con = O.db()
|
||
proxies = [r[0] for r in con.execute("SELECT ruta FROM archivos WHERE raiz = ? AND nombre = 'Botellas Vr2.mov' AND bytes = 2048", (nr,))]
|
||
con.close()
|
||
self.assertEqual(proxies, [os.path.join(nr, R.PAPELERA, "proxies", os.path.relpath(p, nr))])
|
||
self.assertEqual(O.PROGRESO[nr]["estado"], "listo")
|
||
|
||
def test_inventario_de_otra_version(self):
|
||
"""Lo que apunta el escaneo cambió: un inventario viejo se relee antes de planear nada."""
|
||
raiz = cliente("Viejo")
|
||
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"]))
|
||
|
||
|
||
class Nuevo(unittest.TestCase):
|
||
"""Lo que entra nuevo: una pieza, el material de una tarjeta, y la pasada automática."""
|
||
|
||
def test_pieza_nueva(self):
|
||
"""Las piezas se creaban a mano: dos «01» en el mismo mes, «03 - X» junto a «03- X», «portasda»."""
|
||
raiz = cliente("Piezas")
|
||
mk(raiz, "2026/10- Octubre/01- Uno/a.mp4", 2026, 10, 1)
|
||
mk(raiz, "2026/10- Octubre/02- Dos/b.mp4", 2026, 10, 2)
|
||
plan(raiz)
|
||
previa = O.nueva_pieza(raiz, "Ozempic face", 2026, 10, crear=False)
|
||
self.assertEqual((previa["rel"], previa["existe"]), (rel("2026", "10- Octubre", "03- Ozempic face"), ""))
|
||
self.assertFalse(os.path.exists(previa["ruta"]), "la vista previa no debe crear nada")
|
||
r = O.nueva_pieza(raiz, "Ozempic face", 2026, 10)
|
||
self.assertTrue(r.get("ok"), r)
|
||
subs = sorted(os.listdir(r["ruta"]))
|
||
self.assertEqual(subs, sorted(O.CONFIG_DEFECTO["pieza"]["subcarpetas"]))
|
||
self.assertIn("ya existe", O.nueva_pieza(raiz, "ozempic FACE", 2026, 10).get("error", "")) # la misma, escrita distinto
|
||
self.assertEqual(O.nueva_pieza(raiz, "07- Otra cosa", 2026, 10, crear=False)["nombre"], "04- Otra cosa") # el número lo pone la app
|
||
for malo in ("", "a/b", "x" * 90):
|
||
self.assertTrue(O.nueva_pieza(raiz, malo, 2026, 10).get("error"), malo)
|
||
mes_nuevo = O.nueva_pieza(raiz, "Primera", 2026, 11)
|
||
self.assertEqual(mes_nuevo["rel"], rel("2026", "11- Noviembre", "01- Primera"))
|
||
# una pieza recién creada no es un hueco que borrar, ni el mes que se creó para ella
|
||
self.assertEqual(O.carpetas_vacias(raiz)["carpetas"], [])
|
||
self.assertFalse([f for f in O.Plan(O.raiz_cfg(raiz)).correr()["filas"]], "el plan quiere mover una pieza recién creada")
|
||
# deshacer la quita si sigue vacía; si ya tiene algo dentro, la deja
|
||
mk(raiz, "2026/11- Noviembre/01- Primera/Guion/guion.docx", 2026, 11, 2)
|
||
d = O.deshacer(mes_nuevo["lote"])
|
||
self.assertTrue(os.path.isfile(os.path.join(mes_nuevo["ruta"], "Guion", "guion.docx")) and d["errores"], d)
|
||
d = O.deshacer(r["lote"])
|
||
self.assertFalse(os.path.exists(r["ruta"]) or d["errores"], d)
|
||
# donde las piezas no van por mes (campañas), sin número
|
||
anuncios = cliente("Piezas de pauta", "pauta")
|
||
self.assertEqual(O.nueva_pieza(anuncios, "Navidad", 2026, 12)["rel"], rel("Campañas", "2026_Navidad"))
|
||
|
||
def test_bandeja_de_entrada(self):
|
||
"""Lo que se deja en _Entrada se reparte solo por rodaje y cámara, y la bandeja queda vacía."""
|
||
raiz = cliente("Bandeja")
|
||
mk(raiz, "2026/09- Septiembre/01- Pieza/a.png", 2026, 9, 1)
|
||
t = "_Entrada/Tarjeta A/PRIVATE/M4ROOT/"
|
||
mk(raiz, t + "CLIP/C0001.MP4", 2026, 9, 20)
|
||
mk(raiz, t + "CLIP/C0001M01.XML", 2026, 9, 20)
|
||
mk(raiz, t + "MEDIAPRO.XML", 2026, 9, 20)
|
||
mk(raiz, t + "THMBNL/C0001T01.JPG", 2026, 9, 20)
|
||
mk(raiz, t + "SUB/C0001S03.MP4", 2026, 9, 20)
|
||
mk(raiz, "_Entrada/A001_09211200_C001.braw", 2026, 9, 21)
|
||
mk(raiz, "_Entrada/Entrevista Dra/C0005.MP4", 2026, 9, 22)
|
||
antes = nombres(raiz)
|
||
plan(raiz)
|
||
self.assertEqual([a["clave"] for a in O.acciones(raiz)["acciones"] if a["clave"] == "entrada"], ["entrada"])
|
||
self.assertGreaterEqual(O.entrada(raiz)["n"], 4)
|
||
r = O.ordenar_entrada(raiz)
|
||
self.assertTrue(r.get("ok") and r["movimientos"] and not r["errores"], r)
|
||
self.assertEqual(nombres(raiz), antes)
|
||
g = os.path.join(raiz, "2026", "09- Septiembre", "00_Grabacion")
|
||
for esperado in (os.path.join(g, R.nombre_dia("2026-09-20"), "SONY", "C0001.MP4"),
|
||
os.path.join(g, R.nombre_dia("2026-09-20"), "SONY", "C0001M01.XML"),
|
||
os.path.join(g, R.nombre_dia("2026-09-21"), "URSA_BRAW", "A001_09211200_C001.braw"),
|
||
os.path.join(g, R.nombre_dia("2026-09-22"), "Entrevista Dra", "SONY", "C0005.MP4"),
|
||
os.path.join(raiz, "_Para borrar", "proxies", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "SUB", "C0001S03.MP4"),
|
||
os.path.join(raiz, "_Para borrar", "basura", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "THMBNL", "C0001T01.JPG"),
|
||
os.path.join(raiz, "_Para borrar", "basura", "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "MEDIAPRO.XML")):
|
||
self.assertTrue(os.path.isfile(esperado), "falta " + os.path.relpath(esperado, raiz) + "\n" + "\n".join(arbol(raiz)))
|
||
# la bandeja se queda, vacía y sin el esqueleto de la tarjeta, y no sale como hueco que borrar
|
||
self.assertEqual(os.listdir(os.path.join(raiz, "_Entrada")), [])
|
||
self.assertEqual((r["quedan"], O.entrada(raiz)["n"]), (0, 0))
|
||
self.assertFalse([a for a in O.acciones(raiz)["acciones"] if a["clave"] == "entrada"])
|
||
self.assertNotIn("_Entrada", [c["rel"] for c in O.carpetas_vacias(raiz)["carpetas"]])
|
||
# y se deshace: cada cosa vuelve a su sitio dentro de la tarjeta
|
||
d = O.deshacer(r["lote"])
|
||
self.assertFalse(d["errores"], d)
|
||
self.assertTrue(os.path.isfile(os.path.join(raiz, "_Entrada", "Tarjeta A", "PRIVATE", "M4ROOT", "CLIP", "C0001.MP4")))
|
||
self.assertTrue(os.path.isfile(os.path.join(raiz, "_Entrada", "Entrevista Dra", "C0005.MP4")))
|
||
|
||
def test_traer_de_una_tarjeta(self):
|
||
"""La copia desde la tarjeta se comprueba, conserva la fecha del rodaje y no toca el origen."""
|
||
raiz = cliente("Traer")
|
||
mk(raiz, "2026/08- Agosto/01- Pieza/a.png", 2026, 8, 1)
|
||
tarjeta = os.path.join(TMP, "tarjeta-sd")
|
||
clip = mk(tarjeta, "DCIM/100MEDIA/DJI_0001.MP4", 2026, 8, 10, tam=300_000)
|
||
mk(tarjeta, "System Volume Information/indice.dat", 2026, 8, 10)
|
||
with open(clip, "rb") as fh:
|
||
contenido = fh.read()
|
||
self.assertTrue(O.plan_traer(raiz, os.path.join(raiz, "2026")).get("error")) # de dentro no se trae
|
||
self.assertTrue(O.plan_traer(raiz, os.path.join(TMP, "no-existe")).get("error"))
|
||
p = O.plan_traer(raiz, tarjeta)
|
||
self.assertEqual((p["n"], p["bytes"]), (1, 300_000), p)
|
||
self.assertTrue(p["carpeta"].startswith("Tarjeta "))
|
||
r = O.traer(raiz, tarjeta)
|
||
self.assertEqual((r["estado"], r["copiados"], r["errores"]), ("listo", 1, []), r)
|
||
self.assertTrue(r["ordenado"]["ok"] and not r["ordenado"]["errores"], r["ordenado"])
|
||
final = os.path.join(raiz, "2026", "08- Agosto", "00_Grabacion", "10- Lunes", "DJI", "DJI_0001.MP4")
|
||
self.assertTrue(os.path.isfile(final), "\n".join(arbol(raiz)))
|
||
with open(final, "rb") as fh:
|
||
self.assertEqual(fh.read(), contenido)
|
||
self.assertLess(abs(os.path.getmtime(final) - os.path.getmtime(clip)), 2, "no conservó la fecha del rodaje")
|
||
with open(clip, "rb") as fh:
|
||
self.assertEqual(fh.read(), contenido, "tocó el origen")
|
||
self.assertEqual(arbol(tarjeta), [rel("DCIM", "100MEDIA", "DJI_0001.MP4"), rel("System Volume Information", "indice.dat")])
|
||
actas = os.listdir(os.path.join(raiz, ".organilab", "entradas"))
|
||
self.assertEqual(len(actas), 1)
|
||
with open(os.path.join(raiz, ".organilab", "entradas", actas[0]), encoding="utf-8-sig") as fh:
|
||
self.assertIn(rel("DCIM", "100MEDIA", "DJI_0001.MP4") + ";300000;", fh.read())
|
||
# una copia que no queda igual que el original no se da por buena
|
||
malo = mk(tarjeta, "DCIM/100MEDIA/DJI_0002.MP4", 2026, 8, 10, tam=50_000)
|
||
real = O.hashlib.blake2b
|
||
vez = []
|
||
|
||
class Trucado: # la segunda lectura (la de comprobación) sale distinta
|
||
def __init__(self, **kw):
|
||
self.h = real(**kw)
|
||
vez.append(self)
|
||
self.malo = len(vez) % 2 == 0
|
||
|
||
def update(self, b):
|
||
self.h.update(b[::-1] if self.malo else b)
|
||
|
||
def digest(self):
|
||
return self.h.digest()
|
||
|
||
def hexdigest(self):
|
||
return self.h.hexdigest()
|
||
O.hashlib.blake2b = Trucado
|
||
try:
|
||
estado = {"bytes": 0}
|
||
destino = os.path.join(raiz, "_Entrada", "prueba", "DJI_0002.MP4")
|
||
with self.assertRaises(OSError):
|
||
O._copiar_comprobando(malo, destino, estado)
|
||
finally:
|
||
O.hashlib.blake2b = real
|
||
self.assertFalse(os.path.exists(destino) or os.path.exists(destino + ".copiando"), "dejó una copia sin comprobar")
|
||
self.assertEqual(estado["bytes"], 0)
|
||
|
||
def test_versiones_del_mismo_clip(self):
|
||
"""39 clips estaban dos veces con el mismo nombre y distinta calidad, y no cabían juntos en su mes."""
|
||
raiz = cliente("Versiones")
|
||
oct_, may = "2025/10- Octubre/00_Grabacion/BLACKMAGIC_MP4/", "2026/05- Mayo/00_Grabacion/BLACKMAGIC_MP4/"
|
||
buena = mk(raiz, oct_ + "A002_03020727_C001.mp4", 2025, 12, 1, tam=9000)
|
||
peor = mk(raiz, may + "A002_03020727_C001.mp4", 2026, 7, 1, tam=3000)
|
||
mk(raiz, oct_ + "C0001.MP4", 2025, 10, 5, tam=5000) # se llaman igual…
|
||
mk(raiz, may + "C0001.MP4", 2026, 5, 5, tam=4000) # …pero son clips distintos: duran distinto
|
||
# dos exportados con el mismo nombre no son un duplicado: son dos entregas de la pieza (la vertical y
|
||
# la horizontal). No son material de cámara, así que ni se leen: `sonda` fallaría si se los pasaran.
|
||
mk(raiz, "2026/02- Febrero/02- Liftera/Exportados/Liftera VR3 Final.mp4", 2026, 2, 20, tam=7000)
|
||
mk(raiz, "2026/02- Febrero/01- Otra/Exportados/Liftera VR3 Final.mp4", 2026, 2, 21, tam=6000)
|
||
p = plan(raiz)
|
||
self.assertTrue([f for f in p["filas"] if "OJO" in f["motivo"]], "sin comparar, las dos versiones chocan en su mes")
|
||
medidas = {buena: (12.0, 3840, 2160), peor: (12.02, 1920, 1080),
|
||
os.path.join(raiz, oct_.replace("/", S), "C0001.MP4"): (10.0, 1920, 1080),
|
||
os.path.join(raiz, may.replace("/", S), "C0001.MP4"): (25.0, 1920, 1080)}
|
||
|
||
def sonda(ruta):
|
||
d, an, al = medidas[ruta]
|
||
return {"codec": "h264", "perfil": "", "pix_fmt": "yuv420p", "ancho": an, "alto": al, "fps": 25.0, "dur": d,
|
||
"kbps": 1000, "alfa": 0, "acodec": "aac", "pistas_audio": 1}
|
||
viejos = (O.ffprobe, O.herramientas)
|
||
O.ffprobe, O.herramientas = sonda, lambda: {"ffprobe": {"ok": True}}
|
||
try:
|
||
self.assertEqual(O.duplicados(raiz, minimo=0)["por_comparar"], 2)
|
||
d = O.duplicados(raiz, minimo=0, calidades=True)
|
||
finally:
|
||
O.ffprobe, O.herramientas = viejos
|
||
self.assertEqual([(g["tipo"], g["queda"], g["mueven"]) for g in d["grupos"]], [("calidad", buena, [peor])], d["grupos"])
|
||
r = O.apartar_duplicados(raiz, d["grupos"])
|
||
self.assertEqual((r["movimientos"], r["errores"]), (1, []), r)
|
||
self.assertTrue(os.path.isfile(os.path.join(os.path.dirname(buena), "Duplicados", "A002_03020727_C001.mp4")))
|
||
self.assertTrue(os.path.isfile(os.path.join(raiz, may.replace("/", S), "C0001.MP4")), "apartó un clip que solo se llamaba igual")
|
||
# ya no chocan: las dos van a su mes, la peor dentro de Duplicados
|
||
p = O.Plan(O.raiz_cfg(raiz)).correr()
|
||
self.assertFalse([f for f in p["filas"] if "OJO" in f["motivo"]], [f["motivo"] for f in p["filas"]])
|
||
aplicar(raiz, p)
|
||
mar = os.path.join(raiz, "2025", "03- Marzo", "00_Grabacion", "02- Domingo", "BLACKMAGIC_MP4")
|
||
self.assertTrue(os.path.isfile(os.path.join(mar, "A002_03020727_C001.mp4")), "\n".join(arbol(raiz)))
|
||
self.assertTrue(os.path.isfile(os.path.join(mar, "Duplicados", "A002_03020727_C001.mp4")), "\n".join(arbol(raiz)))
|
||
|
||
def test_orden_automatico(self):
|
||
"""La pasada automática hace lo seguro en cada carpeta, y se frena sola cuando no debe seguir."""
|
||
normal = cliente("Auto normal")
|
||
for n in ("C0001.MP4", "C0002.MP4"):
|
||
mk(normal, "2026/04- Abril/Grabacion/" + n, 2026, 4, 10)
|
||
mk(normal, "2026/04- Abril/01 - Botox/final.mp4", 2026, 4, 12) # un retoque de nombre: dudoso, no se toca
|
||
mucho = cliente("Auto demasiado")
|
||
for i in range(6):
|
||
mk(mucho, "2026/04- Abril/Grabacion/C%04d.MP4" % i, 2026, 4, 10)
|
||
todas, ajuste = O.CONFIG["raices"], dict(O.CONFIG["automatico"])
|
||
desconectada = {"ruta": os.path.join(TMP, "no-conectada"), "cliente": "Sin disco", "tipo": "contenido"}
|
||
O.CONFIG["raices"] = [r for r in todas if r["cliente"] == "Auto normal"] + [desconectada]
|
||
ensayo = os.path.join(TMP, "ensayo-tarea")
|
||
os.makedirs(ensayo, exist_ok=True)
|
||
os.environ["ORGANILAB_ENSAYO"] = ensayo
|
||
try:
|
||
res = O.automatico(origen="prueba")
|
||
self.assertEqual([(c["cliente"], c["estado"]) for c in res["carpetas"]], [("Auto normal", "con dudas"), ("Sin disco", "sin conexion")])
|
||
c = res["carpetas"][0]
|
||
self.assertEqual((c["movimientos"], c["dudosos"], c["errores"]), (2, 1, []), c)
|
||
self.assertTrue(os.path.isfile(os.path.join(normal, "2026", "04- Abril", "00_Grabacion", "10- Viernes", "SONY", "C0001.MP4")))
|
||
self.assertTrue(os.path.isdir(os.path.join(normal, "2026", "04- Abril", "01 - Botox")), "tocó lo dudoso")
|
||
self.assertEqual(O.estado_automatico()["ultimo"]["origen"], "prueba")
|
||
self.assertFalse(O.AUTO["activo"])
|
||
# con más movimientos que el límite, la carpeta no se toca y se dice por qué
|
||
O.CONFIG["raices"] = [r for r in todas if r["cliente"] == "Auto demasiado"]
|
||
O.CONFIG["automatico"]["limite"] = 5
|
||
c = O.automatico(origen="prueba")["carpetas"][0]
|
||
self.assertEqual(c["estado"], "demasiado", c)
|
||
self.assertTrue(os.path.isfile(os.path.join(mucho, "2026", "04- Abril", "Grabacion", "C0000.MP4")), "movió pese al límite")
|
||
# encenderlo da de alta la tarea de Windows (aquí, en una carpeta de ensayo) con la orden «automatico»
|
||
self.assertIn("error", O.configurar_automatico(True, "25:00"))
|
||
r = O.configurar_automatico(True, "03:30")
|
||
self.assertTrue(r.get("ok") and r["activo"] and r["hora"] == "03:30", r)
|
||
with open(os.path.join(ensayo, "tarea.json"), encoding="utf-8") as fh:
|
||
tarea = json.load(fh)
|
||
self.assertTrue(tarea["activo"] and tarea["hora"] == "03:30" and tarea["orden"].endswith(" automatico"), tarea)
|
||
self.assertFalse(O.configurar_automatico(False, "03:30")["activo"])
|
||
finally:
|
||
os.environ.pop("ORGANILAB_ENSAYO", None)
|
||
O.CONFIG["raices"] = todas
|
||
O.CONFIG["automatico"] = ajuste
|
||
|
||
|
||
# el lector de BRAW de mentira: habla como braw.py (una línea JSON por evento) sin decodificar nada
|
||
LECTOR_FALSO = """# -*- coding: utf-8 -*-
|
||
import os, sys, json, time
|
||
t = json.load(open(sys.argv[2], encoding="utf-8"))
|
||
modo = os.environ.get("BRAW_FALSO", "bien")
|
||
def di(**kw):
|
||
print(json.dumps(kw), flush=True)
|
||
di(msg="2160x4096 · 30 fps · 60 cuadros · curva de prueba")
|
||
if modo == "falla":
|
||
di(estado="error", log="el decodificador no pudo abrir el clip (prueba)")
|
||
sys.exit(1)
|
||
with open(t["tmp"], "wb") as fh:
|
||
fh.write(b"x" * 200000)
|
||
if modo == "lento":
|
||
for i in range(300):
|
||
di(pct=i / 3.0)
|
||
if os.path.exists(t["cancelar"]):
|
||
os.remove(t["tmp"])
|
||
di(estado="cancelado")
|
||
sys.exit(1)
|
||
time.sleep(0.1)
|
||
di(pct=50.0)
|
||
di(estado="listo", pct=100, segundos=0.1)
|
||
"""
|
||
|
||
|
||
class Convertidor(unittest.TestCase):
|
||
"""El convertidor, medido el 5-oct-2026 sobre la carpeta de Andrea Galeano: los BRAW no habían salido
|
||
nunca (87 clips, 462 GB, tres tandas fallidas por la vía de Resolve) y lo que ya venía bien comprimido
|
||
se reconvertía para ahorrar un 5 %, con una generación de pérdida encima."""
|
||
|
||
MOTORES = {"ffmpeg": True, "gpu": {"nombre": "", "hevc": False, "h264": False, "chroma422": False, "uhq": False},
|
||
"cpu": {"libx265": True, "libx264": True, "prores_ks": True}, "formatos": {k: "cpu" for k in O.FORMATOS}, "chroma422": True}
|
||
|
||
def setUp(self):
|
||
antes = (O.decodificador_braw, O.motores, O.medir_braw, O.ffprobe)
|
||
|
||
def devolver():
|
||
O.decodificador_braw, O.motores, O.medir_braw, O.ffprobe = antes
|
||
O.COLA["cancelar"] = False
|
||
os.environ.pop("BRAW_FALSO", None)
|
||
self.de_verdad = antes
|
||
self.addCleanup(devolver)
|
||
O.decodificador_braw = lambda: {"ok": True, "carpeta": "X:\\decodificador"}
|
||
O.motores = lambda: self.MOTORES
|
||
|
||
def leidos(self, raiz, datos):
|
||
"""Apunta en el inventario lo que ffprobe habría leído de cada clip."""
|
||
con = O.db()
|
||
with con:
|
||
for nombre, d in datos.items():
|
||
d = dict({"perfil": "", "pix_fmt": "yuv420p", "ancho": 3840, "alto": 2160, "fps": 30, "dur": 60, "acodec": "aac"}, **d)
|
||
n = con.execute("UPDATE archivos SET analizado = 1, codec = ?, perfil = ?, pix_fmt = ?, ancho = ?, alto = ?, fps = ?, dur = ?, "
|
||
"kbps = ?, acodec = ? WHERE raiz = ? AND nombre = ?",
|
||
(d["codec"], d["perfil"], d["pix_fmt"], d["ancho"], d["alto"], d["fps"], d["dur"], d["kbps"], d["acodec"],
|
||
os.path.normpath(raiz), nombre)).rowcount
|
||
assert n == 1, nombre
|
||
con.close()
|
||
|
||
def trabajo(self, id_):
|
||
con = O.db()
|
||
t = dict(con.execute("SELECT * FROM trabajos WHERE id = ?", (id_,)).fetchone())
|
||
con.close()
|
||
return t
|
||
|
||
def test_se_convierte_lo_que_ahorra_un_30(self):
|
||
"""Munka, 14-oct-2026: «si un archivo se puede optimizar así sea un 30 % se convierta». Medido sobre las 286
|
||
conversiones hechas: un H.265 de cámara se queda en el 85-99 % (6 se reconvirtieron para nada), y en cambio
|
||
los AV1 de 85 Mb/s que no se ofrecían quedan en la décima parte."""
|
||
raiz = cliente("Comprimidos")
|
||
g = "2025/06- Junio/00_Grabacion/"
|
||
for n in ("A014_06170738_C019.mp4", "C0001.MP4", "C0002.MP4", "C0003.MP4", "C0004.MP4", "C0005.MP4", "C0006.MP4",
|
||
"A015_05250930_C004.mp4", "Duplicados/C0003.MP4"):
|
||
mk(raiz, g + n, 2025, 6, 17)
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {
|
||
"A014_06170738_C019.mp4": {"codec": "h264", "perfil": "Main", "ancho": 4608, "alto": 2592, "kbps": 25000},
|
||
"C0001.MP4": {"codec": "h264", "perfil": "High 4:2:2 Intra", "pix_fmt": "yuv422p10le", "fps": 59.94, "kbps": 596000},
|
||
"C0002.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "fps": 59.94, "kbps": 72000},
|
||
"C0004.MP4": {"codec": "av1", "perfil": "Main", "kbps": 22000},
|
||
"C0005.MP4": {"codec": "prores", "perfil": "HQ", "pix_fmt": "yuv422p10le", "kbps": 870000},
|
||
"C0006.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "kbps": 200000},
|
||
"A015_05250930_C004.mp4": {"codec": "av1", "perfil": "Main", "ancho": 2160, "alto": 3840, "fps": 59.94, "kbps": 83000},
|
||
})
|
||
con = O.db()
|
||
with con:
|
||
con.execute("UPDATE archivos SET analizado = 1, codec = 'h264', perfil = 'High', pix_fmt = 'yuv420p', ancho = 3840, alto = 2160, "
|
||
"fps = 30, dur = 60, kbps = 120000 WHERE nombre = 'C0003.MP4'") # el clip y su copia en Duplicados
|
||
con.close()
|
||
nr = os.path.normpath(raiz)
|
||
cands = {os.path.relpath(c["ruta"], os.path.join(nr, g.replace("/", S))): c for c in O.candidatos(nr, 0, "equilibrado", 0)}
|
||
cs = {k: c["excluido"] for k, c in cands.items()}
|
||
# un H.265 de cámara casi no adelgaza: no se ofrece; uno muy holgado (el techo es 1,8 veces el objetivo), sí
|
||
self.assertTrue(cs["C0002.MP4"].startswith("convertido ahorraría solo un 7 % (H.265 a 72 Mb/s): se convierte desde un 30 %"), cs)
|
||
self.assertEqual(cs["C0006.MP4"], "")
|
||
# el H.264 por debajo del objetivo y los AV1 (el de móvil a 83 Mb/s, uno a 22), que antes no se ofrecían, ahora sí
|
||
self.assertEqual([cs["A014_06170738_C019.mp4"], cs["A015_05250930_C004.mp4"], cs["C0004.MP4"]], ["", "", ""])
|
||
# lo que guarda cada cuadro entero (XAVC-I, ProRes) y el H.264 muy holgado, como siempre
|
||
self.assertEqual([cs["C0001.MP4"], cs["C0003.MP4"], cs["C0005.MP4"]], ["", "", ""])
|
||
# lo apartado como repetido no se convierte: se borra
|
||
self.assertTrue(cs[S.join(("Duplicados", "C0003.MP4"))].startswith("está apartado como repetido"), cs)
|
||
# lo que se estima: un AV1 a 83 Mb/s, como mucho el objetivo (49 % menos); un H.264 a 25, como mucho 0,6 veces
|
||
a = cands["A015_05250930_C004.mp4"]
|
||
self.assertAlmostEqual(a["estimado"] / (83000 * 1000 / 8 * 60), 1 - 0.49, delta=0.01)
|
||
# el mínimo se cambia: pidiendo un 45 %, el H.264 a 25 Mb/s (39 %) ya no entra
|
||
cs45 = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0, ahorro=45)}
|
||
self.assertTrue(cs45["A014_06170738_C019.mp4"].startswith("convertido ahorraría solo un 39 %"), cs45)
|
||
self.assertEqual(cs45["A015_05250930_C004.mp4"], "")
|
||
# a un formato de edición no se le aplica: ahí no se busca que pese menos
|
||
cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0, False, "prores_lt")}
|
||
self.assertEqual(cs["C0002.MP4"], "")
|
||
# la lista de acciones cuenta lo que se puede aligerar
|
||
ajustes = dict(O.CONFIG["optimizar"])
|
||
self.addCleanup(lambda: O.CONFIG["optimizar"].update(ajustes))
|
||
O.CONFIG["optimizar"].update(min_mb=0, meses_minimos=0)
|
||
conv = [a for a in O.acciones(raiz)["acciones"] if a["clave"] == "convertir"]
|
||
self.assertEqual(len(conv), 1)
|
||
self.assertTrue(conv[0]["cifra"].startswith("7 clips"), conv[0])
|
||
# y después de convertir se descarta lo que ahorre menos del mínimo con 10 puntos de margen (con 30, del 20 %)
|
||
t = {"formato": "h265_10bit", "ruta": "C0002.MP4", "bytes_in": 100, "bytes_out": 81}
|
||
self.assertTrue(O._no_compensa(t).startswith("no compensa: pesa el 81 %"))
|
||
self.assertEqual(O._no_compensa(dict(t, bytes_out=79)), "")
|
||
O.CONFIG["optimizar"]["ahorro_minimo"] = 50
|
||
self.assertTrue(O._no_compensa(dict(t, bytes_out=61)).startswith("no compensa"))
|
||
|
||
def test_un_nombre_parecido_no_es_una_conversion(self):
|
||
"""El sufijo solo no basta: «Entrevista_HQ.mp4» en H.264 no lo hizo este programa."""
|
||
raiz = cliente("Parecidos")
|
||
g = "2025/06- Junio/00_Grabacion/"
|
||
for n in ("Entrevista_HQ.mp4", "C0001_10bit.mp4", "C0002_HQ.mov", "C0003_h264.mp4"):
|
||
mk(raiz, g + n, 2025, 6, 17)
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {"Entrevista_HQ.mp4": {"codec": "h264", "perfil": "High", "kbps": 400000},
|
||
"C0001_10bit.mp4": {"codec": "hevc", "perfil": "Main 10", "kbps": 400000},
|
||
"C0002_HQ.mov": {"codec": "prores", "perfil": "HQ", "kbps": 870000},
|
||
"C0003_h264.mp4": {"codec": "hevc", "perfil": "Main", "kbps": 400000}})
|
||
cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(os.path.normpath(raiz), 0, "equilibrado", 0, True)}
|
||
self.assertEqual(cs["Entrevista_HQ.mp4"], "")
|
||
self.assertEqual(cs["C0003_h264.mp4"], "") # dice _h264 y es H.265: tampoco es nuestro
|
||
self.assertIn("ya es una conversión", cs["C0001_10bit.mp4"])
|
||
self.assertIn("ya es una conversión", cs["C0002_HQ.mov"])
|
||
|
||
def test_los_braw_van_por_el_lector(self):
|
||
raiz = cliente("Braws")
|
||
g = "2025/12- Diciembre/00_Grabacion/URSA_BRAW/"
|
||
a = mk(raiz, g + "A014_12091814_C001.braw", 2025, 12, 9)
|
||
b = mk(raiz, g + "A014_12091819_C002.braw", 2025, 12, 9)
|
||
O.escanear(raiz)
|
||
como_ffprobe = {"codec": "braw", "pix_fmt": "", "ancho": 4640, "alto": 2624, "kbps": 560000, "acodec": "pcm_s24le"}
|
||
self.leidos(raiz, {"A014_12091814_C001.braw": como_ffprobe, "A014_12091819_C002.braw": como_ffprobe})
|
||
nr = os.path.normpath(raiz)
|
||
cs = O.candidatos(nr, 0, "equilibrado", 0)
|
||
self.assertEqual([(c["motor"], c["excluido"]) for c in cs], [("braw", ""), ("braw", "")])
|
||
# con el lector, un BRAW sale a cualquier formato
|
||
self.assertEqual([c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0, False, "prores_lt")], ["", ""])
|
||
# sin decodificador se intenta por Resolve, y por ahí solo a H.265 de 10 bits
|
||
O.decodificador_braw = lambda: {"ok": False, "motivo": "no está"}
|
||
self.assertEqual({c["motor"] for c in O.candidatos(nr, 0, "equilibrado", 0)}, {"resolve"})
|
||
self.assertIn("Resolve", O.candidatos(nr, 0, "equilibrado", 0, False, "prores_lt")[0]["excluido"])
|
||
self.assertEqual(O.encolar([a], "equilibrado", "420", "rec709", nr, "prores_lt"), [])
|
||
viejo = O.encolar([a], "equilibrado", "420", "rec709", nr)
|
||
self.assertEqual(self.trabajo(viejo[0])["motor"], "resolve")
|
||
# así quedó la cola de verdad: cada clip fallido dos veces, y uno apuntando a donde ya no está
|
||
con = O.db()
|
||
with con:
|
||
con.execute("UPDATE trabajos SET estado = 'error', log = 'Resolve está en la ventana del Gestor de proyectos' WHERE id = ?", (viejo[0],))
|
||
for ruta in (a, os.path.join(nr, "Orden", "A014_12091814_C001.braw")):
|
||
con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (ruta, ruta[:-5] + "_10bit.mp4", "resolve", "equilibrado", "420", "rec709", 31047,
|
||
"error", 1, time.time(), nr, "h265_10bit"))
|
||
con.close()
|
||
self.assertEqual(O.braws_al_lector(), 0) # sin decodificador no se toca nada
|
||
O.decodificador_braw = lambda: {"ok": True, "carpeta": "X:\\decodificador"}
|
||
self.assertEqual(O.braws_al_lector(), 1)
|
||
con = O.db()
|
||
filas = [tuple(r) for r in con.execute("SELECT ruta, motor, estado FROM trabajos WHERE raiz = ?", (nr,))]
|
||
con.close()
|
||
self.assertEqual(filas, [(a, "braw", "error")])
|
||
# volver a encolar el clip sustituye el intento fallido en vez de dejar otro al lado
|
||
nuevos = O.encolar([a, b], "equilibrado", "420", "rec709", nr)
|
||
self.assertEqual(len(nuevos), 2)
|
||
con = O.db()
|
||
filas = sorted(tuple(r) for r in con.execute("SELECT ruta, motor, estado FROM trabajos WHERE raiz = ?", (nr,)))
|
||
con.execute("DELETE FROM trabajos WHERE raiz = ?", (nr,))
|
||
con.commit()
|
||
con.close()
|
||
self.assertEqual(filas, [(a, "braw", "pendiente"), (b, "braw", "pendiente")])
|
||
|
||
def test_un_braw_pasa_por_la_cola(self):
|
||
"""La cola con un lector de mentira: lo que importa aquí es lo que hace el programa con lo que el lector dice."""
|
||
raiz = cliente("ColaBraw")
|
||
g = "2025/03- Marzo/00_Grabacion/URSA_BRAW/"
|
||
clips = [mk(raiz, g + f"A002_0311042{i}_C04{i}.braw", 2025, 3, 11, tam=1_000_000) for i in (4, 5, 6)]
|
||
mk(raiz, g + "A002_03110424_C044.sidecar", 2025, 3, 11)
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {os.path.basename(c): {"codec": "braw", "pix_fmt": "", "ancho": 4128, "alto": 2176, "kbps": 407000, "dur": 2.0,
|
||
"acodec": "pcm_s24le"} for c in clips})
|
||
nr = os.path.normpath(raiz)
|
||
# el decodificador dice el tamaño de verdad: el clip es vertical, y ffprobe lo leyó tumbado y con relleno
|
||
O.medir_braw = lambda ruta: {"ok": True, "ancho": 2160, "alto": 4096, "fps": 30.0, "cuadros": 60, "dur": 2.0, "tc": "04:27:26:19",
|
||
"camara": "Blackmagic Pocket Cinema Camera 4K", "gamma": "Blackmagic Design Film", "gamut": "Blackmagic Design", "gen": 5}
|
||
lector = os.path.join(APP, "braw.py")
|
||
shutil.copy(lector, lector + ".de-verdad")
|
||
self.addCleanup(lambda: os.replace(lector + ".de-verdad", lector))
|
||
with open(lector, "w", encoding="utf-8") as fh:
|
||
fh.write(LECTOR_FALSO)
|
||
ids = O.encolar(clips, "equilibrado", "420", "rec709", nr)
|
||
self.assertEqual(len(ids), 3)
|
||
|
||
# 1) sale bien
|
||
O.correr_braw(self.trabajo(ids[0]))
|
||
t = self.trabajo(ids[0])
|
||
self.assertEqual(t["estado"], "listo", t["log"])
|
||
self.assertEqual(os.path.getsize(t["salida"]), 200000)
|
||
self.assertIn("curva de prueba", t["log"])
|
||
self.assertEqual(t["kbps"], O.kbps_objetivo(2160, 4096, 30.0, "equilibrado")) # la tasa, con el tamaño de verdad
|
||
con = O.db()
|
||
f = con.execute("SELECT ancho, alto, perfil FROM archivos WHERE ruta = ?", (clips[0],)).fetchone()
|
||
con.close()
|
||
self.assertEqual((f["ancho"], f["alto"], f["perfil"]), (2160, 4096, "Film"))
|
||
self.assertEqual([x for x in os.listdir(O.DATOS) if x.startswith("braw_")], [])
|
||
# la verificación compara con ese tamaño: una salida tumbada no pasa
|
||
bien = {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "ancho": 2160, "alto": 4096, "fps": 30.0, "dur": 2.0, "kbps": 20000,
|
||
"alfa": 0, "acodec": "aac", "pistas_audio": 1}
|
||
O.ffprobe = lambda ruta: dict(bien, ancho=4128, alto=2176)
|
||
v = O.verificar([ids[0]])[0]
|
||
self.assertFalse(v["ok"])
|
||
self.assertIn("2160×4096", v["motivo"])
|
||
O.set_trabajo(ids[0], estado="listo")
|
||
O.ffprobe = lambda ruta: dict(bien)
|
||
self.assertTrue(O.verificar([ids[0]])[0]["ok"])
|
||
# al reemplazar, el .sidecar del BRAW se va con él
|
||
self.assertTrue(O.reemplazar([ids[0]])[0]["ok"])
|
||
apartados = os.path.join(nr, R.PAPELERA, "originales", "2025", "03- Marzo", "00_Grabacion", "URSA_BRAW")
|
||
self.assertEqual(sorted(os.listdir(apartados)), ["A002_03110424_C044.braw", "A002_03110424_C044.sidecar"])
|
||
self.assertTrue(os.path.isfile(t["salida"]))
|
||
|
||
# 2) el lector dice que no puede: el motivo llega tal cual y no queda nada a medias
|
||
os.environ["BRAW_FALSO"] = "falla"
|
||
O.correr_braw(self.trabajo(ids[1]))
|
||
t = self.trabajo(ids[1])
|
||
self.assertEqual((t["estado"], t["log"]), ("error", "el decodificador no pudo abrir el clip (prueba)"))
|
||
self.assertFalse(os.path.exists(t["salida"]) or os.path.exists(t["salida"] + ".parcial.mp4"))
|
||
|
||
# 3) Detener a mitad de un clip
|
||
os.environ["BRAW_FALSO"] = "lento"
|
||
hilo = threading.Thread(target=O.correr_braw, args=(self.trabajo(ids[2]),))
|
||
hilo.start()
|
||
fin = time.time() + 20
|
||
while time.time() < fin and not (self.trabajo(ids[2])["pct"] or 0) > 0:
|
||
time.sleep(0.1)
|
||
O.COLA["cancelar"] = True
|
||
hilo.join(40)
|
||
self.assertFalse(hilo.is_alive())
|
||
t = self.trabajo(ids[2])
|
||
self.assertEqual(t["estado"], "pendiente") # vuelve a esperar: al continuar se hace entero
|
||
self.assertTrue(t["log"].startswith("se detuvo a mitad"), t["log"])
|
||
self.assertFalse(os.path.exists(t["salida"]) or os.path.exists(t["salida"] + ".parcial.mp4"))
|
||
self.assertEqual([x for x in os.listdir(O.DATOS) if x.startswith("braw_")], [])
|
||
|
||
# 4) si ya hay un archivo con el nombre de la salida, no se pisa
|
||
O.COLA["cancelar"] = False
|
||
os.environ["BRAW_FALSO"] = "bien"
|
||
O.set_trabajo(ids[1], estado="pendiente")
|
||
with open(self.trabajo(ids[1])["salida"], "wb") as fh:
|
||
fh.write(b"de otro")
|
||
O.correr_braw(self.trabajo(ids[1]))
|
||
t = self.trabajo(ids[1])
|
||
self.assertEqual(t["estado"], "error")
|
||
self.assertIn("no se pisa", t["log"])
|
||
with open(t["salida"], "rb") as fh:
|
||
self.assertEqual(fh.read(), b"de otro")
|
||
|
||
def test_lo_que_no_compensa_no_reemplaza(self):
|
||
"""8 conversiones esperaban con «Reemplazar 8 verificados»: seis eran copias de una copia, al 85-99 % de su peso."""
|
||
raiz = cliente("NoCompensa")
|
||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||
clips = [mk(raiz, g + n, 2025, 11, 11, tam=300000) for n in ("C0001_10bit.mp4", "C0002.MP4", "C0003.MP4")]
|
||
O.escanear(raiz)
|
||
nr = os.path.normpath(raiz)
|
||
self.leidos(raiz, {"C0001_10bit.mp4": {"codec": "hevc", "perfil": "Main 10", "kbps": 72000},
|
||
"C0002.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000},
|
||
"C0003.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000}})
|
||
ids = []
|
||
con = O.db()
|
||
with con:
|
||
# una reconversión (93 %), un clip que apenas aligera (95 %) y uno que sí (40 %)
|
||
for ruta, pesa in zip(clips, (280000, 285000, 120000)):
|
||
salida = os.path.splitext(ruta)[0] + "_10bit.mp4"
|
||
with open(salida, "wb") as fh:
|
||
fh.write(b"x" * pesa)
|
||
ids.append(con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (ruta, salida, "ffmpeg", "equilibrado", "420", "rec709", 21150, "listo", 300000,
|
||
time.time(), nr, "h265_10bit")).lastrowid)
|
||
con.close()
|
||
O.ffprobe = lambda ruta: {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "ancho": 3840, "alto": 2160, "fps": 30.0, "dur": 60.0,
|
||
"kbps": 20000, "alfa": 0, "acodec": "aac", "pistas_audio": 1}
|
||
v = O.verificar(ids)
|
||
self.assertEqual([x["ok"] for x in v], [False, False, True])
|
||
self.assertIn("93 %", v[0]["motivo"])
|
||
self.assertIn("ya era una conversión", v[0]["motivo"])
|
||
self.assertIn("95 %", v[1]["motivo"])
|
||
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["descartado", "descartado", "verificado"])
|
||
# lo descartado ni se reemplaza…
|
||
r = O.reemplazar(ids)
|
||
self.assertEqual([x["ok"] for x in r], [False, False, True])
|
||
self.assertEqual([os.path.isfile(c) for c in clips], [True, True, False])
|
||
# …ni aunque viniera dado por bueno de una versión anterior: se repasa al arrancar y al reemplazar
|
||
O.set_trabajo(ids[0], estado="verificado")
|
||
O.set_trabajo(ids[1], estado="verificado")
|
||
r = O.reemplazar([ids[1]])[0]
|
||
self.assertFalse(r["ok"])
|
||
self.assertTrue(r["motivo"].startswith("no compensa"), r)
|
||
self.assertEqual(O.repasar_verificados(), 1)
|
||
self.assertEqual([self.trabajo(i)["estado"] for i in ids[:2]], ["descartado", "descartado"])
|
||
self.assertTrue(os.path.isfile(clips[0]) and os.path.isfile(clips[1]))
|
||
# y el clip no se vuelve a ofrecer con la misma calidad; con otra, sí
|
||
cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "equilibrado", 0)}
|
||
self.assertTrue(cs["C0002.MP4"].startswith("ya se probó con esta calidad: pesa el 95 %"), cs)
|
||
cs = {os.path.basename(c["ruta"]): c["excluido"] for c in O.candidatos(nr, 0, "ahorro", 0)}
|
||
self.assertEqual(cs["C0002.MP4"], "")
|
||
# a un formato de edición no se le pide que pese menos
|
||
self.assertEqual(O._no_compensa({"formato": "prores_lt", "ruta": clips[1], "bytes_in": 100, "bytes_out": 900}), "")
|
||
|
||
def test_lo_que_no_compensa_se_aparta(self):
|
||
"""Lo que salió de una conversión que no compensa no se queda junto al original ocupando su nombre, y un
|
||
intento que no pasó la verificación se puede rehacer."""
|
||
raiz = cliente("Apartar")
|
||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||
a, b, c = (mk(raiz, g + n, 2025, 11, 11, tam=300000) for n in ("C0001.MP4", "C0002.MP4", "C0003.MP4"))
|
||
O.escanear(raiz)
|
||
nr = os.path.normpath(raiz)
|
||
self.leidos(raiz, {n: {"codec": "h264", "perfil": "High", "kbps": 120000} for n in ("C0001.MP4", "C0002.MP4", "C0003.MP4")})
|
||
O.ffprobe = lambda ruta: {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "ancho": 3840, "alto": 2160, "fps": 30.0, "dur": 60.0,
|
||
"kbps": 20000, "alfa": 0, "acodec": "aac", "pistas_audio": 1}
|
||
ids = O.encolar([a, b, c], "equilibrado", "420", "rec709", nr)
|
||
self.assertEqual(len(ids), 3)
|
||
for i in ids: # tres conversiones que salieron casi del mismo peso
|
||
with open(self.trabajo(i)["salida"], "wb") as fh:
|
||
fh.write(b"x" * 290000)
|
||
O.set_trabajo(i, estado="listo", bytes_out=290000)
|
||
apartado = lambda t: os.path.join(nr, R.PAPELERA, "descartados", os.path.relpath(t["salida"], nr))
|
||
|
||
# 1) recién convertida y no compensa: lo que salió se aparta en el acto, con su lote en la bitácora
|
||
v = O.verificar([ids[0]])[0]
|
||
self.assertFalse(v["ok"])
|
||
self.assertIn("descartados", v["motivo"])
|
||
t0 = self.trabajo(ids[0])
|
||
self.assertFalse(os.path.exists(t0["salida"]))
|
||
self.assertTrue(os.path.isfile(apartado(t0)))
|
||
self.assertTrue(os.path.isfile(a)) # el original, intacto
|
||
lotes = [e["lote"] for e in O.leer_bitacora() if e.get("op") == "fin" and e.get("raiz") == nr and e["lote"].startswith("descartados-")]
|
||
self.assertEqual(len(lotes), 1)
|
||
O.deshacer(lotes[0]) # y «Deshacer» lo devuelve
|
||
self.assertTrue(os.path.isfile(t0["salida"]))
|
||
|
||
# 2) las que venían «verificadas» de antes: al arrancar solo cambian de estado, sin mover nada…
|
||
O.set_trabajo(ids[1], estado="verificado")
|
||
O.set_trabajo(ids[2], estado="verificado")
|
||
self.assertEqual(O.repasar_verificados(), 2)
|
||
t1, t2 = self.trabajo(ids[1]), self.trabajo(ids[2])
|
||
self.assertEqual((t1["estado"], t2["estado"]), ("descartado", "descartado"))
|
||
self.assertTrue(os.path.isfile(t1["salida"]) and os.path.isfile(t2["salida"]))
|
||
# …volver a convertir el clip con otra calidad aparta lo que salió de aquel y deja libre la ruta…
|
||
nuevos = O.encolar([b], "ahorro", "420", "rec709", nr)
|
||
self.assertEqual(len(nuevos), 1)
|
||
self.assertFalse(os.path.exists(t1["salida"]))
|
||
self.assertTrue(os.path.isfile(apartado(t1)))
|
||
# …y «Limpiar terminados» aparta lo de los que quedaran
|
||
r = O.apartar_descartados([ids[2]])
|
||
self.assertEqual((r["movidos"], r["errores"], r["quedan"]), (1, [], []))
|
||
self.assertFalse(os.path.exists(t2["salida"]))
|
||
self.assertTrue(os.path.isfile(apartado(t2)))
|
||
|
||
# 3) un intento que no pasó la verificación se rehace: su propia salida no bloquea la ruta; otra sí
|
||
tn = self.trabajo(nuevos[0])
|
||
with open(tn["salida"], "wb") as fh:
|
||
fh.write(b"y" * 1000)
|
||
O.set_trabajo(nuevos[0], estado="error", bytes_out=1000, log="verificación: duración 3.00s vs original 60.00s")
|
||
self.assertFalse(O._salida_ajena(self.trabajo(nuevos[0])))
|
||
with open(tn["salida"], "wb") as fh:
|
||
fh.write(b"z" * 999) # alguien dejó otro archivo con ese nombre
|
||
self.assertTrue(O._salida_ajena(self.trabajo(nuevos[0])))
|
||
|
||
def test_la_cola_sigue_al_clip(self):
|
||
"""Línea Gene, 14-oct-2026: el plan llevó «Material Sony» a 00_Grabacion; 138 conversiones en espera
|
||
fallaron buscando cada clip donde ya no estaba, los 11 verificados no se podían reemplazar (y su «_10bit»
|
||
se quedó en la carpeta vieja), y volver a escanear no los reenganchaba."""
|
||
raiz = cliente("ColaSigue")
|
||
nr = os.path.normpath(raiz)
|
||
g = "Material Sony/"
|
||
a = mk(raiz, g + "20250722_Labstream3796.MP4", 2025, 7, 22, tam=300000)
|
||
b = mk(raiz, g + "20250722_Labstream3797.MP4", 2025, 7, 22, tam=300001)
|
||
c = mk(raiz, g + "20250731_Labstream4033.MP4", 2025, 7, 31, tam=300002)
|
||
d = mk(raiz, g + "20250731_Labstream4034.MP4", 2025, 7, 31, tam=300003)
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {os.path.basename(x): {"codec": "h264", "perfil": "High", "kbps": 120000} for x in (a, b, c, d)})
|
||
ia, ib, ic = O.encolar([a, b, c], "equilibrado", "420", "rec709", nr)
|
||
O.set_trabajo(ib, estado="error", log="falló antes por otra cosa")
|
||
# el plan se calcula antes de que c termine de convertirse: su «_10bit» aún no está en el inventario
|
||
p = O.Plan(O.raiz_cfg(raiz)).correr()
|
||
sc = self.trabajo(ic)["salida"]
|
||
with open(sc, "wb") as fh:
|
||
fh.write(b"x" * 120000)
|
||
O.set_trabajo(ic, estado="verificado", bytes_out=120000)
|
||
r = aplicar(raiz, p)
|
||
self.assertFalse(os.path.exists(a) or os.path.exists(c))
|
||
|
||
# 1) los trabajos se fueron con sus clips; el que espera sale junto al clip en su sitio nuevo
|
||
ta, tb, tc = (self.trabajo(i) for i in (ia, ib, ic))
|
||
for t in (ta, tb, tc):
|
||
self.assertTrue(os.path.isfile(t["ruta"]), t)
|
||
self.assertIn("00_Grabacion", t["ruta"])
|
||
self.assertEqual(ta["salida"], os.path.splitext(ta["ruta"])[0] + "_10bit.mp4")
|
||
self.assertEqual((ta["estado"], tb["estado"]), ("pendiente", "error"))
|
||
# …y el convertido acompañó a su original, aunque el plan no lo conocía
|
||
self.assertEqual(os.path.dirname(tc["salida"]), os.path.dirname(tc["ruta"]))
|
||
self.assertTrue(os.path.isfile(tc["salida"]))
|
||
self.assertFalse(os.path.exists(sc))
|
||
# «Buscar clips» no vuelve a ofrecer lo que ya está en la cola o convertido
|
||
cs = {os.path.basename(x["ruta"]): x["en_cola"] for x in O.candidatos(nr, 0, "equilibrado", 0)}
|
||
self.assertEqual((cs["20250722_Labstream3796.MP4"], cs["20250731_Labstream4033.MP4"], cs["20250731_Labstream4034.MP4"]),
|
||
("pendiente", "verificado", ""))
|
||
|
||
# 2) Deshacer los devuelve, y la cola vuelve con ellos
|
||
O.deshacer(r["lote"])
|
||
ta, tc = self.trabajo(ia), self.trabajo(ic)
|
||
self.assertEqual((ta["ruta"], tc["ruta"], tc["salida"]), (a, c, sc))
|
||
self.assertTrue(os.path.isfile(sc))
|
||
self.assertEqual(ta["salida"], os.path.splitext(a)[0] + "_10bit.mp4")
|
||
|
||
# 3) movidos con el Explorador: justo antes de convertir, el que no está falla diciendo qué hacer…
|
||
otra = os.path.join(nr, "Otra carpeta")
|
||
os.makedirs(otra)
|
||
a2, c2 = os.path.join(otra, os.path.basename(a)), os.path.join(otra, os.path.basename(c))
|
||
os.rename(a, a2)
|
||
os.rename(c, c2) # el original verificado se va; su convertido, no
|
||
self.assertIsNone(O._clip_presente(self.trabajo(ia)))
|
||
ta = self.trabajo(ia)
|
||
self.assertEqual(ta["estado"], "error")
|
||
self.assertIn("Vuelve a escanear", ta["log"])
|
||
# …y al volver a escanear se reengancha: vuelve a la cola en su sitio nuevo, sin volver a leer el clip
|
||
O.escanear(raiz)
|
||
ta, tc = self.trabajo(ia), self.trabajo(ic)
|
||
self.assertEqual((ta["ruta"], ta["estado"]), (a2, "pendiente"))
|
||
self.assertEqual(ta["salida"], os.path.splitext(a2)[0] + "_10bit.mp4")
|
||
self.assertTrue(ta["log"].startswith("el clip se había movido"), ta["log"])
|
||
self.assertEqual((tc["ruta"], tc["salida"]), (c2, sc))
|
||
con = O.db()
|
||
leido = con.execute("SELECT analizado, codec FROM archivos WHERE ruta = ?", (a2,)).fetchone()
|
||
con.close()
|
||
self.assertEqual(tuple(leido), (1, "h264"))
|
||
self.assertEqual(O._clip_presente(self.trabajo(ia))["ruta"], a2)
|
||
|
||
# 4) un intento fallido que ya se volvió a encolar en el sitio nuevo sobra: se quita
|
||
bn = os.path.join(otra, os.path.basename(b))
|
||
os.rename(b, bn)
|
||
O.escanear(raiz)
|
||
self.assertEqual(self.trabajo(ib)["ruta"], bn) # el de b (error) se reenganchó y vuelve a esperar
|
||
self.assertEqual(self.trabajo(ib)["estado"], "pendiente")
|
||
con = O.db()
|
||
with con:
|
||
viejo = con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, creado, raiz, formato) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (b, b + "_10bit.mp4", "ffmpeg", "equilibrado", "420", "rec709", 21150, "error",
|
||
300001, time.time(), nr, "h265_10bit")).lastrowid
|
||
con.close()
|
||
O.reubicar_trabajos(nr)
|
||
con = O.db()
|
||
self.assertIsNone(con.execute("SELECT id FROM trabajos WHERE id = ?", (viejo,)).fetchone())
|
||
con.close()
|
||
|
||
# 5) reemplazar con el convertido en otra carpeta: primero se lleva junto al original
|
||
r = O.reemplazar([ic])[0]
|
||
self.assertTrue(r["ok"], r)
|
||
junto = os.path.join(otra, os.path.basename(sc))
|
||
self.assertTrue(os.path.isfile(junto))
|
||
self.assertFalse(os.path.exists(sc) or os.path.exists(c2))
|
||
self.assertTrue(os.path.isfile(os.path.join(nr, R.PAPELERA, "originales", "Otra carpeta", os.path.basename(c2))))
|
||
self.assertEqual(self.trabajo(ic)["salida"], junto)
|
||
|
||
def test_lo_ya_convertido_no_se_convierte_dos_veces(self):
|
||
"""Línea Gene, 14-oct-2026: con la 3.7 los 11 verificados se volvieron a encolar en su sitio nuevo, y uno ya
|
||
se había convertido otra vez. Al reengancharlos: la conversión que aún espera sobra; la ya hecha deja de más
|
||
la vieja."""
|
||
raiz = cliente("DosVeces")
|
||
nr = os.path.normpath(raiz)
|
||
a = mk(raiz, "Material Sony/20250722_Labstream3805.MP4", 2025, 7, 22, tam=300000)
|
||
b = mk(raiz, "Material Sony/20250722_Labstream3766.MP4", 2025, 7, 22, tam=300001)
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {os.path.basename(x): {"codec": "h264", "perfil": "High", "kbps": 120000} for x in (a, b)})
|
||
ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr)
|
||
for i in (ia, ib):
|
||
with open(self.trabajo(i)["salida"], "wb") as fh:
|
||
fh.write(b"x" * 90000)
|
||
O.set_trabajo(i, estado="verificado", bytes_out=90000)
|
||
# la 3.7: los clips se movieron, la cola no se enteró, y se volvieron a encolar en el sitio nuevo
|
||
nueva = os.path.join(nr, "2025", "07- Julio", "00_Grabacion", "22- Martes", "SONY")
|
||
os.makedirs(nueva)
|
||
a2, b2 = os.path.join(nueva, os.path.basename(a)), os.path.join(nueva, os.path.basename(b))
|
||
os.rename(a, a2)
|
||
os.rename(b, b2)
|
||
with open(os.path.splitext(a2)[0] + "_10bit.mp4", "wb") as fh: # el de a ya se convirtió otra vez allí
|
||
fh.write(b"y" * 90000)
|
||
con = O.db()
|
||
with con:
|
||
ids = []
|
||
for ruta, estado in ((a2, "verificado"), (b2, "pendiente")):
|
||
ids.append(con.execute("INSERT INTO trabajos (ruta, salida, motor, perfil, chroma, color, kbps, estado, bytes_in, bytes_out, creado, raiz, formato) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", (ruta, os.path.splitext(ruta)[0] + "_10bit.mp4", "ffmpeg", "equilibrado", "420",
|
||
"rec709", 21150, estado, os.path.getsize(ruta), 90000, time.time(), nr,
|
||
"h265_10bit")).lastrowid)
|
||
con.close()
|
||
O.escanear(raiz)
|
||
ta, tb = self.trabajo(ia), self.trabajo(ib)
|
||
# el de a ya se había vuelto a convertir: sobra el viejo, que queda «no compensa» con su porqué
|
||
self.assertEqual(ta["estado"], "descartado")
|
||
self.assertIn("se volvió a convertir en su sitio nuevo", ta["log"])
|
||
self.assertEqual(self.trabajo(ids[0])["estado"], "verificado")
|
||
# el de b aún esperaba: se quita de la cola, y el viejo se reengancha (se reemplaza junto a su original)
|
||
con = O.db()
|
||
self.assertIsNone(con.execute("SELECT id FROM trabajos WHERE id = ?", (ids[1],)).fetchone())
|
||
con.close()
|
||
self.assertEqual((tb["estado"], tb["ruta"]), ("verificado", b2))
|
||
r = O.reemplazar([ib])[0]
|
||
self.assertTrue(r["ok"], r)
|
||
self.assertTrue(os.path.isfile(os.path.join(nueva, os.path.basename(tb["salida"]))))
|
||
|
||
def _cola_falsa(self, durante=0.4):
|
||
"""Sustituye la conversión por una que tarda cuenta["durante"] segundos (deja su «.parcial» mientras) y cuenta
|
||
cuántas van a la vez."""
|
||
cuenta = {"vivos": 0, "maximo": 0, "vistos": [], "durante": durante}
|
||
candado = threading.Lock()
|
||
|
||
def falso(t):
|
||
with candado:
|
||
cuenta["vivos"] += 1
|
||
cuenta["maximo"] = max(cuenta["maximo"], cuenta["vivos"])
|
||
cuenta["vistos"].append(t["id"])
|
||
with open(t["salida"] + ".parcial.mp4", "wb") as fh:
|
||
fh.write(b"p" * 100)
|
||
O.set_trabajo(t["id"], pct=40)
|
||
fin = time.time() + cuenta["durante"]
|
||
while time.time() < fin and not O.COLA["cancelar"]:
|
||
time.sleep(0.02)
|
||
with candado:
|
||
cuenta["vivos"] -= 1
|
||
if O.COLA["cancelar"]:
|
||
O._quitar_parcial(t["salida"] + ".parcial.mp4")
|
||
O._devolver_a_la_cola(t["id"])
|
||
return
|
||
os.remove(t["salida"] + ".parcial.mp4")
|
||
with open(t["salida"], "wb") as fh:
|
||
fh.write(b"x" * 1000)
|
||
O.set_trabajo(t["id"], estado="listo", pct=100, bytes_out=1000, fin=time.time())
|
||
for k, v in {"correr_ffmpeg": falso, "_verificar_listos": lambda ids: None, "editando": lambda: "",
|
||
"_ya_no_compensa": lambda t: ""}.items():
|
||
self.addCleanup(setattr, O, k, getattr(O, k))
|
||
setattr(O, k, v)
|
||
self.addCleanup(O.CONFIG["optimizar"].pop, "simultaneas", None)
|
||
return cuenta
|
||
|
||
def test_dos_a_la_vez(self):
|
||
"""RTX 5080 del estudio, 14-oct-2026: con un clip a la vez el codificador iba al 46-74 %; dos a la vez salen
|
||
a 1,29× tiempo real entre los dos, frente a 0,77×. Nunca van más de los pedidos, ningún clip se convierte
|
||
dos veces, y «Detener» para a todos."""
|
||
raiz = cliente("ALaVez")
|
||
nr = os.path.normpath(raiz)
|
||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||
clips = [mk(raiz, g + f"C00{i:02d}.MP4", 2025, 11, 11, tam=300000 + i) for i in range(8)]
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
|
||
cuenta = self._cola_falsa()
|
||
O.CONFIG["optimizar"]["simultaneas"] = 2
|
||
# lo que otras pruebas dejaran esperando no entra en esta tanda (y se devuelve al acabar)
|
||
con = O.db()
|
||
otros = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'pendiente'")]
|
||
with con:
|
||
con.execute("UPDATE trabajos SET estado = 'aparte' WHERE estado = 'pendiente'")
|
||
con.close()
|
||
|
||
def devolver():
|
||
c = O.db()
|
||
with c:
|
||
c.executemany("UPDATE trabajos SET estado = 'pendiente' WHERE id = ?", [(i,) for i in otros])
|
||
c.close()
|
||
self.addCleanup(devolver)
|
||
ids = O.encolar(clips[:5], "equilibrado", "420", "rec709", nr)
|
||
self.assertEqual(len(ids), 5)
|
||
self.assertTrue(O.iniciar_cola())
|
||
time.sleep(0.2)
|
||
e = O.eta_cola()
|
||
self.assertEqual(e["paralelo"], 2)
|
||
self.assertEqual(len(e["actuales"]), 2)
|
||
O.COLA["hilo"].join(30)
|
||
self.assertEqual(cuenta["maximo"], 2)
|
||
self.assertEqual(sorted(cuenta["vistos"]), sorted(ids))
|
||
self.assertEqual({self.trabajo(i)["estado"] for i in ids}, {"listo"})
|
||
self.assertEqual((O.COLA["actuales"], O.COLA["activa"], O.COLA["actual"]), ({}, False, None))
|
||
# Detener: los dos que van se cortan, los que esperan se quedan esperando
|
||
cuenta.update(maximo=0, vistos=[])
|
||
ids = O.encolar(clips[5:], "equilibrado", "420", "rec709", nr)
|
||
self.assertTrue(O.iniciar_cola())
|
||
time.sleep(0.2)
|
||
O.COLA["cancelar"], O.COLA["activa"] = True, False # lo que hace «Detener»
|
||
O.COLA["hilo"].join(30)
|
||
estados = sorted(self.trabajo(i)["estado"] for i in ids)
|
||
self.assertEqual(estados, ["pendiente", "pendiente", "pendiente"]) # los cortados vuelven a esperar
|
||
self.assertFalse(O.COLA["cancelar"])
|
||
con = O.db()
|
||
with con:
|
||
con.execute(f"DELETE FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)
|
||
con.close()
|
||
|
||
def test_cerrar_para_todo_y_continuar(self):
|
||
"""Munka, 14-oct-2026: «que al cerrar cierre todos los procesos, hasta las colas que faltan y los que quedan a
|
||
mitad; que al continuar una cola la continúe y vuelva a generar los videos que hagan falta, por donde iba»."""
|
||
raiz = cliente("CerrarTodo")
|
||
nr = os.path.normpath(raiz)
|
||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||
clips = [mk(raiz, g + f"C01{i:02d}.MP4", 2025, 11, 11, tam=310000 + i) for i in range(3)]
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
|
||
cuenta = self._cola_falsa(durante=30)
|
||
O.CONFIG["optimizar"]["simultaneas"] = 2
|
||
con = O.db()
|
||
otros = [r[0] for r in con.execute("SELECT id FROM trabajos WHERE estado = 'pendiente'")]
|
||
with con:
|
||
con.execute("UPDATE trabajos SET estado = 'aparte' WHERE estado = 'pendiente'")
|
||
con.close()
|
||
|
||
def devolver():
|
||
c = O.db()
|
||
with c:
|
||
c.executemany("UPDATE trabajos SET estado = 'pendiente' WHERE id = ?", [(i,) for i in otros])
|
||
c.close()
|
||
self.addCleanup(devolver)
|
||
ids = O.encolar(clips, "equilibrado", "420", "rec709", nr)
|
||
self.assertTrue(O.iniciar_cola())
|
||
fin = time.time() + 10
|
||
while time.time() < fin and len(cuenta["vistos"]) < 2:
|
||
time.sleep(0.05)
|
||
parciales = [self.trabajo(i)["salida"] + ".parcial.mp4" for i in cuenta["vistos"]]
|
||
self.assertTrue(all(os.path.exists(p) for p in parciales))
|
||
# cerrar (o actualizar): para todo, lo que iba vuelve a esperar sin su archivo a medias
|
||
t0 = time.time()
|
||
r = O.detener_todo(seguir=True)
|
||
self.assertLess(time.time() - t0, 10)
|
||
self.assertEqual((r["cola"], r["pendientes"]), (True, 3))
|
||
self.assertFalse(O.COLA["activa"] or O.COLA["actuales"])
|
||
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["pendiente"] * 3)
|
||
self.assertTrue(all(self.trabajo(i)["log"].startswith("se detuvo a mitad (iba al 40 %)") for i in cuenta["vistos"]))
|
||
self.assertFalse(any(os.path.exists(p) for p in parciales))
|
||
self.assertTrue(os.path.exists(O._ruta_seguir()))
|
||
# al volver a abrir, la cola sigue sola por donde iba: primero los que se cortaron
|
||
cuenta.update(vistos=[], durante=0.2)
|
||
primeros = sorted(ids)[:2]
|
||
self.assertTrue(O._seguir_al_abrir())
|
||
self.assertFalse(os.path.exists(O._ruta_seguir()))
|
||
O.COLA["hilo"].join(30)
|
||
self.assertEqual(sorted(cuenta["vistos"][:2]), primeros)
|
||
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["listo"] * 3)
|
||
self.assertFalse(O._seguir_al_abrir()) # sin nada pendiente de seguir, no arranca
|
||
|
||
def test_al_continuar_se_rehace_lo_que_falta(self):
|
||
"""Lo cortado por un «Detener» de antes (cancelado), lo que falló por un corte («fallo inesperado») y el
|
||
convertido que ya no está vuelven a la cola; un error de verdad y lo que sigue en su sitio, no."""
|
||
raiz = cliente("Rehacer")
|
||
nr = os.path.normpath(raiz)
|
||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||
clips = [mk(raiz, g + f"C02{i:02d}.MP4", 2025, 11, 11, tam=320000 + i) for i in range(5)]
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {os.path.basename(c): {"codec": "h264", "perfil": "High", "kbps": 120000} for c in clips})
|
||
ids = O.encolar(clips, "equilibrado", "420", "rec709", nr)
|
||
O.set_trabajo(ids[0], estado="cancelado")
|
||
O.set_trabajo(ids[1], estado="error", log="fallo inesperado: [WinError 5] Acceso denegado: braw_961.json")
|
||
O.set_trabajo(ids[2], estado="error", log="el decodificador no pudo abrir el clip")
|
||
for i in ids[3:]:
|
||
O.set_trabajo(i, estado="verificado", bytes_out=1000)
|
||
with open(self.trabajo(ids[4])["salida"], "wb") as fh:
|
||
fh.write(b"x" * 1000)
|
||
self.assertEqual(O.recuperar_huerfanas(), 2)
|
||
self.assertEqual(O.rehacer_perdidos(), 1)
|
||
self.assertEqual([self.trabajo(i)["estado"] for i in ids], ["pendiente", "pendiente", "error", "pendiente", "verificado"])
|
||
self.assertEqual(self.trabajo(ids[3])["log"], "el convertido ya no estaba: se vuelve a hacer")
|
||
con = O.db()
|
||
with con:
|
||
con.execute(f"DELETE FROM trabajos WHERE id IN ({','.join('?' * len(ids))})", ids)
|
||
con.close()
|
||
|
||
def test_no_se_mueve_lo_que_se_convierte(self):
|
||
"""«Si muevo y luego convierto no funciona bien»: lo que espera en la cola se mueve y la cola lo sigue, pero
|
||
lo que se está convirtiendo ahora (ffmpeg lo tiene abierto) se queda, diciendo por qué. Y una carpeta con
|
||
algo abierto ya no se copia entera para luego dejarla a medias."""
|
||
raiz = cliente("NoMover")
|
||
nr = os.path.normpath(raiz)
|
||
a = mk(raiz, "Material Sony/20250722_Labstream5001.MP4", 2025, 7, 22, tam=300000)
|
||
b = mk(raiz, "Material Sony/20250731_Labstream5002.MP4", 2025, 7, 31, tam=300001)
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {os.path.basename(x): {"codec": "h264", "perfil": "High", "kbps": 120000} for x in (a, b)})
|
||
ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr)
|
||
O.set_trabajo(ia, estado="corriendo")
|
||
p = O.Plan(O.raiz_cfg(raiz)).correr()
|
||
r = O.aplicar(raiz, [f for f in p["filas"] if f["marcado"]])
|
||
self.assertTrue(os.path.exists(a)) # el que se convierte, en su sitio
|
||
self.assertFalse(os.path.exists(b)) # el que espera, movido (y la cola con él)
|
||
self.assertEqual(r["motivos"]["convirtiendo"]["n"], 1)
|
||
self.assertIn("00_Grabacion", self.trabajo(ib)["ruta"])
|
||
con = O.db()
|
||
with con:
|
||
con.execute("DELETE FROM trabajos WHERE id IN (?, ?)", (ia, ib))
|
||
con.close()
|
||
# una carpeta con un archivo abierto: no se copia
|
||
d = os.path.join(nr, "Abierta")
|
||
os.makedirs(d)
|
||
f = os.path.join(d, "clip.mov")
|
||
with open(f, "wb") as fh:
|
||
fh.write(b"x" * 1000)
|
||
abierto = open(f, "rb")
|
||
self.addCleanup(abierto.close)
|
||
try:
|
||
O.mover(d, os.path.join(nr, "Destino Abierta"), "prueba", [])
|
||
except OSError as ex:
|
||
self.assertIn("en uso", str(ex))
|
||
self.assertFalse(os.path.exists(os.path.join(nr, "Destino Abierta")))
|
||
self.assertTrue(os.path.isfile(f))
|
||
else:
|
||
self.skipTest("este disco deja renombrar una carpeta con un archivo abierto")
|
||
|
||
def test_conversiones_sueltas(self):
|
||
"""14-oct-2026: el lector de BRAW y el ffmpeg de una conversión de antes de actualizar seguían colgados siete
|
||
horas después. Al arrancar se cierran los que no tienen quien los espere, y no los de un programa vivo."""
|
||
base = r"C:\Programas\OrganiLAB"
|
||
P = lambda pid, ppid, nombre, cmd: {"ProcessId": pid, "ParentProcessId": ppid, "Name": nombre, "CommandLine": cmd}
|
||
ff = base + r"\ffmpeg\ffmpeg.exe"
|
||
procesos = [
|
||
P(100, 50, "OrganizadorLabstream.exe", f'"{base}\\OrganizadorLabstream.exe"'), # este programa
|
||
P(101, 100, "OrganizadorLabstream.exe", f"{base}\\OrganizadorLabstream.exe {base}\\braw.py convertir x.json"), # su lector
|
||
P(102, 101, "ffmpeg.exe", f'{ff} -i pipe:0 "V:\\G\\a_10bit.mp4.parcial.mp4"'), # el ffmpeg del lector
|
||
P(103, 100, "ffmpeg.exe", f"{ff} -i a.mp4 b.parcial.mp4"), # uno suyo
|
||
P(200, 65492, "OrganizadorLabstream.exe", f"{base}\\OrganizadorLabstream.exe {base}\\braw.py convertir y.json"), # lector suelto
|
||
P(201, 200, "ffmpeg.exe", f"{ff} -i pipe:0 V:\\G\\b.parcial.mp4"), # y su ffmpeg
|
||
P(300, 60, "OrganizadorLabstream.exe", f'"{base}\\OrganizadorLabstream.exe" --automatico'), # la pasada de la noche
|
||
P(301, 300, "ffmpeg.exe", f"{ff} -i c.mp4 c.parcial.mp4"), # y su ffmpeg
|
||
P(401, 4000, "ffmpeg.exe", f"{ff} -i d.mp4 d.parcial.mp4"), # su programa ya no está
|
||
P(500, 1, "ffmpeg.exe", r"C:\otro\ffmpeg.exe -i e.mp4 e.mp4"), # de otro programa
|
||
]
|
||
self.assertEqual(sorted(p["ProcessId"] for p in O._conversores_sueltos(procesos, base, 100)), [200, 401])
|
||
|
||
def test_la_tarjeta_descomprime(self):
|
||
"""Con la cola en marcha, el descompresor de la tarjeta estaba al 0 %: un H.264 4K pasa de 0,54× a 0,86× tiempo
|
||
real si lo descomprime ella. Lo que ella no lee (ProRes) sigue por el procesador, y si con ella falla, se repite."""
|
||
real = O.motores
|
||
self.addCleanup(setattr, O, "motores", real)
|
||
O.motores = lambda: {"gpu": {"nvdec": True, "hevc": True}}
|
||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "gpu"), ["-hwaccel", "cuda"])
|
||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "hevc"}, "gpu"), ["-hwaccel", "cuda"])
|
||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "prores"}, "gpu"), [])
|
||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "cpu"), [])
|
||
O.motores = lambda: {"gpu": {"nvdec": False}}
|
||
self.assertEqual(O._decodificar_con_tarjeta({"codec": "h264"}, "gpu"), [])
|
||
O.motores = real
|
||
ff = shutil.which("ffmpeg")
|
||
if not ff:
|
||
self.skipTest("sin ffmpeg")
|
||
raiz = cliente("Descomprime")
|
||
nr = os.path.normpath(raiz)
|
||
clip = os.path.join(nr, "2025", "11- Noviembre", "00_Grabacion", "SONY", "C0001.MP4")
|
||
os.makedirs(os.path.dirname(clip))
|
||
subprocess.run([ff, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=320x180:rate=30:duration=1",
|
||
"-c:v", "libx264", "-pix_fmt", "yuv420p", clip], check=True)
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {"C0001.MP4": {"codec": "h264", "perfil": "High", "ancho": 320, "alto": 180, "fps": 30, "dur": 1, "kbps": 900}})
|
||
id_ = O.encolar([clip], "equilibrado", "420", "rec709", nr)[0]
|
||
# una descompresión que no arranca: se repite con el procesador y sale igual
|
||
self.addCleanup(setattr, O, "_decodificar_con_tarjeta", O._decodificar_con_tarjeta)
|
||
O._decodificar_con_tarjeta = lambda f, motor: ["-hwaccel", "no_existe"]
|
||
O.correr_ffmpeg(self.trabajo(id_))
|
||
t = self.trabajo(id_)
|
||
self.assertEqual(t["estado"], "listo", t["log"])
|
||
self.assertTrue(os.path.isfile(t["salida"]))
|
||
self.assertNotIn("-hwaccel", t["log"])
|
||
|
||
def test_lo_que_ya_no_compensa_no_se_convierte(self):
|
||
"""Se encolaron con la regla de antes: un H.265 de cámara que se quedaría en el 93 % no se convierte."""
|
||
raiz = cliente("YaNoCompensa")
|
||
nr = os.path.normpath(raiz)
|
||
g = "2025/11- Noviembre/00_Grabacion/SONY/"
|
||
a, b = mk(raiz, g + "C0001.MP4", 2025, 11, 11, tam=300000), mk(raiz, g + "C0002.MP4", 2025, 11, 11, tam=300001)
|
||
O.escanear(raiz)
|
||
self.leidos(raiz, {"C0001.MP4": {"codec": "hevc", "perfil": "Main 10", "pix_fmt": "yuv420p10le", "fps": 59.94, "kbps": 72000},
|
||
"C0002.MP4": {"codec": "h264", "perfil": "High", "kbps": 120000}})
|
||
ia, ib = O.encolar([a, b], "equilibrado", "420", "rec709", nr)
|
||
self.assertTrue(O._ya_no_compensa(self.trabajo(ia)).startswith("convertido ahorraría solo un 7 %"))
|
||
self.assertEqual(O._ya_no_compensa(self.trabajo(ib)), "")
|
||
con = O.db()
|
||
with con:
|
||
con.execute("DELETE FROM trabajos WHERE id IN (?, ?)", (ia, ib))
|
||
con.close()
|
||
|
||
def test_el_codificador_que_toca(self):
|
||
fm = O.FORMATOS["h265_10bit"]
|
||
t = {"chroma": "420", "perfil": "equilibrado", "kbps": 30000}
|
||
con_uhq = dict(self.MOTORES, gpu=dict(self.MOTORES["gpu"], hevc=True, h264=True, uhq=True), formatos={k: "gpu" for k in O.FORMATOS})
|
||
O.motores = lambda: con_uhq
|
||
args, pix = O._args_video(t, fm, "gpu", 30)
|
||
self.assertEqual((pix, args[args.index("-tune") + 1], args[args.index("-profile:v") + 1]), ("p010le", "uhq", "main10"))
|
||
self.assertNotIn("-bf", args) # «uhq» no admite que se le fijen los cuadros B
|
||
self.assertEqual(O._args_video(dict(t, chroma="422"), fm, "gpu", 30)[1], "p210le")
|
||
self.assertIn("hq", O._args_video(dict(t, chroma="422"), fm, "gpu", 30)[0]) # solo donde se midió: 4:2:0
|
||
self.assertIn("hq", O._args_video(t, O.FORMATOS["h264"], "gpu", 30)[0])
|
||
O.motores = lambda: dict(con_uhq, gpu=dict(con_uhq["gpu"], uhq=False))
|
||
args, _ = O._args_video(t, fm, "gpu", 30)
|
||
self.assertEqual(args[args.index("-tune") + 1], "hq")
|
||
self.assertIn("-bf", args)
|
||
# la tarjeta tiene un tamaño máximo de imagen por códec: un 4.6K en H.264 falla («No capable devices found»)
|
||
self.assertEqual(O._motor_de("h264", "420", 4608, 2592)[0], "cpu")
|
||
self.assertEqual(O._motor_de("h264", "420", 3840, 2160)[0], "gpu")
|
||
self.assertEqual(O._motor_de("h265_10bit", "420", 4608, 2592)[0], "gpu")
|
||
self.assertEqual(O._motor_de("h265_10bit", "420", 12288, 6480)[0], "cpu")
|
||
# el contenedor guarda las etiquetas de la cámara, sin arrastrar las marcas de formato del original
|
||
cont, audio = O._args_salida(fm, None)
|
||
self.assertIn("+faststart+use_metadata_tags", cont)
|
||
self.assertIn("major_brand=", cont)
|
||
self.assertEqual(audio, ["-c:a", "aac", "-b:a", "320k"])
|
||
|
||
def test_el_lector_sin_decodificador(self):
|
||
"""Lo que braw.py hace sin tocar el decodificador, y cómo responde si lo que encuentra no sirve."""
|
||
import braw
|
||
self.assertEqual([braw.fraccion(x) for x in (23.976, 24.0, 25.0, 29.97, 30.0, 59.94, 60.0)],
|
||
["24000/1001", "24", "25", "30000/1001", "30", "60000/1001", "60"])
|
||
falso = os.path.join(TMP, "decodificador-falso")
|
||
os.makedirs(falso)
|
||
self.assertNotEqual(braw.buscar(falso), falso)
|
||
with open(os.path.join(falso, braw.NOMBRE), "wb") as fh:
|
||
fh.write(b"esto no es una DLL")
|
||
self.assertEqual(braw.buscar(falso), falso)
|
||
r = braw.sonda(falso)
|
||
self.assertFalse(r["ok"])
|
||
self.assertTrue(r["motivo"])
|
||
r = braw.info(os.path.join(TMP, "no-existe.braw"), falso)
|
||
self.assertFalse(r["ok"])
|
||
self.assertTrue(r["error"].startswith("el clip ya no está en esa ruta"), r)
|
||
|
||
@unittest.skipUnless(os.environ.get("ORGANILAB_BRAW_PRUEBA"), "sin un .braw de prueba (variable ORGANILAB_BRAW_PRUEBA)")
|
||
def test_un_braw_de_verdad(self):
|
||
"""Con el decodificador instalado y un clip real: se mide, se convierte, se verifica."""
|
||
O.decodificador_braw, O.motores, O.medir_braw, O.ffprobe = self.de_verdad
|
||
self.assertTrue(O.decodificador_braw()["ok"], O.decodificador_braw())
|
||
raiz = cliente("BrawReal")
|
||
destino = os.path.join(raiz, "2025", "03- Marzo", "00_Grabacion", "URSA_BRAW", "A002_03110347_C038.braw")
|
||
os.makedirs(os.path.dirname(destino))
|
||
shutil.copy(os.environ["ORGANILAB_BRAW_PRUEBA"], destino)
|
||
O.escanear(raiz)
|
||
nr = os.path.normpath(raiz)
|
||
O.analizar(nr, 0)
|
||
real = O.medir_braw(destino)
|
||
self.assertTrue(real["ok"], real)
|
||
c = O.candidatos(nr, 0, "equilibrado", 0)[0]
|
||
self.assertEqual((c["motor"], c["excluido"], c["res"]), ("braw", "", f"{real['ancho']}x{real['alto']}"))
|
||
ids = O.encolar([destino], "equilibrado", "420", "rec709", nr)
|
||
O.correr_braw(self.trabajo(ids[0]))
|
||
t = self.trabajo(ids[0])
|
||
self.assertEqual(t["estado"], "listo", t["log"])
|
||
v = O.verificar(ids)[0]
|
||
self.assertTrue(v["ok"], v)
|
||
i = O.ffprobe(t["salida"])
|
||
self.assertEqual((i["codec"], i["pix_fmt"], i["ancho"], i["alto"]), ("hevc", "yuv420p10le", real["ancho"], real["alto"]))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
r = unittest.main(verbosity=2, exit=False)
|
||
finally:
|
||
shutil.rmtree(TMP, ignore_errors=True)
|
||
sys.exit(0 if r.result.wasSuccessful() else 1) # la compilación del estudio se detiene si algo falla
|