diff --git a/.gitignore b/.gitignore
index 81f1b7a..af31128 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,11 @@ data/presets-projectm/
data/videos/*
!data/videos/README.txt
+# Catalogo de la galeria y sus miniaturas: describen los videos locales, que
+# tampoco van al repo. Los regenera scripts/catalogar-visuales.py
+data/visuales.json
+data/miniaturas/
+
# Config de runtime (por instalacion): no va al repo
data/mapping.json
data/pm-favoritos.json
diff --git a/README.md b/README.md
index e1f675a..f996a82 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ arranca solo, o en un **portátil Linux** que lanzas cuando quieras. El apartado
│ (micro USB)
▼
┌──────────────────┐ Wi-Fi / Ethernet
- │ Raspberry Pi 5 │◀─────────────────────────── http://192.168.1.71/
+ │ Raspberry Pi 5 │◀─────────────────────────── http://192.168.1.XX/
│ FOSFENO │ (panel en el móvil)
└────────┬─────────┘
│ micro-HDMI
@@ -40,9 +40,15 @@ La carpeta [`docs/`](docs/) tiene la guía completa:
- [Conectarse al panel](docs/conexion.md) — el código QR, `fosfeno.local` y
el router.
- [Uso del panel](docs/uso.md) — cómo se maneja y qué hace cada motor.
+- [El Mezclador VJ](docs/mezclador.md) — las mezclas: las dos capas, visuales
+ de fondo, modos de mezcla y efectos.
- [Projection mapping](docs/mapping.md) — deformar las visuales para encajarlas
en superficies físicas (malla, máscaras) desde el panel.
- [Solución de problemas](docs/problemas.md) — qué hacer cuando algo falla.
+- [Seguridad](docs/seguridad.md) — el panel está abierto a la red local por
+ defecto; cómo cerrarlo con una clave si pinchas fuera de casa.
+- [Arquitectura](docs/arquitectura.md) — para tocar el código: piezas, estado,
+ eventos y cómo añadir un motor.
El panel además lleva un botón de información en cada apartado: explica qué
es, qué necesita y cómo se configura, sin salir del propio panel.
@@ -65,12 +71,30 @@ Visuales de ejemplo — el motor Hydra con su editor de código en vivo:
-## Projection mapping
+## Projection mapping por capas
-Las visuales web (Butterchurn, Hydra, Shaders y Mezclador) se pueden **deformar
-para encajarlas en superficies físicas** desde el panel: superficies con malla
-(curvas), varias a la vez y máscaras para tapar zonas, arrastrando con el ratón
-o el dedo. Se guarda solo en `data/mapping.json`. Guía: [docs/mapping.md](docs/mapping.md).
+Las visuales se pueden **deformar para encajarlas en superficies físicas** desde
+el panel: malla deformable (curvas), varias zonas a la vez y máscaras para tapar
+derrames, arrastrando con el ratón o el dedo.
+
+Y cada zona es una **capa con su propio visual**: MilkDrop a la derecha, otro
+preset distinto a la izquierda y un vídeo en el centro, a la vez.
+
+
+
+
+
+| Fuente de una capa | Qué es |
+|--------------------|--------|
+| El motor de MOTORES | Lo que esté puesto en la otra pestaña del panel |
+| Visuales MilkDrop propias | Un Butterchurn solo para esa capa, con su preset |
+| Shader GLSL | Un shader de la librería, reactivo al audio |
+| Clip o imagen | Un archivo de `data/videos`, en bucle |
+| Cámara | La webcam |
+| Negro | Tapa la zona |
+
+Con opacidad y mezcla (normal o aditiva) por capa. Se guarda solo en
+`data/mapping.json`. Guía: [docs/mapping.md](docs/mapping.md).
## Hardware necesario
@@ -173,6 +197,9 @@ Así se ve el panel de control:
+El panel tiene dos pestañas, a la derecha del título: **MOTORES** (el directo)
+y **MAPPING** (el montaje sobre la superficie física).
+
Desde el panel puedes:
- Encender/apagar las visuales y cambiar de motor.
@@ -182,9 +209,11 @@ Desde el panel puedes:
**sincronizado al compás**.
- **Hydra / Shaders**: editor de código integrado para **escribir o pegar**
tu propio código, más una **librería de fragmentos** lista para cargar.
-- **Mezclador VJ**: activar la cámara, elegir un clip de vídeo, modo de
- mezcla y efectos de color (tono, saturación, colorama, posterizado,
- pixelado, caleidoscopio, feedback, invertir…).
+- **Mezclador VJ**: elegir el fondo (cámara o visuales MilkDrop), el clip de
+ encima, el modo de mezcla y los efectos de color (tono, saturación,
+ colorama, posterizado, pixelado, caleidoscopio, feedback, invertir…).
+- **Mapping**: encajar la imagen sobre la superficie física arrastrando los
+ puntos, con malla deformable y máscaras.
FOSFENO en acción — vídeo corto de demostración:
@@ -194,12 +223,19 @@ FOSFENO en acción — vídeo corto de demostración:
Si el reproductor no se ve, descarga el vídeo: [demo.mp4](docs/assets/demo.mp4)
-### Modo Mezclador (cámara + vídeo)
+### Modo Mezclador (cámara + vídeo + visuales de fondo)
-Copia tus clips en `data/videos/` (formatos `.mp4`, `.webm`, `.mov`…) y
-aparecerán en el panel. El mezclador genera código Hydra por debajo a partir
-de los controles, así que mezcla en tiempo real la webcam, los clips y los
-efectos de color — el equivalente a Resolume, pero corriendo en la propia Pi.
+El mezclador monta **dos capas**: un **fondo** —la webcam o las propias
+**visuales MilkDrop**— y un **clip** encima (vídeo o imagen), combinados con el
+modo de mezcla y los efectos de color. Con un clip de silueta sobre negro y el
+modo *Recorte*, el personaje queda **delante de MilkDrop** latiendo con la
+música.
+
+Los clips se copian a `data/videos/` (`.mp4` H.264, `.webm`, imágenes) o se
+suben desde el propio panel. Por debajo, el mezclador genera código Hydra a
+partir de los controles: el equivalente a Resolume, corriendo en la propia Pi.
+
+Guía completa: [El Mezclador VJ](docs/mezclador.md).
## Estructura
@@ -210,8 +246,10 @@ FOSFENO/
├── backend/server.py Servidor: web + WebSocket + gestión de procesos
├── scripts/lib.sh Funciones de los scripts (logs, versiones)
├── web/panel/ Panel de control (móvil)
-├── web/stage/ Escenario en Chromium (todos los motores web)
+├── web/stage/stage.js Escenario en Chromium (todos los motores web)
+├── web/stage/mapper.js Compositor de projection mapping (WebGL)
├── docs/ Documentación completa
+├── docs/arquitectura.md Cómo está montado, para tocar el código
├── data/hydra-sketches.json Sketches de Hydra de fábrica
├── data/hydra-snippets.json Librería de fragmentos de Hydra para el editor
├── data/shaders.json Shaders GLSL (editables)
diff --git a/backend/server.py b/backend/server.py
index d46667c..1d4de4b 100644
--- a/backend/server.py
+++ b/backend/server.py
@@ -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()
diff --git a/config.json b/config.json
index 5071c9c..c54040c 100644
--- a/config.json
+++ b/config.json
@@ -17,6 +17,10 @@
"hostname": "fosfeno",
"_comment": "Nombre de red de la Raspberry. El panel queda en http://.local/"
},
+ "auth": {
+ "token": "",
+ "_comment": "Vacio = panel abierto a toda la red local (como siempre). Si pones una clave, hara falta para conectarse y para subir archivos; el codigo QR del proyector ya la lleva dentro, asi que se entra escaneando igual. Recomendado en locales publicos: sin clave, cualquiera en la wifi puede apagar el equipo o meter codigo en las visuales."
+ },
"defaults": {
"engine": "butterchurn",
"sensitivity": 1.0,
diff --git a/data/ayuda.json b/data/ayuda.json
index 3bade4e..04a8c2a 100644
--- a/data/ayuda.json
+++ b/data/ayuda.json
@@ -13,9 +13,12 @@
"title": "Mapping (projection mapping)",
"body": [
"Que es: deforma la salida de las visuales para encajarla en superficies fisicas (una pared en angulo, cajas, un objeto). En vez de un rectangulo plano, ajustas la imagen a la forma real.",
- "Como se usa: marca 'Activar mapping' y 'Modo edicion'. En la ventana de las visuales (el proyector) veras cada superficie con sus cuatro esquinas: arrastralas con el raton para encajarla. Doble clic crea una superficie nueva; la tecla Supr borra la seleccionada.",
- "Varias superficies: puedes crear varias (con 'Anadir superficie' o doble clic) y mapear la misma imagen en distintas zonas. Cada una se lista en el panel con su boton de borrar.",
- "Se guarda solo en data/mapping.json y se mantiene al reiniciar. Quita 'Modo edicion' para ocultar los tiradores mientras pinchas.",
+ "Donde esta: en su propia pestana MAPPING, arriba a la derecha del titulo. La pestana MOTORES tiene lo de siempre (encendido, audio, motores y sus controles) y esta lo de encajar la imagen, para que no se mezclen el montaje y el directo. Cuando el mapping esta activado sale un punto verde en la pestana, aunque estes mirando MOTORES.",
+ "Como se usa: marca 'Activar mapping' y pulsa '+ Superficie'. En la previsualizacion del panel arrastra los puntos (con el raton o con el dedo) hasta encajar la forma sobre tu superficie real. Se ve en el proyector al instante.",
+ "Curvar: selecciona una superficie tocando dentro de ella y usa '+ malla' para subdividirla (hasta 6x6). Con la malla subdividida puedes arrastrar cualquier punto, no solo las esquinas, y adaptarla a superficies que no son planas.",
+ "'+ Mascara' anade un recuadro negro que tapa lo que no quieres que se vea (bordes, derrames de luz, huecos entre superficies).",
+ "'Modo edicion' saca ademas los tiradores sobre las propias visuales, por si prefieres ajustar alli con un raton. Quitalo para que no se vean mientras pinchas.",
+ "Se guarda solo en data/mapping.json y se mantiene al reiniciar.",
"Ojo: no deforma projectM (que va en su propia ventana nativa). Usa Butterchurn, Hydra, Shaders o el Mezclador para tener todo mapeable."
]
},
@@ -68,14 +71,16 @@
"mixer": {
"title": "Motor Mezclador VJ",
"body": [
- "Que es: el modo de video. Mezcla la imagen de una camara web con clips de video y efectos de color. Es lo mas parecido a un programa de VJ como Resolume.",
- "Requisitos: una webcam USB que cumpla el estandar UVC. Para los clips, copia tus archivos de video en la carpeta data/videos del proyecto; van bien los .mp4 (H.264) y los .webm.",
- "Anadir material: con el boton 'Subir video o imagen' del panel puedes mandar clips (mp4, webm...) e imagenes (jpg, png, gif, webp) a la galeria desde el movil o el ordenador, sin tocar carpetas. Tambien puedes generar clips automaticos de tus pelis con DETEKTION (carpeta VISUALES/DETEKTION).",
- "Como se configura: elige la fuente (camara, video o mezcla de las dos), el modo de mezcla y los efectos de color con los controles deslizantes. La casilla de pulso al ritmo hace que la imagen lata con los graves.",
- "Personajes sobre visuales: pon Fuente en Mezcla, Fondo en 'Visuales Butter', elige un clip _silueta-negro de la galeria (los de DETEKTION) y modo de mezcla 'Recorte'. El negro del clip se vuelve transparente y el personaje queda delante de MilkDrop latiendo con la musica. El deslizador de mezcla ajusta el umbral del recorte (subelo si queda halo oscuro en los bordes).",
- "El preset de MilkDrop del fondo es el que estuviera elegido en el motor Butter: escogelo alli antes de entrar al Mezclador. projectM (el nativo) no puede ir de fondo: corre en una ventana aparte, fuera del navegador.",
- "Si la pantalla se queda en negro, mira el aviso del panel: dice si falta elegir video, si la camara esta apagada o si algo fallo. La camara se reintenta apagando y encendiendo su casilla; un video que no carga se reintenta eligiendolo otra vez en la lista.",
- "Si tienes mas de una camara, eligela en la lista de camaras del panel. El boton Actualizar lista de videos vuelve a leer la carpeta data/videos por si has copiado clips nuevos."
+ "Que es: el modo de video. Monta DOS CAPAS, una encima de otra, y las combina en directo. Es lo mas parecido a un programa de VJ como Resolume.",
+ "Las dos capas son siempre las mismas. Capa de abajo (el FONDO): la camara web o las visuales Butter (MilkDrop). Capa de encima (el CLIP): un video o una imagen de la galeria. El panel esta numerado en ese orden: 1 que se ve, 2 el fondo, 3 el clip, 4 como se juntan.",
+ "1 - Que se ve. 'Solo el fondo' ensena unicamente la capa de abajo. 'Solo el clip' ensena unicamente el video. 'Los dos' los combina, y es el unico caso en el que aparece el paso 4.",
+ "2 - Fondo. Aqui esta la respuesta a 'como pongo visuales de fondo': cambia el desplegable de Camara a 'Visuales Butter (MilkDrop)'. La camara se apaga sola (con su piloto) y su sitio lo ocupan las visuales. Debajo aparece el preset de MilkDrop que hace de fondo, con Anterior/Siguiente y la casilla de cambio automatico: es el mismo preset del motor Butter, asi que lo que elijas aqui queda elegido tambien alli.",
+ "3 - Clip. Los videos e imagenes de la galeria (carpeta data/videos). Con 'Subir video o imagen' mandas material nuevo desde el movil o el ordenador, sin tocar carpetas. Valen .mp4 (H.264), .webm, .jpg, .png, .gif y .webp. Tambien puedes generar clips de siluetas de tus pelis con DETEKTION (carpeta VISUALES/DETEKTION).",
+ "4 - Como se juntan. 'Recorte' vuelve transparente el negro del clip y deja la figura DELANTE del fondo; ahi el primer deslizador pasa a ser el umbral del recorte (subelo si queda halo oscuro en los bordes). Los demas modos (fundido, diferencia, multiplicar, sumar, capa) mezclan las dos capas enteras.",
+ "Personajes sobre visuales, la receta completa: 'Los dos' + Fondo 'Visuales Butter' + un clip _silueta-negro de la galeria + modo 'Recorte'. El personaje queda delante de MilkDrop latiendo con la musica.",
+ "La linea naranja de debajo del paso 4 dice en una frase lo que va a salir por el proyector con los ajustes que tengas puestos. Si no estas seguro de que estas montando, leela.",
+ "projectM (el nativo) no puede ir de fondo: corre en una ventana aparte, fuera del navegador. El fondo de visuales es siempre Butter.",
+ "Si la pantalla se queda en negro, mira el aviso del panel: dice si falta elegir video, si la camara esta apagada o si algo fallo. La camara se reintenta apagando y encendiendo su casilla; un video que no carga se reintenta eligiendolo otra vez en la lista."
]
},
"audio": {
@@ -103,6 +108,32 @@
"Como se configura: elige un ejemplo de la libreria y se carga en el editor, listo para ejecutarse. Puedes modificarlo o pegar codigo tuyo. El boton Ejecutar lanza lo que haya en el editor. El boton Limpiar lo vacia.",
"Si el codigo tiene un error, FOSFENO lo avisa en la parte de arriba del panel y el detalle tecnico queda en la consola del navegador."
]
+ },
+ "capas": {
+ "title": "Capas del mapping",
+ "body": [
+ "Que es: cada capa es una zona de la proyeccion con SU PROPIO visual. Puedes tener MilkDrop a la derecha, otro preset distinto a la izquierda y un video en el centro, cada uno encajado en su sitio.",
+ "Crear: '+ Capa' anade una zona nueva (sale escalonada para que no tape a la anterior). Selecciona la capa y en Propiedades eliges que se proyecta en ella.",
+ "El orden importa: la lista se lee como en cualquier mesa de VJ, la de arriba es la que queda DELANTE en el proyector. Con las flechas la subes o la bajas.",
+ "El circulo de cada fila enciende y apaga la capa sin borrarla: util para probar en directo. La ✕ la borra.",
+ "Sin ninguna capa y con el mapping activado, sale el motor elegido en MOTORES a pantalla completa, como siempre.",
+ "'+ Mascara' no es una capa: es un recuadro negro que se pinta encima de todo para tapar derrames de luz."
+ ]
+ },
+ "fuentes": {
+ "title": "Que se proyecta en una capa",
+ "body": [
+ "Cada capa elige su fuente por separado. Estas son:",
+ "El motor de MOTORES: lo que este puesto en la otra pestana (Butterchurn, Hydra, Shaders o el Mezclador). Si pones esto en varias capas, todas ensenan lo mismo: sirve para repetir el mismo visual en varias paredes.",
+ "Visuales MilkDrop propias: una instancia de Butterchurn SOLO para esta capa, con su preset. Es lo que permite tener dos looks de MilkDrop distintos a la vez, uno en cada zona. Puedes fijar un preset o dejar que vaya cambiando solo cada X segundos.",
+ "Shader GLSL: uno de los shaders de la libreria, reaccionando al audio igual que en el motor de shaders.",
+ "Clip o imagen de la galeria: un archivo de data/videos. Los clips van en bucle y sin sonido. Si dos capas usan el mismo clip, se reproduce una sola vez y se comparte (no cuesta el doble).",
+ "Camara: la webcam. Tambien se comparte entre capas.",
+ "Negro: no pinta nada. Sirve para tapar una zona sin usar mascara.",
+ "Opacidad y mezcla: la opacidad deja ver lo que hay debajo; 'Sumar' apila luz en vez de tapar (las zonas oscuras de la capa dejan pasar la de abajo), que es como se hacen los solapes bonitos.",
+ "projectM no puede ir en una capa: es un programa nativo que corre en su propia ventana, fuera del navegador. Para el mismo tipo de visual, usa 'Visuales MilkDrop propias', que son los mismos presets.",
+ "Ojo con la Raspberry: cada capa de MilkDrop o de shader es un motor de verdad funcionando. Dos o tres van bien; con muchas mas la cosa se arrastra. Los clips y la camara son mucho mas baratos."
+ ]
}
}
-}
\ No newline at end of file
+}
diff --git a/data/videos/README.txt b/data/videos/README.txt
index 494ed56..63a7c29 100644
--- a/data/videos/README.txt
+++ b/data/videos/README.txt
@@ -15,3 +15,17 @@ Ejemplo desde linea de comandos para reescalar un video pesado:
ffmpeg -i original.mp4 -vf scale=-2:720 -c:v libx264 -an clip.mp4
Los videos NO se suben al repositorio (estan en .gitignore).
+
+
+Catalogo
+--------
+
+Cada vez que metas clips nuevos, vuelve a pasar el catalogador: es lo que hace
+que el panel los liste por pelicula y con nombre legible en vez de por nombre
+de archivo, y lo que avisa de los que casi no tienen figura.
+
+ python3 scripts/catalogar-visuales.py # cataloga
+ python3 scripts/catalogar-visuales.py --limpiar # + aparta los que no son
+ # siluetas (ver docs)
+
+Explicado a fondo en docs/visuales.md.
diff --git a/docs/README.md b/docs/README.md
index 4b524f3..5127ff8 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -20,16 +20,32 @@ para consultarse a saltos después.
Cómo correr FOSFENO en un portátil Debian, Ubuntu o Mint, sin Raspberry Pi.
- [Uso del panel](uso.md)
- Cómo se maneja desde el móvil, qué hace cada motor de visuales y cómo se
- configura cada opción.
+ Cómo se maneja desde el móvil, las dos pestañas (MOTORES y MAPPING), qué
+ hace cada motor de visuales y cómo se configura cada opción.
+
+- [El Mezclador VJ](mezclador.md)
+ Las mezclas a fondo: las dos capas, cómo poner visuales de MilkDrop de
+ fondo, los modos de mezcla y los efectos de color.
+
+- [La galería de visuales](visuales.md)
+ El catálogo de clips: cómo se generan las fichas, cómo distingue una silueta
+ de un trozo de película y por qué unos clips salen marcados como flojos.
- [Projection mapping](mapping.md)
- Cómo deformar las visuales para encajarlas en superficies físicas: malla,
- máscaras y edición desde el panel arrastrando con el ratón o el dedo.
+ Cómo deformar las visuales para encajarlas en superficies físicas, y cómo
+ poner **un visual distinto en cada zona**: capas, fuentes, malla y máscaras.
- [Solución de problemas](problemas.md)
Qué hacer cuando algo no arranca, no se ve o no suena. Incluye cómo leer
los mensajes de error que aparecen en el propio panel.
+- [Seguridad](seguridad.md)
+ Qué asume el diseño, qué pasa si pinchas en una wifi pública y cómo cerrar
+ el panel con una clave compartida (el QR ya la lleva dentro).
+
+- [Arquitectura](arquitectura.md)
+ Para quien vaya a tocar el código: las tres piezas, el estado, los eventos
+ de WebSocket, cómo se añade un motor y los criterios que sigue el proyecto.
+
Si solo quieres empezar rápido, el archivo `README.md` de la raíz del
proyecto tiene la versión resumida.
diff --git a/docs/arquitectura.md b/docs/arquitectura.md
new file mode 100644
index 0000000..fc01257
--- /dev/null
+++ b/docs/arquitectura.md
@@ -0,0 +1,237 @@
+# Arquitectura
+
+Esta página es para quien vaya a **tocar el código** (o para uno mismo dentro de
+seis meses). Explica cómo está montado FOSFENO, dónde vive cada cosa y cómo se
+añade algo nuevo sin romper el resto.
+
+## Las tres piezas
+
+```
+ ┌───────────────┐ WebSocket ┌────────────────┐ WebSocket ┌──────────────┐
+ │ PANEL │ ────────────▶ │ SERVIDOR │ ────────────▶ │ ESCENARIO │
+ │ web/panel/ │ ◀──────────── │ backend/ │ ◀──────────── │ web/stage/ │
+ │ (el móvil) │ estado │ server.py │ estado │ (Chromium) │
+ └───────────────┘ └───────┬────────┘ └──────┬───────┘
+ │ subprocess │ HDMI
+ ▼ ▼
+ projectM (nativo) [ proyector ]
+```
+
+- **Servidor** (`backend/server.py`, Flask + Flask-SocketIO). Guarda **el
+ estado**, sirve los archivos web, lanza y vigila projectM, enruta el audio con
+ `pactl` y persiste lo que hay que persistir.
+- **Panel** (`web/panel/`). La interfaz del móvil. **No calcula nada**: refleja
+ el estado y manda órdenes.
+- **Escenario** (`web/stage/`). La página que sale por el proyector. Es la que
+ **de verdad pinta**: Butterchurn, Hydra, Shaders y el Mezclador viven aquí,
+ igual que el detector de BPM y la captura de audio.
+
+Regla de oro: **el estado vive en el servidor**, y todo el mundo lo recibe
+entero en cada cambio. Ni el panel ni el escenario guardan verdades propias; si
+algo hay que recordar, va al estado.
+
+## El estado
+
+Es un único diccionario en `server.py` (`state`) que se difunde con
+`socketio.emit("state", state)` en cada cambio. Sus ramas:
+
+| Rama | Qué guarda |
+|------|------------|
+| `engine` | motor activo: `projectm`, `butterchurn`, `hydra`, `shaders`, `mixer` |
+| `power` | visuales encendidas o en negro |
+| `sensitivity` | ganancia aplicada al audio antes del análisis |
+| `audio` | fuente (`mic` / `monitor`), tarjeta elegida y BPM detectado |
+| `butterchurn` | preset, cambio automático (segundos o compases) y transición |
+| `hydra` / `shaders` | el código que se está ejecutando y su etiqueta |
+| `mixer` | las dos capas (`source`, `fondo`, `camOn`, `video`), modo de mezcla y efectos |
+| `projectm` | categoría, visual, variante, favoritos, monitor, congelado |
+| `mapping` | `enabled`, `edit`, `masks[]` y `surfaces[]`, donde cada superficie es una **capa** (geometría + `fuente` + opacidad + mezcla) |
+| `meta` | listas detectadas: presets, cámaras, micrófonos, vídeos, monitores |
+| `status` / `notifications` / `network` | qué se ve, avisos e IP para el QR |
+
+Lo que sobrevive a un reinicio se guarda en `data/`:
+`mapping.json` (mapping) y `pm-favoritos.json` (favoritos de projectM). El resto
+del estado es de la sesión.
+
+## Eventos de WebSocket
+
+**Del panel al servidor**
+
+| Evento | Qué hace |
+|--------|----------|
+| `set_power`, `set_engine`, `set_sensitivity` | los tres mandos básicos |
+| `update_settings {engine, patch}` | parchea una rama del estado (`butterchurn`, `mixer`, `audio`, `projectm`) |
+| `run_code {engine, code, label}` | ejecuta código Hydra o GLSL |
+| `engine_command {action}` | `next`, `prev`, `lock`, `fix_current` |
+| `set_mapping {…}` | activar, editar, superficies y máscaras (persiste) |
+| `pm_favorito {action…}` | añadir, borrar o lanzar un favorito de projectM |
+| `rescan_devices`, `rescan_videos`, `reacquire_audio` | volver a mirar el hardware y la galería |
+| `system {action}` | apagar o reiniciar la máquina |
+
+**Del escenario al servidor**
+
+| Evento | Qué hace |
+|--------|----------|
+| `stage_meta` | publica lo que ha detectado el navegador (micrófonos, cámaras, presets) |
+| `stage_status {label, bpm}` | qué se está viendo y a cuántos BPM |
+| `stage_notify {level, message}` | manda un aviso a la banda del panel |
+| `audio_route {target}` | pide enrutar la captura al monitor del sistema o al micro |
+| `set_mapping` | al arrastrar los tiradores sobre las propias visuales |
+
+**Del servidor a los clientes**: `state` (el estado entero), `status` (solo
+etiqueta y BPM, más ligero), `notify`, `stage_command`, `stage_rescan`,
+`stage_reacquire`.
+
+**Nada de esto pide credenciales por defecto.** Cualquiera que alcance el
+puerto puede emitir cualquier evento, y eso incluye apagar la máquina y
+ejecutar código en el escenario. Si se configura `auth.token`, el handler de
+`connect` rechaza a quien no lo traiga en `auth`, y con eso queda cerrado todo
+lo demás de golpe. Ver [Seguridad](seguridad.md).
+
+Lo que entra por el socket **no es de fiar**, así que se filtra antes de tocar
+el estado: `update_settings` pasa por una lista blanca de claves por motor
+(`CLAVES_AJUSTES`), y `set_mapping` sanea capas y máscaras y limita cuántas
+acepta. `mapping.json` se escribe **con un segundo de retardo**: arrastrar un
+punto emite ~16 veces por segundo y no queremos escribir la microSD a ese
+ritmo.
+
+## Los motores
+
+Cada motor es independiente: si a uno le falta su librería o revienta al
+arrancar, **los demás siguen funcionando**. `boot()` en `stage.js` los inicia
+uno a uno dentro de su propio `try`.
+
+| Motor | Dónde corre | Lienzo |
+|-------|-------------|--------|
+| **projectM** | proceso nativo, ventana propia | ninguno (la página se queda en negro debajo) |
+| **Butterchurn** | navegador, WebGL | `#butterchurn` |
+| **Hydra** | navegador, WebGL | `#hydra` |
+| **Shaders** | navegador, WebGL a pelo | `#shaders` |
+| **Mezclador** | navegador, **sobre Hydra** | `#hydra` |
+
+`applyState()` decide qué lienzo se ve y apaga los demás. El mezclador no es un
+motor aparte: **genera código Hydra** (`buildMixerCode`) a partir de los
+ajustes; por eso comparte lienzo con Hydra y nunca coinciden.
+
+Butterchurn tiene una particularidad: puede estar pintando **sin ser el motor
+activo**, cuando hace de capa de fondo del mezclador. Esa condición está en un
+único sitio, `butterLive()`, y de ella dependen el preset, el cambio automático
+y los botones de siguiente/anterior.
+
+### Añadir un motor nuevo
+
+1. `ENGINES` en `server.py` y una rama en `state` con sus ajustes.
+2. Un lienzo en `web/stage/index.html` y su rama en `applyState()`.
+3. Un botón `.engine` en `web/panel/index.html` y su tarjeta de controles.
+4. Mostrar u ocultar esa tarjeta en `render()` de `panel.js`.
+5. Una entrada en `data/ayuda.json` para el botón de información.
+
+## Audio y BPM
+
+Toda la cadena de audio está **en el navegador** (`stage.js`):
+`getUserMedia` → `GainNode` (la sensibilidad) → `AnalyserNode`. De ahí beben el
+detector de ritmo, Butterchurn, Hydra y los shaders.
+
+El detector (`BeatDetector`) mira la energía de graves, marca un pulso cuando
+supera su media reciente, agrupa los intervalos entre pulsos por parecido y se
+queda con el grupo mayoritario. El resultado se dobla o se divide hasta caer en
+70–180 BPM. Se publica en `fosBeat` y llega a los motores como `u_bpm`/`u_beat`
+(shaders) o `bpm` (Hydra).
+
+La fuente **monitor** («audio del navegador») merece una nota: Chromium oculta
+los monitores de PulseAudio/PipeWire al enumerar dispositivos. El plan B es
+capturar la entrada por defecto y pedir al servidor que **mueva ese stream** al
+monitor de la salida con `pactl`. Solo funciona con servidor y escenario en la
+misma máquina — que es el caso del kiosko y del modo portátil.
+
+## Mapping y capas
+
+Dos módulos, con una división limpia: **`layers.js` produce imágenes** y
+**`mapper.js` las coloca**.
+
+```
+ layers.js mapper.js
+ ───────── ─────────
+ capa 1 → Butterchurn → canvas ┐
+ capa 2 → clip .mp4 → video ├→ resolver(surface) → textura → malla → #output
+ capa 3 → ShaderEngine → canvas ┘ (WebGL)
+ capa 4 → "motor" → el lienzo del motor activo
+```
+
+**`layers.js`** mantiene una instancia por capa y la pone al día con el estado
+(`sync`): crea las nuevas, cambia el preset de las que lo hayan cambiado y
+destruye las borradas. Decide también qué se comparte y qué no:
+
+- Con **estado propio** (Butterchurn, shaders): **una instancia por capa**, para
+ que dos zonas puedan llevar presets distintos.
+- Sin estado (clips, cámara): **compartidas por contenido** con cuenta de usos,
+ así el mismo clip en dos capas se reproduce una sola vez.
+
+Cada capa se renderiza en un lienzo de **640×360**: el mapper la estira al
+deformarla, y esa resolución es lo que hace viable tener varias a la vez en una
+Raspberry.
+
+**`mapper.js`** es el compositor WebGL. Para cada superficie pide su textura al
+`resolver` (que es `FosLayers.fuenteDe`), la sube **una sola vez por fotograma
+y por clave de fuente**, y la dibuja sobre la malla con una homografía por
+celda. Encima aplica opacidad (`uAlpha`) y modo de mezcla (`blendFunc`: normal
+o aditivo), y al final pinta las máscaras en negro.
+
+Las superficies se guardan en coordenadas **normalizadas 0..1**, así que un
+mapping hecho a 1080p sigue valiendo en otra resolución. El formato completo
+está documentado en la cabecera de `mapper.js`. Guía de uso:
+[Projection mapping](mapping.md).
+
+Una capa **no puede ser projectM**: es un proceso nativo con su propia ventana,
+fuera del alcance de un contexto WebGL del navegador. Su equivalente es la
+fuente `butter`, que son los mismos presets de MilkDrop.
+
+## Estructura de archivos
+
+```
+FOSFENO/
+├── backend/server.py Estado, WebSocket, procesos, audio, subidas
+├── web/panel/ Panel de control (móvil): index.html, panel.js, panel.css
+├── web/stage/ Escenario: stage.js (motores + audio), layers.js, mapper.js
+├── web/lib/ Librerías servidas tal cual (butterchurn, hydra, codemirror…)
+├── data/ Contenido y ajustes que persisten (ver abajo)
+├── scripts/ Arranque del kiosko, build de projectM, lib.sh
+├── docs/ Esta documentación
+├── install.sh Instalador multi-distro (Debian, Fedora, Arch, openSUSE)
+└── fosfeno Lanzador del modo portátil
+```
+
+`data/` mezcla dos cosas: **contenido editable** que sí va al repositorio
+(`hydra-sketches.json`, `hydra-snippets.json`, `shaders.json`, `ayuda.json`,
+`presets-projectm/`) y **estado de cada instalación** que no va
+(`mapping.json`, `pm-favoritos.json`, `videos/`) — ver `.gitignore`.
+
+## Criterios que sigue el código
+
+Vale la pena respetarlos al tocar algo:
+
+- **Nunca callar un fallo.** Todo error acaba en la banda de avisos del panel
+ con una frase que dice qué hacer, no un volcado técnico. Para eso está
+ `report()` en el escenario y `notify()` en el servidor.
+- **Que un fallo no se lleve el resto por delante.** Sin cámara, sin micro o sin
+ projectM, lo demás sigue proyectando.
+- **El estado manda.** Si algo hay que recordar entre clientes, va al estado; no
+ se guardan verdades locales en el panel.
+- **Textos en castellano y sin jerga.** Tanto la interfaz como los avisos están
+ escritos para alguien que no ha leído el código.
+- **Nada de recursos huérfanos.** Cada instancia de motor lleva su `destruir()`
+ y suelta lo suyo: `requestAnimationFrame`, temporizadores, `MediaStream` (el
+ piloto de la cámara) y **el contexto WebGL** (`soltarGL`). El navegador solo
+ aguanta ~16 contextos y al pasarse mata el más viejo, que es el del
+ compositor de mapping: dejarlos colgando acaba en proyector negro.
+- **Cambiar un ajuste no recrea el motor.** En `layers.js`, la firma de una
+ fuente decide si la instancia se reaprovecha; los ajustes (preset, cambio
+ automático) se aplican en caliente con `aplicar()`.
+
+### Trampa conocida: `hydra.hush()` vacía las fuentes
+
+Al salir del Mezclador hacia otro motor se llama a `hydra.hush()`, y eso
+**borra `s0` y `s1`**. Por eso el escenario recuerda en `mixerS1` qué elemento
+tenía enganchado y lo vuelve a enlazar (`reengancharMixer`) al volver: sin
+eso, ir a Butter y volver dejaba el mezclador sin la capa de encima. Si algún
+día se añade otra fuente de Hydra, hay que recordarla igual.
diff --git a/docs/assets/README.md b/docs/assets/README.md
index bfca0c4..d210788 100644
--- a/docs/assets/README.md
+++ b/docs/assets/README.md
@@ -6,7 +6,14 @@ Material gráfico que usa la documentación.
- `raspberry-pi-5.jpg` — la placa Raspberry Pi 5. Se usa en `requisitos.md`.
- `milkdrop.jpg` — ejemplo de visuales estilo MilkDrop. Se usa en `uso.md`.
- `hydra.png` — el entorno de Hydra. Se usa en `uso.md`.
-- `panel.png` — captura del panel de control. Se usa en `uso.md` y el README.
+- `panel.png` — captura del panel de control (pestaña MOTORES). Se usa en
+ `uso.md` y el README.
+- `mezclador.png` — la tarjeta del Mezclador VJ con visuales de fondo. Se usa
+ en `mezclador.md`.
+- `mapping.png` — la pestaña MAPPING con la previsualización. Se usa en
+ `mapping.md`.
+- `capas.png` — tres capas con visuales distintos (dos MilkDrop + un clip +
+ una franja de shader). Se usa en `mapping.md` y el README.
- `demo.mp4` — vídeo corto de FOSFENO en marcha. Se usa en el README
(recomprimido para que no pese; el original eran 80 MB).
diff --git a/docs/assets/capas.png b/docs/assets/capas.png
new file mode 100644
index 0000000..0ada21e
Binary files /dev/null and b/docs/assets/capas.png differ
diff --git a/docs/assets/mapping.png b/docs/assets/mapping.png
new file mode 100644
index 0000000..1076860
Binary files /dev/null and b/docs/assets/mapping.png differ
diff --git a/docs/assets/mezclador.png b/docs/assets/mezclador.png
new file mode 100644
index 0000000..a4eb488
Binary files /dev/null and b/docs/assets/mezclador.png differ
diff --git a/docs/assets/panel.png b/docs/assets/panel.png
index 9d5d1e5..16ea556 100644
Binary files a/docs/assets/panel.png and b/docs/assets/panel.png differ
diff --git a/docs/conexion.md b/docs/conexion.md
index 263c77c..245aef4 100644
--- a/docs/conexion.md
+++ b/docs/conexion.md
@@ -36,7 +36,7 @@ a la Raspberry el nombre de red `fosfeno`, y `fosfeno.local` es la forma
estándar de localizar un equipo por su nombre en la red local.
**Escribir la dirección IP.** Es la que aparece en la pantalla de conexión del
-proyector, algo como `http://192.168.1.71/`. Funciona siempre, pero la IP puede
+proyector, algo como `http://192.168.1.XX/`. Funciona siempre, pero la IP puede
cambiar de un día para otro, así que es la opción de reserva.
## El móvil tiene que estar en la misma red
diff --git a/docs/mapping.md b/docs/mapping.md
index 682e3eb..5376940 100644
--- a/docs/mapping.md
+++ b/docs/mapping.md
@@ -1,50 +1,164 @@
# Projection mapping
+> **Pestañas de capa.** Cada capa tiene su propia pestaña arriba, junto a
+> MOTORES y MAPPING, y el botón **+ CAPA** crea una y te deja ya dentro de
+> ella. Esa vista enseña solo la previsualización y los ajustes de *esa* capa:
+> es la forma de trabajar cuando hay varias, sin ir y volver a la lista.
+>
+> **Visuales de projectM en una capa.** La fuente "Visuales MilkDrop" tiene
+> ahora un selector de **biblioteca**: los ~100 presets de Butterchurn, o los
+> **9795 `.milk` de projectM** (categoría / visual / variante, o "uno al
+> azar"). Los `.milk` se traducen a Butterchurn en el propio navegador al
+> elegirlos. Ver `pendiente.md` punto 2 para el detalle y sus límites.
+
+
FOSFENO puede **deformar las visuales** para encajarlas en superficies físicas
(una pared en ángulo, cajas, un objeto, varias zonas de una fachada) en vez de
proyectar un simple rectángulo. Todo se hace **desde el panel**, arrastrando con
el ratón o el dedo, y se **guarda solo**.
+Y no es una sola imagen troceada: **cada zona es una capa con su propio
+visual**. MilkDrop a la derecha, otro preset distinto a la izquierda y un vídeo
+en el centro, cada uno en su sitio y a la vez. Eso está en
+[Capas: un visual distinto en cada zona](#capas-un-visual-distinto-en-cada-zona).
+
Se aplica a los motores que corren en el navegador: **Butterchurn, Hydra,
Shaders y Mezclador**. (projectM nativo aún no; ver [Limitaciones](#limitaciones).)
+## Dónde está: la pestaña MAPPING
+
+El mapping tiene **su propia pestaña**, a la derecha del título **FOSFENO**, al
+lado de **MOTORES**. Están separados a propósito: MOTORES es el directo
+(encendido, audio, motores) y MAPPING es el montaje. Así ni se estorban ni se
+confunden.
+
+En la pestaña MAPPING la tarjeta ocupa el ancho entero, para que la
+previsualización sea grande y se puedan arrastrar los puntos con comodidad —
+también con el dedo, desde el móvil.
+
+Mientras el mapping esté activado, la pestaña lleva un **punto verde**: desde
+MOTORES se ve de un vistazo que la salida se está deformando.
+
+
+
+
+
## En 4 pasos
-1. Elige un motor que **no** sea projectM (Butterchurn, Hydra, Shaders o Mezcla).
-2. En el panel, sección **Mapping**, marca **Activar mapping**.
-3. Pulsa **+ Superficie**. Aparece un recuadro con las visuales en la
- previsualización del panel (y en el proyector).
-4. En la **previsualización** del panel, **arrastra los puntos** para encajar la
- forma sobre tu superficie real. Se ve en el proyector al instante.
+1. Entra en la pestaña **MAPPING** y marca **Activar mapping**.
+2. Pulsa **+ Capa**. Aparece un recuadro en la previsualización del panel (y en
+ el proyector).
+3. **Arrastra los puntos** para encajar la forma sobre tu superficie real. Se ve
+ en el proyector al instante.
+4. Con la capa seleccionada, en **Propiedades** eliges **qué se proyecta ahí**.
-## La sección Mapping del panel
+## La pestaña MAPPING por dentro
+
+Tiene tres bloques, de arriba abajo: la **salida**, las **capas** y las
+**propiedades** de la capa que tengas seleccionada.
+
+**Salida**
- **Activar mapping** — enciende/apaga la deformación.
-- **Modo edición** — muestra u oculta los tiradores en la **ventana de las
- visuales** (por si prefieres ajustar ahí con un ratón). Para el uso normal no
- hace falta: con la previsualización del panel basta.
-- **+ Superficie** — añade una superficie nueva (un cuadrilátero).
+- **Editar en el escenario** — saca los tiradores y la malla de colores
+ también sobre las propias visuales (por si prefieres ajustar ahí con un
+ ratón). Para el uso normal **no hace falta**: con la previsualización del
+ panel basta, y así el proyector sale limpio. Es un modo de montaje, no un
+ ajuste: **arranca siempre apagado**, aunque lo dejaras puesto.
+- **Previsualización** — el recuadro donde arrastras los puntos (ratón o dedo).
+ Cada capa lleva escrito su nombre y qué visual tiene dentro.
+
+**Capas**
+
+- **+ Capa** — añade una zona nueva. Sale escalonada, para que no tape a la
+ anterior, y queda seleccionada.
- **+ Máscara** — añade una **máscara**: un recuadro **negro** que tapa una zona
(útil para recortar el derrame de luz fuera de la superficie física).
-- **Reset** — borra todas las superficies y máscaras.
-- **Previsualización** — el recuadro donde arrastras los puntos (ratón o dedo).
- Muestra una rejilla dentro de cada superficie para que veas cómo se deforma.
-- **Lista** — cada superficie y máscara con su tamaño de malla y un botón **✕**
- para borrarla.
+
+> **Capa ≠ máscara.** Una **capa** proyecta algo (eliges qué en Propiedades);
+> una **máscara** solo tapa y no lleva visual. Es la confusión más fácil: si
+> creaste una máscara buscando dónde poner el visual, selecciónala y pulsa
+> **Convertirla en capa** — se sustituye por una capa en el mismo sitio y con
+> la misma forma.
+- **Reset** — borra todas las capas y máscaras.
+- La **lista** se lee como en cualquier mesa de VJ: **la de arriba es la que
+ queda delante** en el proyector. Cada fila tiene:
+
+ | Botón | Qué hace |
+ |-------|----------|
+ | ◉ / ○ | Enciende o apaga la capa sin borrarla |
+ | ▲ ▼ | La sube o la baja en el orden de pintado |
+ | ✕ | La borra |
+
+**Propiedades** — nombre, qué se proyecta, opacidad, mezcla y malla. Se explican
+abajo.
+
+## Capas: un visual distinto en cada zona
+
+Esto es lo que separa el mapping de FOSFENO de un simple recorte: **cada capa
+elige su propia fuente**, así que la proyección puede llevar cosas distintas a
+la vez.
+
+| Fuente | Qué es | Coste |
+|--------|--------|-------|
+| **El motor de MOTORES** | Lo que esté puesto en la otra pestaña. En varias capas a la vez, todas enseñan lo mismo | ninguno |
+| **Visuales MilkDrop propias** | Un Butterchurn **solo para esa capa**, con su preset. Es lo que permite dos looks de MilkDrop distintos a la vez | alto |
+| **Shader GLSL** | Uno de los shaders de la librería, reaccionando al audio | medio |
+| **Clip o imagen** | Un archivo de `data/videos`. En bucle y sin sonido | bajo |
+| **Cámara** | La webcam | bajo |
+| **Negro** | Nada: tapa la zona sin usar máscara | ninguno |
+
+Las capas de MilkDrop y de shader **son motores de verdad funcionando**: cada
+una se renderiza aparte y el mapper la estira al deformarla. Los clips y la
+cámara, en cambio, **se comparten**: si dos capas usan el mismo clip, se
+reproduce una sola vez.
+
+### La receta del ejemplo
+
+Tres zonas, tres visuales distintos:
+
+1. **+ Capa** → nómbrala «Pared izquierda» → fuente **Visuales MilkDrop
+ propias** → elige un preset.
+2. **+ Capa** → «Centro» → fuente **Clip o imagen** → elige tu vídeo.
+3. **+ Capa** → «Pared derecha» → fuente **Visuales MilkDrop propias** → *otro*
+ preset, o marca **Cambiar de preset solo** para que vaya rotando.
+4. Arrastra cada una a su sitio en la previsualización.
+
+
+
+
+
+### Transiciones
+
+Las capas de **Visuales MilkDrop** llevan su propio deslizador de
+**transición (0–8 s)**: es lo que tarda un preset en disolverse en el
+siguiente. A 0 el cambio es un corte seco; subiéndolo, uno se funde con el
+otro sin que se note. Combinado con **Cambiar de preset solo**, la capa va
+alternando visuales sola y en suave.
+
+El fundido lo hace el propio Butterchurn mientras carga, así que no cuesta
+rendimiento aparte.
+
+### Opacidad y mezcla
+
+Cuando dos capas se solapan, mandan estos dos ajustes:
+
+- **Opacidad** — cuánto deja ver lo que hay debajo.
+- **Mezcla** — **Normal** tapa lo de debajo; **Sumar** apila luz, así que las
+ zonas oscuras de la capa dejan pasar la de abajo. Sumar es lo que da los
+ solapes bonitos entre visuales; normal es lo que quieres cuando cada capa va
+ en su pared y no se tocan.
## Superficies con malla (curvar)
-Una superficie nueva es un cuadrilátero de 4 esquinas (corrección de
-perspectiva / *keystone*). Para superficies **curvas**:
+Una capa nueva es un cuadrilátero de 4 esquinas (corrección de perspectiva /
+*keystone*). Para superficies **curvas**:
-1. Selecciona la superficie (toca dentro de ella en la previsualización).
-2. Aparece **− malla / + malla**: cada **+ malla** la subdivide (2×2, 3×3…
- hasta 6×6).
+1. Selecciona la capa (toca dentro de ella en la previsualización).
+2. En Propiedades, **Malla**: cada **+** la subdivide (2×2, 3×3… hasta 6×6).
3. Ahora puedes arrastrar **cualquier punto** de la rejilla (no solo las
esquinas) para curvarla y adaptarla a superficies no planas.
-Puedes crear **varias superficies** y mapear la misma imagen en distintas zonas.
-
## Máscaras
Una máscara es un cuadrilátero **negro** que se pinta encima de todo. Sirve para
@@ -60,18 +174,39 @@ cero, usa **Reset** o borra ese archivo.
## Editar en la ventana de las visuales (opcional)
-Con **Modo edición** activo, los tiradores también aparecen sobre las propias
-visuales. Ahí, además, funcionan atajos de teclado:
+Con **Editar en el escenario** activo, los tiradores también aparecen sobre las
+propias visuales, con el nombre y la fuente de cada capa escritos encima. Ahí,
+además, funcionan atajos de teclado:
-- **doble clic** en un hueco: crea una superficie nueva.
-- **+ / −**: subdivide/reduce la malla de la superficie seleccionada.
-- **Supr**: borra la superficie o máscara seleccionada.
+- **doble clic** en un hueco: crea una capa nueva.
+- **+ / −**: subdivide/reduce la malla de la capa seleccionada.
+- **Supr**: borra la capa o máscara seleccionada.
+
+## Cuánto aguanta
+
+Cada capa de **MilkDrop** o de **shader** es un motor de verdad renderizando
+aparte, así que tienen un coste real:
+
+| Equipo | Capas de MilkDrop / shader | Capas de clip o cámara |
+|--------|---------------------------|------------------------|
+| Portátil con GPU | 3–4 sin despeinarse | muchas |
+| Raspberry Pi 5 | 2, con margen justo | varias |
+| Raspberry Pi 4 | 1 | unas pocas |
+
+Cada capa se renderiza a **640×360** y el mapper la estira al deformarla: en una
+zona de la proyección no se nota, y es lo que hace viable tener varias a la vez.
+Si va lenta, lo primero que hay que quitar son las capas de MilkDrop de más;
+clips y cámara cuestan mucho menos, y además **se comparten** entre capas.
## Limitaciones
-- **projectM nativo no se mapea todavía.** projectM corre en su propia ventana
- fuera del navegador, así que el mapper (que trabaja sobre las visuales web) no
- lo alcanza. Para mapear el mismo tipo de gráficos, usa **Butterchurn**
- (MilkDrop en el navegador). El soporte de projectM está en estudio.
-- El mapping es una capa de deformación sobre la imagen final; no cambia el
- contenido de cada motor.
+- **projectM nativo no puede ir en una capa.** projectM corre en su propia
+ ventana fuera del navegador, así que el mapper no lo alcanza. Para el mismo
+ tipo de visual, usa la fuente **Visuales MilkDrop propias**: son los mismos
+ presets de MilkDrop, dentro del navegador, y además puedes tener varios
+ distintos a la vez. Meter la ventana nativa como capa (capturándola) está en
+ estudio.
+- El mapping deforma y compone; no cambia el contenido de cada motor.
+
+Cómo está montado por dentro (el compositor WebGL, las capas, el formato de las
+superficies): [Arquitectura](arquitectura.md#mapping-y-capas).
diff --git a/docs/mezclador.md b/docs/mezclador.md
new file mode 100644
index 0000000..50eca27
--- /dev/null
+++ b/docs/mezclador.md
@@ -0,0 +1,182 @@
+# El Mezclador VJ (mezclas)
+
+El **Mezclador** es el motor de vídeo de FOSFENO. No es un motor de visuales
+generativas como los otros: es una **mesa de mezclas de dos capas** que combina
+en directo una capa de fondo con un clip, y les aplica efectos de color.
+
+Se elige con el botón redondo **Mezcla** del apartado *Motor de visuales*, en la
+pestaña **MOTORES**.
+
+## El modelo mental: siempre dos capas
+
+Todo el mezclador se entiende con esta idea. Hay **dos capas y solo dos**, y
+siempre son las mismas:
+
+```
+ ┌──────────────────────────────────────────────┐
+ │ CAPA DE ENCIMA :: el CLIP │ un vídeo o una imagen
+ │ (data/videos/…) │ de la galería
+ ├──────────────────────────────────────────────┤
+ │ CAPA DE ABAJO :: el FONDO │ la cámara web
+ │ (cámara o visuales Butter / MilkDrop) │ o las visuales Butter
+ └──────────────────────────────────────────────┘
+ ▼
+ [ modo de mezcla ] → proyector
+```
+
+La tarjeta del panel está numerada en ese mismo orden, y ese orden es la
+respuesta a casi cualquier duda:
+
+| Paso | Qué decide |
+|------|------------|
+| **1 · Qué se ve** | si sale solo el fondo, solo el clip, o los dos |
+| **2 · Fondo** | qué hay en la capa de abajo: **cámara** o **visuales Butter** |
+| **3 · Clip** | qué vídeo o imagen va en la capa de encima |
+| **4 · Cómo se juntan** | el modo de mezcla (solo aparece si se ven los dos) |
+
+Debajo del paso 4 hay una **línea naranja de resumen** que dice, en una frase,
+lo que va a salir por el proyector con los ajustes que tengas puestos. Si en
+algún momento no sabes qué estás montando, esa línea lo dice.
+
+
+
+
+
+## Poner visuales de fondo (MilkDrop detrás del vídeo)
+
+Esto es lo que suele costar encontrar, así que va aparte. **Sí está integrado**
+y funciona así:
+
+1. Motor **Mezcla**.
+2. Paso 2, desplegable **Fondo (capa de abajo)** → **Visuales Butter
+ (MilkDrop)**.
+3. La cámara se apaga sola (piloto incluido) y su sitio en la capa de abajo lo
+ ocupan las visuales de MilkDrop.
+4. Justo debajo aparece el **preset de MilkDrop** que hace de fondo, con
+ **« Anterior / Siguiente »** y la casilla **Cambiar el fondo solo**.
+
+Ese preset es **el mismo del motor Butter**: lo que elijas aquí queda elegido
+también allí, y al revés. El cambio automático también es el mismo (los
+segundos o los compases se ajustan en la tarjeta del motor Butter).
+
+> **projectM no puede ir de fondo.** projectM es un programa nativo que corre
+> en su propia ventana, fuera del navegador, así que el mezclador no puede
+> leerlo como capa. El fondo de visuales es siempre Butterchurn — que es el
+> mismo MilkDrop, con los mismos presets, dentro del navegador.
+
+## Receta: un personaje recortado sobre las visuales
+
+Es el uso estrella del mezclador y encadena todo lo anterior:
+
+1. **1 · Qué se ve** → **Los dos**.
+2. **2 · Fondo** → **Visuales Butter (MilkDrop)**, y elige ahí el preset.
+3. **3 · Clip** → un clip de silueta sobre negro (los `_silueta-negro` que
+ genera DETEKTION, el proyecto hermano de `VISUALES/`, o cualquier vídeo con
+ fondo negro).
+4. **4 · Cómo se juntan** → **Recorte (personaje delante)**.
+
+El negro del clip se vuelve transparente y la figura queda **delante** de
+MilkDrop, latiendo con la música. En este modo el primer deslizador deja de ser
+la mezcla A/B y pasa a llamarse **Umbral del recorte**: súbelo si queda un halo
+oscuro alrededor de la figura, bájalo si se está comiendo partes del personaje.
+
+## Paso 1 · Qué se ve
+
+- **Solo el fondo** — a pantalla completa. El clip no se usa.
+- **Solo el clip** — a pantalla completa. El fondo no se usa (ni la cámara ni
+ las visuales Butter: si eliges esto, el fondo queda atenuado en el panel).
+- **Los dos** — se combinan según el paso 4.
+
+Los controles que en ese momento no pintan nada **no desaparecen: se atenúan**,
+para que se vea que existen sin que despisten.
+
+## Paso 2 · Fondo (capa de abajo)
+
+**Cámara.** Con la casilla *Cámara activada* y, si tienes más de una, el
+desplegable para elegirla. Si la cámara falla, el aviso sale en la banda de
+arriba del panel; para reintentar, apaga y enciende la casilla.
+
+**Visuales Butter (MilkDrop).** Lo explicado más arriba. La cámara se libera de
+verdad mientras tanto.
+
+## Paso 3 · Clip (capa de encima)
+
+Los clips salen de la carpeta `data/videos/`. Hay dos formas de meterlos:
+
+- **Copiándolos a la carpeta** y pulsando **Actualizar lista**.
+- **Con el botón Subir vídeo o imagen**, desde el móvil o el ordenador, sin
+ tocar carpetas.
+
+Formatos que van bien:
+
+| Tipo | Formatos | Nota |
+|------|----------|------|
+| Vídeo | `.mp4` (H.264), `.webm` | En Raspberry, 720p o menos |
+| Imagen | `.jpg`, `.png`, `.gif`, `.webp` | Se queda fija como capa |
+
+Los vídeos se reproducen **en bucle y sin sonido** (FOSFENO escucha la música de
+la sala, no la del clip).
+
+## Paso 4 · Cómo se juntan
+
+Solo aparece con **Los dos**.
+
+| Modo | Qué hace |
+|------|----------|
+| **Recorte** | El negro del clip se vuelve transparente: la figura queda **delante** del fondo. El deslizador pasa a ser el umbral |
+| **Fundido** | Disuelve una capa sobre la otra según el deslizador de mezcla |
+| **Diferencia** | Resta las dos capas: contornos y colores invertidos donde coinciden |
+| **Multiplicar** | Oscurece: solo sobrevive lo que es claro en las dos |
+| **Sumar** | Aclara: suma la luz de las dos capas |
+| **Capa** | Superpone el clip usando su canal alfa |
+
+## Los efectos de color
+
+Se aplican **al resultado ya mezclado**, en este orden (importa: el
+caleidoscopio deforma antes de que el color entre en juego):
+
+```
+caleidoscopio → rotación → pixelado → tono → saturación → contraste →
+brillo → colorama → posterizar → invertir → pulso al ritmo → feedback
+```
+
+- **Mezcla A/B** — cuánta capa de encima frente a la de abajo (o el umbral, en
+ modo Recorte).
+- **Tono / Saturación / Contraste / Brillo** — corrección de color de toda la
+ vida.
+- **Colorama** — recicla los colores; en valores altos psicodelia pura.
+- **Posterizar** — reduce el número de colores por franjas.
+- **Pixelado** — baja la resolución aparente en bloques.
+- **Caleidoscopio** — repite la imagen en N sectores en espejo.
+- **Rotación** — gira la imagen.
+- **Feedback** — realimenta el fotograma anterior: estelas y túneles. Con
+ valores altos la imagen tarda en limpiarse.
+- **Invertir colores** — el negativo.
+- **Pulso al ritmo** — la imagen late con los graves detectados (usa el mismo
+ análisis de audio que alimenta el BPM).
+
+## Por debajo
+
+El mezclador **no es un motor aparte**: genera código [Hydra](https://hydra.ojack.xyz/)
+a partir de los controles y lo ejecuta. La cámara entra como fuente `s0` (o el
+lienzo de Butterchurn, si el fondo son visuales) y el clip como `s1`. Por eso
+el mezclador y el motor Hydra comparten el mismo lienzo y nunca están activos a
+la vez.
+
+Como todo lo que corre en el navegador, la salida del mezclador **se puede
+mapear**: ver [Projection mapping](mapping.md).
+
+## Si algo no se ve
+
+FOSFENO no se queda callado: el motivo sale en la banda de avisos, arriba del
+panel.
+
+| Aviso | Qué hacer |
+|-------|-----------|
+| «La cámara está apagada» | Marca *Cámara activada* en el paso 2 |
+| «No hay vídeo elegido» | Elige uno en el paso 3 |
+| «No hay vídeos» | Sube uno, o copia archivos a `data/videos` y pulsa *Actualizar lista* |
+| «No se pudo activar la cámara» | Comprueba el cable, pulsa *Buscar dispositivos de nuevo* y apaga/enciende la casilla |
+| «No se pudo cargar …» | El formato no lo traga el navegador: pásalo a `.mp4` (H.264) o `.webm` |
+
+Más casos en [Solución de problemas](problemas.md).
diff --git a/docs/pendiente.md b/docs/pendiente.md
new file mode 100644
index 0000000..d423c1e
--- /dev/null
+++ b/docs/pendiente.md
@@ -0,0 +1,162 @@
+# Pendiente
+
+Lo que sabemos que falta o falla, con el diagnóstico ya hecho para no volver a
+investigarlo desde cero. Ordenado por lo que más duele.
+
+---
+
+## 1. Las capas no son de verdad independientes
+
+**Lo que pasa.** Se pueden poner dos capas con visuales MilkDrop, pero acaban
+siendo **la misma imagen**. Y no hay forma de decir "en esta zona el Mezclador,
+en esta otra Butter", configurando cada una por su cuenta.
+
+Son **tres cosas distintas** mezcladas en el mismo síntoma:
+
+### 1a. Dos capas Butter sin preset elegido salen idénticas — ✅ HECHO
+
+`web/stage/layers.js`, en `crearButter()`, hacía:
+
+```js
+let i = Math.max(0, nombres.indexOf(f.preset));
+```
+
+Con `preset: ""` (lo que quedaba si la lista de presets aún no había llegado
+al panel al crear la capa), `indexOf("")` devuelve `-1` y el `Math.max(0, -1)`
+lo dejaba en **0**: todas las capas así arrancaban en el mismo preset.
+
+**Cómo quedó:** sin preset elegido, cada instancia coge uno **al azar** y lo
+devuelve en `presetElegido`. `sync()` avisa por `deps.onPreset(id, preset)`,
+que `stage.js` (`anotarPresetDeCapa`) escribe en el estado con `set_mapping`,
+así que el panel enseña cuál le tocó en vez de dejar el desplegable en blanco.
+No recrea la instancia: la firma de una capa butter sigue siendo el tipo a
+secas.
+
+### 1b. Las capas puestas en "El motor de MOTORES" comparten imagen — ✅ HECHO
+
+Esa fuente es, literalmente, el lienzo del motor activo. Dos capas así van a
+enseñar lo mismo siempre — no es un fallo, es lo que significa. El problema era
+que **era la fuente por defecto** de toda capa nueva, así que el primer
+resultado que veía cualquiera era "dos capas iguales".
+
+**Cómo quedó:** una capa nueva ya no nace en "motor". `panel.js` tiene
+`fuenteDeCapaNueva()`, que la estrena en MilkDrop con preset al azar
+(contenido distinto desde el primer clic) y solo cae en "motor" si Butterchurn
+todavía no ha dado su lista de presets. Lo usan tanto `+ Capa` como
+`Máscara → Capa`.
+
+### 1c. Falta el Mezclador (y Hydra) como fuente de capa — *(el trabajo de verdad)*
+
+Hoy las fuentes son: motor, butter, shader, clip, cámara, negro. **No está el
+Mezclador**, así que "una zona con el Mezclador y otra con Butter" solo se
+puede hacer vía "motor", y entonces manda el selector global.
+
+Por qué no está: el Mezclador **es** Hydra (genera código Hydra y lo ejecuta), y
+Hydra está montado como instancia única global (`makeGlobal: true`, con `s0`,
+`s1`, `o0` en el espacio global). Para tener dos mezcladores independientes hay
+que instanciar Hydra varias veces con `makeGlobal: false` y usar la API por
+instancia (`h.synth.src(...)`), lo que obliga a reescribir `buildMixerCode` para
+que no dependa de los nombres globales.
+
+**Coste realista:** medio día, más el gasto de GPU de un Hydra por capa. Antes
+de meterse, decidir si compensa frente a la alternativa barata: dejar el
+Mezclador solo como motor a pantalla completa y que las capas tiren de
+MilkDrop/shader/clip, que es lo que ya funciona.
+
+### 1d. Los dos paneles no están relacionados
+
+MOTORES y MAPPING van cada uno por su lado: el preset que eliges en el motor
+Butter y el de una capa MilkDrop son listas distintas, no comparten favoritos,
+y lo que tocas en uno no se refleja en el otro.
+
+**Hacia dónde:** una sola biblioteca de presets y una sola lista de favoritos,
+usada por el motor Butter, por las capas del mapping y por projectM. Esto se
+solapa con el punto 2: es el mismo trabajo de unificación.
+
+---
+
+## 2. projectM en el mapper — ✅ HECHO (camino A)
+
+**Lo que pasaba.** Al elegir projectM no había forma de mapearlo ni de usarlo
+en una capa, y ahí está **la biblioteca de verdad**: 9795 presets `.milk` en 11
+categorías y 189 visuales. Butterchurn solo trae ~100 propios.
+
+**Por qué no se podía.** projectM es un proceso nativo que pinta en su propia
+ventana X11/Wayland. El compositor de mapping es WebGL dentro del navegador y
+solo puede usar como textura lo que vive en la página.
+
+**Cómo quedó.** Se descartó capturar la ventana (frágil en Wayland) y compilar
+projectM a WebAssembly (un proyecto en sí). Se hizo el **camino A**: traducir
+los `.milk` a presets de Butterchurn **en el propio navegador**, al elegirlos.
+
+- `web/lib/milkdrop-preset-converter.min.js` (npm `milkdrop-preset-converter`,
+ añadido a `web/package.json` y a `install.sh`).
+- `layers.js` → `cargarMilk(ruta)`: pide el `.milk` al backend (ya los servía),
+ lo convierte y se lo pasa a `viz.loadPreset()`. Cachea **la promesa**, no el
+ resultado, para que dos capas pidiendo el mismo preset lo conviertan una vez.
+- La capa MilkDrop tiene ahora `biblioteca: "butter" | "projectm"`. La firma de
+ la capa **sigue siendo `'butter'`**, así que cambiar de biblioteca no recrea
+ la instancia ni gasta un contexto WebGL más.
+- El panel tiene selector de biblioteca y, con projectM, los tres niveles
+ (categoría / visual / variante) más un botón de "uno al azar".
+
+**Medido, no supuesto:**
+
+- **100/100** presets de una muestra repartida por toda la biblioteca se
+ convierten sin excepción, incluidos **84/84** de los que llevan shaders
+ `warp_1`/`comp_1` (que eran justo los dudosos).
+- Conversión de un `.milk` real en el navegador: **7 ms**.
+- Una capa del mapper con `Dancer/Glowsticks/285.milk` renderiza sin un solo
+ aviso del escenario.
+
+> Cuidado al medir esto: si el `.milk` no se descarga, Butterchurn **sigue
+> pintando su preset de reserva**. Una prueba que solo mire "¿hay píxeles
+> encendidos?" da OK igual. Por eso la comprobación exige además que el
+> escenario no haya soltado ningún aviso. La primera versión de la prueba dio
+> un falso OK exactamente por esto.
+
+**Lo que queda de este punto:** la conversión no es perfecta al 100 % en
+fidelidad — algún shader complejo puede verse distinto del original en
+projectM. Se convierte y se ve, pero si un preset no convence, la salida es
+probar otra variante. Y falta unificar los favoritos entre el motor projectM
+y las capas (parte del punto 1d).
+
+---
+
+## 3. Los clips salían del revés en el mapping — ✅ ARREGLADO
+
+**Lo que pasaba.** Al poner un vídeo en una capa del mapping, salía volteado
+verticalmente (boca abajo).
+
+**Por qué.** `web/stage/mapper.js`, en `initGL()`, hacía
+`gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)`. Pero el modelo del mapper va
+con el origen **arriba-izquierda** (`vUV.y = 0` es el borde de arriba de la
+superficie) y `texImage2D` ya sube la primera fila de la imagen en `t = 0`.
+Con el flip puesto, `t = 0` pasaba a ser la ÚLTIMA fila: todo del revés.
+Se notaba con los clips porque una visual de MilkDrop es casi simétrica y no
+canta.
+
+**Comprobado**, no deducido: con una fuente mitad roja arriba / mitad azul
+abajo, renderizando el `mapper.js` real en Chromium headless y leyendo el
+píxel de arriba con `readPixels`, antes salía AZUL y ahora sale ROJO.
+
+De paso se midió el **Mezclador** (Hydra, `s1.init({src})`) con la misma
+prueba: ese sale bien, no hay que tocarlo. Si alguna vez se ve del revés ahí,
+no es este fallo.
+
+> Al medir esto, ojo con `readPixels` sobre un lienzo sin
+> `preserveDrawingBuffer`: devuelve negro y es facilísimo leerlo como "está
+> volteado". Hay que reservar el contexto con `preserveDrawingBuffer: true`
+> ANTES de que la librería llame a `getContext`.
+
+---
+
+## 4. Menor: no hay fundido al cambiar de motor
+
+Pasar de Mezcla a Butter es un corte seco. Dentro de una capa MilkDrop sí hay
+transición suave (deslizador de 0–8 s), pero cruzar **dos motores** exige
+renderizar los dos a la vez durante el cruce y fundirlos en el compositor.
+
+Se puede hacer en el mapper (dibujar la capa dos veces, con la fuente vieja
+bajando de alfa y la nueva subiendo), pero cuesta tener los dos motores vivos a
+la vez unos segundos. Decidir si merece la pena.
diff --git a/docs/seguridad.md b/docs/seguridad.md
new file mode 100644
index 0000000..c2e9b25
--- /dev/null
+++ b/docs/seguridad.md
@@ -0,0 +1,94 @@
+# Seguridad
+
+FOSFENO es un **aparato de directo**, no un servicio en internet. Está pensado
+para una red local: tú, tu móvil y el proyector. Esta página cuenta qué asume
+el diseño, qué pasa si la red no es de fiar, y cómo cerrarlo.
+
+## El modelo de amenaza, en una frase
+
+Cualquiera que alcance `http:///` **manda sobre las visuales**.
+Por defecto no hay contraseña: es lo cómodo para casa, un ensayo o una red
+tuya, y es como funcionó desde el principio.
+
+Eso está bien en tu salón. En **la wifi de un bar o de un festival** significa
+que un desconocido puede:
+
+- Apagar o reiniciar el equipo en mitad de la sesión.
+- Cambiar de motor, de preset y de mapping.
+- **Ejecutar código** en el escenario, porque el editor de Hydra/GLSL es
+ precisamente eso, y el navegador del kiosko arranca con permiso automático de
+ **cámara y micrófono**.
+- Llenarte la tarjeta subiendo archivos a la galería.
+
+No es un fallo escondido: es la consecuencia de no pedir credenciales. La
+solución está abajo y son dos líneas de configuración.
+
+## La clave compartida
+
+En `config.json`:
+
+```json
+"auth": {
+ "token": "loquesea-largo-y-tuyo"
+}
+```
+
+O sin tocar el archivo, por variable de entorno:
+
+```bash
+FOSFENO_TOKEN=loquesea-largo-y-tuyo ./fosfeno
+```
+
+Con la clave puesta:
+
+- **El código QR del proyector ya la lleva dentro.** Escaneas y entras, igual
+ que siempre. No hay que teclear nada.
+- Quien solo conozca la IP **no se conecta**: el WebSocket rechaza la conexión,
+ así que no puede ni mirar el estado ni mandar órdenes.
+- Las subidas a la galería también la piden.
+- El escenario la recibe del propio servidor al abrirse. No hay nada que
+ configurar en el navegador.
+
+La contrapartida: si escribes la dirección a mano tendrás que añadir
+`?k=tu-clave` al final. Por eso el QR es el camino cómodo.
+
+> Con `token` vacío (el valor de fábrica) todo funciona exactamente como antes.
+> Actívala cuando pinches fuera de casa.
+
+## Lo que ya está cerrado
+
+Estas no dependen de que actives nada:
+
+| | |
+|---|---|
+| **Path traversal** | Las rutas de archivos (`/panel`, `/stage`, `/lib`, `/data`, `/pm/variantes`) resuelven y comprueban que no salen de su carpeta. Probado con `../`, `%2e%2e%2f`, doble codificación y rutas absolutas |
+| **Nombres de archivo subidos** | Pasan por `secure_filename` y una lista blanca de extensiones. No se puede escribir fuera de `data/videos` ni subir un `.sh` |
+| **Inyección de comandos** | Ningún `shell=True`, ningún `os.system`. Todos los procesos se lanzan con lista de argumentos |
+| **Rutas de presets de projectM** | La categoría y el visual vienen del panel, así que se validan contra la carpeta de presets antes de usarse |
+| **XSS en el panel** | Los nombres de clips, capas y favoritos se pintan con `textContent`, nunca con `innerHTML` |
+| **CORS** | El WebSocket solo acepta su **mismo origen**. Antes admitía cualquiera, así que una web abierta en el móvil de alguien de la sala podía mandar órdenes |
+| **Tamaño de subida** | 512 MB por archivo (antes 4 GB, suficiente para llenar una microSD en tres peticiones) |
+| **Payloads del WebSocket** | Los ajustes se filtran por lista blanca de claves y las capas del mapping se sanean antes de guardarse, así que no se puede meter basura en el estado ni en `data/mapping.json` |
+
+## Lo que NO deberías hacer
+
+- **No lo expongas a internet.** Nada de abrir el puerto 80 en el router ni
+ ponerle un túnel. No está pensado para eso y la clave compartida no es
+ autenticación seria.
+- **No dejes el apagado remoto a mano de una red pública** sin clave. El
+ instalador da permiso `sudo` sin contraseña para `reboot` y `poweroff`
+ (`install.sh`), que es lo que hace que el botón del panel funcione.
+- **No pongas datos personales en `data/videos`.** Cualquiera que entre al
+ panel puede listarlos y reproducirlos.
+
+## Lo que no se sube al repositorio
+
+`.gitignore` deja fuera lo de cada instalación: `.venv/`, `*.log` (el log del
+servidor lleva IPs), `data/mapping.json`, `data/pm-favoritos.json`,
+`data/videos/*` y `data/presets-projectm/`. En el repo solo van código,
+documentación y los presets de fábrica.
+
+## Si encuentras algo
+
+Es un proyecto pequeño de un hacklab: abre una incidencia en el Gitea del
+proyecto contando qué has visto y cómo reproducirlo.
diff --git a/docs/uso.md b/docs/uso.md
index 213acde..aaf3de7 100644
--- a/docs/uso.md
+++ b/docs/uso.md
@@ -18,6 +18,25 @@ conectado con la Raspberry. Rojo quiere decir que se ha perdido la conexión.

+## Las dos pestañas: MOTORES y MAPPING
+
+A la derecha del título **FOSFENO** hay dos pestañas. Separan las dos cosas que
+se hacen con el panel, que no tienen nada que ver entre sí:
+
+- **MOTORES** — el directo. Encendido, audio, sensibilidad, el motor de
+ visuales y sus controles. Es la pestaña de siempre y la que está abierta al
+ arrancar.
+- **MAPPING** — el montaje. Encajar la imagen sobre la superficie física:
+ superficies, malla y máscaras, con una previsualización grande para arrastrar
+ los puntos.
+
+Lo normal es pasar por MAPPING una vez al montar y quedarse en MOTORES el resto
+de la noche. Si el mapping está activado, aparece un **punto verde** en su
+pestaña, para que se sepa que la salida se está deformando aunque estés mirando
+MOTORES.
+
+El panel recuerda en qué pestaña lo dejaste.
+
## Avisos y errores
Justo debajo de la cabecera aparecen los avisos. Si algo va mal (la cámara no
@@ -56,9 +75,10 @@ librería de fragmentos listos para usar; eliges uno y se carga en el editor.
de código. Los shaders reciben información del audio y del ritmo, así que se
mueven con la música.
-**Mezclador.** El modo de vídeo. Mezcla la imagen de una webcam USB con clips
-de vídeo y efectos de color. Es lo más parecido a un programa de VJ como
-Resolume, pero funcionando dentro de la Raspberry.
+**Mezclador.** El modo de vídeo. Monta dos capas —un fondo (la webcam o las
+propias visuales de MilkDrop) y un clip encima— y las combina con efectos de
+color. Es lo más parecido a un programa de VJ como Resolume, pero funcionando
+dentro de la Raspberry.
## Audio y BPM
@@ -92,15 +112,23 @@ código es GLSL y tienes los uniforms `u_time`, `u_bass`, `u_mid`, `u_treble`,
## El modo Mezclador
-Primero copia tus clips de vídeo en la carpeta `data/videos` del proyecto.
-Aparecen solos en el desplegable de vídeo del panel. Para que vayan finos en
-la Raspberry conviene que sean clips cortos, en 720p o menos y en H.264.
+El mezclador monta **dos capas**: un **fondo** (la cámara web, o las visuales
+Butter/MilkDrop) y un **clip** encima (un vídeo o una imagen de la galería). La
+tarjeta del panel va numerada en ese orden: qué se ve, el fondo, el clip y cómo
+se juntan. Debajo hay una línea de resumen que dice, en una frase, lo que va a
+salir por el proyector.
-En el panel eliges la fuente: solo la cámara, solo el vídeo, o la mezcla de
-las dos. Debajo tienes el modo de mezcla y una fila de controles de color:
-tono, saturación, contraste, brillo, colorama, posterizado, pixelado,
-caleidoscopio, rotación y feedback. La casilla de pulso al ritmo hace que la
-imagen lata con los graves.
+Para **poner visuales de fondo**, en el paso 2 cambia el desplegable de *Cámara*
+a **Visuales Butter (MilkDrop)**: la cámara se apaga y su sitio lo ocupan las
+visuales, con su propio selector de preset ahí mismo. Combinado con un clip de
+silueta y el modo *Recorte*, el personaje queda delante de MilkDrop.
+
+Los clips se copian a `data/videos` o se suben desde el propio panel con el
+botón *Subir vídeo o imagen*. Para que vayan finos en la Raspberry conviene que
+sean cortos, en 720p o menos y en H.264.
+
+La guía completa, con los modos de mezcla, los efectos de color y las recetas:
+[El Mezclador VJ](mezclador.md).
## Apagar y reiniciar
diff --git a/docs/visuales.md b/docs/visuales.md
new file mode 100644
index 0000000..a18c3c8
--- /dev/null
+++ b/docs/visuales.md
@@ -0,0 +1,160 @@
+# La galería de visuales
+
+Los clips que se ven en el Mezclador y en las capas del mapping viven en
+`data/videos`. Además del archivo suelto, hay un **catálogo**
+(`data/visuales.json`) con la ficha de cada uno: de qué película sale, quién
+aparece, cuánto dura y — lo importante — **si es una silueta de verdad y cómo
+de llena está**.
+
+Sin esa ficha el panel solo puede enseñar `p3-davy-jones-tormenta_silueta-negro.mp4`.
+Con ella enseña *Davy Jones (en la tormenta)*, agrupado bajo *Piratas del
+Caribe: En el fin del mundo*, y marca los que casi no tienen figura.
+
+---
+
+## Cómo se genera
+
+```bash
+cd ~/COFRE/CODERS/MUSIKA/VISUALES/FOSFENO
+
+python3 scripts/catalogar-visuales.py # cataloga y escribe el JSON
+python3 scripts/catalogar-visuales.py --limpiar # + aparta los que no son siluetas
+```
+
+Necesita `ffmpeg`/`ffprobe` y `numpy`. Tarda unos segundos por clip: decodifica
+2 fotogramas por segundo a 160x90 en gris y saca las cuentas de ahí.
+
+Hay que volver a pasarlo **cada vez que entran clips nuevos** en la galería. El
+panel lo recarga solo al pulsar el botón de recargar del Mezclador, o al subir
+un archivo; si el JSON no está, el panel sigue funcionando con los nombres de
+archivo pelados.
+
+## Qué mide, y por qué así
+
+**¿Es una silueta o es un trozo de película tal cual?** En un clip siluetado
+por DETEKTION el fondo es negro **puro** (valor 0), cosa que no pasa nunca en
+una imagen de cine, por oscura que sea. Así que basta con medir qué porcentaje
+del cuadro está a cero:
+
+| | negro puro |
+|---|---|
+| Clips siluetados de la galería | 0,63 – 0,90 |
+| Cortes sin siluetear | 0,04 – 0,06 |
+
+No hay zona gris, y por eso el umbral está en 0,30. No es un número inventado:
+sale de medir la galería entera.
+
+**¿Tiene figura suficiente?** Un clip puede estar perfectamente siluetado y aun
+así no servir, porque la escena era muy oscura y el recorte salió casi vacío.
+Se mide el porcentaje del cuadro por encima de gris 32, y se mira la **mediana**
+(no un fotograma suelto: casi todos los clips tienen tramos vacíos y tramos
+llenos, y juzgar por el fotograma central engaña).
+
+| calidad | criterio | qué significa |
+|---|---|---|
+| `buena` | mediana ≥ 0,03 | se ve bien sobre el fondo |
+| `floja` | mediana < 0,03 | figura pequeña o muy oscura |
+| `vacia` | mediana < 0,005 o más de la mitad de fotogramas vacíos | apenas hay nada |
+| `descartable` | negro puro < 0,30 | no es una silueta: se ve el fondo |
+
+## Qué hace `--limpiar`
+
+Los `descartable` (los que enseñan el fondo entero de la película) se **mueven**
+a `data/videos/_descartados/`. No se borran: si un día quieres volver a pasarlos
+por DETEKTION, siguen ahí. El servidor no los sirve, porque solo lista archivos
+sueltos de `data/videos`, no subcarpetas.
+
+Los `floja` y `vacia` **no se tocan**: son siluetas correctas, solo que oscuras.
+Se quedan en la galería marcadas, y ya decides tú si las usas — sobre un fondo
+de MilkDrop brillante, una silueta tenue puede funcionar.
+
+## La ficha
+
+```json
+{
+ "archivo": "p3-davy-jones-tormenta_silueta-negro.mp4",
+ "obra": "Piratas del Caribe: En el fin del mundo",
+ "anio": 2007,
+ "titulo": "Davy Jones (en la tormenta)",
+ "tipo": "silueta",
+ "modo": "silueta sobre negro",
+ "origen": "detektion",
+ "personajes": ["Davy Jones"],
+ "etiquetas": ["en la tormenta"],
+ "ancho": 1152, "alto": 480, "fps": 23.98, "duracion": 30.03,
+ "metricas": { "negroPuro": 0.737, "figura": 0.0582,
+ "figuraPico": 0.1974, "brillo": 8.49, "vacios": 0.03 },
+ "clase": "silueta", "calidad": "buena", "aviso": "",
+ "miniatura": "miniaturas/p3-davy-jones-tormenta_silueta-negro.jpg"
+}
+```
+
+La miniatura no es un fotograma cualquiera: es **el fotograma con más figura**
+de todo el clip, que es el que dice de verdad qué vas a ver.
+
+### De dónde salen los nombres
+
+El catálogo lee las dos convenciones de DETEKTION:
+
+- `siluetear.py` → `_silueta-.mp4` → `tipo: silueta`
+- `recortar.py` → `_13m26s.mp4` → `tipo: corte` (crudo, **sin** siluetear)
+
+y del resto del nombre saca la película (`p1`…`p5`, `hackers`, `tron`…), los
+personajes conocidos y las palabras de escena. Las tablas están arriba del
+todo en `scripts/catalogar-visuales.py`: **para añadir películas o personajes
+nuevos se tocan ahí y ya**.
+
+Un clip que no encaje en ninguna tabla no rompe nada: sale con el nombre
+prettificado y agrupado en "Sin catalogar".
+
+## Transparencia de verdad (canal alfa)
+
+Hay **dos formas** de que un personaje se vea sobre las visuales, y conviene
+no confundirlas:
+
+**1. Recorte por luminancia (lo de siempre).** El clip `_silueta-negro.mp4`
+tiene fondo negro opaco, y es el Mezclador el que lo hace transparente al
+vuelo: `src(s0).layer(src(s1).luma(umbral, 0.15))`. Funciona y va en H.264,
+que es lo que mejor decodifica una Raspberry. Pega: hay un umbral que ajustar,
+y las zonas oscuras del propio personaje (pelo, ropa negra) se comen con él.
+
+**2. Canal alfa de verdad (`_silueta-alfa.webm`).** El clip lleva la
+transparencia dentro, en un WebM con VP9. No hay umbral, no hay bordes
+comidos: los píxeles del fondo simplemente **no existen**, y debajo se ve la
+capa que haya. Se genera con:
+
+```bash
+.venv/bin/python3 siluetear.py clip.mp4 --modo alfa
+```
+
+FOSFENO ya lo compone bien sin tocar nada: el fragment shader del mapper
+saca `gl_FragColor = vec4(c.rgb, c.a * uAlpha)` y mezcla con
+`SRC_ALPHA / ONE_MINUS_SRC_ALPHA`, así que una capa con alfa deja ver la capa
+dibujada antes que ella. **Comprobado**, no supuesto: con una capa de fondo
+roja y encima un clip alfa, el 48,6 % de la pantalla sale roja por los huecos.
+
+> Para que se vea a través hace falta que **haya algo debajo**: otra capa del
+> mapping dibujada antes (MilkDrop, shader, cámara). Si el clip alfa es la
+> única capa, debajo solo está el negro del compositor.
+
+### Dos trampas del alfa en WebM
+
+- **El decodificador VP9 nativo de ffmpeg se come el alfa en silencio**: no
+ falla, devuelve el vídeo entero opaco. Para leerlo hay que pedir
+ `-c:v libvpx-vp9` a mano. Es exactamente por esto que el catalogador mide
+ estos clips con `ffmpeg -c:v libvpx-vp9 ... -vf format=rgba,alphaextract`
+ (y el `format=rgba` no es adorno: sin él `alphaextract` no negocia formato
+ y ffmpeg aborta).
+- **VP9 no tiene decodificación por hardware en la Raspberry Pi 5**, que sí la
+ tiene para H.264/HEVC. En el portátil va sobrado; en la Pi, con clips de
+ 480p cortos debería ir, pero **hay que probarlo antes de un bolo**. Si se
+ atraganta, el camino 1 (negro + luma key) sigue ahí.
+
+## En el panel
+
+`server.py` carga el catálogo en `state.meta.visuales` (`{archivo: ficha}`),
+filtrado contra lo que hay de verdad en disco para que un catálogo viejo no
+enseñe clips borrados. El panel lo usa en `fillClipSelect()`: las listas de
+clips salen agrupadas por película con `