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

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

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

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

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

237
docs/arquitectura.md Normal file
View file

@ -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
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.