Mapping in-browser (Fase 1) + trabajo en curso

Projection mapping editable desde el navegador, sin herramientas externas:
- web/stage/mapper.js (nuevo): compositor WebGL que texturiza el motor activo
  sobre superficies deformables (homografia por vertice: keystone correcto).
- Editor con raton en el escenario (crear, arrastrar esquinas, mover, borrar)
  y previsualizacion arrastrable en el panel (raton/tactil) para editar desde
  el movil sin tocar la ventana de visuales.
- Backend: estado "mapping" + evento set_mapping, persistido en data/mapping.json
  (ignorado en git). Seccion "Mapping" en el panel + texto de ayuda.
- Solo motores web (butterchurn/hydra/shaders/mixer); projectM no se mapea.
- .gitignore: excluye config de runtime (mapping.json, pm-favoritos.json).

Incluye ademas trabajo en curso previo sin commitear (server.py, stage.js,
panel.js/css, ayuda.json, hydra-sketches.json, problemas.md,
start-projectm.sh), subido junto a peticion del autor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-07-27 00:19:30 +02:00
parent 0b1546cecc
commit 8dd58bb293
12 changed files with 1815 additions and 85 deletions

4
.gitignore vendored
View file

@ -15,6 +15,10 @@ data/presets-projectm/
data/videos/*
!data/videos/README.txt
# Config de runtime (por instalacion): no va al repo
data/mapping.json
data/pm-favoritos.json
# Temporales
*.log
*.tmp

View file

@ -14,6 +14,7 @@ Motores: projectm (nativo), butterchurn, hydra, shaders (GLSL) y mixer
import json
import os
import re
import shutil
import socket
import subprocess
@ -21,8 +22,9 @@ import threading
import time
from pathlib import Path
from flask import Flask, send_from_directory
from flask import Flask, request, send_from_directory
from flask_socketio import SocketIO
from werkzeug.utils import secure_filename
# --------------------------------------------------------------------------
# Rutas y configuracion
@ -32,6 +34,8 @@ WEB = BASE / "web"
DATA = BASE / "data"
VIDEOS = DATA / "videos"
VIDEO_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")
IMAGE_EXT = (".jpg", ".jpeg", ".png", ".gif", ".webp")
MEDIA_EXT = VIDEO_EXT + IMAGE_EXT # la galeria del mezclador admite ambos
def load_json(path, default):
@ -51,6 +55,7 @@ PORT = int(os.environ.get("FOSFENO_PORT") or CFG.get("server", {}).get("port", 8
NO_KIOSK = bool(os.environ.get("FOSFENO_NO_KIOSK"))
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="*")
# --------------------------------------------------------------------------
@ -73,7 +78,28 @@ def list_videos():
if not VIDEOS.is_dir():
return []
return sorted(f.name for f in VIDEOS.iterdir()
if f.suffix.lower() in VIDEO_EXT)
if f.suffix.lower() in MEDIA_EXT)
def list_projectm_presets():
"""Categorias y visuales de data/presets-projectm. El pack se organiza
como categoria/visual/variante.milk: cada 'visual' (Aurora, Blobby...)
es una carpeta con variantes del mismo estilo. Devuelve
{categoria: [visuales]}. Las carpetas '!...' se saltan (transiciones)."""
base = DATA / "presets-projectm"
cats = {}
if not base.is_dir():
return cats
for d in sorted(base.iterdir()):
if not d.is_dir() or d.name.startswith(("!", ".")):
continue
visuals = sorted(
v.name for v in d.iterdir()
if v.is_dir() and any(f.suffix.lower() == ".milk"
for f in v.iterdir()))
if visuals:
cats[d.name] = visuals
return cats
# --------------------------------------------------------------------------
@ -85,7 +111,9 @@ state = {
"engine": DEFAULTS.get("engine", "butterchurn"),
"power": True,
"sensitivity": float(DEFAULTS.get("sensitivity", 1.0)),
"audio": {"device": "", "bpm": 0},
# source: "mic" (microfono de la sala) o "monitor" (lo que suena en el
# propio equipo: navegador, Spotify... captado por el monitor del sistema).
"audio": {"device": "", "bpm": 0, "source": "mic"},
"butterchurn": {
"preset": "", "shuffle": True,
"interval": 20, "intervalMode": "seconds", "blendTime": 2.7,
@ -99,18 +127,40 @@ state = {
"code": first_code(SHADERS, DEFAULTS.get("shader", "")),
},
"mixer": {
"source": "cam", "camOn": True, "cameraId": 0, "video": "",
"mix": 0.5, "blendMode": "blend",
# fondo: capa de abajo de la mezcla ("cam" o "butter" = MilkDrop)
"source": "cam", "fondo": "cam", "camOn": True, "cameraId": 0,
"video": "", "mix": 0.5, "blendMode": "blend",
"hue": 0.0, "saturate": 1.0, "contrast": 1.0, "brightness": 0.0,
"colorama": 0.0, "posterize": 0, "pixelate": 0, "kaleid": 0,
"rotate": 0.0, "feedback": 0.0, "invert": False, "beatPulse": False,
},
"projectm": {"available": False},
# projectM: categoria (subcarpeta de presets), preset concreto (fijo),
# segundos por visual y orden aleatorio. Cambiarlos relanza projectM.
# locked: visual congelado con la tecla de bloqueo (se pierde al relanzar).
# nowPlaying/nowCategory/nowVisual: lo que esta en pantalla ahora mismo,
# leido del log de projectM (~/.config/projectM/projectMSDL.log).
# monitor: "auto" (la pantalla del escenario) o el numero de monitor.
# variante: un .milk exacto elegido a dedo ("" = rota dentro del visual).
# favoritos: visuales guardados para pincharlos en directo (persisten
# en data/pm-favoritos.json).
"projectm": {"available": False, "category": "", "preset": "",
"duration": 60, "shuffle": True, "locked": False,
"nowPlaying": "", "nowCategory": "", "nowVisual": "",
"monitor": "auto", "variante": "",
"favoritos": load_json(DATA / "pm-favoritos.json", [])},
# mapping: projection mapping in-browser. enabled = deformar la salida;
# 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": []}),
"meta": {
"butterchurnPresets": [],
"audioDevices": [],
"cameraDevices": [],
"videos": list_videos(),
"projectmPresets": {},
"monitors": [],
},
"status": {"label": ""},
"notifications": NOTIFY_HISTORY,
@ -209,16 +259,135 @@ def stop_projectm():
procs["projectm"] = None
def save_favoritos():
"""Persiste los visuales favoritos de projectM en data/pm-favoritos.json."""
try:
with open(DATA / "pm-favoritos.json", "w", encoding="utf-8") as fh:
json.dump(state["projectm"]["favoritos"], fh,
ensure_ascii=False, indent=2)
except OSError as exc:
notify("warn", f"No pude guardar los favoritos: {exc}")
def list_monitors():
"""Monitores conectados segun xrandr, para el selector del panel.
Devuelve [{'id': 1, 'name': 'eDP-1 (1920x1080)'}, ...]; el id va en el
formato de projectMSDL (1 = primer monitor)."""
if not shutil.which("xrandr"):
return []
mons = []
try:
out = subprocess.run(
["xrandr", "--listmonitors"], capture_output=True, text=True,
timeout=3, check=False).stdout
for line in out.splitlines():
m = re.search(r"^\s*(\d+):\s+\+?\*?(\S+)\s+(\d+)/\d+x(\d+)/\d+",
line)
if m:
idx, name, w, h = m.groups()
mons.append({"id": int(idx) + 1,
"name": f"{name} ({w}x{h})"})
except (OSError, subprocess.SubprocessError):
pass
return mons
def monitor_of_stage():
"""Numero de monitor (formato projectMSDL: 1 = primero) donde esta la
ventana del escenario. Con varias pantallas, projectM debe abrirse
siempre encima del escenario (el proyector) y no donde este el raton.
Devuelve 0 (posicion por defecto del SO) si no se puede averiguar."""
if not shutil.which("xdotool") or not shutil.which("xrandr"):
return 0
try:
geo = subprocess.run(
["xdotool", "search", "--name", "FOSFENO :: Escenario",
"getwindowgeometry"], capture_output=True, text=True,
timeout=3, check=False).stdout
pos = re.search(r"Position: (-?\d+),(-?\d+)", geo)
if not pos:
return 0
wx, wy = int(pos.group(1)), int(pos.group(2))
mons = subprocess.run(
["xrandr", "--listmonitors"], capture_output=True, text=True,
timeout=3, check=False).stdout
for line in mons.splitlines():
m = re.search(
r"^\s*(\d+):\s+\S+\s+(\d+)/\d+x(\d+)/\d+([+-]\d+)([+-]\d+)",
line)
if not m:
continue
idx, w, h, x, y = (int(v) for v in m.groups())
if x <= wx < x + w and y <= wy < y + h:
return idx + 1
except (OSError, subprocess.SubprocessError, ValueError):
pass
return 0
def start_projectm():
"""Lanza projectM nativo; su ventana se superpone a Chromium."""
"""Lanza projectM nativo con los ajustes del panel (categoria, preset
fijo, duracion, aleatorio); su ventana se superpone a Chromium."""
stop_projectm()
script = BASE / "scripts" / "start-projectm.sh"
pm = state["projectm"]
base = DATA / "presets-projectm"
path = base
if pm.get("category") and (base / pm["category"]).is_dir():
path = base / pm["category"]
try:
procs["projectm"] = subprocess.Popen(["bash", str(script)])
print("[FOSFENO] projectM lanzado.")
duration = max(2, int(pm.get("duration") or 20))
except (TypeError, ValueError):
duration = 20
shuffle = "1" if pm.get("shuffle", True) else "0"
# 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():
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():
fijado = DATA / "pm-fijado"
shutil.rmtree(fijado, ignore_errors=True)
fijado.mkdir(parents=True, exist_ok=True)
shutil.copy(origen, fijado / origen.name)
path = fijado
# Pantalla elegida en el panel: "auto" sigue al escenario; un numero
# la fija aunque el escenario este en otra (o no exista la ventana).
try:
mon = int(pm.get("monitor"))
except (TypeError, ValueError):
mon = 0
if mon <= 0:
mon = monitor_of_stage()
env = dict(os.environ,
FOSFENO_PM_PATH=str(path),
FOSFENO_PM_DURATION=str(duration),
FOSFENO_PM_SHUFFLE=shuffle,
FOSFENO_PM_MONITOR=str(mon))
# El bloqueo con tecla no sobrevive al relanzamiento del proceso
pm["locked"] = False
try:
procs["projectm"] = subprocess.Popen(["bash", str(script)], env=env)
print(f"[FOSFENO] projectM lanzado (presets: {path.name}, "
f"{duration}s, shuffle={shuffle}).")
except OSError as exc:
notify("error", f"No se pudo lanzar projectM: {exc}. "
"Prueba con otro motor de visuales.")
return
# projectM (SDL) abre su captura en la entrada por defecto; si toca
# "audio del sistema", mover su stream en cuanto aparezca en pactl.
# En la Raspberry projectM tarda varios segundos en arrancar: se le da
# margen de sobra (2s + 30 intentos x 0.3s = ~11s).
if state["audio"]["source"] == "monitor":
threading.Timer(
2.0, lambda: route_captures("monitor", quiet=True,
attempts=30)).start()
def projectm_key(key):
@ -233,6 +402,53 @@ def projectm_key(key):
)
# projectM escribe en su log cada visual que muestra, con la ruta completa:
# es la forma fiable de saber que hay en pantalla en cada momento.
PM_LOG = Path.home() / ".config" / "projectM" / "projectMSDL.log"
def projectm_now_playing():
"""Ultimo 'Displaying preset' del log -> (categoria, visual, variante)."""
try:
with open(PM_LOG, "rb") as fh:
fh.seek(0, os.SEEK_END)
fh.seek(max(0, fh.tell() - 8192))
tail = fh.read().decode("utf-8", "replace")
except OSError:
return "", "", ""
ruta = ""
for line in tail.splitlines():
if "Displaying preset:" in line:
ruta = line.split("Displaying preset:", 1)[1].strip()
if not ruta:
return "", "", ""
try:
cat, visual, variante = Path(ruta).relative_to(
DATA / "presets-projectm").parts
except ValueError:
return "", "", Path(ruta).stem # preset fuera del catalogo propio
return cat, visual, Path(variante).stem
def watch_projectm_log():
"""Bucle de fondo: refleja en el estado el visual que projectM muestra,
para que el panel ensene su nombre y pueda fijarlo con un boton."""
while True:
time.sleep(2)
if not (state["engine"] == "projectm" and state["power"]
and is_running(procs["projectm"])):
continue
cat, visual, variante = projectm_now_playing()
if not variante or variante == state["projectm"]["nowPlaying"]:
continue
with lock:
state["projectm"]["nowPlaying"] = variante
state["projectm"]["nowCategory"] = cat
state["projectm"]["nowVisual"] = visual
state["status"]["label"] = variante
broadcast()
def apply_engine():
"""Arranca o detiene projectM segun el motor y el estado de encendido."""
use_native = state["engine"] == "projectm" and state["power"]
@ -279,6 +495,42 @@ def data_files(fname):
return send_from_directory(str(DATA), fname)
@app.route("/pm/variantes")
def pm_variantes():
"""Lista las variantes (.milk) de un visual, para el selector del panel."""
base = (DATA / "presets-projectm").resolve()
d = (base / request.args.get("categoria", "")
/ request.args.get("visual", "")).resolve()
if not str(d).startswith(str(base) + os.sep) or not d.is_dir():
return {"variantes": []}
return {"variantes": sorted(
f.stem for f in d.iterdir() if f.suffix.lower() == ".milk")}
@app.route("/upload", methods=["POST"])
def upload_media():
"""Sube un video o una imagen a la galeria del mezclador (data/videos)."""
f = request.files.get("file")
if not f or not f.filename:
return {"ok": False, "error": "No llego ningun archivo."}, 400
name = secure_filename(f.filename)
if not name or not name.lower().endswith(MEDIA_EXT):
return {"ok": False,
"error": "Formato no valido. Sube video (mp4, webm, mov, "
"m4v, ogv) o imagen (jpg, png, gif, webp)."}, 400
try:
VIDEOS.mkdir(parents=True, exist_ok=True)
f.save(str(VIDEOS / name))
except OSError as exc:
notify("error", f"No se pudo guardar '{name}': {exc}")
return {"ok": False, "error": f"No se pudo guardar: {exc}"}, 500
with lock:
state["meta"]["videos"] = list_videos()
notify("info", f"'{name}' anadido a la galeria del mezclador.")
broadcast()
return {"ok": True, "name": name}
# --------------------------------------------------------------------------
# Eventos WebSocket
# --------------------------------------------------------------------------
@ -343,12 +595,51 @@ def on_run_code(data):
@socketio.on("update_settings")
def on_update_settings(data):
"""Actualiza ajustes de un motor: butterchurn, mixer o audio."""
"""Actualiza ajustes de un motor: butterchurn, mixer, audio o projectm."""
engine = (data or {}).get("engine")
patch = (data or {}).get("patch", {})
if engine in ("butterchurn", "mixer", "audio") and isinstance(patch, dict):
if engine in ("butterchurn", "mixer", "audio", "projectm") \
and isinstance(patch, dict):
with lock:
state[engine].update(patch)
# Los ajustes de projectM se aplican relanzandolo (tarda ~1s)
if engine == "projectm" and state["engine"] == "projectm" \
and state["power"]:
start_projectm()
# Cambio de fuente de audio: ademas del reenganche que hara el
# escenario, enrutamos aqui por si esta cerrado o si projectM (que
# no pasa por el navegador) es el motor activo. Con un microfono
# concreto elegido no se toca nada: lo fija el propio navegador.
src = patch.get("source") if engine == "audio" else None
if src == "monitor" or (src == "mic" and not state["audio"]["device"]):
threading.Timer(
1.5, lambda: route_captures(src, quiet=True)).start()
broadcast()
def save_mapping():
try:
with open(DATA / "mapping.json", "w", encoding="utf-8") as fh:
json.dump(state["mapping"], fh, ensure_ascii=False, indent=2)
except OSError as e:
notify("warn", f"No se pudo guardar el mapping: {e}")
@socketio.on("set_mapping")
def on_set_mapping(data):
"""Projection mapping: activar/editar y guardar las superficies.
Lo usan el escenario (al arrastrar esquinas) y el panel (activar, añadir,
borrar, reset). Guarda en data/mapping.json y difunde a todos."""
data = data or {}
with lock:
m = state["mapping"]
if "enabled" in data:
m["enabled"] = bool(data["enabled"])
if "edit" in data:
m["edit"] = bool(data["edit"])
if "surfaces" in data and isinstance(data["surfaces"], list):
m["surfaces"] = data["surfaces"]
save_mapping()
broadcast()
@ -356,12 +647,69 @@ def on_update_settings(data):
def on_engine_command(data):
action = (data or {}).get("action")
if state["engine"] == "projectm":
projectm_key({"next": "n", "prev": "p"}.get(action, "n"))
pm = state["projectm"]
if action in ("next", "prev"):
projectm_key({"next": "n", "prev": "p"}[action])
elif action == "lock":
# La tecla espacio congela/suelta el visual en pantalla al
# instante, sin relanzar projectM (el titulo pone [locked]).
projectm_key("space")
with lock:
pm["locked"] = not pm.get("locked")
broadcast()
elif action == "fix_current":
# Fija en las listas la categoria y el visual que se ven ahora:
# projectM se relanza rotando solo entre variantes de ese look.
if pm.get("nowCategory") and pm.get("nowVisual"):
with lock:
pm["category"] = pm["nowCategory"]
pm["preset"] = pm["nowVisual"]
pm["variante"] = ""
start_projectm()
broadcast()
else:
notify("warn", "Todavia no se que visual esta en pantalla: "
"espera un par de segundos y vuelve a probar.")
else:
socketio.emit("stage_command", {"action": action,
"value": (data or {}).get("value")})
@socketio.on("pm_favorito")
def on_pm_favorito(data):
"""Favoritos de projectM: guardar el visual en pantalla, quitar uno,
o lanzar uno a dedo (para pinchar visuales en directo)."""
data = data or {}
action = data.get("action")
pm = state["projectm"]
if action == "add":
fav = {"categoria": pm.get("nowCategory"),
"visual": pm.get("nowVisual"),
"variante": pm.get("nowPlaying")}
if not all(fav.values()):
notify("warn", "Todavia no se que visual esta en pantalla: "
"espera un par de segundos y vuelve a probar.")
return
if fav not in pm["favoritos"]:
with lock:
pm["favoritos"].append(fav)
save_favoritos()
notify("info", f"Favorito guardado: {fav['variante']}")
elif action == "del":
fav = {k: data.get(k) for k in ("categoria", "visual", "variante")}
with lock:
pm["favoritos"] = [f for f in pm["favoritos"] if f != fav]
save_favoritos()
elif action == "play":
with lock:
pm["category"] = data.get("categoria") or ""
pm["preset"] = data.get("visual") or ""
pm["variante"] = data.get("variante") or ""
if state["engine"] == "projectm" and state["power"]:
start_projectm()
broadcast()
@socketio.on("stage_meta")
def on_stage_meta(data):
"""El escenario nos envia las listas que solo conoce el navegador."""
@ -378,8 +726,12 @@ def on_stage_meta(data):
@socketio.on("rescan_devices")
def on_rescan_devices():
"""Pide al escenario que vuelva a buscar microfonos y camaras."""
"""Pide al escenario que vuelva a buscar microfonos y camaras.
Aprovecha para refrescar la lista de pantallas conectadas."""
with lock:
state["meta"]["monitors"] = list_monitors()
socketio.emit("stage_rescan")
broadcast()
@socketio.on("reacquire_audio")
@ -388,6 +740,96 @@ def on_reacquire_audio():
socketio.emit("stage_reacquire")
def pactl_default(kind):
"""Nombre del sink o source por defecto ('sink' | 'source'), o None."""
try:
out = subprocess.check_output(["pactl", f"get-default-{kind}"],
text=True, timeout=5).strip()
except (OSError, subprocess.SubprocessError):
return None
return out or None
# Aplicaciones cuyos streams de captura enrutamos: el navegador de las
# visuales (Chromium/Firefox) y projectM (SDL), que siempre abre la
# entrada por defecto y no se puede configurar de otra forma.
CAPTURE_APPS = ("chrom", "firefox", "projectm")
def capture_streams():
"""Indices de los streams de captura de las visuales, leidos de pactl."""
try:
out = subprocess.check_output(["pactl", "list", "source-outputs"],
text=True, timeout=5)
except (OSError, subprocess.SubprocessError):
return []
indices, current = [], None
for line in out.splitlines():
line = line.strip()
if line.startswith("Source Output #"):
current = line.split("#", 1)[1]
elif current and ("application.process.binary" in line
or "application.name" in line):
if any(app in line.lower() for app in CAPTURE_APPS):
indices.append(current)
current = None
return indices
def route_captures(target, quiet=False, attempts=10):
"""Mueve los streams de captura al 'monitor' de la salida por defecto
(target='monitor': lo que suena en el equipo) o los devuelve a la
entrada por defecto (target='mic').
Hace falta porque Chromium no lista los monitores de PulseAudio/PipeWire
al enumerar dispositivos y projectM (SDL) captura siempre la entrada por
defecto: la unica via fiable es mover sus streams con pactl. Requiere que
las visuales corran en esta misma maquina (kiosko o modo portatil)."""
if target == "monitor":
sink = pactl_default("sink")
dest = sink + ".monitor" if sink else None
else:
dest = pactl_default("source")
if not dest:
notify("warn", "pactl no pudo averiguar la salida/entrada por "
"defecto del sistema. Revisa el audio con 'pactl "
"info'; mientras tanto usa el microfono.")
return
# Los streams tardan un instante en aparecer tras abrir la captura.
streams = []
for _ in range(attempts):
streams = capture_streams()
if streams:
break
time.sleep(0.3)
if not streams:
if not quiet:
notify("warn", "No hay ningun stream de captura que mover (ni "
"navegador ni projectM). Recarga el escenario o "
"reinicia FOSFENO y vuelve a intentarlo.")
return
moved = all(
subprocess.run(["pactl", "move-source-output", idx, dest],
check=False).returncode == 0
for idx in streams)
if not moved:
notify("warn", f"pactl no pudo mover la captura a '{dest}'. Si la "
"salida acaba de cambiar (p. ej. bluetooth), pulsa "
"'Aplicar entrada' otra vez; si sigue fallando, "
"revisa 'pactl list sources short'.")
elif target == "monitor" and not quiet:
notify("info", f"Audio del sistema conectado ({dest}).")
@socketio.on("audio_route")
def on_audio_route(data):
"""El escenario pide enrutar la captura: 'monitor' (lo que suena en el
equipo) o 'mic' (entrada por defecto)."""
target = (data or {}).get("target")
if target in ("monitor", "mic"):
route_captures(target)
@socketio.on("stage_status")
def on_stage_status(data):
if isinstance(data, dict):
@ -418,9 +860,17 @@ def on_rescan_videos():
@socketio.on("system")
def on_system(data):
action = (data or {}).get("action")
if action not in ("reboot", "shutdown"):
return
# En modo portatil estos botones apagarian el ordenador de trabajo:
# solo tienen sentido en la Raspberry del kiosko.
if NO_KIOSK:
notify("warn", "Modo portatil: el boton de reiniciar/apagar es "
"para la Raspberry. En este equipo hazlo a mano.")
return
if action == "reboot":
subprocess.Popen(["sudo", "reboot"])
elif action == "shutdown":
else:
subprocess.Popen(["sudo", "poweroff"])
@ -429,6 +879,11 @@ def on_system(data):
# --------------------------------------------------------------------------
def setup_audio():
"""Marca el microfono USB como fuente de audio por defecto (best-effort)."""
# En modo portatil no hay micro USB dedicado y la fuente se elige en el
# panel (micro o "audio del navegador"). Evitamos forzar nada y el aviso
# de "no hay micro USB", que aqui solo confundiria.
if NO_KIOSK:
return
match = CFG.get("audio", {}).get("matchSource", "usb").lower()
try:
out = subprocess.check_output(["pactl", "list", "short", "sources"],
@ -475,6 +930,8 @@ def check_install():
def main():
binary = CFG.get("projectm", {}).get("binary", "projectMSDL")
state["projectm"]["available"] = shutil.which(binary) is not None
state["meta"]["projectmPresets"] = list_projectm_presets()
state["meta"]["monitors"] = list_monitors()
print(f"[FOSFENO] projectM nativo: "
f"{'disponible' if state['projectm']['available'] else 'no instalado'}")
print(f"[FOSFENO] Videos encontrados: {len(state['meta']['videos'])}")
@ -486,6 +943,7 @@ def main():
if not NO_KIOSK:
threading.Timer(3.0, start_chromium).start()
threading.Thread(target=watchdog, daemon=True).start()
threading.Thread(target=watch_projectm_log, daemon=True).start()
net = state["network"]
suffix = "" if PORT == 80 else f":{PORT}"

View file

@ -9,6 +9,16 @@
"Para apagar la Raspberry de verdad, usa los botones de Reiniciar y Apagar del final del panel."
]
},
"mapping": {
"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.",
"Ojo: no deforma projectM (que va en su propia ventana nativa). Usa Butterchurn, Hydra, Shaders o el Mezclador para tener todo mapeable."
]
},
"engines": {
"title": "Motor de visuales",
"body": [
@ -20,9 +30,15 @@
"projectm": {
"title": "Motor projectM",
"body": [
"Que es: el visualizador clasico de MilkDrop, compilado dentro de la Raspberry. Reacciona al audio por si solo y va rotando entre miles de presets.",
"Requisitos: se compila durante la instalacion. Si la compilacion fallo, este motor sale como no disponible y hay que volver a compilarlo. Los otros cuatro motores funcionan sin el.",
"Como se configura: projectM es un programa nativo, su ventana se pone por encima de las demas visuales. Los botones Anterior y Siguiente cambian de preset, pero eso solo funciona en sesion X11; en Wayland projectM rota presets automaticamente."
"Que es: el visualizador clasico MilkDrop en version nativa. Corre fuera del navegador, en su propia ventana, y reacciona a la musica igual que el resto de motores.",
"Probar y elegir: los botones Anterior/Siguiente saltan a mano y la linea 'En pantalla' dice como se llama lo que se esta viendo. Cuando aparezca uno que te guste, pulsa Congelar visual: se queda clavado al instante, sin cortes (vuelve a pulsar para soltarlo).",
"Fijar este estilo apunta en las listas la categoria y el visual que se ven ahora: projectM rotara solo entre las variantes de ese mismo look. Tambien puedes elegirlos a mano en las listas.",
"Elegir a dedo: cada visual es una carpeta con varias variantes .milk. Con categoria y visual elegidos, la tercera lista muestra las variantes: elige una y projectM se queda con esa exacta (y sobrevive a reinicios, al contrario que Congelar).",
"Favoritos: cuando aparezca un visual que te guste, pulsa 'Guardar el de pantalla' y se apunta en la lista de favoritos (se guardan en data/pm-favoritos.json, sobreviven a reinicios). En directo, cada favorito es un boton: tocalo y se lanza esa variante exacta. La X lo quita de la lista.",
"Ritmo de cambio: el deslizador de segundos por visual controla cada cuanto rota en automatico (de 5 segundos a 10 minutos), y la casilla de orden aleatorio decide si va en orden o barajado.",
"Pantalla: elige en que monitor se abre projectM. En Auto se abre siempre donde este el escenario (el proyector). Si conectas o quitas pantallas, pulsa Buscar dispositivos en el apartado Audio para refrescar la lista.",
"Cada cambio de listas o del deslizador relanza projectM y la pantalla queda en negro un segundo: es normal (Congelar y Anterior/Siguiente no relanzan). Al relanzar, el visual congelado se suelta.",
"El audio lo coge de la fuente elegida en el apartado Audio (microfono o audio del navegador), como los demas motores."
]
},
"butterchurn": {
@ -53,8 +69,12 @@
"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, conectada antes de encender la Raspberry. Para los clips, copia tus archivos de video en la carpeta data/videos del proyecto.",
"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."
]
},
@ -62,9 +82,11 @@
"title": "Audio y BPM",
"body": [
"Que es: aqui se elige por que entrada escucha FOSFENO la musica, y se ve el BPM que detecta.",
"Requisitos: un microfono USB o una tarjeta de sonido USB con entrada. La Raspberry no tiene entrada de audio propia.",
"Como se configura: por defecto FOSFENO coge el microfono USB automaticamente. Si tienes varias entradas, eligela en la lista. El BPM se calcula solo a partir del sonido y tarda unos segundos en estabilizarse.",
"Si conectas el microfono o la camara con la Raspberry ya encendida, pulsa el boton Buscar dispositivos de nuevo y apareceran en sus listas sin tener que reiniciar."
"Dos fuentes: 'Microfono (sala)' escucha el ambiente con un micro; 'Audio del navegador' capta directamente lo que suena en el propio equipo (Spotify, YouTube, lo que reproduzcas) aunque lo oigas por los cascos, sin necesidad de micro.",
"Audio del navegador: al elegirlo, FOSFENO mueve solo la captura al 'monitor' del sistema (PulseAudio/PipeWire) con pactl. Vale para todos los motores, projectM incluido. Si cambias de salida (por ejemplo conectas o quitas unos cascos bluetooth), pulsa 'Aplicar entrada' para que se recoloque.",
"Si sale un aviso de que no se pudo mover la captura, comprueba que algo esta sonando y vuelve a pulsar 'Aplicar entrada'. Como ultimo recurso, revisa 'pactl list sources short' en una terminal.",
"Como se configura: por defecto FOSFENO coge el microfono. Si tienes varias entradas, eligela en la lista. El BPM se calcula solo a partir del sonido y tarda unos segundos en estabilizarse.",
"Si conectas el microfono o la camara con el equipo ya encendido, pulsa el boton Buscar dispositivos de nuevo y apareceran en sus listas sin tener que reiniciar."
]
},
"sensibilidad": {
@ -83,4 +105,4 @@
]
}
}
}
}

View file

@ -18,5 +18,9 @@
"estrellas": {
"name": "Estrellas",
"code": "noise(() => 3 + a.fft[1]*6, 0.15).thresh(() => 0.62 - a.fft[0]*0.45).color(0.7,0.9,1.0).modulateRotate(osc(2), 0.2).out(o0)"
},
"piratas-recorte": {
"name": "PIRATAS: personaje sobre visuales",
"code": "/* personaje recortado (clip _silueta-negro) con visuales detras. Cambia el nombre del clip por cualquier otro _silueta-negro de la galeria. */ s1.initVideo(\"/data/videos/p3-duelo_silueta-negro.mp4\"); osc(8, 0.06, () => 1 + a.fft[0]*2).color(1.0, 0.45, 0.1).kaleid(3).modulate(noise(2), () => a.fft[1]*0.5).out(o1); src(o1).layer(src(s1).luma(0.12, 0.18)).out(o0)"
}
}
}

