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

5
.gitignore vendored
View file

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

View file

@ -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:
<img src="docs/assets/hydra.png" alt="Hydra" width="940">
</div>
## 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.
<div align="center">
<img src="docs/assets/capas.png" alt="Tres capas con visuales distintos" width="900">
</div>
| 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:
<img src="docs/assets/panel.png" alt="Panel de control de FOSFENO" width="940">
</div>
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)

View file

@ -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()

View file

@ -17,6 +17,10 @@
"hostname": "fosfeno",
"_comment": "Nombre de red de la Raspberry. El panel queda en http://<hostname>.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,

View file

@ -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."
]
}
}
}
}

View file

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

View file

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

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.

View file

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

BIN
docs/assets/capas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
docs/assets/mapping.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
docs/assets/mezclador.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Before After
Before After

View file

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

View file

@ -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.
<div align="center">
<img src="assets/mapping.png" alt="La pestaña MAPPING del panel" width="720">
</div>
## 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.
<div align="center">
<img src="assets/capas.png" alt="Tres capas con visuales distintos" width="820">
</div>
### Transiciones
Las capas de **Visuales MilkDrop** llevan su propio deslizador de
**transición (08 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 | 34 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).

182
docs/mezclador.md Normal file
View file

@ -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.
<div align="center">
<img src="assets/mezclador.png" alt="La tarjeta del Mezclador VJ" width="480">
</div>
## 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).

162
docs/pendiente.md Normal file
View file

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

94
docs/seguridad.md Normal file
View file

@ -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://<ip-de-fosfeno>/` **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.

View file

@ -18,6 +18,25 @@ conectado con la Raspberry. Rojo quiere decir que se ha perdido la conexión.
![Panel de control de FOSFENO](assets/panel.png)
## 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

160
docs/visuales.md Normal file
View file

@ -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``<algo>_silueta-<modo>.mp4``tipo: silueta`
- `recortar.py``<peli>_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 `<optgroup>`, con el título legible, la
duración y la marca de calidad (`· poca figura`, `· casi vacio`), y el aviso
completo en el `title` de cada opción.

View file

@ -261,6 +261,10 @@ find_copy() { # find_copy <paquete> <nombre> <destino> <descripcion>
}
find_copy butterchurn butterchurn.min.js lib/butterchurn.min.js "Butterchurn"
find_copy butterchurn-presets butterchurnPresets.min.js lib/butterchurn-presets.min.js "Presets de Butterchurn"
# Traduce los .milk de projectM a presets de Butterchurn: es lo que permite
# usar la biblioteca grande (9795 presets) dentro de una capa del mapping.
find_copy milkdrop-preset-converter milkdrop-preset-converter.min.js \
lib/milkdrop-preset-converter.min.js "Conversor de presets .milk"
copy_lib node_modules/socket.io-client/dist/socket.io.min.js \
lib/socket.io.min.js "Socket.IO"
copy_lib node_modules/qrcode-generator/qrcode.js \

390
scripts/catalogar-visuales.py Executable file
View file

