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

12 KiB
Raw Permalink Blame History

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.

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): getUserMediaGainNode (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.

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.