View file

@ -37,24 +37,68 @@ El BPM en cero quiere decir que FOSFENO no está recibiendo audio, o que el
audio es demasiado flojo.
Comprueba que el micrófono USB está conectado. En el apartado Audio del panel,
abre el desplegable y elige a mano la entrada correcta. Si tu micrófono no
aparece, conéctalo y reinicia la Raspberry, porque la lista se hace al
arrancar.
pulsa «Buscar dispositivos», abre el desplegable, elige la entrada correcta y
pulsa «Aplicar entrada». Ya no hace falta reiniciar para que aparezcan
entradas nuevas.
Si el audio entra pero el BPM no se estabiliza, sube la sensibilidad. El
detector de ritmo necesita música con un pulso claro; con música muy suave o
sin percusión puede no encontrar el tempo, y a veces marca el doble o la
mitad del valor real.
Si al arrancar sale el aviso «Sin entrada de audio», las visuales siguen
funcionando pero no reaccionan a la música: conecta un micrófono (o elige
«Audio del navegador») y pulsa «Buscar dispositivos» y «Aplicar entrada».
## El audio del navegador (Spotify, YouTube) no mueve las visuales
La fuente «Audio del navegador» captura lo que suena en el propio equipo
usando el «monitor» de PulseAudio/PipeWire. Chromium no deja elegir ese
monitor directamente, así que FOSFENO mueve la captura por su cuenta con
`pactl`. Eso vale para todos los motores, projectM incluido (projectM captura
siempre la entrada por defecto y FOSFENO recoloca su stream al lanzarlo).
Si no reacciona:
- Pon algo a sonar primero y pulsa «Aplicar entrada».
- Si cambias de salida (conectas o quitas unos cascos bluetooth, cambias a
HDMI...), la captura se queda en la salida antigua: pulsa «Aplicar
entrada» otra vez para que se recoloque en la nueva.
- Lee el aviso del panel: si dice que no pudo mover la captura o averiguar
la salida por defecto, revisa el audio del sistema con `pactl info` y
`pactl list sources short` en una terminal.
- Al volver a «Micrófono (sala)», FOSFENO devuelve la captura a la entrada
por defecto. Si notas que sigue «escuchando» la música del equipo, pulsa
«Aplicar entrada» de nuevo.
## La cámara no funciona en el Mezclador
Tiene que ser una webcam USB que cumpla el estándar UVC. El módulo de cámara
con cable plano de la Raspberry no sirve en esta versión.
Conecta la cámara antes de encender la Raspberry. Si la conectas con el
sistema ya arrancado, reinicia. Si en el panel sale un aviso de que la cámara
no responde, es que el sistema no la ve; pruébala en otro puerto USB o con
otro cable.
Si la conectas con el sistema ya arrancado, pulsa «Buscar dispositivos» en el
panel para que aparezca en la lista. Cuando la cámara falla, el panel lo dice
con un aviso (y el motivo: no conectada, ocupada por otro programa...); para
reintentar, apaga y enciende la casilla «Cámara». Si aun así no responde,
pruébala en otro puerto USB o con otro cable.
Al salir del Mezclador la cámara se apaga (se le va el LED); se vuelve a
encender sola al entrar de nuevo.
## El Mezclador se queda en negro
Mira el aviso del panel, que dice exactamente qué falta:
- «La cámara está apagada»: activa la casilla «Cámara».
- «No hay vídeo elegido»: selecciona un clip en la lista.
- «No hay vídeos»: copia archivos a `data/videos` y pulsa el botón de
actualizar la lista.
- «No se pudo cargar el vídeo»: el archivo está dañado o el códec no está
soportado. Convierte el clip a .mp4 (H.264) o .webm y vuelve a elegirlo
en la lista para reintentar.
En el modo «mezcla» hacen falta las dos fuentes (cámara y vídeo); si solo
hay una, la otra mitad de la mezcla se ve negra.
## projectM no está disponible
@ -73,6 +117,11 @@ Ese script avisa en qué paso falla. Lo más habitual es que falte alguna
librería de desarrollo; vuelve a lanzar `bash install.sh` para reinstalar las
dependencias del sistema.
Si projectM se ve pero no reacciona a la música: projectM escucha siempre la
entrada por defecto del sistema. Con la fuente «Audio del navegador» activa,
FOSFENO mueve su captura al monitor de la salida al lanzarlo; si acabas de
cambiar de salida o de fuente, pulsa «Aplicar entrada» en el panel.
## Las visuales van a tirones
Suele ser calor. Una Raspberry sin disipador, después de un rato con visuales,

