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>
12 KiB
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 conpactly 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.
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
ENGINESenserver.pyy una rama enstatecon sus ajustes.- Un lienzo en
web/stage/index.htmly su rama enapplyState(). - Un botón
.engineenweb/panel/index.htmly su tarjeta de controles. - Mostrar u ocultar esa tarjeta en
render()depanel.js. - Una entrada en
data/ayuda.jsonpara 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.
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 ynotify()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 conaplicar().
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.