@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""
FOSFENO :: Catalogo de visuales
==========================================================================
Recorre data/videos y escribe data/visuales.json: la ficha de cada clip de
la galeria (de que peli sale, quien aparece, cuanto dura, si es una silueta
de verdad y como de llena esta) mas una miniatura en data/miniaturas.
El panel lo usa para listar los clips por pelicula y con nombre legible en
vez del nombre del archivo, y para avisar de los que casi no tienen imagen.
scripts/catalogar-visuales.py # cataloga y escribe el JSON
scripts/catalogar-visuales.py --limpiar # ademas aparta los que no son
# siluetas (se ve el fondo)
Como distingue una silueta de un trozo de pelicula tal cual: en un clip
siluetado el fondo es negro PURO (valor 0), asi que basta medir que
porcentaje del fotograma esta a cero. En los clips siluetados de la galeria
sale 0,63-0,90; en los recortes sin siluetear, 0,04-0,06. No hay zona gris.
Necesita ffmpeg/ffprobe y numpy.
"""
import argparse
import json
import re
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
try:
import numpy as np
except ImportError:
sys.exit("Falta numpy: pip install numpy (o usa el .venv de DETEKTION)")
BASE = Path(__file__).resolve().parent.parent
VIDEOS = BASE / "data" / "videos"
MINIS = BASE / "data" / "miniaturas"
SALIDA = BASE / "data" / "visuales.json"
DESCARTES = VIDEOS / "_descartados"
VIDEO_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")
IMAGE_EXT = (".jpg", ".jpeg", ".png", ".gif", ".webp")
# Muestreo para las metricas: 2 fotogramas por segundo a 160x90 en gris.
FPS_MUESTRA = 2
MW, MH = 160, 90
# Umbrales (ver cabecera: medidos sobre la galeria real, no inventados)
UMBRAL_SILUETA = 0.30 # % de negro puro a partir del cual es una silueta
UMBRAL_FIGURA = 32 # gris a partir del cual cuenta como figura
VACIA_FIGURA = 0.005 # menos del 0,5% del cuadro = fotograma sin nada
CALIDAD_VACIA = 0.005 # mediana de figura por debajo => clip sin contenido
CALIDAD_FLOJA = 0.03 # mediana por debajo => clip flojo (poca imagen)
MUERTOS_VACIA = 0.50 # mas de la mitad de fotogramas vacios => sin contenido
# --------------------------------------------------------------------------
# Nombres: de que obra sale cada clip y quien aparece
# --------------------------------------------------------------------------
OBRAS = {
"p1": ("Piratas del Caribe: La maldicion de la Perla Negra", 2003),
"p2": ("Piratas del Caribe: El cofre del hombre muerto", 2006),
"p3": ("Piratas del Caribe: En el fin del mundo", 2007),
"p4": ("Piratas del Caribe: En mareas misteriosas", 2011),
"p5": ("Piratas del Caribe: La venganza de Salazar", 2017),
"hackers": ("Hackers", 1995),
"tron": ("Tron", 1982),
"devs": ("Devs", 2020),
"avatar": ("Avatar", 2009),
# Claves de varias palabras: se prueban de la mas larga a la mas corta
"rick-and-morty": ("Rick and Morty", 2013),
"rickmorty": ("Rick and Morty", 2013),
"los-pinguinos-de-madagascar": ("Los pinguinos de Madagascar", 2014),
"pinguinos": ("Los pinguinos de Madagascar", 2014),
"el-club-de-la-lucha": ("El club de la lucha", 1999),
"fightclub": ("El club de la lucha", 1999),
"alien-earth": ("Alien Earth", 2025),
}
# Personajes reconocidos. Las claves con guion son de dos palabras y se
# prueban antes que las sueltas.
PERSONAJES = {
"bootstrap-bill": "Bootstrap Bill Turner",
"davy-jones": "Davy Jones",
"jack": "Jack Sparrow",
"will": "Will Turner",
"elizabeth": "Elizabeth Swann",
"barbossa": "Hector Barbossa",
"barbanegra": "Barbanegra",
"angelica": "Angelica",
"carina": "Carina Smyth",
"henry": "Henry Turner",
"salazar": "Capitan Salazar",
"gibbs": "Joshamee Gibbs",
"pintel": "Pintel",
"ragetti": "Ragetti",
"norrington": "James Norrington",
"beckett": "Cutler Beckett",
"calypso": "Tia Dalma",
}
# Lo que no es un personaje describe la escena. Igual que PERSONAJES, las
# claves con guion son de dos palabras y se prueban antes que las sueltas.
ESCENAS = {
"consejo-piratas": "consejo de piratas",
"nina": "de nina",
"tripulacion": "y su tripulacion",
"pirata": "pirata",
"cielo": "contra el cielo",
"tormenta": "en la tormenta",
"consejo": "consejo",
"piratas": "de piratas",
"duelo": "duelo",
"holandes": "el Holandes Errante",
"singapur": "Singapur",
"sirenas": "sirenas",
}
MODOS = {"negro": "silueta sobre negro", "croma": "croma verde",
"sombra": "sombra solida",
"alfa": "transparencia real (canal alfa)"}
# Morralla de los nombres de descarga: no describe nada del clip.
RUIDO = {"remastered", "bluray", "brrip", "webrip", "web", "webdl", "hdtv",
"extended", "proper", "repack", "x264", "x265", "hevc", "h264",
"aac", "ac3", "dts", "10bit", "1080p", "720p", "2160p", "4k",
"yify", "yts", "eztv", "rarbg", "sample"}
def bonito(txt):
return txt.replace("-", " ").replace("_", " ").strip().capitalize()
def analizar_nombre(stem):
"""Del nombre de archivo saca obra, personajes, etiquetas y titulo."""
# Las dos convenciones de DETEKTION:
# siluetear.py -> <algo>_silueta-<modo>.mp4 (ya recortado)
# recortar.py -> <peli>_13m26s.mp4 (corte crudo, sin siluetear)
tipo, modo, momento, origen = "clip", "", "", "manual"
resto = stem
if "_silueta-" in stem:
resto, modo = stem.split("_silueta-", 1)
tipo, origen = "silueta", "detektion"
elif "_" in stem:
resto, cola = stem.split("_", 1)
if re.fullmatch(r"\d+m\d+s", cola):
momento, tipo, origen = cola, "corte", "detektion"
trozos = resto.split("-")
# Se prueba el prefijo mas largo primero: "rick-and-morty" antes que
# "rick", que si no cada serie de varias palabras se parte mal.
obra = anio = None
for n in range(min(5, len(trozos)), 0, -1):
clave = "-".join(trozos[:n]).lower()
if clave in OBRAS:
obra, anio = OBRAS[clave]
trozos = trozos[n:]
break
if not obra:
# Nombre largo tipo "hackers-1995-remastered-1080p-bluray": busca la
# obra por cualquiera de sus trozos antes de rendirse.
for i, t in enumerate(trozos):
if t.lower() in OBRAS:
obra, anio = OBRAS[t.lower()]
trozos = trozos[i + 1:]
break
if not obra:
obra, anio = bonito(resto), None
personajes, etiquetas = [], []
i = 0
while i < len(trozos):
par = "-".join(trozos[i:i + 2]).lower()
uno = trozos[i].lower()
if par in PERSONAJES:
personajes.append(PERSONAJES[par]); i += 2; continue
if par in ESCENAS:
etiquetas.append(ESCENAS[par]); i += 2; continue
if uno in PERSONAJES:
personajes.append(PERSONAJES[uno])
elif uno in ESCENAS:
etiquetas.append(ESCENAS[uno])
elif re.fullmatch(r"s\d{1,2}e\d{1,2}", uno):
etiquetas.append(uno.upper()) # capitulo: S09E01
elif re.fullmatch(r"\d+m\d+s", uno):
etiquetas.append("min " + uno.replace("m", ":").rstrip("s"))
elif uno and uno not in RUIDO and not uno.isdigit():
etiquetas.append(uno)
i += 1
if personajes:
titulo = " y ".join(personajes)
if etiquetas:
titulo += " (" + ", ".join(etiquetas) + ")"
elif etiquetas:
titulo = ", ".join(etiquetas).capitalize()
elif momento:
titulo = "Fragmento en " + momento.replace("m", " min ").replace("s", " s")
else:
titulo = bonito(resto)
return {"obra": obra, "anio": anio, "titulo": titulo, "tipo": tipo,
"modo": MODOS.get(modo, modo), "momento": momento,
"origen": origen, "personajes": personajes, "etiquetas": etiquetas}
# --------------------------------------------------------------------------
# Medidas
# --------------------------------------------------------------------------
def ffprobe(path):
cmd = ["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate",
"-show_entries", "format=duration", "-of", "json", str(path)]
try:
out = json.loads(subprocess.run(cmd, capture_output=True,
text=True, check=True).stdout)
except (subprocess.CalledProcessError, json.JSONDecodeError):
return None
st = (out.get("streams") or [{}])[0]
fps = 0.0
try:
num, den = st.get("r_frame_rate", "0/1").split("/")
fps = round(float(num) / float(den), 2) if float(den) else 0.0
except (ValueError, ZeroDivisionError):
pass
return {"ancho": st.get("width", 0), "alto": st.get("height", 0),
"fps": fps,
"duracion": round(float(out.get("format", {}).get("duration") or 0), 2)}
def muestrear(path, canal_alfa=False):
"""Fotogramas muestreados en gris, o None si no decodifica.
Con canal_alfa mide la TRANSPARENCIA en vez de la luminancia: hace falta
para los .webm de silueta, donde lo que separa figura de fondo es el
canal alfa y no el negro. Ojo con el decodificador: el vp9 nativo de
ffmpeg se come el alfa en silencio (devuelve todo opaco), asi que hay
que pedir libvpx-vp9 a mano.
"""
cmd = ["ffmpeg", "-v", "error"]
if canal_alfa:
cmd += ["-c:v", "libvpx-vp9"]
cmd += ["-i", str(path), "-vf",
f"fps={FPS_MUESTRA},scale={MW}:{MH},"
# alphaextract necesita que le llegue un formato CON alfa: sin el
# format=rgba delante, el filtro no negocia y ffmpeg aborta
+ ("format=rgba,alphaextract" if canal_alfa else "format=gray"),
"-pix_fmt", "gray", "-f", "rawvideo", "-"]
raw = subprocess.run(cmd, capture_output=True).stdout
n = len(raw) // (MW * MH)
if not n:
return None
return np.frombuffer(raw[:n * MW * MH], dtype=np.uint8).reshape(n, MH, MW)
def medir(fr):
negro = float((fr <= 2).mean())
figura = (fr > UMBRAL_FIGURA).mean(axis=(1, 2))
return {
"negroPuro": round(negro, 3),
"figura": round(float(np.median(figura)), 4),
"figuraPico": round(float(np.percentile(figura, 90)), 4),
"brillo": round(float(fr.mean()), 2),
"vacios": round(float((figura < VACIA_FIGURA).mean()), 2),
"_mejor": int(np.argmax(figura)),
}
def clasificar(m, es_alfa=False):
"""(clase, calidad, aviso). clase: silueta | con-fondo.
Con es_alfa las metricas vienen del canal alfa, no de la luminancia:
'negroPuro' es entonces el porcentaje de cuadro transparente. Un clip
con alfa nunca puede ser 'con-fondo' como mucho, opaco entero.
"""
if m["negroPuro"] < UMBRAL_SILUETA:
if es_alfa:
return ("silueta", "vacia",
"Lleva canal alfa pero casi no hay zona transparente: "
"el recorte no separo la figura del fondo.")
return ("con-fondo", "descartable",
"No esta siluetado: se ve el fondo entero de la pelicula. "
"Pasalo por DETEKTION (siluetear.py) antes de usarlo.")
if m["figura"] < CALIDAD_VACIA or m["vacios"] > MUERTOS_VACIA:
return ("silueta", "vacia",
"Silueta correcta pero casi siempre vacia: apenas hay figura.")
if m["figura"] < CALIDAD_FLOJA:
return ("silueta", "floja",
"Figura pequena o muy oscura: se vera poco sobre el fondo.")
return ("silueta", "buena", "")
def miniatura(path, segundo, destino):
destino.parent.mkdir(parents=True, exist_ok=True)
cmd = ["ffmpeg", "-v", "error", "-ss", f"{segundo:.2f}", "-i", str(path),
"-frames:v", "1", "-vf", "scale=320:-2", "-q:v", "4",
str(destino), "-y"]
return subprocess.run(cmd, capture_output=True).returncode == 0
# --------------------------------------------------------------------------
def catalogar(limpiar=False):
if not VIDEOS.is_dir():
sys.exit(f"No existe {VIDEOS}")
fichas, apartados = [], []
archivos = sorted(f for f in VIDEOS.iterdir()
if f.is_file()
and f.suffix.lower() in VIDEO_EXT + IMAGE_EXT)
for f in archivos:
ficha = {"archivo": f.name, "peso": f.stat().st_size}
ficha.update(analizar_nombre(f.stem))
if f.suffix.lower() in IMAGE_EXT:
ficha.update({"clase": "imagen", "calidad": "buena", "aviso": "",
"tipo": "imagen"})
info = ffprobe(f) or {}
ficha.update(info)
mini = MINIS / (f.stem + ".jpg")
if miniatura(f, 0, mini):
ficha["miniatura"] = "miniaturas/" + mini.name
fichas.append(ficha)
print(f" imagen {f.name}")
continue
info = ffprobe(f)
if not info:
print(f" ILEGIBLE {f.name}")
ficha.update({"clase": "roto", "calidad": "descartable",
"aviso": "ffprobe no puede leerlo."})
fichas.append(ficha)
continue
ficha.update(info)
# Las siluetas con canal alfa se miden por la transparencia
es_alfa = f.name.endswith("_silueta-alfa.webm")
fr = muestrear(f, canal_alfa=es_alfa)
if fr is None:
ficha.update({"clase": "roto", "calidad": "descartable",
"aviso": "No se pudo decodificar ningun fotograma."})
fichas.append(ficha)
print(f" ILEGIBLE {f.name}")
continue
m = medir(fr)
mejor = m.pop("_mejor") / FPS_MUESTRA
clase, calidad, aviso = clasificar(m, es_alfa)
ficha.update({"metricas": m, "clase": clase, "calidad": calidad,
"aviso": aviso})
mini = MINIS / (f.stem + ".jpg")
if miniatura(f, mejor, mini):
ficha["miniatura"] = "miniaturas/" + mini.name
if limpiar and clase == "con-fondo":
DESCARTES.mkdir(parents=True, exist_ok=True)
shutil.move(str(f), str(DESCARTES / f.name))
if mini.exists():
mini.unlink()
apartados.append(f.name)
print(f" APARTADO {f.name} ({aviso})")
continue
fichas.append(ficha)
print(f" {calidad:<10} {f.name[:44]:<44} "
f"negro={m['negroPuro']:.2f} figura={m['figura']:.3f}")
datos = {
"generado": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"total": len(fichas),
"clips": fichas,
}
SALIDA.parent.mkdir(parents=True, exist_ok=True)
SALIDA.write_text(json.dumps(datos, indent=2, ensure_ascii=False),
encoding="utf-8")
print(f"\n{len(fichas)} fichas -> {SALIDA.relative_to(BASE)}")
if apartados:
print(f"{len(apartados)} apartados -> {DESCARTES.relative_to(BASE)}/")
porcal = {}
for x in fichas:
porcal[x.get("calidad", "?")] = porcal.get(x.get("calidad", "?"), 0) + 1
print("Calidad: " + ", ".join(f"{k}={v}" for k, v in sorted(porcal.items())))
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="Cataloga la galeria de FOSFENO")
ap.add_argument("--limpiar", action="store_true",
help="aparta en _descartados/ los clips sin siluetear")
catalogar(ap.parse_args().limpiar)

View file

@ -8,6 +8,7 @@
"butterchurn-presets": "^2.4.7",
"codemirror": "^5.65.16",
"hydra-synth": "^1.3.29",
"milkdrop-preset-converter": "^0.1.2",
"qrcode-generator": "^1.4.4",
"socket.io-client": "^4.7.5"
}

View file

@ -9,15 +9,33 @@
<link rel="stylesheet" href="/lib/codemirror/material-darker.css">
</head>
<body>
<header>
<h1>FOSFENO</h1>
<span id="conn" class="dot off" title="Conexion"></span>
</header>
<!-- Barra superior fija: titulo + pestanas + banda de avisos.
Van juntas en un mismo bloque sticky para que el aviso quede siempre
pegado debajo del titulo, aunque las pestanas salten de linea. -->
<div class="topbar">
<header>
<h1>FOSFENO</h1>
<!-- Dos zonas del panel: MOTORES (lo de siempre) y MAPPING (aparte,
para que no se mezclen los controles de directo con los de
encaje sobre la superficie fisica). -->
<nav class="tabs" id="tabs">
<button class="tab active" data-view="motores">MOTORES</button>
<button class="tab" data-view="mapping">MAPPING<span
id="tab-map-dot" class="tabdot" hidden></span></button>
<!-- Una pestana por capa del mapping: se generan solas (renderTabsCapas)
para poder saltar a editar cada capa sin pasar por MAPPING -->
<span id="tabs-capas"></span>
<button class="tab tabnew" id="tab-nueva-capa"
title="Crear una capa nueva y abrirla">+ CAPA</button>
</nav>
<span id="conn" class="dot off" title="Conexion"></span>
</header>
<!-- Banda de avisos: aqui aparece cualquier error o aviso -->
<div id="notif" class="notif" hidden>
<span id="notif-msg"></span>
<button id="notif-close" aria-label="Cerrar aviso">&times;</button>
<!-- Banda de avisos: aqui aparece cualquier error o aviso -->
<div id="notif" class="notif" hidden>
<span id="notif-msg"></span>
<button id="notif-close" aria-label="Cerrar aviso">&times;</button>
</div>
</div>
<!-- En movil las tarjetas van en una sola columna; en pantalla ancha,
@ -148,38 +166,69 @@
<p class="hint" id="editor-hint"></p>
</section>
<!-- Mezclador VJ -->
<!-- Mezclador VJ.
Se lee de arriba abajo como dos capas: el FONDO (capa de abajo:
camara o visuales Butter) y el CLIP de la galeria (capa de
encima), mas la forma de juntarlos. El resumen de mas abajo
dice en una linea que va a salir por el proyector. -->
<section class="card" id="ctl-mixer" hidden>
<div class="cardhead">
<span class="label">Mezclador VJ</span>
<button class="info" data-help="mixer">i</button>
</div>
<!-- 1. Que se ve -->
<span class="label">1 &middot; Que se ve</span>
<div class="seg" id="mixer-source">
<button class="segbtn" data-src="cam">Camara</button>
<button class="segbtn" data-src="video">Video</button>
<button class="segbtn" data-src="mix">Mezcla</button>
<button class="segbtn" data-src="cam">Solo el fondo</button>
<button class="segbtn" data-src="video">Solo el clip</button>
<button class="segbtn" data-src="mix">Los dos</button>
</div>
<div class="row">
<span class="label">Fondo (capa de abajo)</span>
<!-- 2. Fondo: capa de abajo -->
<div class="row" id="mix-fondo-row">
<span class="label">2 &middot; Fondo (capa de abajo)</span>
<select id="mix-fondo" class="inline">
<option value="cam">Camara</option>
<option value="butter">Visuales Butter (MilkDrop)</option>
</select>
</div>
<label class="check">
<input type="checkbox" id="mix-cam"> Camara activada
</label>
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
<!-- Fondo = camara -->
<div class="stack" id="mix-cam-block">
<label class="check">
<input type="checkbox" id="mix-cam"> Camara activada
</label>
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
</div>
<!-- Fondo = visuales Butter: se elige aqui mismo el preset de
MilkDrop que hace de fondo, sin salir del mezclador. -->
<div class="stack" id="mix-bg-block" hidden>
<select id="mix-bg-preset"><option>Cargando presets...</option></select>
<div class="row">
<button id="mix-bg-prev" class="cmd">&laquo; Anterior</button>
<button id="mix-bg-next" class="cmd">Siguiente &raquo;</button>
</div>
<label class="check">
<input type="checkbox" id="mix-bg-shuffle"> Cambiar el fondo solo
</label>
<p class="hint">Es el mismo preset y el mismo cambio automatico del
motor Butter: lo que elijas aqui queda elegido tambien alli.</p>
</div>
<!-- 3. Clip de la galeria: capa de encima -->
<span class="label" id="mix-clip-label">3 &middot; Clip (capa de encima)</span>
<select id="mix-video"><option value="">-- sin video --</option></select>
<div class="row">
<button id="mix-rescan" class="cmd">Actualizar lista</button>
<button id="mix-upload-btn" class="cmd">Subir video o imagen</button>
<span id="mix-upload-status" class="hint"></span>
</div>
<span id="mix-upload-status" class="hint"></span>
<input type="file" id="mix-upload" hidden
accept="video/*,image/jpeg,image/png,image/gif,image/webp">
<div class="row">
<span class="label">Modo de mezcla</span>
<!-- 4. Como se juntan las dos capas -->
<div class="row" id="mix-blend-row">
<span class="label">4 &middot; Como se juntan</span>
<select id="mix-blend" class="inline">
<option value="recorte">Recorte (personaje delante)</option>
<option value="blend">Fundido</option>
@ -189,6 +238,10 @@
<option value="layer">Capa</option>
</select>
</div>
<!-- Resumen en una linea de lo que sale por el proyector -->
<p class="hint resumen" id="mix-resumen"></p>
<div id="mixer-sliders"></div>
<label class="check">
<input type="checkbox" id="mix-invert"> Invertir colores
@ -248,37 +301,165 @@
deslizador relanza projectM (~1 s en negro).</p>
</section>
<!-- Mapping (projection mapping) -->
<section class="card" id="card-mapping">
<!-- ===================== PESTANA MAPPING ===================== -->
<!-- Salida: encendido y previsualizacion -->
<section class="card" id="card-mapping" data-vista="mapping">
<div class="cardhead">
<span class="label">Mapping</span>
<button class="info" data-help="mapping">i</button>
</div>
<label class="check">
<input type="checkbox" id="map-enabled"> Activar mapping
</label>
<label class="check">
<input type="checkbox" id="map-edit"> Modo edicion (arrastrar en el escenario)
</label>
<div class="row">
<button id="map-add" class="cmd accent">+ Superficie</button>
<label class="check">
<input type="checkbox" id="map-enabled"> Activar mapping
</label>
<label class="check">
<input type="checkbox" id="map-edit"> Editar en el escenario
</label>
</div>
<span class="label">Previsualizacion (arrastra los puntos)</span>
<canvas id="map-preview"></canvas>
<p class="hint">Arrastra los puntos para encajar cada capa en su sitio.
Toca dentro de una capa para seleccionarla. Se guarda solo.</p>
</section>
<!-- Capas: la lista, al estilo de una mesa de VJ -->
<section class="card" id="card-capas" data-vista="mapping">
<div class="cardhead">
<span class="label">Capas</span>
<button class="info" data-help="capas">i</button>
</div>
<div class="row">
<button id="map-add" class="cmd accent">+ Capa</button>
<button id="map-add-mask" class="cmd">+ Mascara</button>
<button id="map-reset" class="cmd">Reset</button>
</div>
<span class="label">Previsualizacion (arrastra los puntos)</span>
<canvas id="map-preview"
style="width:100%;aspect-ratio:16/9;background:#0a0a0a;border:1px solid #333;border-radius:6px;display:block;margin:6px 0;touch-action:none;cursor:crosshair;"></canvas>
<div class="row" id="map-selbar" hidden>
<span class="label" id="map-selinfo">Superficie seleccionada</span>
<button id="map-sub-dec" class="cmd">&minus; malla</button>
<span id="map-sub-val" class="value">1x1</span>
<button id="map-sub-inc" class="cmd">+ malla</button>
</div>
<p class="hint"><b>Capa</b> = una zona que PROYECTA algo (eliges que en
Propiedades). <b>Mascara</b> = un recuadro negro que TAPA lo que haya
debajo; no lleva visual.</p>
<div id="map-list"></div>
<p class="hint">Arrastra los puntos para deformar. Selecciona una
superficie y usa <b>+ malla</b> para subdividir y <b>curvarla</b>.
<b>+ Mascara</b> tapa zonas con un recuadro negro (recorta derrames
de luz). Se guarda solo.</p>
<p class="hint" id="map-list-vacio">Sin capas: con el mapping activado
y ninguna capa, sale el motor a pantalla completa. Pulsa
<b>+ Capa</b> para trocear la proyeccion en zonas.</p>
</section>
<!-- Propiedades de la capa seleccionada -->
<section class="card" id="card-propiedades" data-vista="mapping">
<div class="cardhead">
<span class="label" id="prop-titulo">Propiedades</span>
<button class="info" data-help="fuentes">i</button>
</div>
<p class="hint" id="prop-vacio">Selecciona una capa (en la lista o
tocando dentro de ella en la previsualizacion) para elegir que
visual se proyecta en esa zona.</p>
<div class="stack" id="prop-capa" hidden>
<div class="row">
<span class="label">Nombre</span>
<input type="text" id="prop-nombre" class="txt" maxlength="24"
placeholder="Capa">
</div>
<span class="label">Que se proyecta aqui</span>
<select id="prop-fuente">
<option value="motor">El motor elegido en MOTORES</option>
<option value="butter">Visuales MilkDrop (propias de esta capa)</option>
<option value="shader">Shader GLSL</option>
<option value="video">Clip o imagen de la galeria</option>
<option value="camara">Camara</option>
<option value="negro">Negro (tapar)</option>
</select>
<!-- Ajustes propios de cada fuente -->
<div class="stack fuente-op" id="fop-butter" hidden>
<span class="label">Biblioteca de presets</span>
<select id="prop-biblioteca">
<option value="butter">Butterchurn &mdash; ~100 presets, los mas ligeros</option>
<option value="projectm">projectM &mdash; tu biblioteca .milk (9795)</option>
</select>
<!-- Biblioteca de Butterchurn -->
<div class="stack" id="fop-bib-butter">
<select id="prop-preset"><option>Cargando presets...</option></select>
<div class="row">
<button id="prop-preset-prev" class="cmd">&laquo; Anterior</button>
<button id="prop-preset-next" class="cmd">Siguiente &raquo;</button>
</div>
</div>
<!-- Biblioteca de projectM: los .milk se traducen al vuelo -->
<div class="stack" id="fop-bib-milk" hidden>
<select id="prop-milk-cat"><option value="">-- categoria --</option></select>
<select id="prop-milk-vis"><option value="">-- visual --</option></select>
<select id="prop-milk-var"><option value="">-- variante --</option></select>
<button id="prop-milk-azar" class="cmd">Uno al azar</button>
<p class="hint" id="prop-milk-est">Los .milk se traducen a
Butterchurn en el propio navegador, al elegirlos. Alguno puede
salir distinto al original: si uno no convence, prueba otra
variante.</p>
</div>
<label class="check">
<input type="checkbox" id="prop-auto"> Cambiar de preset solo
</label>
<div class="row" id="prop-auto-row" hidden>
<span class="label">Cada <span id="prop-seg-val" class="value">20</span> s</span>
</div>
<input id="prop-seg" type="range" min="3" max="180" step="1" value="20" hidden>
<div class="row">
<span class="label">Transicion:
<span id="prop-tr-val" class="value">2.5</span> s</span>
</div>
<input id="prop-transicion" type="range" min="0" max="8" step="0.1" value="2.5">
<p class="hint">A 0 el preset cambia de golpe; subiendolo, uno se
disuelve en el siguiente sin que se note el corte.</p>
</div>
<div class="stack fuente-op" id="fop-shader" hidden>
<select id="prop-shader"><option value="">-- elige un shader --</option></select>
</div>
<div class="stack fuente-op" id="fop-video" hidden>
<select id="prop-video"><option value="">-- elige un clip --</option></select>
</div>
<div class="stack fuente-op" id="fop-camara" hidden>
<select id="prop-camara"><option value="0">Camara por defecto</option></select>
</div>
<div class="row">
<span class="label">Opacidad: <span id="prop-op-val" class="value">100</span>%</span>
</div>
<input id="prop-opacidad" type="range" min="0" max="1" step="0.02" value="1">
<div class="row">
<span class="label">Mezcla con lo de debajo</span>
<select id="prop-mezcla" class="inline">
<option value="normal">Normal (tapa)</option>
<option value="sumar">Sumar (apila luz)</option>
</select>
</div>
<div class="row">
<span class="label">Malla (para curvar)</span>
<span class="grupo">
<button id="map-sub-dec" class="cmd">&minus;</button>
<span id="map-sub-val" class="value">1x1</span>
<button id="map-sub-inc" class="cmd">+</button>
</span>
</div>
</div>
<div class="stack" id="prop-mascara" hidden>
<p class="hint">Una mascara solo TAPA: es un recuadro negro que se
pinta encima de todo, para recortar derrames de luz. No lleva
visual, por eso aqui no hay nada que elegir. Arrastra sus esquinas
en la previsualizacion.</p>
<p class="hint resumen">Si lo que querias era proyectar algo en esta
zona, eso es una <b>capa</b>, no una mascara.</p>
<button id="mask-a-capa" class="cmd accent">Convertirla en capa</button>
</div>
</section>
</div>

View file

@ -35,11 +35,17 @@ body {
padding-bottom: 48px;
}
/* --- Cabecera con el titulo centrado --- */
/* --- Barra superior fija: titulo + pestanas + avisos --- */
.topbar {
position: sticky; top: 0; z-index: 10; background: var(--bg);
}
/* --- Cabecera con el titulo centrado y las pestanas a su derecha --- */
header {
position: sticky; top: 0; z-index: 10;
position: relative;
display: flex; align-items: center; justify-content: center;
padding: 16px 20px; border-bottom: 2px solid var(--green-d);
flex-wrap: wrap; gap: 10px 20px;
padding: 14px 46px; border-bottom: 2px solid var(--green-d);
background: var(--bg);
}
header h1 {
@ -50,6 +56,35 @@ header h1 {
#conn {
position: absolute; right: 20px; top: 50%; transform: translateY(-50%);
}
/* --- Pestanas MOTORES / MAPPING --- */
.tabs { display: flex; gap: 8px; }
.tab {
position: relative; cursor: pointer;
font-family: "Xirod", "Arial Black", sans-serif;
font-size: 12px; font-weight: 400; letter-spacing: 0.07em;
padding: 10px 15px; border-radius: 10px;
background: #161d10; border: 2px solid var(--line); color: var(--dim);
}
.tab.active {
border-color: var(--orange); color: var(--orange-n);
box-shadow: 0 0 12px rgba(255, 122, 0, 0.35);
}
/* Punto verde en la pestana MAPPING cuando el mapping esta activado, para
saber desde MOTORES que la salida se esta deformando. */
.tabdot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--green); box-shadow: 0 0 8px var(--green);
margin-left: 7px; vertical-align: middle;
}
/* En movil el titulo baja de tamano para que las pestanas quepan al lado */
@media (max-width: 560px) {
header { padding: 12px 34px; gap: 8px 12px; }
header h1 { font-size: 23px; }
.tab { padding: 9px 11px; font-size: 11px; }
#conn { right: 12px; }
}
.dot { width: 13px; height: 13px; border-radius: 50%; display: inline-block; }
.dot.on { background: var(--green); box-shadow: 0 0 9px var(--green); }
.dot.off { background: var(--red); box-shadow: 0 0 9px var(--red); }
@ -82,6 +117,61 @@ main {
}
}
/* --- Vistas (pestanas) ---
MOTORES ensena todo menos las tarjetas de mapping, y al reves. La vista
"capa" es MAPPING recortado: solo la previa y las propiedades de UNA capa,
sin la lista, para poder centrarse en su efecto. */
body[data-view="motores"] [data-vista="mapping"] { display: none !important; }
body[data-view="mapping"] main > .column > :not([data-vista="mapping"]),
body[data-view="capa"] main > .column > :not([data-vista="mapping"]) {
display: none !important;
}
body[data-view="capa"] #card-capas { display: none !important; }
body[data-view="mapping"] main,
body[data-view="capa"] main {
flex-direction: column; width: 100%; max-width: 900px;
}
body[data-view="mapping"] .column,
body[data-view="capa"] .column { display: contents; }
/* Pestanas de capa: mas pequenas que MOTORES/MAPPING, para que se lean como
lo que son (un atajo a cada capa) y no compitan con las dos principales. */
.tab.tabcapa, .tab.tabnew {
font-size: 0.78em; padding: 6px 10px; letter-spacing: 0.06em;
max-width: 11ch; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
.tab.tabnew { border-style: dashed; opacity: 0.85; }
.tab.tabnew:hover { opacity: 1; }
/* En pantalla ancha el mapping se reparte: la previsualizacion grande a la
izquierda (que es donde se trabaja) y las capas con sus propiedades a la
derecha, sin tener que bajar y subir todo el rato. */
@media (min-width: 1000px) {
body[data-view="mapping"] main {
display: grid; width: 96%; max-width: 1700px;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
grid-template-areas: "previa capas" "previa props" "previa .";
align-items: start; align-content: start; gap: 16px;
}
body[data-view="mapping"] #card-mapping { grid-area: previa; }
body[data-view="mapping"] #card-capas { grid-area: capas; }
body[data-view="mapping"] #card-propiedades { grid-area: props; }
/* La previsualizacion manda: que ocupe todo el alto util disponible */
body[data-view="mapping"] #map-preview { aspect-ratio: 16 / 9; }
/* La vista de una capa: previa a la izquierda, sus ajustes a la derecha */
body[data-view="capa"] main {
display: grid; width: 96%; max-width: 1700px;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
grid-template-areas: "previa props";
align-items: start; align-content: start; gap: 16px;
}
body[data-view="capa"] #card-mapping { grid-area: previa; }
body[data-view="capa"] #card-propiedades { grid-area: props; }
body[data-view="capa"] #map-preview { aspect-ratio: 16 / 9; }
}
/* --- Tarjetas --- */
.card {
background: var(--card); border: 1px solid var(--line);
@ -90,11 +180,12 @@ main {
}
.card.center { align-items: center; }
/* Cabecera de tarjeta: titulo centrado, boton de info a la derecha */
/* Cabecera de tarjeta: titulo centrado, boton de info a la derecha.
El hueco lateral evita que un titulo largo se meta debajo del boton. */
.cardhead {
position: relative; width: 100%;
display: flex; align-items: center; justify-content: center;
min-height: 30px;
min-height: 30px; padding: 0 34px;
}
/* Titulo de cada tarjeta: mas grande y en la fuente Xirod */
.cardhead .label {
@ -108,8 +199,12 @@ main {
.hint { color: var(--dim); font-size: 12px; line-height: 1.45; }
.row { display: flex; align-items: center; justify-content: space-between;
gap: 10px; }
gap: 10px; flex-wrap: wrap; }
.row .cmd { flex: 1; }
/* En una fila de etiqueta + desplegable, el desplegable no baja de un ancho
usable: si no cabe, la fila parte en dos lineas en vez de estrujarlo. */
.row .label { flex: 1 1 auto; }
.row select.inline { flex: 0 1 auto; min-width: 148px; max-width: 100%; }
/* --- Boton de informacion (circular naranja, esquina de la tarjeta) --- */
.info {
@ -139,11 +234,18 @@ main {
display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
}
.engine {
width: 96px; height: 96px; border-radius: 50%;
width: 96px; aspect-ratio: 1 / 1; border-radius: 50%;
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 2px; cursor: pointer;
background: var(--card); border: 3px solid var(--line); color: var(--txt);
}
/* En movil, algo mas pequenos para que entren TRES por fila: con 96 px se
quedaban en un 2+2+1 descuadrado (flex parte la linea antes de encoger). */
@media (max-width: 560px) {
.engines { gap: 10px; }
.engine { width: 86px; }
.engine strong { font-size: 12px; }
}
.engine strong { font-size: 13px; }
.engine small { font-size: 9px; color: var(--dim); }
.engine.active {
@ -222,15 +324,14 @@ input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
font-size: 12px; font-family: monospace; padding: 4px 0 8px;
}
/* --- Banda de avisos --- */
/* --- Banda de avisos (dentro de la barra superior fija) --- */
.notif {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; font-size: 14px; font-weight: 600;
position: sticky; top: 66px; z-index: 9;
}
.notif.info { background: var(--green); color: var(--ink); }
.notif.warn { background: var(--orange); color: var(--ink); }
.notif.error { background: var(--red); color: #fff; }
.notif.lv-info { background: var(--green); color: var(--ink); }
.notif.lv-warn { background: var(--orange); color: var(--ink); }
.notif.lv-error { background: var(--red); color: #fff; }
.notif span { flex: 1; }
.notif button {
background: transparent; border: none; color: inherit;
@ -257,6 +358,70 @@ input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
}
.modal-box p { color: var(--txt); font-size: 14px; line-height: 1.5; }
/* --- Bloques agrupados dentro de una tarjeta ---
Un grupo de controles que aparece o desaparece junto (por ejemplo los de
la camara o los del fondo Butter en el mezclador). */
.stack { display: flex; flex-direction: column; gap: 12px; }
/* Controles que ahora mismo no pintan nada: siguen visibles pero apagados,
asi se ve que existen sin que despisten. */
.dim { opacity: 0.42; }
/* Resumen del mezclador: la linea que dice que va a salir por el proyector */
.hint.resumen {
border-left: 3px solid var(--orange); padding: 6px 0 6px 10px;
color: var(--txt); font-size: 13px;
}
/* --- Previsualizacion del mapping --- */
#map-preview {
width: 100%; aspect-ratio: 16 / 9; display: block; margin: 6px 0;
background: #0a0a0a; border: 1px solid var(--line); border-radius: 8px;
touch-action: none; cursor: crosshair;
}
/* --- Lista de capas del mapping ---
Una fila por capa: nombre + fuente a la izquierda, botones a la derecha.
La fila entera selecciona; los botones no la seleccionan. */
#map-list { display: flex; flex-direction: column; gap: 8px; }
.capa {
display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 9px 10px; border-radius: 11px;
background: #161d10; border: 2px solid var(--line);
}
.capa.sel { border-color: var(--orange); box-shadow: 0 0 12px rgba(255,122,0,0.3); }
.capa.oculta { opacity: 0.45; }
.capa .txt { flex: 1; min-width: 0; }
.capa .nom {
display: block; color: var(--txt); font-weight: 700; font-size: 14px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.capa .src {
display: block; color: var(--dim); font-size: 11px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.capa.mascara .nom { color: #ff6b6b; }
.capa button {
flex: 0 0 auto; width: 34px; height: 34px; border-radius: 9px; cursor: pointer;
background: #0d120c; border: 1px solid var(--line); color: var(--txt);
font-size: 14px; font-weight: 700; line-height: 1; padding: 0;
}
.capa button:disabled { opacity: 0.3; }
.capa .del { color: var(--red); }
/* Malla: los +/- son botones pequenos y van juntos, no barras a lo ancho */
.grupo { display: inline-flex; align-items: center; gap: 8px; flex: 0 0 auto; }
#map-sub-dec, #map-sub-inc {
flex: 0 0 auto; width: 46px; padding: 10px 0; font-size: 17px;
}
#map-sub-val { min-width: 40px; text-align: center; }
/* Campo de texto (nombre de la capa) */
.txt[type="text"] {
flex: 1; padding: 10px; border-radius: 10px; font-size: 14px;
background: #161d10; color: var(--txt); border: 1px solid var(--line);
}
/* Favoritos de projectM: lista de visuales para pinchar en directo */
#pm-fav-list {
display: flex; flex-direction: column; gap: 8px;

File diff suppressed because it is too large Load diff

View file

@ -70,7 +70,11 @@
<script src="/lib/qrcode.js"></script>
<script src="/lib/butterchurn.min.js"></script>
<script src="/lib/butterchurn-presets.min.js"></script>
<!-- Traduce los .milk de projectM a presets de Butterchurn, para poder
usar la biblioteca grande dentro de una capa del mapping -->
<script src="/lib/milkdrop-preset-converter.min.js"></script>
<script src="/lib/hydra-synth.js"></script>
<script src="/stage/layers.js"></script>
<script src="/stage/mapper.js"></script>
<script src="/stage/stage.js"></script>
</body>

457
web/stage/layers.js Normal file
View file

@ -0,0 +1,457 @@
/*
* FOSFENO :: Capas del mapping
* --------------------------------------------------------------------------
* Cada superficie del mapping puede llevar su PROPIA fuente: una instancia de
* Butterchurn con su preset, un shader GLSL, un clip de la galeria, la camara
* o el motor que este activo. Este modulo crea esas fuentes, las mantiene y
* le entrega al mapper el elemento que tiene que usar como textura.
*
* Fuente (va dentro de cada superficie, en data/mapping.json):
* { tipo: "motor" } el motor elegido en el panel
* { tipo: "butter", preset, auto, segundos } instancia propia de MilkDrop
* biblioteca: "butter" (los ~100 que trae Butterchurn) o "projectm"
* (los .milk de data/presets-projectm); con "projectm", milk lleva la
* ruta relativa del preset ("Dancer/Glowsticks/285.milk")
* { tipo: "shader", shader } shader GLSL de la libreria
* { tipo: "video", archivo } clip o imagen de data/videos
* { tipo: "camara", camaraId } webcam
* { tipo: "negro" } nada (tapa la zona)
*
* Cada capa se renderiza en un lienzo propio y PEQUENO (el mapper la estira
* al deformarla), que es lo que hace viable tener varias a la vez en una
* Raspberry. Las instancias con estado (Butterchurn, shaders) son una por
* superficie; las que no lo tienen (clips, camara) se comparten.
*
* API global:
* FosLayers.init({ audioCtx, gainNode, analyser, crearShader, shaders, report,
* onPreset(surfaceId, preset) }) preset elegido al azar
* FosLayers.setMotor(canvas) lienzo del motor activo (fuente "motor")
* FosLayers.sync(surfaces) crea/actualiza/destruye segun el estado
* FosLayers.fuenteDe(surface) -> { el, clave } o null (lo usa el mapper)
* FosLayers.presets() nombres de preset disponibles
*/
(function () {
'use strict';
const ANCHO = 640; // resolucion interna de cada capa (se estira luego)
const ALTO = 360;
const SEGUNDOS = 20; // cambio automatico de preset por defecto
let deps = null;
let motorEl = null;
// Instancias con estado: una por superficie (id de superficie -> capa)
const capas = new Map();
// Fuentes compartidas sin estado: clip o camara (clave -> recurso)
const compartidas = new Map();
const IMAGEN_RE = /\.(jpe?g|png|gif|webp)$/i;
function aviso(nivel, msg) {
if (deps && deps.report) deps.report(nivel, msg);
else console.warn('[FOSFENO] capas:', msg);
}
function lienzo() {
const cv = document.createElement('canvas');
cv.width = ANCHO; cv.height = ALTO;
return cv;
}
/* Suelta de verdad el contexto WebGL de un lienzo que se tira. Sin esto, el
contexto sigue vivo hasta que pase el recolector: el navegador solo
aguanta unos 16 a la vez y al pasarse mata EL MAS VIEJO, que es el del
compositor de mapping. Resultado: proyector en negro para siempre. */
function soltarGL(cv) {
try {
const g = cv.getContext('webgl2') || cv.getContext('webgl');
const ext = g && g.getExtension('WEBGL_lose_context');
if (ext) ext.loseContext();
} catch (e) { /* el lienzo no llego a tener contexto */ }
}
// La fuente de una superficie, siempre normalizada (las superficies
// antiguas no la llevan: son del motor activo, como hasta ahora).
function fuenteDe(s) {
const f = (s && s.fuente) || {};
return { tipo: f.tipo || 'motor', preset: f.preset || '', auto: !!f.auto,
segundos: f.segundos || SEGUNDOS, shader: f.shader || '',
archivo: f.archivo || '', camaraId: f.camaraId || 0,
biblioteca: f.biblioteca === 'projectm' ? 'projectm' : 'butter',
milk: f.milk || '',
// segundos de fundido al pasar de un preset al siguiente
transicion: typeof f.transicion === 'number' ? f.transicion : 2.5 };
}
/* Firma de una fuente: si no cambia, la capa se deja viva y solo se le
pasan los ajustes nuevos con aplicar(). Para Butterchurn la firma es el
tipo a secas, a proposito: cambiar de preset o de cambio automatico NO
puede recrear la instancia, porque cada instancia nueva es un contexto
WebGL mas (ver soltarGL). */
function firma(f) {
if (f.tipo === 'butter') return 'butter';
if (f.tipo === 'shader') return 'shader|' + f.shader;
if (f.tipo === 'video') return 'video|' + f.archivo;
if (f.tipo === 'camara') return 'camara|' + f.camaraId;
return f.tipo;
}
/* ------------------------------------------------- presets .milk (projectM)
La biblioteca gorda son los 9795 .milk de data/presets-projectm, que
projectM lee en nativo y Butterchurn no entiende. Se traducen aqui, en el
navegador, con milkdrop-preset-converter: se pide el .milk al backend (ya
los sirve tal cual), se convierte y se le pasa a viz.loadPreset como
cualquier otro preset. Tarda unos milisegundos y no gasta disco: no hay
que pre-convertir 9795 ficheros en la tarjeta de la Raspberry.
La cache guarda la promesa, no el resultado: si dos capas piden el mismo
preset a la vez, se convierte una sola vez. */
const milkCache = new Map();
function cargarMilk(ruta) {
if (milkCache.has(ruta)) return milkCache.get(ruta);
const conv = window.milkdropPresetConverter;
if (!conv || !conv.convertPreset) {
return Promise.reject(new Error(
'falta la libreria de conversion de presets .milk'));
}
const p = fetch('/data/presets-projectm/'
+ ruta.split('/').map(encodeURIComponent).join('/'))
.then((r) => {
if (!r.ok) throw new Error('no esta en la biblioteca (' + r.status + ')');
return r.text();
})
.then((txt) => conv.convertPreset(txt))
.then((preset) => {
if (!preset || !preset.baseVals) throw new Error('conversion vacia');
return preset;
})
.catch((e) => { milkCache.delete(ruta); throw e; }); // que se reintente
milkCache.set(ruta, p);
return p;
}
// ---------------------------------------------------------------- Butter
function crearButter(f) {
if (typeof window.butterchurn === 'undefined' || !window.butterchurnPresets) {
aviso('warn', 'No hay Butterchurn: la capa de visuales MilkDrop se queda en negro.');
return null;
}
const bch = window.butterchurn.default || window.butterchurn;
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
const presets = bp.getPresets();
const nombres = Object.keys(presets);
const cv = lienzo();
let viz;
try {
viz = bch.createVisualizer(deps.audioCtx, cv, {
width: ANCHO, height: ALTO, pixelRatio: 1,
});
viz.connectAudio(deps.gainNode);
} catch (e) {
aviso('error', 'No se pudo crear la capa de visuales MilkDrop: ' + e.message);
return null;
}
/* Sin preset elegido (capa recien creada antes de que la lista llegara al
panel), indexOf devuelve -1 y el Math.max lo dejaba en 0: TODAS las
capas asi arrancaban en el mismo preset y parecian la misma imagen.
Ahora cada instancia coge uno al azar y lo devuelve en presetElegido,
para que el panel lo ensene en vez de dejar el desplegable en blanco. */
let i = nombres.indexOf(f.preset);
let presetElegido = '';
if (i < 0) {
i = Math.floor(Math.random() * nombres.length);
presetElegido = nombres[i];
}
let raf = null, timer = null, seg = 0, fundido = f.transicion;
// El fundido lo hace el propio Butterchurn al cargar: a 0 es corte seco,
// a 6 segundos se disuelve uno en otro sin que se note el cambio.
const cargar = (nombre) => {
if (!presets[nombre]) return;
try { viz.loadPreset(presets[nombre], fundido); } catch (e) { /* preset roto */ }
};
const paso = () => { i = (i + 1) % nombres.length; cargar(nombres[i]); };
/* Monta o desmonta el cambio automatico sin tocar la instancia. */
const ritmo = (auto, segundos) => {
const s = Math.max(2, segundos || SEGUNDOS);
if (!!timer === !!auto && (!auto || s === seg)) return;
clearInterval(timer); timer = null; seg = s;
if (auto) timer = setInterval(paso, s * 1000);
};
/* Carga un .milk de la biblioteca de projectM. Va por promesa, asi que
hay que descartar el resultado si mientras tanto la capa cambio de
preset o se destruyo: si no, el que tarde mas en convertirse pisa al
que el usuario acaba de elegir. */
let milkActual = ''; // el que se pidio el ultimo
let muerta = false;
const cargarDeMilk = (ruta) => {
milkActual = ruta;
cargarMilk(ruta).then((preset) => {
if (muerta || milkActual !== ruta) return;
try { viz.loadPreset(preset, fundido); }
catch (e) { aviso('warn', 'El preset .milk "' + ruta + '" no se pudo '
+ 'cargar: ' + e.message); }
}).catch((e) => {
if (muerta || milkActual !== ruta) return;
aviso('warn', 'No se pudo convertir el preset "' + ruta + '": '
+ e.message + '. La capa se queda con el anterior.');
});
};
if (f.biblioteca === 'projectm' && f.milk) cargarDeMilk(f.milk);
else cargar(nombres[i]);
const bucle = () => { viz.render(); raf = requestAnimationFrame(bucle); };
bucle();
ritmo(f.auto, f.segundos);
return {
el: cv,
// Con un .milk clavado no hay preset de Butterchurn que anotar
presetElegido: (f.biblioteca === 'projectm' && f.milk) ? '' : presetElegido,
// Todos los ajustes se aplican en caliente: nunca hay que recrear la
// instancia (y con ella, un contexto WebGL nuevo).
aplicar(nf) {
fundido = nf.transicion;
if (nf.biblioteca === 'projectm') {
// El cambio automatico es de la lista de Butterchurn: con un .milk
// clavado no tiene sentido dejarlo girando por encima.
if (nf.milk && nf.milk !== milkActual) cargarDeMilk(nf.milk);
ritmo(false, nf.segundos);
return;
}
if (milkActual) { // volvemos a la biblioteca de Butterchurn
milkActual = '';
cargar(nombres[i]);
}
if (!nf.auto && nf.preset && nf.preset !== nombres[i]) {
const j = nombres.indexOf(nf.preset);
if (j >= 0) { i = j; cargar(nombres[i]); }
}
ritmo(nf.auto, nf.segundos);
},
destruir() {
muerta = true;
if (raf) cancelAnimationFrame(raf);
clearInterval(timer);
// Sin desconectar, cada instancia tirada deja un analizador colgando
// del audio compartido para siempre.
try { viz.disconnectAudio(deps.gainNode); } catch (e) { /* ya suelto */ }
soltarGL(cv);
},
};
}
// ---------------------------------------------------------------- Shader
function crearShaderCapa(f, nombre) {
const codigo = (deps.shaders && deps.shaders[f.shader]
&& deps.shaders[f.shader].code) || '';
if (!codigo) {
aviso('warn', 'La capa "' + nombre + '" es de shader pero no tiene '
+ 'ninguno elegido: seleccionalo en Propiedades.');
return null;
}
const cv = lienzo();
let eng;
try {
eng = deps.crearShader(cv, { width: ANCHO, height: ALTO });
// Que no compile no puede dejar el contexto colgando (ver soltarGL)
if (!eng.load(codigo)) {
aviso('warn', 'El shader de la capa "' + nombre + '" no compila.');
soltarGL(cv);
return null;
}
eng.start();
} catch (e) {
aviso('error', 'La capa de shader "' + nombre + '" fallo: ' + e.message);
soltarGL(cv);
return null;
}
return {
el: cv, aplicar() {},
destruir() {
try { eng.stop(); } catch (e) { /* ya parado */ }
soltarGL(cv);
},
};
}
// ------------------------------------------------- Clip / imagen / camara
// Sin estado propio: varias superficies pueden compartir el mismo recurso.
function recursoCompartido(clave, crear) {
let r = compartidas.get(clave);
if (!r) { r = crear(); if (!r) return null; compartidas.set(clave, r); }
r.usos = (r.usos || 0) + 1;
return r;
}
/* Ojo al soltar: vaciar el src de un <video> o de una <img> dispara su
evento 'error', y sin la bandera saldria un aviso falso de "no pudo
cargar" cada vez que cambias de clip o quitas la capa. */
function crearVideo(f) {
if (!f.archivo) return null;
const url = '/data/videos/' + encodeURIComponent(f.archivo);
let soltado = false;
if (IMAGEN_RE.test(f.archivo)) {
const img = new Image();
img.onerror = () => {
if (!soltado) aviso('warn', "La capa no pudo cargar la imagen '" + f.archivo + "'.");
};
img.src = url;
return { el: img, soltar() { soltado = true; img.src = ''; } };
}
const vid = document.createElement('video');
vid.muted = true; vid.loop = true; vid.playsInline = true;
vid.addEventListener('error', () => {
if (soltado) return;
aviso('warn', "La capa no pudo cargar '" + f.archivo
+ "': prueba con .mp4 (H.264) o .webm.");
});
vid.addEventListener('loadeddata', () => vid.play().catch(() => {}));
vid.src = url;
return {
el: vid,
soltar() {
soltado = true;
vid.pause();
vid.removeAttribute('src'); // mas limpio que src="" para soltarlo
vid.load();
},
};
}
function crearCamara(f) {
const vid = document.createElement('video');
vid.muted = true; vid.playsInline = true;
let stream = null, muerto = false;
navigator.mediaDevices.enumerateDevices().then((devs) => {
const cams = devs.filter((d) => d.kind === 'videoinput');
if (!cams.length) throw new Error('no hay ninguna camara conectada');
const pick = cams[f.camaraId] || cams[0];
return navigator.mediaDevices.getUserMedia({
audio: false, video: { deviceId: { exact: pick.deviceId } },
});
}).then((st) => {
// La capa pudo borrarse o cambiar de camara mientras esta tardaba en
// abrirse (1-3 s por USB): si no se corta aqui, el piloto se queda
// encendido hasta recargar el escenario.
if (muerto) { st.getTracks().forEach((t) => t.stop()); return null; }
stream = st; vid.srcObject = st;
return vid.play();
}).catch((e) => {
if (!muerto) aviso('error', 'La capa de camara fallo: ' + e.message);
});
return {
el: vid,
soltar() {
muerto = true;
if (stream) stream.getTracks().forEach((t) => t.stop());
vid.srcObject = null;
},
};
}
// ------------------------------------------------------------ ciclo de vida
function crear(f, nombre) {
if (f.tipo === 'butter') return crearButter(f);
if (f.tipo === 'shader') return crearShaderCapa(f, nombre);
if (f.tipo === 'video') {
if (!f.archivo) {
aviso('warn', 'La capa "' + nombre + '" no tiene clip elegido: se ve '
+ 'negra hasta que selecciones uno en Propiedades.');
return null;
}
const r = recursoCompartido('video|' + f.archivo, () => crearVideo(f));
return r ? { el: r.el, compartida: 'video|' + f.archivo, aplicar() {}, destruir() {} } : null;
}
if (f.tipo === 'camara') {
const r = recursoCompartido('camara|' + f.camaraId, () => crearCamara(f));
return r ? { el: r.el, compartida: 'camara|' + f.camaraId, aplicar() {}, destruir() {} } : null;
}
// "motor" y "negro" no necesitan instancia; cualquier otro tipo es de una
// version distinta y hay que decirlo en vez de dejar la zona en negro.
if (f.tipo !== 'motor' && f.tipo !== 'negro') {
aviso('warn', 'La capa "' + nombre + '" usa una fuente desconocida ("'
+ f.tipo + '"): elige otra en Propiedades.');
}
return null;
}
function soltarCompartida(clave) {
const r = compartidas.get(clave);
if (!r) return;
r.usos--;
if (r.usos <= 0) { try { r.soltar(); } catch (e) { /* ya suelto */ } compartidas.delete(clave); }
}
function destruirCapa(id) {
const c = capas.get(id);
if (!c) return;
if (c.inst) {
try { c.inst.destruir(); } catch (e) { /* ya destruida */ }
if (c.inst.compartida) soltarCompartida(c.inst.compartida);
}
capas.delete(id);
}
const API = {
init(opciones) { deps = opciones || {}; },
setMotor(canvas) { motorEl = canvas || null; },
/* Pone las capas al dia con las superficies del estado: crea las nuevas,
actualiza las que solo han cambiado de ajuste y tira las que sobran. */
sync(surfaces) {
if (!deps) return;
const vivos = new Set();
(surfaces || []).forEach((s, i) => {
if (!s || !s.id) return;
vivos.add(s.id);
const f = fuenteDe(s);
// Una capa apagada o a cero no gasta nada: ni motores renderizando ni
// la camara encendida por algo que no se ve. Mismo criterio que usa
// el mapper para no pintarla.
const oculta = s.visible === false
|| (typeof s.opacidad === 'number' && s.opacidad <= 0);
const sig = oculta ? 'oculta' : firma(f);
const c = capas.get(s.id);
if (c && c.sig === sig) {
if (c.inst && c.inst.aplicar) c.inst.aplicar(f);
return;
}
if (c) destruirCapa(s.id);
if (oculta || f.tipo === 'motor' || f.tipo === 'negro') {
capas.set(s.id, { sig: sig, inst: null });
return;
}
const inst = crear(f, s.nombre || 'Capa ' + (i + 1));
capas.set(s.id, { sig: sig, inst: inst });
// Si la capa tuvo que elegir preset por su cuenta, que el estado se
// entere: asi el desplegable de Propiedades deja de salir en blanco.
if (inst && inst.presetElegido && deps.onPreset)
deps.onPreset(s.id, inst.presetElegido);
});
Array.from(capas.keys()).forEach((id) => { if (!vivos.has(id)) destruirCapa(id); });
},
/* Que elemento tiene que usar el mapper como textura de esta superficie.
La clave sirve para no subir la misma imagen dos veces por fotograma. */
fuenteDe(s) {
const f = fuenteDe(s);
if (f.tipo === 'negro') return null;
if (f.tipo === 'motor') return motorEl ? { el: motorEl, clave: 'motor' } : null;
const c = capas.get(s.id);
if (!c || !c.inst || !c.inst.el) return null;
return { el: c.inst.el, clave: c.inst.compartida || ('capa:' + s.id) };
},
presets() {
if (!window.butterchurnPresets) return [];
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
return Object.keys(bp.getPresets());
},
};
window.FosLayers = API;
})();

View file

@ -1,26 +1,34 @@
/*
* FOSFENO :: Mapper (projection mapping en el navegador) Fase 2
* FOSFENO :: Mapper (projection mapping en el navegador) Fase 3
* --------------------------------------------------------------------------
* Coge el canvas del motor activo como TEXTURA y lo pinta deformado sobre
* SUPERFICIES con MALLA (rejilla n x n deformable => superficies curvas, no
* solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que
* Pinta CAPAS deformadas sobre superficies con MALLA (rejilla n x n => curvas,
* no solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que
* tapan zonas / recortan derrames de luz).
*
* Cada superficie es una CAPA con su propia fuente: el motor activo, una
* instancia propia de MilkDrop, un shader, un clip o la camara (las crea
* layers.js). Asi se puede tener MilkDrop a la derecha, otro preset a la
* izquierda y un video en el centro, cada uno encajado en su sitio.
*
* Modelo (0..1, origen arriba-izquierda; se guarda en data/mapping.json):
* surface = { id, n, points:[[x,y] x (n+1)^2] } // rejilla n x n celdas
* mask = { id, corners:[[x,y] x4] } // TL,TR,BR,BL, negro
* surface = { id, n, points:[[x,y] x (n+1)^2],
* nombre, visible, opacidad, mezcla:"normal"|"sumar",
* fuente:{ tipo, ... } } // ver layers.js
* mask = { id, corners:[[x,y] x4] } // TL,TR,BR,BL, negro
* mapping = { enabled, edit, surfaces:[...], masks:[...] }
*
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1.
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1, y
* las que no llevan fuente se pintan con el motor activo, como siempre.
*
* Render: cada celda de la malla se dibuja como un parche con homografia
* (cuadrado unidad -> celda) sobre una sub-malla fina => perspectiva correcta.
*
* API global:
* FosMapper.init({ onChange }) arranca
* FosMapper.setSource(canvas) motor activo (o null)
* FosMapper.setMapping(m) estado de mapping
* onChange({surfaces, masks}) al editar (arrastrar), para guardar
* FosMapper.init({ onChange, resolver }) arranca
* FosMapper.setSource(canvas) lienzo del motor activo (fuente "motor")
* FosMapper.setMapping(m) estado de mapping
* onChange({surfaces, masks}) al editar (arrastrar), para guardar
* resolver(surface) -> { el, clave } con la textura de la capa
*/
(function () {
'use strict';
@ -32,11 +40,15 @@
const M = { enabled: false, edit: false, surfaces: [], masks: [] };
let source = null;
let onChange = null;
let resolver = null; // surface -> { el, clave } (lo pone layers.js)
let aviso = function () {}; // report() del escenario: nada se queda callado
let outputEl, editEl, gl, ectx;
let prog, aUV, uH, uTex, uOff, uScale; // programa de textura
let progF, aPos, uColor, fillBuf; // programa de relleno (mascaras)
let tex, gridBuf, gridIdx, gridCount, dpr = 1;
let prog, aUV, uH, uTex, uOff, uScale, uAlpha; // programa de textura
let progF, aPos, uColor, fillBuf; // programa de relleno (mascaras)
let gridBuf, gridIdx, gridCount, dpr = 1;
const texturas = new Map(); // clave de fuente -> { tex, subida, visto }
let frame = 0;
let selected = null; // { type:'surface'|'mask', id }
let drag = null; // { type, id, pt|null, sx, sy, orig }
@ -46,16 +58,39 @@
const sidx = (c, r, n) => r * (n + 1) + c;
// ------------------------------------------------------------------ modelo
/* Normaliza una superficie: geometria a formato malla y ajustes de capa con
sus valores por defecto. Lo que no venga (superficies de antes de las
capas) se rellena para que el resto del codigo no tenga que preguntar. */
function migrate(s) {
const capa = {
id: s.id,
nombre: s.nombre || '',
visible: s.visible !== false,
opacidad: typeof s.opacidad === 'number' ? s.opacidad : 1,
mezcla: s.mezcla === 'sumar' ? 'sumar' : 'normal',
fuente: s.fuente || { tipo: 'motor' },
};
if (Array.isArray(s.points) && s.n) {
return { id: s.id, n: s.n, points: s.points.map((p) => [p[0], p[1]]) };
}
if (Array.isArray(s.corners)) { // formato Fase 1 -> n=1
capa.n = s.n; capa.points = s.points.map((p) => [p[0], p[1]]);
} else if (Array.isArray(s.corners)) { // formato Fase 1 -> n=1
const c = s.corners;
return { id: s.id, n: 1, points: [[c[0][0], c[0][1]], [c[1][0], c[1][1]],
[c[3][0], c[3][1]], [c[2][0], c[2][1]]] }; // TL,TR,BL,BR (row-major)
capa.n = 1;
capa.points = [[c[0][0], c[0][1]], [c[1][0], c[1][1]],
[c[3][0], c[3][1]], [c[2][0], c[2][1]]]; // TL,TR,BL,BR (row-major)
} else {
capa.n = 1;
capa.points = [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]];
}
return { id: s.id, n: 1, points: [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]] };
return capa;
}
/* Mascaras saneadas. Una sin esquinas (mapping.json a medio escribir, de
otra version o tocado a mano) no puede tumbar el escenario entero: se
descarta y punto. */
function migrateMasks(lista) {
return (Array.isArray(lista) ? lista : [])
.filter((k) => k && Array.isArray(k.corners) && k.corners.length === 4)
.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) }));
}
// punto [x,y] a coordenada normalizada (u,v) de la superficie (bilineal por celda)
@ -74,7 +109,11 @@
const pts = [];
for (let r = 0; r <= newN; r++)
for (let c = 0; c <= newN; c++) pts.push(sampleSurf(s, c / newN, r / newN));
return { id: s.id, n: newN, points: pts };
// Solo cambia la malla: los ajustes de la capa (fuente, nombre, opacidad)
// se conservan tal cual.
const out = migrate(s);
out.n = newN; out.points = pts;
return out;
}
// homografia cuadrado unidad -> cuadrilatero (Heckbert), mat3 por columnas
@ -117,7 +156,11 @@
function initGL() {
gl = outputEl.getContext('webgl', { alpha: false, antialias: true })
|| outputEl.getContext('experimental-webgl');
if (!gl) { console.error('[FOSFENO] mapper: sin WebGL'); return false; }
if (!gl) {
aviso('error', 'El mapping necesita WebGL y este navegador no lo tiene: '
+ 'las visuales saldran sin deformar.');
return false;
}
prog = linkProg(
`attribute vec2 aUV; uniform mat3 uH; uniform vec2 uOff; uniform vec2 uScale;
@ -126,12 +169,15 @@
gl_Position = vec4(d.x*2.0-1.0, 1.0-d.y*2.0, 0.0, 1.0);
vUV = uOff + aUV*uScale; }`,
`precision mediump float; varying vec2 vUV; uniform sampler2D uTex;
void main(){ gl_FragColor = texture2D(uTex, vUV); }`);
uniform float uAlpha;
void main(){ vec4 c = texture2D(uTex, vUV);
gl_FragColor = vec4(c.rgb, c.a * uAlpha); }`);
aUV = gl.getAttribLocation(prog, 'aUV');
uH = gl.getUniformLocation(prog, 'uH');
uTex = gl.getUniformLocation(prog, 'uTex');
uOff = gl.getUniformLocation(prog, 'uOff');
uScale = gl.getUniformLocation(prog, 'uScale');
uAlpha = gl.getUniformLocation(prog, 'uAlpha');
progF = linkProg(
`attribute vec2 aPos; void main(){ gl_Position = vec4(aPos,0.0,1.0); }`,
@ -158,16 +204,53 @@
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW);
tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
/* SIN voltear la textura al subirla. El modelo 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. Poniendo
UNPACK_FLIP_Y a true, t = 0 pasaba a ser la ultima fila y todo salia
del reves se notaba sobre todo con los clips de video. */
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.enable(gl.BLEND);
return true;
}
/* Una textura por fuente, subida una sola vez por fotograma: si dos
superficies comparten el mismo clip, la imagen no viaja dos veces. */
function texturaDe(src) {
const el = src.el;
if (!el) return null;
const w = el.videoWidth || el.naturalWidth || el.width;
const h = el.videoHeight || el.naturalHeight || el.height;
if (!w || !h) return null;
if (el.tagName === 'VIDEO' && el.readyState < 2) return null;
let e = texturas.get(src.clave);
if (!e) {
e = { tex: gl.createTexture(), subida: -1, visto: -1 };
gl.bindTexture(gl.TEXTURE_2D, e.tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
texturas.set(src.clave, e);
}
e.visto = frame;
if (e.subida !== frame) {
e.subida = frame;
gl.bindTexture(gl.TEXTURE_2D, e.tex);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el); }
catch (err) { return null; }
}
return e.tex;
}
/* Tira las texturas de fuentes que ya no se usan (capas borradas). */
function limpiarTexturas() {
if (frame % 300) return;
texturas.forEach((e, clave) => {
if (frame - e.visto > 120) { gl.deleteTexture(e.tex); texturas.delete(clave); }
});
}
function drawSurfaceGL(s) {
const n = s.n;
const P = (c, r) => s.points[sidx(c, r, n)];
@ -182,32 +265,63 @@
}
}
// Pantalla entera: lo que se ve cuando el mapping esta activo pero aun no
// hay ninguna superficie creada (pasada directa del motor).
const PANTALLA = { id: '__pantalla__', n: 1, visible: true, opacidad: 1,
mezcla: 'normal', fuente: { tipo: 'motor' },
points: [[0, 0], [1, 0], [0, 1], [1, 1]] };
// La fuente de una capa: la resuelve layers.js; si no hay resolver (o la
// capa es del motor), tira del lienzo del motor activo.
function fuenteDeCapa(capa) {
if (resolver) {
const r = resolver(capa);
if (r && r.el) return r;
if (capa.fuente && capa.fuente.tipo !== 'motor') return null;
}
return (source && source.width) ? { el: source, clave: 'motor' } : null;
}
function renderGL() {
frame++;
gl.viewport(0, 0, outputEl.width, outputEl.height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
if (!source || !source.width) return;
gl.bindTexture(gl.TEXTURE_2D, tex);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); }
catch (e) { return; }
// superficies
// superficies (cada una con su propia fuente)
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
gl.enableVertexAttribArray(aUV);
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.uniform1i(uTex, 0);
if (!M.surfaces.length) {
drawSurfaceGL({ n: 1, points: [[0, 0], [1, 0], [0, 1], [1, 1]] }); // pasada directa
} else {
for (const s of M.surfaces) drawSurfaceGL(migrate(s));
}
// mascaras (cuadrilateros negros encima)
// M.surfaces ya viene normalizado de setMapping / onDown / onDblClick:
// no hace falta migrar otra vez en cada fotograma (eran cientos de
// arrays nuevos por segundo, justo en la maquina que menos lo aguanta).
const lista = M.surfaces.length ? M.surfaces : [PANTALLA];
for (const capa of lista) {
if (!capa.visible || capa.opacidad <= 0) continue;
if (capa.fuente.tipo === 'negro') continue;
const src = fuenteDeCapa(capa);
if (!src) continue;
const t = texturaDe(src);
if (!t) continue;
gl.bindTexture(gl.TEXTURE_2D, t);
gl.uniform1f(uAlpha, capa.opacidad);
// "sumar" apila luz (las zonas oscuras dejan ver la capa de abajo);
// "normal" tapa lo que haya debajo.
if (capa.mezcla === 'sumar') gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
else gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
drawSurfaceGL(capa);
}
limpiarTexturas();
// mascaras (cuadrilateros negros encima). Vuelve a mezcla normal: si la
// ultima capa iba en "sumar", un negro sumado no taparia nada.
if (M.masks.length) {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(progF);
gl.uniform4f(uColor, 0, 0, 0, 1);
gl.bindBuffer(gl.ARRAY_BUFFER, fillBuf);
@ -225,6 +339,28 @@
}
// ------------------------------------------------------------------ editor
const NOMBRE_FUENTE = {
motor: 'motor activo', butter: 'MilkDrop', shader: 'shader',
video: 'clip', camara: 'camara', negro: 'negro',
};
function etiquetaFuente(f) {
f = f || {};
const base = NOMBRE_FUENTE[f.tipo] || 'motor activo';
if (f.tipo === 'butter' && f.preset) return base + ' · ' + f.preset;
if (f.tipo === 'video' && f.archivo) return base + ' · ' + f.archivo;
return base;
}
/* Rotulo centrado que no se sale de la pantalla: una capa pegada al borde
tendria el nombre cortado por la mitad. */
function rotulo(txt, x, y, w) {
let t = txt;
while (t.length > 6 && ectx.measureText(t).width > w - 24) t = t.slice(0, -2);
if (t !== txt) t = t.slice(0, -1) + '…';
const media = ectx.measureText(t).width / 2;
ectx.fillText(t, Math.min(Math.max(x, media + 10), w - media - 10), y);
}
function drawEditor() {
const w = window.innerWidth, h = window.innerHeight;
ectx.setTransform(dpr, 0, 0, dpr, 0, 0);
@ -232,9 +368,9 @@
ectx.font = '600 14px system-ui, sans-serif';
ectx.textAlign = 'center'; ectx.textBaseline = 'middle';
// superficies (malla)
M.surfaces.forEach((raw, i) => {
const s = migrate(raw); const n = s.n;
// superficies (malla). Ya vienen normalizadas de setMapping.
M.surfaces.forEach((s, i) => {
const n = s.n;
const sel = selected && selected.type === 'surface' && selected.id === s.id;
const col = sel ? '#00e5ff' : '#b4ff00';
const P = (c, r) => { const p = s.points[sidx(c, r, n)]; return [p[0] * w, p[1] * h]; };
@ -242,9 +378,16 @@
ectx.strokeStyle = col; ectx.lineWidth = sel ? 2 : 1.3;
for (let r = 0; r <= n; r++) { ectx.beginPath(); for (let c = 0; c <= n; c++) { const p = P(c, r); c ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
for (let c = 0; c <= n; c++) { ectx.beginPath(); for (let r = 0; r <= n; r++) { const p = P(c, r); r ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
// numero
const cc = P(n / 2 | 0, n / 2 | 0);
ectx.fillStyle = col; ectx.fillText(String(i + 1), cc[0], cc[1]);
// nombre de la capa y su fuente, en el centro de verdad: el medio de
// las cuatro esquinas (con n=1, P(n/2|0, n/2|0) caia en la esquina).
const esq = [P(0, 0), P(n, 0), P(n, n), P(0, n)];
const cc = [(esq[0][0] + esq[1][0] + esq[2][0] + esq[3][0]) / 4,
(esq[0][1] + esq[1][1] + esq[2][1] + esq[3][1]) / 4];
ectx.fillStyle = col;
rotulo(s.nombre || ('Capa ' + (i + 1)), cc[0], cc[1] - 9, w);
ectx.font = '12px system-ui, sans-serif';
rotulo(etiquetaFuente(s.fuente), cc[0], cc[1] + 10, w);
ectx.font = '600 14px system-ui, sans-serif';
// tiradores (todos los puntos)
for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) {
const p = P(c, r);
@ -364,7 +507,9 @@
function onUp() { if (drag && dragged) emitNow(); drag = null; }
function defaultSurface(cx, cy, s) {
return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4), n: 1,
return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4),
n: 1, nombre: '', visible: true, opacidad: 1, mezcla: 'normal',
fuente: { tipo: 'motor' },
points: [[clamp01(cx - s), clamp01(cy - s)], [clamp01(cx + s), clamp01(cy - s)],
[clamp01(cx - s), clamp01(cy + s)], [clamp01(cx + s), clamp01(cy + s)]] };
}
@ -394,7 +539,7 @@
function emitNow() {
if (emitTimer) { clearTimeout(emitTimer); emitTimer = null; }
if (onChange) onChange({
surfaces: M.surfaces.map((s) => { const m = migrate(s); return { id: m.id, n: m.n, points: m.points }; }),
surfaces: M.surfaces.map(migrate), // geometria + ajustes de la capa
masks: M.masks.map((m) => ({ id: m.id, corners: m.corners })),
});
}
@ -422,10 +567,24 @@
init(opts) {
opts = opts || {};
onChange = opts.onChange || null;
resolver = opts.resolver || null;
aviso = opts.report || function () {};
outputEl = document.getElementById('output');
editEl = document.getElementById('mapedit');
if (!outputEl || !editEl) { console.error('[FOSFENO] mapper: faltan #output/#mapedit'); return; }
if (!outputEl || !editEl) {
aviso('error', 'Falta el lienzo del mapping en la pagina del '
+ 'escenario: recarga el escenario.');
return;
}
ectx = editEl.getContext('2d');
// Si el navegador tira este contexto (demasiadas capas a la vez), el
// mapping se queda negro en silencio: que al menos se sepa por que.
outputEl.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
aviso('error', 'El navegador ha cerrado el contexto grafico del '
+ 'mapping (demasiadas capas a la vez). Quita alguna capa de '
+ 'MilkDrop o de shader y recarga el escenario.');
});
if (!initGL()) return;
editEl.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
@ -440,8 +599,10 @@
M.enabled = !!m.enabled;
M.edit = !!m.edit;
if (!drag) { // no piso la geometria mientras arrastro aqui
if (Array.isArray(m.surfaces)) M.surfaces = m.surfaces.map(migrate);
M.masks = Array.isArray(m.masks) ? m.masks.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) })) : [];
if (Array.isArray(m.surfaces)) {
M.surfaces = m.surfaces.filter((s) => s && s.id).map(migrate);
}
M.masks = migrateMasks(m.masks);
if (selected) {
const ok = selected.type === 'surface' ? findSurf(selected.id) >= 0 : findMask(selected.id) >= 0;
if (!ok) selected = null;

View file

@ -9,7 +9,11 @@
* Incluye deteccion de BPM en vivo y seleccion de tarjeta de audio.
*/
const socket = io();
// Clave compartida (config.json -> auth.token). Viene en la propia direccion
// del escenario; si no hay clave configurada, queda vacia y todo va como
// siempre. Se reenvia en el QR para que el movil entre de un escaneo.
const CLAVE = new URLSearchParams(location.search).get("k") || "";
const socket = io({ auth: { token: CLAVE } });
const msgEl = document.getElementById("msg");
const bcCanvas = document.getElementById("butterchurn");
const hydraCanvas = document.getElementById("hydra");
@ -120,8 +124,12 @@ let beatDetector = null;
// u_bpm, u_beat (fase 0..1), u_fft (sampler2D)
// ==========================================================================
class ShaderEngine {
constructor(canvas, analyserNode) {
/* size: {width, height} fija el lienzo (capas del mapping, que se
renderizan pequenas y las estira el mapper). Sin size, el shader ocupa
la pantalla entera, que es como va el motor de shaders normal. */
constructor(canvas, analyserNode, size) {
this.canvas = canvas;
this.size = size || null;
this.analyser = analyserNode;
this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
this.raf = null;
@ -220,8 +228,8 @@ class ShaderEngine {
_frame() {
const gl = this.gl;
const w = this.canvas.width = window.innerWidth;
const h = this.canvas.height = window.innerHeight;
const w = this.canvas.width = this.size ? this.size.width : window.innerWidth;
const h = this.canvas.height = this.size ? this.size.height : window.innerHeight;
gl.viewport(0, 0, w, h);
if (this.program) {
const b = this._bands();
@ -366,8 +374,9 @@ function beatLoop() {
const isBeat = beatDetector ? beatDetector.update(now) : false;
try { window.bpm = fosBeat.bpm || 30; } catch (e) { /* hydra no cargado */ }
// Cambio de preset de Butterchurn sincronizado al compas
if (isBeat && state && state.engine === "butterchurn" && state.power
// Cambio de preset de Butterchurn sincronizado al compas (tanto si es el
// motor elegido como si hace de fondo del mezclador)
if (isBeat && butterLive()
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "beats") {
bcBeatCount++;
if (bcBeatCount >= (state.butterchurn.interval || 16)) {
@ -407,9 +416,24 @@ function initButterchurn() {
function loadButterchurnPreset(name) {
if (!bcPresets[name] || !bcViz) return;
bcCurrent = name; // recuerda el intento aunque falle, para no reintentarlo en bucle
// El indice sigue al preset que suena: asi 'siguiente' continua desde el
// que se ve, tanto si se eligio en la lista como si vino del cambio
// automatico.
const i = bcNames.indexOf(name);
if (i >= 0) bcIndex = i;
// El estado guarda siempre el preset que esta en pantalla. Sin esto, el
// cambio automatico volvia atras en cuanto llegaba cualquier estado
// nuevo (el escenario recargaba el ultimo preset elegido a mano).
if (!state || state.butterchurn.preset !== name) {
socket.emit("update_settings",
{ engine: "butterchurn", patch: { preset: name } });
}
try {
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
socket.emit("stage_status", { label: name });
socket.emit("stage_status", {
label: (state && state.engine === "mixer")
? "Mezclador VJ · fondo: " + name : name,
});
} catch (e) {
report("warn", "El preset '" + name + "' fallo al cargar en esta "
+ "tarjeta grafica. Pasa a otro con 'siguiente' o eligelo en la lista.");
@ -421,6 +445,17 @@ function bcStep(delta) {
loadButterchurnPreset(bcNames[bcIndex]);
}
/* Butterchurn esta pintando ahora mismo? Puede ser el motor elegido o el
fondo del mezclador (la capa de abajo). En los dos casos manda el mismo
preset y el mismo cambio automatico, para que el fondo del mezclador se
maneje igual que el motor Butter. */
function butterLive() {
if (!state || !state.power) return false;
if (state.engine === "butterchurn") return true;
return state.engine === "mixer" && state.mixer.fondo === "butter"
&& (state.mixer.source === "cam" || state.mixer.source === "mix");
}
function bcLoop() {
bcViz.setRendererSize(window.innerWidth, window.innerHeight);
bcViz.render();
@ -429,21 +464,30 @@ function bcLoop() {
function startButterchurn() { if (!bcRAF) bcLoop(); }
/* Ajustes con los que se monto el temporizador del cambio automatico. Sin
esta firma, cada estado nuevo (mover un deslizador, subir un clip...)
reiniciaba la cuenta y con intervalos largos el preset no cambiaba nunca. */
let bcShuffleSig = "";
function stopButterchurn() {
if (bcRAF) cancelAnimationFrame(bcRAF);
bcRAF = null;
clearInterval(bcTimer);
bcTimer = null;
bcShuffleSig = "";
}
function refreshShuffle() {
const b = (state && state.butterchurn) || {};
const sig = butterLive()
? [b.shuffle, b.intervalMode, b.interval].join("|") : "off";
if (sig === bcShuffleSig) return;
bcShuffleSig = sig;
clearInterval(bcTimer);
bcTimer = null;
bcBeatCount = 0;
if (state && state.engine === "butterchurn" && state.power
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "seconds") {
bcTimer = setInterval(() => bcStep(1),
(state.butterchurn.interval || 20) * 1000);
if (butterLive() && b.shuffle && b.intervalMode === "seconds") {
bcTimer = setInterval(() => bcStep(1), (b.interval || 20) * 1000);
}
}
@ -515,6 +559,13 @@ let mixerCamFailed = null; // cameraId que fallo (no reintentar en bucle)
let mixerVideoEl = null; // elemento <video> del clip actual
let mixerWarned = ""; // ultimo aviso de fuente no visible mostrado
let mixerButter = false; // s0 es el lienzo de Butterchurn (fondo butter)
/* Que hay enganchado a s1 (el clip o la imagen de encima). Hydra.hush() borra
TODAS las fuentes al salir del mezclador, asi que hay que recordarlo para
volver a engancharlo al entrar: sin esto, al ir al motor Butter (o a
projectM, o a Shaders) y volver, el clip desaparecia y la mezcla salia sin
la capa de encima. */
let mixerS1 = null; // { el, dynamic }
let mixerSueltas = false; // hubo hush(): s0/s1 estan vacias
function stopMixerCam() {
if (mixerCamStream) mixerCamStream.getTracks().forEach((t) => t.stop());
@ -530,10 +581,21 @@ function stopMixer() {
mixerWarned = "";
mixerCamFailed = null;
mixerButter = false;
mixerSueltas = true;
stopMixerCam();
if (mixerVideoEl) mixerVideoEl.pause();
}
/* Vuelve a enganchar a Hydra lo que ya teniamos cargado, sin recargarlo. */
function reengancharMixer() {
if (!mixerSueltas) return;
mixerSueltas = false;
if (mixerS1 && mixerS1.el) {
try { s1.init({ src: mixerS1.el, dynamic: mixerS1.dynamic }); }
catch (e) { mixerS1 = null; }
}
}
async function initCamSource(index) {
const devs = await navigator.mediaDevices.enumerateDevices();
const cams = devs.filter((d) => d.kind === "videoinput");
@ -560,6 +622,7 @@ function initVideoSource(name) {
vid.addEventListener("loadeddata", () => {
vid.play().catch(() => { /* el flag de autoplay del kiosko lo permite */ });
s1.init({ src: vid, dynamic: true });
mixerS1 = { el: vid, dynamic: true };
resolve(vid);
});
vid.addEventListener("error", () =>
@ -576,6 +639,7 @@ function initImageSource(name) {
const img = new Image();
img.onload = () => {
s1.init({ src: img, dynamic: false });
mixerS1 = { el: img, dynamic: false };
resolve(img);
};
img.onerror = () =>
@ -585,6 +649,9 @@ function initImageSource(name) {
}
async function ensureMixerSources(m) {
// Al volver de otro motor, Hydra tiene las fuentes vacias (hush): lo
// primero es reengancharlas antes de decidir que falta.
reengancharMixer();
// Fondo "butter": el lienzo de Butterchurn sigue renderizando (oculto,
// el visible es el de Hydra) y hace de capa de abajo s0 en lugar de la
// camara. Asi los recortes de personajes van DELANTE de MilkDrop.
@ -592,11 +659,13 @@ async function ensureMixerSources(m) {
&& (m.source === "cam" || m.source === "mix");
if (wantButter && bcViz) {
stopMixerCam();
if (!bcCurrent) {
loadButterchurnPreset((state && state.butterchurn.preset)
|| bcNames[bcIndex]);
}
// Mismo criterio que en el motor Butter: manda el preset del estado
// (elegido en el panel, en el mezclador o por el cambio automatico).
const quiere = (state && state.butterchurn.preset) || "";
if (quiere && quiere !== bcCurrent) loadButterchurnPreset(quiere);
else if (!bcCurrent) loadButterchurnPreset(bcNames[bcIndex]);
startButterchurn();
refreshShuffle();
if (!mixerButter) {
mixerButter = true;
s0.init({ src: bcCanvas, dynamic: true });
@ -691,6 +760,7 @@ function show(canvas) {
msgEl.style.display = canvas ? "none" : "block";
// el mapper (si el mapping está activo) usa este canvas como textura
if (window.FosMapper) FosMapper.setSource(canvas);
if (window.FosLayers) FosLayers.setMotor(canvas);
}
/* Pantalla de conexion: muestra la direccion del panel y un codigo QR en el
@ -700,8 +770,9 @@ function renderInfoScreen() {
show(null);
const net = state.network || {};
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
const ipUrl = "http://" + (net.ip || "...") + port + "/";
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + port + "/";
const k = CLAVE ? "?k=" + encodeURIComponent(CLAVE) : "";
const ipUrl = "http://" + (net.ip || "...") + port + "/" + k;
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + port + "/" + k;
document.getElementById("info-url").textContent = ipUrl;
document.getElementById("info-sub").textContent = "o tambien: " + nameUrl;
if (typeof qrcode !== "undefined" && lastQrUrl !== ipUrl && net.ip) {
@ -716,11 +787,39 @@ function renderInfoScreen() {
document.getElementById("info").style.display = "flex";
}
/* Una capa MilkDrop sin preset elegido coge uno al azar (para que dos capas
nuevas no salgan identicas). Lo guardamos en el estado para que el panel
ensene cual le toco en vez de dejar el desplegable en blanco. No recrea la
instancia: la firma de una capa butter es el tipo a secas. */
function anotarPresetDeCapa(surfaceId, preset) {
const map = (state && state.mapping) || {};
const surf = (map.surfaces || []).find((s) => s.id === surfaceId);
if (!surf || (surf.fuente && surf.fuente.preset)) return;
surf.fuente = Object.assign({}, surf.fuente, { preset: preset });
socket.emit("set_mapping", { surfaces: map.surfaces, masks: map.masks });
}
function applyState(next) {
const prevDevice = currentDevice;
const prevSource = currentSource;
state = next;
if (window.FosMapper) FosMapper.setMapping(state.mapping || {});
const map = state.mapping || {};
// El mapping nunca puede tumbar el escenario: un mapping.json raro dejaria
// el proyector en negro y, de paso, sin poder contarlo (los avisos se
// pintan despues). Va aislado y con su propio aviso.
try {
if (window.FosMapper) FosMapper.setMapping(map);
// Las capas se ponen al dia con el estado: crea las nuevas, cambia el
// preset de las que lo hayan cambiado y tira las borradas. Solo con el
// mapping encendido y las visuales dadas: apagado no queremos motores
// extra quemando GPU ni la camara con el piloto puesto.
if (window.FosLayers) {
FosLayers.sync(map.enabled && state.power ? (map.surfaces || []) : []);
}
} catch (e) {
report("error", "El mapping no se pudo aplicar (" + e.message + "). "
+ "Pulsa Reset en la pestana MAPPING del panel.");
}
if (gainNode) gainNode.gain.value = state.sensitivity;
// Cambio de fuente (micro / audio del navegador) o de tarjeta de audio.
@ -803,17 +902,30 @@ function applyState(next) {
// ==========================================================================
socket.on("state", (next) => applyState(next));
/* Al (re)conectar, el escenario le devuelve al servidor lo que solo sabe el
navegador: la lista de presets y los dispositivos. Si el servidor se ha
reiniciado, su estado nace vacio y sin esto el panel se queda sin presets
ni camaras hasta recargar el escenario a mano. */
socket.on("connect", () => {
if (bcNames.length) socket.emit("stage_meta", { butterchurnPresets: bcNames });
enumerateInputs().catch(() => { /* aun sin permisos: ya se reintenta */ });
});
// --- Mapper (projection mapping en el navegador) ---
// Arranca el compositor/editor; al editar (arrastrar), guarda en el servidor.
// El resolver es lo que hace que cada superficie pinte SU fuente: se la pide
// a layers.js, que mantiene una instancia por capa.
if (window.FosMapper) {
FosMapper.init({
onChange: (surfaces) => socket.emit("set_mapping", { surfaces: surfaces }),
onChange: (m) => socket.emit("set_mapping", m),
resolver: (s) => (window.FosLayers ? FosLayers.fuenteDe(s) : null),
report: report,
});
}
socket.on("stage_command", (cmd) => {
if (!state) return;
if (state.engine === "butterchurn") {
if (butterLive()) {
if (cmd.action === "next") bcStep(1);
else if (cmd.action === "prev") bcStep(-1);
}
@ -863,6 +975,29 @@ async function boot() {
try {
shaderEngine = new ShaderEngine(shaderCanvas, analyser);
} catch (e) { report("error", "El motor de shaders fallo: " + e.message); }
// Capas del mapping: cada superficie puede tener su propia fuente. Necesita
// el audio compartido (para que MilkDrop y los shaders de cada capa
// reaccionen a la misma musica) y la libreria de shaders.
let shaders = {};
try {
shaders = await fetch("/data/shaders.json").then((r) => r.json());
} catch (e) {
report("warn", "No se pudo cargar la libreria de shaders: las capas de "
+ "shader del mapping no estaran disponibles.");
}
if (window.FosLayers) {
FosLayers.init({
audioCtx: audioCtx, gainNode: gainNode, analyser: analyser,
shaders: shaders, report: report,
crearShader: (canvas, size) => new ShaderEngine(canvas, analyser, size),
onPreset: anotarPresetDeCapa,
});
// El estado pudo llegar antes de que las capas estuvieran listas
if (state && state.mapping && state.mapping.enabled) {
FosLayers.sync(state.mapping.surfaces || []);
}
}
msgEl.textContent = "FOSFENO listo";
}