View file

@ -1,13 +1,29 @@
#!/usr/bin/env bash
# Lanza projectM nativo a pantalla completa. Lo invoca el servidor cuando
# se selecciona el motor "projectM" desde el panel.
#
# El servidor pasa los ajustes del panel por variables de entorno:
# FOSFENO_PM_PATH carpeta de presets (categoria o preset fijo)
# FOSFENO_PM_DURATION segundos por visual
# FOSFENO_PM_SHUFFLE orden aleatorio (1/0)
DIR="$(cd "$(dirname "$0")/.." && pwd)"
PRESETS="$DIR/data/presets-projectm"
PRESETS="${FOSFENO_PM_PATH:-$DIR/data/presets-projectm}"
# projectMSDL (frontend SDL2 de projectM v4). La ruta de presets se puede
# fijar tambien en ~/.config/projectMSDL/projectMSDL.properties
ARGS=()
if [ -d "$PRESETS" ]; then
exec projectMSDL --presetPath "$PRESETS"
else
exec projectMSDL
ARGS+=(--presetPath "$PRESETS")
fi
[ -n "${FOSFENO_PM_DURATION:-}" ] && ARGS+=(--presetDuration "$FOSFENO_PM_DURATION")
[ -n "${FOSFENO_PM_SHUFFLE:-}" ] && ARGS+=(--shuffleEnabled "$FOSFENO_PM_SHUFFLE")
# Pantalla completa sin bordes (sin cambiar la resolucion del proyector):
# asi los relanzamientos no dejan una ventana flotante, solo un parpadeo.
ARGS+=(--fullscreen 1 --exclusive 0)
# Y siempre en el monitor donde esta el escenario (lo detecta el servidor):
# sin esto, cada relanzamiento aterriza en la pantalla donde este el raton.
if [ -n "${FOSFENO_PM_MONITOR:-}" ] && [ "$FOSFENO_PM_MONITOR" != "0" ]; then
ARGS+=(--monitor "$FOSFENO_PM_MONITOR")
fi
exec projectMSDL "${ARGS[@]}"

