Capas independientes, projectM en el mapping y catalogo de la galeria

Mapping
- Los clips salian del reves: mapper.js activaba UNPACK_FLIP_Y_WEBGL, pero el
  modelo va con origen arriba-izquierda y texImage2D ya sube la primera fila
  en t=0, asi que el flip la mandaba al final. Comprobado renderizando el
  mapper real en Chromium headless con una fuente mitad roja / mitad azul.
- projectM se puede usar YA en una capa: los .milk de data/presets-projectm se
  traducen a Butterchurn en el navegador al elegirlos (milkdrop-preset-
  converter). La capa MilkDrop gana 'biblioteca' (butter | projectm) sin
  cambiar su firma, para no recrear el contexto WebGL al cambiar de una a otra.
  Medido: 100/100 presets de una muestra convierten, 84/84 de los que llevan
  shaders warp/comp; ~7 ms por preset.
- Cada capa tiene su pestana arriba y su propia vista, y '+ CAPA' crea una y
  entra en ella: con varias capas, ir y volver a MAPPING no era viable.
- Dos capas MilkDrop sin preset ya no salen identicas (cogian el indice 0):
  cada instancia elige uno al azar y lo escribe en el estado.
- Una capa nueva ya no nace en la fuente "motor", que es el lienzo del motor
  activo y hacia que dos capas ensenaran lo mismo.

Galeria de visuales
- scripts/catalogar-visuales.py: ficha de cada clip (pelicula, personajes,
  duracion) y, sobre todo, si es una silueta de verdad y cuanta figura tiene.
  Distingue silueta de corte crudo por el negro puro del fondo: 0,63-0,90
  frente a 0,04-0,06, sin zona gris.
- El panel lista los clips agrupados por pelicula, con nombre legible y aviso
  de los que casi no tienen figura, en vez del nombre del archivo.
- Soporte de siluetas con canal alfa (.webm VP9): transparencia de verdad, sin
  recorte por luminancia. El shader del mapper ya la respeta.

Documentacion
- docs/visuales.md nuevo; pendiente.md al dia con lo hecho y lo que queda.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-08-02 14:56:01 +02:00
parent 5d5223db9f
commit f017246f0e
30 changed files with 3771 additions and 292 deletions

View file

