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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:56:01 +02:00

237 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
70180 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.