View file

@ -40,15 +40,21 @@
<span class="label">Audio</span>
<button class="info" data-help="audio">i</button>
</div>
<div class="seg" id="audio-source">
<button id="audio-src-mic" class="segbtn" data-src="mic">Microfono (sala)</button>
<button id="audio-src-monitor" class="segbtn" data-src="monitor">Audio del navegador</button>
</div>
<select id="audio-device"><option>Detectando entradas...</option></select>
<button id="audio-apply" class="cmd accent">Aplicar microfono</button>
<button id="audio-apply" class="cmd accent">Aplicar entrada</button>
<button id="dev-rescan" class="cmd">Buscar dispositivos de nuevo</button>
<div class="row">
<span class="label">BPM detectado</span>
<span id="bpm" class="bpm">--</span>
</div>
<p class="hint">Si conectas el microfono o la camara con el equipo ya
encendido, pulsa este boton para que aparezcan.</p>
<p class="hint">Con <b>Audio del navegador</b> las visuales reaccionan a
lo que suena en el equipo (Spotify, YouTube...) aunque lo oigas por los
cascos. Si conectas un microfono con el equipo ya encendido, pulsa
Buscar dispositivos de nuevo.</p>
</section>
<!-- Sensibilidad -->
@ -61,9 +67,9 @@
<input id="sens" type="range" min="0" max="4" step="0.1" value="1">
</section>
<!-- Estado -->
<!-- Estado: visual que se esta proyectando ahora mismo -->
<section class="card" id="card-now">
<span class="label">Ahora suena</span>
<span class="label">En pantalla</span>
<div id="now" class="now">-</div>
</section>
@ -153,15 +159,29 @@
<button class="segbtn" data-src="video">Video</button>
<button class="segbtn" data-src="mix">Mezcla</button>
</div>
<div class="row">
<span class="label">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>
<select id="mix-video"><option value="">-- sin video --</option></select>
<button id="mix-rescan" class="cmd">Actualizar lista de videos</button>
<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>
<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>
<select id="mix-blend" class="inline">
<option value="recorte">Recorte (personaje delante)</option>
<option value="blend">Fundido</option>
<option value="diff">Diferencia</option>
<option value="mult">Multiplicar</option>
@ -184,10 +204,75 @@
<span class="label">projectM</span>
<button class="info" data-help="projectm">i</button>
</div>
<select id="pm-category">
<option value="">Todas las categorias</option>
</select>
<select id="pm-preset">
<option value="">-- rotando (elige uno para fijarlo) --</option>
</select>
<select id="pm-variante">
<option value="">-- variantes: rotando (elige una a dedo) --</option>
</select>
<div class="row">
<button class="cmd" data-action="prev">&laquo; Anterior</button>
<button class="cmd" data-action="next">Siguiente &raquo;</button>
</div>
<div class="row">
<button id="pm-lock" class="cmd">Congelar visual</button>
<button id="pm-fix" class="cmd">Fijar este estilo</button>
</div>
<p class="hint" id="pm-now">En pantalla: ...</p>
<div class="cardhead">
<span class="label">Favoritos (para pinchar en directo)</span>
</div>
<div class="row">
<button id="pm-fav-add" class="cmd accent">&#9733; Guardar el de pantalla</button>
</div>
<div id="pm-fav-list"></div>
<label class="check">
<input type="checkbox" id="pm-shuffle"> Orden aleatorio
</label>
<div class="row">
<span class="label">Pantalla</span>
<select id="pm-monitor" class="inline">
<option value="auto">Auto (la del escenario)</option>
</select>
</div>
<div class="row">
<span class="label">Segundos por visual: <span id="pm-dur-val">60</span> s</span>
</div>
<input id="pm-duration" type="range" min="5" max="600" step="5" value="60">
<p class="hint">Congelar deja clavado el visual que se ve (al momento,
sin cortes). Fijar este estilo lo apunta en las listas y projectM
rota solo entre variantes de ese look. Cada cambio de listas o
deslizador relanza projectM (~1 s en negro).</p>
</section>
<!-- Mapping (projection mapping) -->
<section class="card" id="card-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">+ Anadir superficie</button>
<button id="map-reset" class="cmd">Reset</button>
</div>
<span class="label">Previsualizacion (arrastra las esquinas)</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 id="map-list"></div>
<p class="hint">1) Elige un motor que no sea projectM. 2) Pulsa
<b>Anadir superficie</b>. 3) <b>Arrastra las esquinas</b> aqui en la
previsualizacion (raton o dedo) para encajar la forma; se ve en el
proyector al instante. Cada superficie mapea la misma imagen. Se
guarda solo.</p>
</section>
</div>

View file

@ -256,3 +256,14 @@ input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
text-align: center;
}
.modal-box p { color: var(--txt); font-size: 14px; line-height: 1.5; }
/* Favoritos de projectM: lista de visuales para pinchar en directo */
#pm-fav-list {
display: flex; flex-direction: column; gap: 8px;
max-height: 220px; overflow-y: auto;
}
#pm-fav-list .favplay {
text-align: left; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
#pm-fav-list .favdel { flex: 0 0 auto; }

View file