@ -53,10 +53,25 @@ HOST = CFG.get("server", {}).get("host", "0.0.0.0")
# modo portatil (script 'fosfeno') usa el puerto 8080 sin tocar config.json.
PORT = int(os.environ.get("FOSFENO_PORT") or CFG.get("server", {}).get("port", 80))
NO_KIOSK = bool(os.environ.get("FOSFENO_NO_KIOSK"))
# Clave compartida para la red local. Vacia (por defecto) = abierto, como
# siempre: en casa o en un ensayo no molesta. Si se pone (config.json ->
# auth.token, o la variable FOSFENO_TOKEN), hace falta para conectarse al
# panel y para subir archivos, y el codigo QR del proyector ya la lleva: el
# que escanea entra, el que solo conoce la IP no. Recomendado en local
# publico, donde cualquiera en la wifi podria apagar el equipo o meter
# codigo en las visuales.
TOKEN = os.environ.get("FOSFENO_TOKEN") or CFG.get("auth", {}).get("token", "")
app = Flask(__name__, static_folder=None)
app.config["MAX_CONTENT_LENGTH"] = 4 * 1024 ** 3 # subidas de hasta 4 GB
socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*")
# Tope por archivo subido. Con 4 GB, cualquiera en la red podia llenar la
# tarjeta de la Raspberry en tres subidas; con esto siguen cabiendo clips
# largos de sobra.
app.config["MAX_CONTENT_LENGTH"] = 512 * 1024 ** 2 # 512 MB
# cors_allowed_origins=None => solo el mismo origen, calculado del Host de
# cada peticion (asi vale igual por IP, por fosfeno.local o por localhost).
# Con el comodin "*", una web cualquiera abierta en un movil de la sala podia
# conectarse al socket y mandar ordenes.
socketio = SocketIO(app, async_mode="threading", cors_allowed_origins=None)
# --------------------------------------------------------------------------
# Datos cargados de disco
@ -81,6 +96,19 @@ def list_videos():
if f.suffix.lower() in MEDIA_EXT)
def load_visuales():
"""Fichas de la galeria: de que peli sale cada clip, quien aparece y como
de llena esta la silueta. Lo genera scripts/catalogar-visuales.py; si no
esta, el panel sigue funcionando con los nombres de archivo pelados.
Devuelve {archivo: ficha} y solo de lo que sigue en disco, para que un
catalogo viejo no ensene clips borrados."""
ruta = DATA / "visuales.json"
datos = load_json(ruta, {}) if ruta.is_file() else {}
hay = set(list_videos())
return {c["archivo"]: c for c in datos.get("clips", [])
if isinstance(c, dict) and c.get("archivo") in hay}
def list_projectm_presets():
"""Categorias y visuales de data/presets-projectm. El pack se organiza
como categoria/visual/variante.milk: cada 'visual' (Aurora, Blobby...)
@ -152,14 +180,19 @@ state = {
# edit = mostrar los tiradores en el escenario para ajustarlo con el raton;
# surfaces = [{id, corners:[[x,y]x4]}] normalizado 0..1. Persiste en
# data/mapping.json.
"mapping": load_json(DATA / "mapping.json",
{"enabled": False, "edit": False,
"surfaces": [], "masks": []}),
"mapping": dict(load_json(DATA / "mapping.json",
{"enabled": False, "surfaces": [], "masks": []}),
# 'edit' (los tiradores encima de las propias visuales) es
# un modo de montaje, no un ajuste: arranca siempre
# apagado para que el proyector salga limpio, sin marcos
# de colores ni circulos en las esquinas.
edit=False),
"meta": {
"butterchurnPresets": [],
"audioDevices": [],
"cameraDevices": [],
"videos": list_videos(),
"visuales": load_visuales(),
"projectmPresets": {},
"monitors": [],
},
@ -235,7 +268,8 @@ def start_chromium():
notify("error", "No se encontro Chromium. Las visuales web no pueden "
"mostrarse. Ejecuta install.sh para instalarlo.")
return
url = f"http://localhost:{PORT}/stage"
# El escenario tambien necesita la clave para hablar con el servidor
url = f"http://localhost:{PORT}/stage" + (f"?k={TOKEN}" if TOKEN else "")
procs["chromium"] = subprocess.Popen([
browser,
"--kiosk", f"--app={url}",
@ -326,6 +360,18 @@ def monitor_of_stage():
return 0
def dentro_de(base, *partes):
"""Ruta compuesta solo si no se sale de 'base'. Categoria, visual y
variante llegan del panel, y con '../..' se podria apuntar a cualquier
carpeta del disco."""
base = Path(base).resolve()
try:
p = base.joinpath(*partes).resolve()
except (OSError, ValueError):
return None
return p if str(p).startswith(str(base) + os.sep) else None
def start_projectm():
"""Lanza projectM nativo con los ajustes del panel (categoria, preset
fijo, duracion, aleatorio); su ventana se superpone a Chromium."""
@ -334,8 +380,10 @@ def start_projectm():
pm = state["projectm"]
base = DATA / "presets-projectm"
path = base
if pm.get("category") and (base / pm["category"]).is_dir():
path = base / pm["category"]
if pm.get("category"):
cat = dentro_de(base, pm["category"])
if cat and cat.is_dir():
path = cat
try:
duration = max(2, int(pm.get("duration") or 20))
except (TypeError, ValueError):
@ -344,15 +392,15 @@ def start_projectm():
# Visual concreto elegido: cada visual es una carpeta con variantes del
# mismo estilo -> projectM rota solo dentro de ese look.
if pm.get("preset") and pm.get("category"):
vis = base / pm["category"] / pm["preset"]
if vis.is_dir():
vis = dentro_de(base, pm["category"], pm["preset"])
if vis and vis.is_dir():
path = vis
# Variante exacta elegida a dedo: projectM solo entiende carpetas, asi
# que se le prepara una carpeta con esa unica variante dentro.
if pm.get("variante") and pm.get("category") and pm.get("preset"):
origen = (base / pm["category"] / pm["preset"]
/ (pm["variante"] + ".milk"))
if origen.is_file():
origen = dentro_de(base, pm["category"], pm["preset"],
pm["variante"] + ".milk")
if origen and origen.is_file():
fijado = DATA / "pm-fijado"
shutil.rmtree(fijado, ignore_errors=True)
fijado.mkdir(parents=True, exist_ok=True)
@ -511,6 +559,10 @@ def pm_variantes():
@app.route("/upload", methods=["POST"])
def upload_media():
"""Sube un video o una imagen a la galeria del mezclador (data/videos)."""
# Con clave puesta, subir tambien la pide: si no, cualquiera en la wifi
# puede llenar la tarjeta de la Raspberry.
if TOKEN and request.args.get("k") != TOKEN:
return {"ok": False, "error": "No autorizado."}, 403
f = request.files.get("file")
if not f or not f.filename:
return {"ok": False, "error": "No llego ningun archivo."}, 400
@ -527,6 +579,7 @@ def upload_media():
return {"ok": False, "error": f"No se pudo guardar: {exc}"}, 500
with lock:
state["meta"]["videos"] = list_videos()
state["meta"]["visuales"] = load_visuales()
notify("info", f"'{name}' anadido a la galeria del mezclador.")
broadcast()
return {"ok": True, "name": name}
@ -536,8 +589,13 @@ def upload_media():
# Eventos WebSocket
# --------------------------------------------------------------------------
@socketio.on("connect")
def on_connect():
def on_connect(auth=None):
# Sin la clave no se conecta: ni ordenes, ni codigo en las visuales, ni
# apagar la maquina. Con TOKEN vacio entra todo el mundo, como siempre.
if TOKEN and (auth or {}).get("token") != TOKEN:
return False
socketio.emit("state", state)
return None
@socketio.on("hello")
@ -594,6 +652,35 @@ def on_run_code(data):
broadcast()
# Claves que cada motor acepta en update_settings. Sin esta lista, cualquiera
# en la red puede meter claves inventadas o cadenas kilometricas en el estado,
# que ademas se difunde entero a todos los clientes en cada cambio.
CLAVES_AJUSTES = {
"butterchurn": {"preset", "shuffle", "interval", "intervalMode", "blendTime"},
"mixer": {"source", "fondo", "camOn", "cameraId", "video", "mix",
"blendMode", "hue", "saturate", "contrast", "brightness",
"colorama", "posterize", "pixelate", "kaleid", "rotate",
"feedback", "invert", "beatPulse"},
"audio": {"device", "bpm", "source"},
"projectm": {"category", "preset", "duration", "shuffle", "monitor",
"variante"},
}
def patch_saneado(engine, patch):
"""Deja solo claves conocidas con valores simples y de tamano razonable."""
permitidas = CLAVES_AJUSTES.get(engine, set())
limpio = {}
for clave, valor in patch.items():
if clave not in permitidas:
continue
if isinstance(valor, str) and len(valor) > 512:
continue
if isinstance(valor, (str, int, float, bool)):
limpio[clave] = valor
return limpio
@socketio.on("update_settings")
def on_update_settings(data):
"""Actualiza ajustes de un motor: butterchurn, mixer, audio o projectm."""
@ -601,6 +688,7 @@ def on_update_settings(data):
patch = (data or {}).get("patch", {})
if engine in ("butterchurn", "mixer", "audio", "projectm") \
and isinstance(patch, dict):
patch = patch_saneado(engine, patch)
with lock:
state[engine].update(patch)
# Los ajustes de projectM se aplican relanzandolo (tarda ~1s)
@ -618,14 +706,43 @@ def on_update_settings(data):
broadcast()
_map_timer = None
def save_mapping():
"""Guarda el mapping con un segundo de retardo. Arrastrar un punto emite
~16 veces por segundo y no queremos escribir la tarjeta SD 16 veces por
segundo: se reprograma y solo escribe cuando el usuario suelta."""
global _map_timer
if _map_timer:
_map_timer.cancel()
_map_timer = threading.Timer(1.0, _write_mapping)
_map_timer.daemon = True
_map_timer.start()
def _write_mapping():
try:
with lock:
datos = json.dumps(state["mapping"], ensure_ascii=False, indent=2)
with open(DATA / "mapping.json", "w", encoding="utf-8") as fh:
json.dump(state["mapping"], fh, ensure_ascii=False, indent=2)
fh.write(datos)
except OSError as e:
notify("warn", f"No se pudo guardar el mapping: {e}")
def capas_saneadas(lista, clave_geo):
"""Descarta lo que no tenga forma de capa/mascara y limita cuantas se
aceptan. Sin esto, cualquiera en la red puede dejar mapping.json lleno de
basura (y persiste entre reinicios)."""
limpio = []
for it in lista[:64]:
if isinstance(it, dict) and isinstance(it.get("id"), str) \
and isinstance(it.get(clave_geo), list):
limpio.append(it)
return limpio
@socketio.on("set_mapping")
def on_set_mapping(data):
"""Projection mapping: activar/editar y guardar las superficies.
@ -639,9 +756,9 @@ def on_set_mapping(data):
if "edit" in data:
m["edit"] = bool(data["edit"])
if "surfaces" in data and isinstance(data["surfaces"], list):
m["surfaces"] = data["surfaces"]
m["surfaces"] = capas_saneadas(data["surfaces"], "points")
if "masks" in data and isinstance(data["masks"], list):
m["masks"] = data["masks"]
m["masks"] = capas_saneadas(data["masks"], "corners")
m.setdefault("masks", [])
save_mapping()
broadcast()
@ -729,7 +846,7 @@ def on_stage_meta(data):
@socketio.on("rescan_devices")
def on_rescan_devices():
def on_rescan_devices(data=None):
"""Pide al escenario que vuelva a buscar microfonos y camaras.
Aprovecha para refrescar la lista de pantallas conectadas."""
with lock:
@ -739,7 +856,7 @@ def on_rescan_devices():
@socketio.on("reacquire_audio")
def on_reacquire_audio():
def on_reacquire_audio(data=None):
"""Pide al escenario que vuelva a conectar la captura del microfono."""
socketio.emit("stage_reacquire")
@ -855,9 +972,10 @@ def on_stage_notify(data):
@socketio.on("rescan_videos")
def on_rescan_videos():
def on_rescan_videos(data=None):
with lock:
state["meta"]["videos"] = list_videos()
state["meta"]["visuales"] = load_visuales()
broadcast()