@ -168,6 +168,9 @@ function render() {
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
(d) => d.id, (d) => d.name);
if (state.audio.device) $("#audio-device").value = state.audio.device;
const source = state.audio.source || "mic";
$("#audio-src-mic").classList.toggle("active", source === "mic");
$("#audio-src-monitor").classList.toggle("active", source === "monitor");
$("#bpm").textContent = state.audio.bpm > 0 ? state.audio.bpm : "--";
$("#sens").value = state.sensitivity;
@ -181,8 +184,10 @@ function render() {
renderButterchurn();
renderEditor();
renderMixer();
renderProjectM();
renderMapping();
$("#now").textContent = state.status.label || "-";
$("#now").textContent = nowText() || "-";
const net = state.network || {};
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
@ -198,6 +203,21 @@ function render() {
}
}
/* Texto unico de "que se esta viendo". Lo usan la tarjeta En pantalla y la
tarjeta de projectM, para que ambas cuenten exactamente lo mismo. Con
projectM se ensena con la misma taxonomia de las listas (Categoria /
Visual) y despues la variante concreta que rota dentro de ese look. */
function nowText() {
if (state.engine === "projectm") {
const pm = state.projectm || {};
if (!pm.nowPlaying) return "";
const sitio = (pm.nowCategory && pm.nowVisual)
? pm.nowCategory + " / " + pm.nowVisual + " · " : "";
return sitio + pm.nowPlaying + (pm.locked ? " [congelado]" : "");
}
return state.status.label || "";
}
function fillSelectOnce(sel, items, valFn, txtFn) {
items = items || [];
if (sel.dataset.count == items.length) return;
@ -264,6 +284,7 @@ function renderMixer() {
fillSelectOnce($("#mix-video"), (state.meta.videos || []).map((v) => ({ v })),
(o) => o.v, (o) => o.v);
$("#mix-video").value = m.video || "";
$("#mix-fondo").value = m.fondo || "cam";
$("#mix-blend").value = m.blendMode;
$("#mix-invert").checked = m.invert;
$("#mix-beat").checked = m.beatPulse;
@ -276,12 +297,343 @@ function renderMixer() {
});
}
function renderProjectM() {
if (state.engine !== "projectm") return;
const pm = state.projectm || {};
const cats = state.meta.projectmPresets || {};
const catSel = $("#pm-category");
const catSig = Object.keys(cats).join("|");
if (catSel.dataset.sig !== catSig) {
catSel.dataset.sig = catSig;
catSel.innerHTML = '<option value="">Todas las categorias</option>';
Object.keys(cats).forEach((c) => {
const opt = document.createElement("option");
opt.value = c;
opt.textContent = c + " (" + cats[c].length + ")";
catSel.appendChild(opt);
});
}
catSel.value = pm.category || "";
// La lista de presets depende de la categoria elegida
const preSel = $("#pm-preset");
const presets = cats[pm.category] || [];
const preSig = (pm.category || "") + "|" + presets.length;
if (preSel.dataset.sig !== preSig) {
preSel.dataset.sig = preSig;
preSel.innerHTML = presets.length
? '<option value="">-- rotando (elige uno para fijarlo) --</option>'
: '<option value="">-- elige antes una categoria --</option>';
presets.forEach((p) => {
const opt = document.createElement("option");
opt.value = p;
opt.textContent = p.replace(/\.milk$/i, "");
preSel.appendChild(opt);
});
}
preSel.value = pm.preset || "";
// Tercer nivel: variantes (.milk) del visual elegido, para clavar una
// a dedo. La lista se pide al servidor al cambiar de visual.
const varSel = $("#pm-variante");
const varSig = (pm.category || "") + "|" + (pm.preset || "");
if (varSel.dataset.sig !== varSig) {
varSel.dataset.sig = varSig;
varSel.innerHTML = pm.category && pm.preset
? '<option value="">-- variantes: rotando (elige una a dedo) --</option>'
: '<option value="">-- elige antes un visual --</option>';
if (pm.category && pm.preset) {
fetch("/pm/variantes?categoria=" + encodeURIComponent(pm.category)
+ "&visual=" + encodeURIComponent(pm.preset))
.then((r) => r.json())
.then((d) => {
if (varSel.dataset.sig !== varSig) return; // cambio a medias
(d.variantes || []).forEach((v) => {
const opt = document.createElement("option");
opt.value = v;
opt.textContent = v;
varSel.appendChild(opt);
});
varSel.value = (state.projectm && state.projectm.variante) || "";
})
.catch(() => {});
}
} else {
varSel.value = pm.variante || "";
}
// Favoritos: una fila por visual guardado, para lanzarlo de un toque
const favList = $("#pm-fav-list");
const favs = pm.favoritos || [];
const favSig = favs.map((f) => f.categoria + "/" + f.visual + "/"
+ f.variante).join("|");
if (favList.dataset.sig !== favSig) {
favList.dataset.sig = favSig;
favList.innerHTML = "";
favs.forEach((f) => {
const row = document.createElement("div");
row.className = "row";
const play = document.createElement("button");
play.className = "cmd favplay";
play.textContent = "▶ " + f.variante;
play.title = f.categoria + " / " + f.visual;
play.addEventListener("click", () => socket.emit("pm_favorito", {
action: "play", categoria: f.categoria,
visual: f.visual, variante: f.variante,
}));
const del = document.createElement("button");
del.className = "cmd favdel";
del.textContent = "✕";
del.title = "Quitar de favoritos";
del.addEventListener("click", () => socket.emit("pm_favorito", {
action: "del", categoria: f.categoria,
visual: f.visual, variante: f.variante,
}));
row.appendChild(play);
row.appendChild(del);
favList.appendChild(row);
});
}
$("#pm-shuffle").checked = pm.shuffle !== false;
// Selector de pantalla: Auto + monitores detectados por el servidor
const monSel = $("#pm-monitor");
const mons = state.meta.monitors || [];
const monSig = mons.map((m) => m.id + m.name).join("|");
if (monSel.dataset.sig !== monSig) {
monSel.dataset.sig = monSig;
monSel.innerHTML = '<option value="auto">Auto (la del escenario)</option>';
mons.forEach((m) => {
const opt = document.createElement("option");
opt.value = m.id;
opt.textContent = "Pantalla " + m.id + " - " + m.name;
monSel.appendChild(opt);
});
}
monSel.value = String(pm.monitor || "auto");
const dur = $("#pm-duration");
if (document.activeElement !== dur) dur.value = pm.duration || 60;
$("#pm-dur-val").textContent = pm.duration || 60;
const lockBtn = $("#pm-lock");
lockBtn.classList.toggle("accent", !!pm.locked);
lockBtn.textContent = pm.locked ? "Congelado (soltar)" : "Congelar visual";
$("#pm-now").textContent = "En pantalla: " + (nowText() || "...");
}
// ==========================================================================
// Projection mapping: previsualizacion arrastrable + lista
// ==========================================================================
let mapSurfaces = []; // copia local (para editar sin parpadeos)
let mapSel = null; // indice de superficie seleccionada
let mapDrag = null; // { idx, corner|null, sx, sy, orig }
let mapEmitTimer = null;
const MAP_H = 15; // radio del tirador (px)
const cloneSurfaces = (arr) =>
(arr || []).map((s) => ({ id: s.id, corners: s.corners.map((p) => [p[0], p[1]]) }));
// interpolacion bilineal dentro del cuadrilatero (pts = TL,TR,BR,BL)
function bilerp(pts, u, v) {
const tx = pts[0][0] + (pts[1][0] - pts[0][0]) * u;
const ty = pts[0][1] + (pts[1][1] - pts[0][1]) * u;
const bx = pts[3][0] + (pts[2][0] - pts[3][0]) * u;
const by = pts[3][1] + (pts[2][1] - pts[3][1]) * u;
return [tx + (bx - tx) * v, ty + (by - ty) * v];
}
function drawMapPreview() {
const cv = $("#map-preview");
if (!cv) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
const w = cv.clientWidth, h = cv.clientHeight;
if (!w || !h) return;
const W = Math.round(w * dpr), H = Math.round(h * dpr);
if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; }
const ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = "#333"; ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
ctx.font = "600 13px system-ui, sans-serif";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
if (!mapSurfaces.length) {
ctx.fillStyle = "rgba(255,255,255,0.4)";
ctx.fillText("Pulsa '+ Anadir superficie'", w / 2, h / 2);
return;
}
mapSurfaces.forEach((s, i) => {
const pts = s.corners.map(([x, y]) => [x * w, y * h]);
const sel = i === mapSel;
// rejilla interna: hace visible la deformacion
ctx.strokeStyle = sel ? "rgba(0,229,255,0.4)" : "rgba(180,255,0,0.22)";
ctx.lineWidth = 1;
const N = 4;
for (let g = 0; g <= N; g++) {
const t = g / N;
ctx.beginPath();
for (let k = 0; k <= N; k++) { const p = bilerp(pts, k / N, t); k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }
ctx.stroke();
ctx.beginPath();
for (let k = 0; k <= N; k++) { const p = bilerp(pts, t, k / N); k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); }
ctx.stroke();
}
// contorno + relleno
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < 4; k++) ctx.lineTo(pts[k][0], pts[k][1]);
ctx.closePath();
ctx.fillStyle = sel ? "rgba(0,229,255,0.08)" : "rgba(180,255,0,0.05)";
ctx.fill();
ctx.lineWidth = sel ? 2.5 : 1.5;
ctx.strokeStyle = sel ? "#00e5ff" : "#b4ff00";
ctx.stroke();
// numero
const cx = (pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4;
const cy = (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4;
ctx.fillStyle = sel ? "#00e5ff" : "rgba(180,255,0,0.9)";
ctx.fillText(String(i + 1), cx, cy);
// tiradores
pts.forEach(([px, py]) => {
ctx.beginPath(); ctx.arc(px, py, MAP_H * 0.7, 0, Math.PI * 2);
ctx.fillStyle = sel ? "#00e5ff" : "#fff"; ctx.fill();
ctx.lineWidth = 2; ctx.strokeStyle = "#000"; ctx.stroke();
});
});
}
function mapLocalPt(ev) {
const r = $("#map-preview").getBoundingClientRect();
return { x: (ev.clientX - r.left) / r.width, y: (ev.clientY - r.top) / r.height, w: r.width, h: r.height };
}
function mapPointInQuad(x, y, c) {
let inside = false;
for (let i = 0, j = 3; i < 4; j = i++) {
const xi = c[i][0], yi = c[i][1], xj = c[j][0], yj = c[j][1];
if (((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / ((yj - yi) || 1e-9) + xi)) inside = !inside;
}
return inside;
}
function onMapDown(ev) {
ev.preventDefault();
const p = mapLocalPt(ev);
const rx = (MAP_H + 8) / p.w, ry = (MAP_H + 8) / p.h;
for (let i = mapSurfaces.length - 1; i >= 0; i--) {
const c = mapSurfaces[i].corners;
for (let k = 0; k < 4; k++) {
const dx = (c[k][0] - p.x) / rx, dy = (c[k][1] - p.y) / ry;
if (dx * dx + dy * dy <= 1) {
mapSel = i; mapDrag = { idx: i, corner: k, sx: p.x, sy: p.y, orig: c.map((q) => q.slice()) };
drawMapPreview(); return;
}
}
}
for (let i = mapSurfaces.length - 1; i >= 0; i--) {
if (mapPointInQuad(p.x, p.y, mapSurfaces[i].corners)) {
mapSel = i; mapDrag = { idx: i, corner: null, sx: p.x, sy: p.y, orig: mapSurfaces[i].corners.map((q) => q.slice()) };
drawMapPreview(); return;
}
}
mapSel = null; mapDrag = null; drawMapPreview();
}
function onMapMove(ev) {
if (!mapDrag) return;
ev.preventDefault();
const p = mapLocalPt(ev);
const dx = p.x - mapDrag.sx, dy = p.y - mapDrag.sy;
const s = mapSurfaces[mapDrag.idx]; if (!s) return;
const cl = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
if (mapDrag.corner == null) {
for (let k = 0; k < 4; k++) {
s.corners[k][0] = cl(mapDrag.orig[k][0] + dx);
s.corners[k][1] = cl(mapDrag.orig[k][1] + dy);
}
} else {
s.corners[mapDrag.corner][0] = cl(mapDrag.orig[mapDrag.corner][0] + dx);
s.corners[mapDrag.corner][1] = cl(mapDrag.orig[mapDrag.corner][1] + dy);
}
drawMapPreview(); scheduleMapEmit();
}
function onMapUp() { if (mapDrag) { emitMapNow(); mapDrag = null; } }
function scheduleMapEmit() { if (!mapEmitTimer) mapEmitTimer = setTimeout(() => { mapEmitTimer = null; emitMapNow(); }, 60); }
function emitMapNow() {
if (mapEmitTimer) { clearTimeout(mapEmitTimer); mapEmitTimer = null; }
socket.emit("set_mapping", { surfaces: mapSurfaces });
}
// refleja estado, dibuja la previsualizacion y la lista de superficies
function renderMapping() {
const m = state.mapping || { enabled: false, edit: false, surfaces: [] };
const en = $("#map-enabled"), ed = $("#map-edit");
if (document.activeElement !== en) en.checked = !!m.enabled;
if (document.activeElement !== ed) ed.checked = !!m.edit;
ed.disabled = !m.enabled;
const surfaces = m.surfaces || [];
// previsualizacion (no piso la geometria mientras arrastro aqui)
if (!mapDrag) mapSurfaces = cloneSurfaces(surfaces);
if (mapSel != null && mapSel >= mapSurfaces.length) mapSel = null;
drawMapPreview();
// lista con boton de borrar (se reconstruye solo si cambia)
const list = $("#map-list");
const sig = surfaces.map((s) => s.id).join("|") + "#" + surfaces.length;
if (list.dataset.sig === sig) return;
list.dataset.sig = sig;
list.innerHTML = "";
if (!surfaces.length) return;
surfaces.forEach((s, i) => {
const row = document.createElement("div");
row.className = "row";
const lbl = document.createElement("span");
lbl.className = "label";
lbl.textContent = "Superficie " + (i + 1);
const del = document.createElement("button");
del.className = "cmd favdel";
del.textContent = "✕";
del.title = "Borrar superficie";
del.addEventListener("click", () => {
const rest = ((state.mapping && state.mapping.surfaces) || [])
.filter((x) => x.id !== s.id);
socket.emit("set_mapping", { surfaces: rest });
});
row.appendChild(lbl);
row.appendChild(del);
list.appendChild(row);
});
}
// ==========================================================================
// Eventos de la interfaz
// ==========================================================================
$("#power").addEventListener("click", () =>
socket.emit("set_power", { on: !state.power }));
// --- Mapping ---
$("#map-enabled").addEventListener("change", (e) =>
socket.emit("set_mapping", { enabled: e.target.checked }));
$("#map-edit").addEventListener("change", (e) =>
socket.emit("set_mapping", { edit: e.target.checked }));
$("#map-add").addEventListener("click", () => {
const cur = (state.mapping && state.mapping.surfaces) || [];
const s = 0.2, cx = 0.5, cy = 0.5;
const nueva = {
id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4),
corners: [[cx - s, cy - s], [cx + s, cy - s], [cx + s, cy + s], [cx - s, cy + s]],
};
socket.emit("set_mapping",
{ enabled: true, edit: true, surfaces: cur.concat([nueva]) });
});
$("#map-reset").addEventListener("click", () => {
if (confirm("Borrar todas las superficies de mapping?"))
socket.emit("set_mapping", { surfaces: [] });
});
// Previsualizacion arrastrable (raton + tactil via pointer events)
(function () {
const mp = $("#map-preview");
if (!mp) return;
mp.addEventListener("pointerdown", (e) => { mp.setPointerCapture(e.pointerId); onMapDown(e); });
mp.addEventListener("pointermove", onMapMove);
mp.addEventListener("pointerup", onMapUp);
mp.addEventListener("pointercancel", onMapUp);
window.addEventListener("resize", () => { if (state) drawMapPreview(); });
})();
$$(".engine").forEach((b) =>
b.addEventListener("click", () =>
socket.emit("set_engine", { engine: b.dataset.engine })));
@ -295,6 +647,14 @@ $("#modal").addEventListener("click", (e) => {
$("#notif-close").addEventListener("click", () => { $("#notif").hidden = true; });
// Eleccion de fuente: micro de la sala o audio del propio equipo (monitor).
// El cambio de 'source' ya hace que el escenario reconecte la captura; el
// boton "Aplicar entrada" queda como reintento manual.
$$("#audio-source .segbtn").forEach((b) =>
b.addEventListener("click", () =>
socket.emit("update_settings",
{ engine: "audio", patch: { source: b.dataset.src } })));
$("#audio-device").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "audio", patch: { device: e.target.value } }));
@ -367,6 +727,9 @@ $("#dev-rescan").addEventListener("click", () =>
$("#mix-video").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { video: e.target.value } }));
$("#mix-fondo").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { fondo: e.target.value } }));
$("#mix-blend").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { blendMode: e.target.value } }));
@ -378,6 +741,71 @@ $("#mix-beat").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "mixer", patch: { beatPulse: e.target.checked } }));
// --- projectM ---
$("#pm-category").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm",
patch: { category: e.target.value, preset: "", variante: "" } }));
$("#pm-preset").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { preset: e.target.value, variante: "" } }));
$("#pm-variante").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { variante: e.target.value } }));
$("#pm-fav-add").addEventListener("click", () =>
socket.emit("pm_favorito", { action: "add" }));
$("#pm-shuffle").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { shuffle: e.target.checked } }));
$("#pm-duration").addEventListener("input", (e) =>
$("#pm-dur-val").textContent = e.target.value);
$("#pm-duration").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { duration: parseInt(e.target.value, 10) } }));
$("#pm-monitor").addEventListener("change", (e) =>
socket.emit("update_settings",
{ engine: "projectm", patch: { monitor: e.target.value } }));
$("#pm-lock").addEventListener("click", () =>
socket.emit("engine_command", { action: "lock" }));
$("#pm-fix").addEventListener("click", () =>
socket.emit("engine_command", { action: "fix_current" }));
// --- Subida a la galeria del mezclador ---
$("#mix-upload-btn").addEventListener("click", () => $("#mix-upload").click());
$("#mix-upload").addEventListener("change", () => {
const f = $("#mix-upload").files[0];
if (!f) return;
const status = $("#mix-upload-status");
const fd = new FormData();
fd.append("file", f);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
status.textContent = "subiendo " + Math.round(e.loaded / e.total * 100) + "%";
}
};
xhr.onload = () => {
let r = {};
try { r = JSON.parse(xhr.responseText); } catch (e) { /* respuesta rara */ }
status.textContent = r.ok ? "subido ✓" : "";
if (!r.ok) {
showNotif({ level: "error",
message: r.error || "La subida fallo (" + xhr.status + ")." });
}
$("#mix-upload").value = "";
setTimeout(() => { status.textContent = ""; }, 5000);
};
xhr.onerror = () => {
status.textContent = "";
$("#mix-upload").value = "";
showNotif({ level: "error", message: "La subida fallo: no hay conexion "
+ "con el servidor de FOSFENO." });
};
status.textContent = "subiendo 0%";
xhr.send(fd);
});
// --- Comandos siguiente/anterior ---
$$(".cmd[data-action]").forEach((b) =>
b.addEventListener("click", () =>
@ -402,7 +830,7 @@ socket.on("status", (s) => {
if (!state) return;
state.status.label = s.label;
state.audio.bpm = s.bpm;
$("#now").textContent = s.label || "-";
$("#now").textContent = nowText() || "-";
$("#bpm").textContent = s.bpm > 0 ? s.bpm : "--";
});
socket.on("notify", (entry) => showNotif(entry));

View file

@ -14,6 +14,9 @@
width: 100vw; height: 100vh; display: none;
}
canvas.visible { display: block; }
/* Mapper: salida deformada (encima de los motores) y capa de edición */
#output { z-index: 5; }
#mapedit { z-index: 6; background: transparent; }
#msg {
position: absolute; top: 50%; left: 0; right: 0;
transform: translateY(-50%); text-align: center;
@ -41,6 +44,7 @@
padding: 1.6vh; border-radius: 1.4vh; image-rendering: pixelated;
}
.info-sub { color: #6f8a52; font-size: 2.3vh; font-family: monospace; }
#info { z-index: 20; }
</style>
</head>
<body>
@ -58,12 +62,16 @@
<canvas id="butterchurn"></canvas>
<canvas id="hydra"></canvas>
<canvas id="shaders"></canvas>
<!-- Mapper: salida deformada + capa de edición (encima de los motores) -->
<canvas id="output"></canvas>
<canvas id="mapedit"></canvas>
<script src="/lib/socket.io.min.js"></script>
<script src="/lib/qrcode.js"></script>
<script src="/lib/butterchurn.min.js"></script>
<script src="/lib/butterchurn-presets.min.js"></script>
<script src="/lib/hydra-synth.js"></script>
<script src="/stage/mapper.js"></script>
<script src="/stage/stage.js"></script>
</body>
</html>

396
web/stage/mapper.js Normal file
View file

@ -0,0 +1,396 @@
/*
* FOSFENO :: Mapper (projection mapping en el navegador)
* --------------------------------------------------------------------------
* Coge el canvas del motor activo (butterchurn/hydra/shaders/mixer) como
* TEXTURA y lo pinta deformado sobre una o varias SUPERFICIES (cuadriláteros)
* en un canvas WebGL a pantalla completa (#output). Cada superficie se ajusta
* con el ratón: crear, arrastrar esquinas, mover, borrar. La geometría se
* guarda en el servidor (data/mapping.json) y se sincroniza por WebSocket.
*
* Deformación: homografía "cuadrado unidad -> cuadrilátero" (Heckbert),
* evaluada por vértice sobre una malla subdividida => keystone/perspectiva
* correctos. La base ya sirve para crecer a malla curva y máscaras.
*
* API global (la usa stage.js):
* FosMapper.init({ onChange }) arranca (crea contextos y el bucle)
* FosMapper.setSource(canvas) canvas del motor activo (o null)
* FosMapper.setMapping(m) { enabled, edit, surfaces:[{id,corners}] }
* onChange(surfaces) se llama al editar (arrastrar) para guardar
*
* corners = [ [x,y] x4 ] en 0..1 (TL, TR, BR, BL), origen arriba-izquierda.
*/
(function () {
'use strict';
const GRID = 12; // subdivisiones de la malla (perspectiva suave)
const HANDLE = 11; // radio del tirador de esquina (px CSS)
const M = { enabled: false, edit: false, surfaces: [] };
let source = null; // canvas del motor activo
let onChange = null;
let outputEl, editEl, gl, ectx;
let prog, aUV, uH, uTex, tex, quadBuf, idxBuf, idxCount;
let dpr = 1;
// interacción
let selected = null; // id de superficie seleccionada
let drag = null; // { id, corner|null, startX, startY, orig }
let dragged = false; // hubo movimiento real (para no emitir de más)
// ------------------------------------------------------------------ utils
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
function defaultSurface(cx, cy, s) {
// cuadrilátero centrado en (cx,cy) de semilado s (en 0..1)
cx = cx == null ? 0.5 : cx;
cy = cy == null ? 0.5 : cy;
s = s == null ? 0.28 : s;
return {
id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4),
corners: [
[clamp01(cx - s), clamp01(cy - s)],
[clamp01(cx + s), clamp01(cy - s)],
[clamp01(cx + s), clamp01(cy + s)],
[clamp01(cx - s), clamp01(cy + s)],
],
};
}
// Homografía cuadrado unidad -> cuadrilátero (corners TL,TR,BR,BL, y hacia
// abajo). Devuelve mat3 en orden por columnas para WebGL. (Heckbert 1989)
function squareToQuad(c) {
const x0 = c[0][0], y0 = c[0][1];
const x1 = c[1][0], y1 = c[1][1];
const x2 = c[2][0], y2 = c[2][1];
const x3 = c[3][0], y3 = c[3][1];
const dx1 = x1 - x2, dx2 = x3 - x2, dx3 = x0 - x1 + x2 - x3;
const dy1 = y1 - y2, dy2 = y3 - y2, dy3 = y0 - y1 + y2 - y3;
let a, b, cc, d, e, f, g, h;
if (Math.abs(dx3) < 1e-9 && Math.abs(dy3) < 1e-9) {
a = x1 - x0; b = x2 - x1; cc = x0;
d = y1 - y0; e = y2 - y1; f = y0;
g = 0; h = 0;
} else {
const den = dx1 * dy2 - dy1 * dx2 || 1e-9;
g = (dx3 * dy2 - dy3 * dx2) / den;
h = (dx1 * dy3 - dy1 * dx3) / den;
a = x1 - x0 + g * x1; b = x3 - x0 + h * x3; cc = x0;
d = y1 - y0 + g * y1; e = y3 - y0 + h * y3; f = y0;
}
// columnas: col0=(a,d,g) col1=(b,e,h) col2=(cc,f,1)
return new Float32Array([a, d, g, b, e, h, cc, f, 1]);
}
// ------------------------------------------------------------------ WebGL
function initGL() {
gl = outputEl.getContext('webgl', { alpha: false, antialias: true, premultipliedAlpha: false })
|| outputEl.getContext('experimental-webgl');
if (!gl) { console.error('[FOSFENO] mapper: sin WebGL'); return false; }
const vs = `
attribute vec2 aUV;
uniform mat3 uH;
varying vec2 vUV;
void main() {
vec3 p = uH * vec3(aUV, 1.0);
vec2 d = p.xy / p.z; // destino 0..1, y hacia abajo
gl_Position = vec4(d.x * 2.0 - 1.0, 1.0 - d.y * 2.0, 0.0, 1.0);
vUV = aUV;
}`;
const fs = `
precision mediump float;
varying vec2 vUV;
uniform sampler2D uTex;
void main() { gl_FragColor = texture2D(uTex, vUV); }`;
prog = link(vs, fs);
if (!prog) return false;
aUV = gl.getAttribLocation(prog, 'aUV');
uH = gl.getUniformLocation(prog, 'uH');
uTex = gl.getUniformLocation(prog, 'uTex');
// malla GRIDxGRID de vértices con uv en 0..1
const verts = [];
for (let j = 0; j <= GRID; j++)
for (let i = 0; i <= GRID; i++)
verts.push(i / GRID, j / GRID);
const idx = [];
const row = GRID + 1;
for (let j = 0; j < GRID; j++)
for (let i = 0; i < GRID; i++) {
const p = j * row + i;
idx.push(p, p + 1, p + row, p + 1, p + row + 1, p + row);
}
idxCount = idx.length;
quadBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
idxBuf = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
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);
return true;
}
function link(vsSrc, fsSrc) {
const c = (t, s) => {
const sh = gl.createShader(t);
gl.shaderSource(sh, s); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
console.error('[FOSFENO] mapper shader:', gl.getShaderInfoLog(sh));
return sh;
};
const p = gl.createProgram();
gl.attachShader(p, c(gl.VERTEX_SHADER, vsSrc));
gl.attachShader(p, c(gl.FRAGMENT_SHADER, fsSrc));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
console.error('[FOSFENO] mapper link:', gl.getProgramInfoLog(p));
return null;
}
return p;
}
function renderGL() {
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;
// sube el frame actual del motor a la textura
gl.bindTexture(gl.TEXTURE_2D, tex);
try {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
} catch (e) { return; }
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf);
gl.enableVertexAttribArray(aUV);
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.uniform1i(uTex, 0);
const surfs = M.surfaces.length
? M.surfaces
: [{ corners: [[0, 0], [1, 0], [1, 1], [0, 1]] }]; // sin superficies: pasada directa
for (const s of surfs) {
gl.uniformMatrix3fv(uH, false, squareToQuad(s.corners));
gl.drawElements(gl.TRIANGLES, idxCount, gl.UNSIGNED_SHORT, 0);
}
}
// ------------------------------------------------------------------ editor
function drawEditor() {
const w = window.innerWidth, h = window.innerHeight;
ectx.setTransform(dpr, 0, 0, dpr, 0, 0);
ectx.clearRect(0, 0, w, h);
ectx.font = '600 14px system-ui, sans-serif';
ectx.textAlign = 'center';
ectx.textBaseline = 'middle';
M.surfaces.forEach((s, i) => {
const pts = s.corners.map(([x, y]) => [x * w, y * h]);
const sel = s.id === selected;
// relleno + contorno
ectx.beginPath();
ectx.moveTo(pts[0][0], pts[0][1]);
for (let k = 1; k < 4; k++) ectx.lineTo(pts[k][0], pts[k][1]);
ectx.closePath();
ectx.fillStyle = sel ? 'rgba(0,229,255,0.12)' : 'rgba(180,255,0,0.06)';
ectx.fill();
ectx.lineWidth = sel ? 3 : 2;
ectx.strokeStyle = sel ? '#00e5ff' : '#b4ff00';
ectx.stroke();
// número de superficie en el centro
const cx = (pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4;
const cy = (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4;
ectx.fillStyle = sel ? '#00e5ff' : 'rgba(180,255,0,0.85)';
ectx.fillText(String(i + 1), cx, cy);
// tiradores de esquina
pts.forEach(([px, py], ci) => {
ectx.beginPath();
ectx.arc(px, py, HANDLE, 0, Math.PI * 2);
ectx.fillStyle = sel ? '#00e5ff' : '#ffffff';
ectx.fill();
ectx.lineWidth = 2;
ectx.strokeStyle = '#000';
ectx.stroke();
void ci;
});
});
// ayuda
ectx.textAlign = 'left';
ectx.fillStyle = 'rgba(255,255,255,0.6)';
ectx.font = '13px system-ui, sans-serif';
ectx.fillText('MAPPING · arrastra esquinas · mueve dentro · doble clic: nueva · Supr: borrar', 16, h - 18);
}
function pick(mx, my) {
const w = window.innerWidth, h = window.innerHeight;
// primero esquinas (de la más reciente/encima hacia atrás)
for (let i = M.surfaces.length - 1; i >= 0; i--) {
const s = M.surfaces[i];
for (let c = 0; c < 4; c++) {
const px = s.corners[c][0] * w, py = s.corners[c][1] * h;
if (Math.hypot(mx - px, my - py) <= HANDLE + 6)
return { id: s.id, corner: c };
}
}
// luego interior de una superficie
for (let i = M.surfaces.length - 1; i >= 0; i--) {
const s = M.surfaces[i];
if (pointInQuad(mx / w, my / h, s.corners)) return { id: s.id, corner: null };
}
return null;
}
function pointInQuad(x, y, c) {
let inside = false;
for (let i = 0, j = 3; i < 4; j = i++) {
const xi = c[i][0], yi = c[i][1], xj = c[j][0], yj = c[j][1];
if (((yi > y) !== (yj > y)) &&
(x < ((xj - xi) * (y - yi)) / ((yj - yi) || 1e-9) + xi)) inside = !inside;
}
return inside;
}
const surfaceById = (id) => M.surfaces.find((s) => s.id === id);
function onDown(ev) {
const mx = ev.clientX, my = ev.clientY;
const hit = pick(mx, my);
dragged = false;
if (!hit) { selected = null; drag = null; return; }
selected = hit.id;
const s = surfaceById(hit.id);
drag = {
id: hit.id, corner: hit.corner,
startX: mx, startY: my,
orig: s.corners.map((p) => p.slice()),
};
ev.preventDefault();
}
function onMove(ev) {
if (!drag) return;
const w = window.innerWidth, h = window.innerHeight;
const dx = (ev.clientX - drag.startX) / w;
const dy = (ev.clientY - drag.startY) / h;
if (Math.abs(ev.clientX - drag.startX) + Math.abs(ev.clientY - drag.startY) > 1) dragged = true;
const s = surfaceById(drag.id);
if (!s) return;
if (drag.corner == null) {
for (let c = 0; c < 4; c++) {
s.corners[c][0] = clamp01(drag.orig[c][0] + dx);
s.corners[c][1] = clamp01(drag.orig[c][1] + dy);
}
} else {
s.corners[drag.corner][0] = clamp01(drag.orig[drag.corner][0] + dx);
s.corners[drag.corner][1] = clamp01(drag.orig[drag.corner][1] + dy);
}
scheduleEmit();
}
function onUp() {
if (drag && dragged) emitNow();
drag = null;
}
function onDblClick(ev) {
const s = defaultSurface(ev.clientX / window.innerWidth, ev.clientY / window.innerHeight, 0.2);
M.surfaces.push(s);
selected = s.id;
emitNow();
}
function onKey(ev) {
if (!M.edit) return;
if ((ev.key === 'Delete' || ev.key === 'Backspace') && selected) {
M.surfaces = M.surfaces.filter((s) => s.id !== selected);
selected = null;
emitNow();
ev.preventDefault();
}
}
// emisión (throttle durante el arrastre, inmediata al soltar)
let emitTimer = null;
function scheduleEmit() {
if (emitTimer) return;
emitTimer = setTimeout(() => { emitTimer = null; emitNow(); }, 60);
}
function emitNow() {
if (emitTimer) { clearTimeout(emitTimer); emitTimer = null; }
if (onChange) onChange(M.surfaces.map((s) => ({ id: s.id, corners: s.corners })));
}
// ------------------------------------------------------------------ tamaños
function fit(el, wantGL) {
const w = Math.round(window.innerWidth * dpr);
const h = Math.round(window.innerHeight * dpr);
if (el.width !== w || el.height !== h) { el.width = w; el.height = h; }
void wantGL;
}
// ------------------------------------------------------------------ bucle
function loop() {
requestAnimationFrame(loop);
dpr = Math.min(2, window.devicePixelRatio || 1);
const editing = M.enabled && M.edit;
outputEl.style.display = M.enabled ? 'block' : 'none';
editEl.style.display = editing ? 'block' : 'none';
editEl.style.pointerEvents = editing ? 'auto' : 'none';
document.body.style.cursor = editing ? 'crosshair' : 'none';
if (!M.enabled) return;
fit(outputEl, true);
fit(editEl, false);
renderGL();
if (editing) drawEditor();
}
// ------------------------------------------------------------------ API
const API = {
init(opts) {
opts = opts || {};
onChange = opts.onChange || null;
outputEl = document.getElementById('output');
editEl = document.getElementById('mapedit');
if (!outputEl || !editEl) { console.error('[FOSFENO] mapper: faltan #output/#mapedit'); return; }
ectx = editEl.getContext('2d');
if (!initGL()) return;
editEl.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
editEl.addEventListener('dblclick', onDblClick);
window.addEventListener('keydown', onKey);
requestAnimationFrame(loop);
},
setSource(canvas) { source = canvas || null; },
setMapping(m) {
m = m || {};
M.enabled = !!m.enabled;
M.edit = !!m.edit;
// no piso la geometría mientras se está arrastrando en esta pantalla
if (!drag && Array.isArray(m.surfaces)) {
M.surfaces = m.surfaces.map((s) => ({
id: s.id, corners: s.corners.map((p) => [p[0], p[1]]),
}));
if (selected && !surfaceById(selected)) selected = null;
}
},
getMapping() { return M; },
};
window.FosMapper = API;
})();

View file

@ -17,6 +17,7 @@ const shaderCanvas = document.getElementById("shaders");
let audioCtx, gainNode, analyser, micSource, micStream;
let currentDevice = "";
let currentSource = "mic"; // "mic" (micro de la sala) o "monitor" (lo que suena en el equipo)
let state = null;
let lastQrUrl = ""; // ultima URL para la que se genero el QR
@ -268,6 +269,59 @@ async function acquireMic(deviceId) {
currentDevice = deviceId || "";
}
/* Busca la fuente "monitor" del sistema: en Linux, PulseAudio/PipeWire expone
por cada salida de audio una entrada "Monitor of ..." que contiene
exactamente lo que se esta reproduciendo (navegador, Spotify...), aunque
suene por los cascos. Capturarla permite visuales reactivas sin micro.
Si el usuario ya eligio un monitor concreto en la lista, se respeta. */
async function resolveMonitorId() {
const devs = await navigator.mediaDevices.enumerateDevices();
const inputs = devs.filter((d) => d.kind === "audioinput");
const isMonitor = (d) =>
/monitor|loopback|what.?u.?hear|stereo mix/i.test(d.label || "");
const chosen = inputs.find((d) =>
d.deviceId === (state && state.audio && state.audio.device) && isMonitor(d));
if (chosen) return chosen.deviceId;
const mon = inputs.find(isMonitor);
return mon ? mon.deviceId : null;
}
/* Conecta la captura segun la fuente elegida en el panel. Las llamadas se
encadenan una detras de otra: dos cambios rapidos en el panel ya no pueden
solaparse y dejar streams huerfanos capturando. */
let audioChain = Promise.resolve();
function applyAudioSource() {
const run = audioChain.catch(() => {}).then(() => doApplyAudioSource());
audioChain = run.catch(() => {});
return run;
}
async function doApplyAudioSource() {
const src = (state && state.audio && state.audio.source) || "mic";
if (src === "monitor") {
const id = await resolveMonitorId();
if (id) {
await acquireMic(id);
} else {
/* Chromium oculta los monitores de PulseAudio/PipeWire al enumerar
dispositivos (Firefox si los muestra). Plan B: capturamos la entrada
por defecto y el servidor mueve este stream al monitor de la salida
con pactl. Solo funciona con escenario y servidor en la misma
maquina, que es el caso del kiosko y del modo portatil. */
await acquireMic(null);
socket.emit("audio_route", { target: "monitor" });
}
} else {
const dev = (state && state.audio && state.audio.device) || null;
await acquireMic(dev);
/* Sin tarjeta concreta elegida, PipeWire puede "recordar" que el
navegador capturaba el monitor (stream-restore) y dejarlo alli:
pedimos al servidor que devuelva la captura a la entrada por defecto. */
if (!dev) socket.emit("audio_route", { target: "mic" });
}
currentSource = src;
}
async function initAudio() {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
await audioCtx.resume();
@ -277,9 +331,18 @@ async function initAudio() {
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.8;
gainNode.connect(analyser);
await acquireMic(null);
beatDetector = new BeatDetector(analyser);
await enumerateInputs();
// Sin entrada de audio las visuales siguen (aunque no reactivas): mejor
// avisar que dejar el proyector en negro.
try {
await acquireMic(null);
} catch (err) {
report("warn", "Sin entrada de audio (" + err.message + "). Las "
+ "visuales funcionan pero no reaccionan a la musica: conecta un "
+ "microfono y pulsa 'Buscar dispositivos' y 'Aplicar entrada' "
+ "en el panel.");
}
await enumerateInputs().catch(() => {});
}
/* Lista los microfonos y las camaras y los envia al panel. Se llama al
@ -329,6 +392,11 @@ function initButterchurn() {
pixelRatio: window.devicePixelRatio || 1,
});
bcViz.connectAudio(gainNode);
if (!window.butterchurnPresets) {
report("error", "Faltan los presets de Butterchurn (libreria no "
+ "copiada). Vuelve a ejecutar install.sh.");
return;
}
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
bcPresets = bp.getPresets();
bcNames = Object.keys(bcPresets);
@ -337,10 +405,15 @@ function initButterchurn() {
}
function loadButterchurnPreset(name) {
if (!bcPresets[name]) return;
bcCurrent = name;
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
socket.emit("stage_status", { label: name });
if (!bcPresets[name] || !bcViz) return;
bcCurrent = name; // recuerda el intento aunque falle, para no reintentarlo en bucle
try {
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
socket.emit("stage_status", { label: 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.");
}
}
function bcStep(delta) {
@ -401,6 +474,13 @@ function runHydraCode(code) {
function buildMixerCode(m) {
let base;
if (m.source === "video") base = "src(s1)";
else if (m.source === "mix" && m.blendMode === "recorte") {
// Recorte: el negro del clip se hace transparente (luma key) y la
// figura queda DELANTE del fondo. El deslizador de mezcla ajusta el
// umbral del recorte (mas alto = se come mas borde oscuro).
const umbral = (0.05 + 0.30 * Number(m.mix || 0.5)).toFixed(3);
base = `src(s0).layer(src(s1).luma(${umbral}, 0.15))`;
}
else if (m.source === "mix") base = `src(s0).${m.blendMode}(src(s1), ${m.mix})`;
else base = "src(s0)";
@ -424,33 +504,174 @@ function buildMixerCode(m) {
return c;
}
/* Inicializa las fuentes (camara s0, video s1) solo cuando cambian. */
function ensureMixerSources(m) {
const wantCam = m.camOn && (m.source === "cam" || m.source === "mix");
if (wantCam && (mixerCam !== true || mixerCamId !== m.cameraId)) {
/* Fuentes del mezclador (camara s0, video s1). No usamos s0.initCam ni
s1.initVideo de Hydra porque se tragan los errores: si la camara falla o
el video no carga, la pantalla se queda en negro sin decir nada. Creamos
nosotros la captura y el <video>, avisamos al panel de cualquier fallo y
solo cuando estan listos se los pasamos a Hydra con init({src}). */
let mixerCamStream = null; // stream de la webcam, para poder apagarla
let mixerCamPending = false; // evita dos aperturas de camara a la vez
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)
function stopMixerCam() {
if (mixerCamStream) mixerCamStream.getTracks().forEach((t) => t.stop());
mixerCamStream = null;
mixerCam = false;
mixerCamId = null;
}
/* Apaga las fuentes del mezclador al salir de el (camara con su LED
incluido) y fuerza a recomponer la cadena al volver. */
function stopMixer() {
mixerSig = "";
mixerWarned = "";
mixerCamFailed = null;
mixerButter = false;
stopMixerCam();
if (mixerVideoEl) mixerVideoEl.pause();
}
async function initCamSource(index) {
const devs = await navigator.mediaDevices.enumerateDevices();
const cams = devs.filter((d) => d.kind === "videoinput");
if (!cams.length) throw new Error("no hay ninguna camara conectada");
const pick = cams[index] || cams[0];
const stream = await navigator.mediaDevices.getUserMedia({
audio: false, video: { deviceId: { exact: pick.deviceId } },
});
const vid = document.createElement("video");
vid.muted = true;
vid.playsInline = true;
vid.srcObject = stream;
await vid.play();
s0.init({ src: vid, dynamic: true });
return stream;
}
function initVideoSource(name) {
return new Promise((resolve, reject) => {
const vid = document.createElement("video");
vid.muted = true;
vid.loop = true;
vid.playsInline = true;
vid.addEventListener("loadeddata", () => {
vid.play().catch(() => { /* el flag de autoplay del kiosko lo permite */ });
s1.init({ src: vid, dynamic: true });
resolve(vid);
});
vid.addEventListener("error", () =>
reject(new Error("formato no soportado o archivo danado")));
vid.src = "/data/videos/" + encodeURIComponent(name);
});
}
// La galeria tambien admite imagenes fijas (jpg, png, gif, webp)
const IMAGE_RE = /\.(jpe?g|png|gif|webp)$/i;
function initImageSource(name) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
s1.init({ src: img, dynamic: false });
resolve(img);
};
img.onerror = () =>
reject(new Error("no se pudo cargar la imagen"));
img.src = "/data/videos/" + encodeURIComponent(name);
});
}
async function ensureMixerSources(m) {
// 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.
const wantButter = m.fondo === "butter"
&& (m.source === "cam" || m.source === "mix");
if (wantButter && bcViz) {
stopMixerCam();
if (!bcCurrent) {
loadButterchurnPreset((state && state.butterchurn.preset)
|| bcNames[bcIndex]);
}
startButterchurn();
if (!mixerButter) {
mixerButter = true;
s0.init({ src: bcCanvas, dynamic: true });
}
} else if (mixerButter) {
mixerButter = false;
stopButterchurn();
}
const wantCam = !wantButter && m.camOn
&& (m.source === "cam" || m.source === "mix");
if (wantCam && !mixerCamPending && mixerCamFailed !== m.cameraId
&& (mixerCam !== true || mixerCamId !== m.cameraId)) {
mixerCamPending = true;
try {
s0.initCam(m.cameraId);
stopMixerCam();
mixerCamStream = await initCamSource(m.cameraId);
mixerCam = true;
mixerCamId = m.cameraId;
mixerCamFailed = null;
} catch (e) {
report("error", "No se pudo activar la camara. Comprueba que la "
+ "webcam USB esta conectada, o elige otra camara en el panel.");
stopMixerCam();
mixerCamFailed = m.cameraId;
report("error", "No se pudo activar la camara (" + e.message + "). "
+ "Comprueba que esta conectada, pulsa 'Buscar dispositivos' y "
+ "apaga y enciende la casilla 'Camara' para reintentar.");
} finally {
mixerCamPending = false;
}
} else if (!wantCam) {
mixerCam = false;
} else if (!wantCam && mixerCam) {
stopMixerCam();
mixerCamFailed = null;
}
const wantVideo = m.video && (m.source === "video" || m.source === "mix");
if (wantVideo && m.video !== mixerVideo) {
try { s1.initVideo("/data/videos/" + encodeURIComponent(m.video)); }
catch (e) {
report("error", "No se pudo cargar el video '" + m.video + "'.");
const name = m.video;
mixerVideo = name; // evita recargar el mismo clip con cada estado
if (mixerVideoEl) { mixerVideoEl.pause(); mixerVideoEl.src = ""; mixerVideoEl = null; }
try {
if (IMAGE_RE.test(name)) {
await initImageSource(name); // imagen fija: sin elemento video
} else {
mixerVideoEl = await initVideoSource(name);
}
} catch (e) {
mixerVideo = ""; // deja reintentar eligiendolo otra vez en la lista
report("error", "No se pudo cargar '" + name + "': "
+ e.message + ". Prueba con un .mp4 (H.264), .webm, .jpg o .png.");
}
mixerVideo = m.video;
} else if (wantVideo && mixerVideoEl && mixerVideoEl.paused) {
// Al volver al mezclador el clip estaba pausado por stopMixer()
mixerVideoEl.play().catch(() => {});
}
}
/* Texto de aviso si la fuente elegida no puede verse todavia. */
function mixerWarnings(m) {
const w = [];
if ((m.source === "cam" || m.source === "mix") && !m.camOn
&& m.fondo !== "butter")
w.push("La camara esta apagada: activa la casilla 'Camara'.");
if ((m.source === "video" || m.source === "mix") && !m.video)
w.push(((state && state.meta.videos) || []).length
? "No hay video elegido: selecciona uno en la lista."
: "No hay videos: copia archivos a data/videos y pulsa el boton de recargar.");
return w.join(" ");
}
function applyMixer(m) {
ensureMixerSources(m);
ensureMixerSources(m).catch((e) =>
report("error", "El mezclador fallo preparando las fuentes: " + e.message));
const warn = mixerWarnings(m);
if (warn && warn !== mixerWarned) report("warn", warn);
mixerWarned = warn;
const sig = JSON.stringify(m);
if (sig !== mixerSig) {
mixerSig = sig;
@ -468,6 +689,8 @@ function show(canvas) {
shaderCanvas.classList.toggle("visible", canvas === shaderCanvas);
document.getElementById("info").style.display = "none";
msgEl.style.display = canvas ? "none" : "block";
// el mapper (si el mapping está activo) usa este canvas como textura
if (window.FosMapper) FosMapper.setSource(canvas);
}
/* Pantalla de conexion: muestra la direccion del panel y un codigo QR en el
@ -495,13 +718,19 @@ function renderInfoScreen() {
function applyState(next) {
const prevDevice = currentDevice;
const prevSource = currentSource;
state = next;
if (window.FosMapper) FosMapper.setMapping(state.mapping || {});
if (gainNode) gainNode.gain.value = state.sensitivity;
// Cambio de tarjeta de audio
if (state.audio.device && state.audio.device !== prevDevice) {
acquireMic(state.audio.device).catch(() =>
report("error", "No se pudo cambiar a la tarjeta de audio elegida."));
// Cambio de fuente (micro / audio del navegador) o de tarjeta de audio.
const source = state.audio.source || "mic";
const sourceChanged = source !== prevSource;
const deviceChanged = source === "mic"
&& state.audio.device && state.audio.device !== prevDevice;
if (audioCtx && (sourceChanged || deviceChanged)) {
applyAudioSource().catch(() =>
report("error", "No se pudo cambiar la fuente de audio elegida."));
}
// Mientras nadie haya abierto el panel, o con las visuales apagadas,
@ -511,16 +740,23 @@ function applyState(next) {
stopButterchurn();
if (shaderEngine) shaderEngine.stop();
if (hydra) hydra.hush();
mixerSig = "";
if (showConnect) renderInfoScreen();
else show(null);
stopMixer();
if (showConnect) {
renderInfoScreen();
} else {
// Motor projectM: esta pagina queda en negro debajo de su ventana.
msgEl.textContent = (state.projectm && state.projectm.available)
? "projectM nativo en marcha (en su propia ventana)"
: "projectM no esta instalado: elige otro motor en el panel";
show(null);
}
return;
}
if (state.engine === "butterchurn") {
if (hydra) hydra.hush();
if (shaderEngine) shaderEngine.stop();
mixerSig = "";
stopMixer();
if (!bcViz) { show(null); return; }
show(bcCanvas);
startButterchurn();
@ -532,7 +768,7 @@ function applyState(next) {
} else if (state.engine === "hydra") {
stopButterchurn();
if (shaderEngine) shaderEngine.stop();
mixerSig = "";
stopMixer();
if (!hydra) { show(null); return; }
show(hydraCanvas);
if (state.hydra.code !== hydraCode) runHydraCode(state.hydra.code);
@ -540,16 +776,21 @@ function applyState(next) {
} else if (state.engine === "shaders") {
stopButterchurn();
if (hydra) hydra.hush();
mixerSig = "";
stopMixer();
if (!shaderEngine) { show(null); return; }
show(shaderCanvas);
if (shaderEngine && state.shaders.code !== shaderCode) {
if (shaderEngine.load(state.shaders.code)) shaderCode = state.shaders.code;
// Recuerda el intento aunque no compile: asi el aviso de error sale
// una sola vez y no se repite con cada actualizacion del estado.
shaderCode = state.shaders.code;
shaderEngine.load(state.shaders.code);
}
if (shaderEngine) shaderEngine.start();
} else if (state.engine === "mixer") {
stopButterchurn();
// Con fondo "butter" el lienzo de Butterchurn debe seguir renderizando
// (oculto): es la capa de abajo del mezclador.
if (state.mixer.fondo !== "butter") stopButterchurn();
if (shaderEngine) shaderEngine.stop();
if (!hydra) { show(null); return; }
show(hydraCanvas);
@ -562,6 +803,14 @@ function applyState(next) {
// ==========================================================================
socket.on("state", (next) => applyState(next));
// --- Mapper (projection mapping en el navegador) ---
// Arranca el compositor/editor; al editar (arrastrar), guarda en el servidor.
if (window.FosMapper) {
FosMapper.init({
onChange: (surfaces) => socket.emit("set_mapping", { surfaces: surfaces }),
});
}
socket.on("stage_command", (cmd) => {
if (!state) return;
if (state.engine === "butterchurn") {
@ -575,12 +824,13 @@ socket.on("stage_rescan", () => {
report("warn", "No se pudieron volver a leer los dispositivos."));
});
// El panel pide reconectar la captura del microfono (boton "Aplicar").
// El panel pide reconectar la captura de audio (boton "Aplicar entrada").
socket.on("stage_reacquire", () => {
const dev = (state && state.audio && state.audio.device) || null;
acquireMic(dev)
.then(() => report("info", "Microfono conectado."))
.catch(() => report("error", "No se pudo conectar con el microfono elegido."));
applyAudioSource()
.then(() => report("info", (state && state.audio && state.audio.source) === "monitor"
? "Capturando el audio del navegador/sistema."
: "Microfono conectado."))
.catch(() => report("error", "No se pudo conectar con la entrada de audio elegida."));
});
window.addEventListener("resize", () => {
@ -591,15 +841,14 @@ window.addEventListener("resize", () => {
// Arranque
// ==========================================================================
async function boot() {
// El audio es la base de todo: si falla, no se puede continuar.
// El audio va primero, pero si falla las visuales siguen adelante:
// un escenario sin reactividad es mejor que un proyector en negro.
try {
await initAudio();
beatLoop();
} catch (err) {
report("error", "No se pudo iniciar el audio: " + err.message
+ ". Comprueba el microfono y recarga la pagina.");
msgEl.textContent = "ERROR: audio (" + err.message + ")";
return;
+ ". Las visuales funcionaran sin reaccionar a la musica.");
}
// Cada motor se inicia por separado: si una libreria falta o falla, los
// demas motores siguen funcionando igual.