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:
parent
0b1546cecc
commit
8dd58bb293
12 changed files with 1815 additions and 85 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -15,6 +15,10 @@ data/presets-projectm/
|
||||||
data/videos/*
|
data/videos/*
|
||||||
!data/videos/README.txt
|
!data/videos/README.txt
|
||||||
|
|
||||||
|
# Config de runtime (por instalacion): no va al repo
|
||||||
|
data/mapping.json
|
||||||
|
data/pm-favoritos.json
|
||||||
|
|
||||||
# Temporales
|
# Temporales
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ Motores: projectm (nativo), butterchurn, hydra, shaders (GLSL) y mixer
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
@ -21,8 +22,9 @@ import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
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 flask_socketio import SocketIO
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Rutas y configuracion
|
# Rutas y configuracion
|
||||||
|
|
@ -32,6 +34,8 @@ WEB = BASE / "web"
|
||||||
DATA = BASE / "data"
|
DATA = BASE / "data"
|
||||||
VIDEOS = DATA / "videos"
|
VIDEOS = DATA / "videos"
|
||||||
VIDEO_EXT = (".mp4", ".webm", ".mov", ".m4v", ".ogv")
|
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):
|
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"))
|
NO_KIOSK = bool(os.environ.get("FOSFENO_NO_KIOSK"))
|
||||||
|
|
||||||
app = Flask(__name__, static_folder=None)
|
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="*")
|
socketio = SocketIO(app, async_mode="threading", cors_allowed_origins="*")
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -73,7 +78,28 @@ def list_videos():
|
||||||
if not VIDEOS.is_dir():
|
if not VIDEOS.is_dir():
|
||||||
return []
|
return []
|
||||||
return sorted(f.name for f in VIDEOS.iterdir()
|
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"),
|
"engine": DEFAULTS.get("engine", "butterchurn"),
|
||||||
"power": True,
|
"power": True,
|
||||||
"sensitivity": float(DEFAULTS.get("sensitivity", 1.0)),
|
"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": {
|
"butterchurn": {
|
||||||
"preset": "", "shuffle": True,
|
"preset": "", "shuffle": True,
|
||||||
"interval": 20, "intervalMode": "seconds", "blendTime": 2.7,
|
"interval": 20, "intervalMode": "seconds", "blendTime": 2.7,
|
||||||
|
|
@ -99,18 +127,40 @@ state = {
|
||||||
"code": first_code(SHADERS, DEFAULTS.get("shader", "")),
|
"code": first_code(SHADERS, DEFAULTS.get("shader", "")),
|
||||||
},
|
},
|
||||||
"mixer": {
|
"mixer": {
|
||||||
"source": "cam", "camOn": True, "cameraId": 0, "video": "",
|
# fondo: capa de abajo de la mezcla ("cam" o "butter" = MilkDrop)
|
||||||
"mix": 0.5, "blendMode": "blend",
|
"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,
|
"hue": 0.0, "saturate": 1.0, "contrast": 1.0, "brightness": 0.0,
|
||||||
"colorama": 0.0, "posterize": 0, "pixelate": 0, "kaleid": 0,
|
"colorama": 0.0, "posterize": 0, "pixelate": 0, "kaleid": 0,
|
||||||
"rotate": 0.0, "feedback": 0.0, "invert": False, "beatPulse": False,
|
"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": {
|
"meta": {
|
||||||
"butterchurnPresets": [],
|
"butterchurnPresets": [],
|
||||||
"audioDevices": [],
|
"audioDevices": [],
|
||||||
"cameraDevices": [],
|
"cameraDevices": [],
|
||||||
"videos": list_videos(),
|
"videos": list_videos(),
|
||||||
|
"projectmPresets": {},
|
||||||
|
"monitors": [],
|
||||||
},
|
},
|
||||||
"status": {"label": ""},
|
"status": {"label": ""},
|
||||||
"notifications": NOTIFY_HISTORY,
|
"notifications": NOTIFY_HISTORY,
|
||||||
|
|
@ -209,16 +259,135 @@ def stop_projectm():
|
||||||
procs["projectm"] = None
|
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():
|
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()
|
stop_projectm()
|
||||||
script = BASE / "scripts" / "start-projectm.sh"
|
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:
|
try:
|
||||||
procs["projectm"] = subprocess.Popen(["bash", str(script)])
|
duration = max(2, int(pm.get("duration") or 20))
|
||||||
print("[FOSFENO] projectM lanzado.")
|
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:
|
except OSError as exc:
|
||||||
notify("error", f"No se pudo lanzar projectM: {exc}. "
|
notify("error", f"No se pudo lanzar projectM: {exc}. "
|
||||||
"Prueba con otro motor de visuales.")
|
"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):
|
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():
|
def apply_engine():
|
||||||
"""Arranca o detiene projectM segun el motor y el estado de encendido."""
|
"""Arranca o detiene projectM segun el motor y el estado de encendido."""
|
||||||
use_native = state["engine"] == "projectm" and state["power"]
|
use_native = state["engine"] == "projectm" and state["power"]
|
||||||
|
|
@ -279,6 +495,42 @@ def data_files(fname):
|
||||||
return send_from_directory(str(DATA), 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
|
# Eventos WebSocket
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
@ -343,12 +595,51 @@ def on_run_code(data):
|
||||||
|
|
||||||
@socketio.on("update_settings")
|
@socketio.on("update_settings")
|
||||||
def on_update_settings(data):
|
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")
|
engine = (data or {}).get("engine")
|
||||||
patch = (data or {}).get("patch", {})
|
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:
|
with lock:
|
||||||
state[engine].update(patch)
|
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()
|
broadcast()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -356,12 +647,69 @@ def on_update_settings(data):
|
||||||
def on_engine_command(data):
|
def on_engine_command(data):
|
||||||
action = (data or {}).get("action")
|
action = (data or {}).get("action")
|
||||||
if state["engine"] == "projectm":
|
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:
|
else:
|
||||||
socketio.emit("stage_command", {"action": action,
|
socketio.emit("stage_command", {"action": action,
|
||||||
"value": (data or {}).get("value")})
|
"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")
|
@socketio.on("stage_meta")
|
||||||
def on_stage_meta(data):
|
def on_stage_meta(data):
|
||||||
"""El escenario nos envia las listas que solo conoce el navegador."""
|
"""El escenario nos envia las listas que solo conoce el navegador."""
|
||||||
|
|
@ -378,8 +726,12 @@ def on_stage_meta(data):
|
||||||
|
|
||||||
@socketio.on("rescan_devices")
|
@socketio.on("rescan_devices")
|
||||||
def 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")
|
socketio.emit("stage_rescan")
|
||||||
|
broadcast()
|
||||||
|
|
||||||
|
|
||||||
@socketio.on("reacquire_audio")
|
@socketio.on("reacquire_audio")
|
||||||
|
|
@ -388,6 +740,96 @@ def on_reacquire_audio():
|
||||||
socketio.emit("stage_reacquire")
|
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")
|
@socketio.on("stage_status")
|
||||||
def on_stage_status(data):
|
def on_stage_status(data):
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
|
@ -418,9 +860,17 @@ def on_rescan_videos():
|
||||||
@socketio.on("system")
|
@socketio.on("system")
|
||||||
def on_system(data):
|
def on_system(data):
|
||||||
action = (data or {}).get("action")
|
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":
|
if action == "reboot":
|
||||||
subprocess.Popen(["sudo", "reboot"])
|
subprocess.Popen(["sudo", "reboot"])
|
||||||
elif action == "shutdown":
|
else:
|
||||||
subprocess.Popen(["sudo", "poweroff"])
|
subprocess.Popen(["sudo", "poweroff"])
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -429,6 +879,11 @@ def on_system(data):
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
def setup_audio():
|
def setup_audio():
|
||||||
"""Marca el microfono USB como fuente de audio por defecto (best-effort)."""
|
"""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()
|
match = CFG.get("audio", {}).get("matchSource", "usb").lower()
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(["pactl", "list", "short", "sources"],
|
out = subprocess.check_output(["pactl", "list", "short", "sources"],
|
||||||
|
|
@ -475,6 +930,8 @@ def check_install():
|
||||||
def main():
|
def main():
|
||||||
binary = CFG.get("projectm", {}).get("binary", "projectMSDL")
|
binary = CFG.get("projectm", {}).get("binary", "projectMSDL")
|
||||||
state["projectm"]["available"] = shutil.which(binary) is not None
|
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: "
|
print(f"[FOSFENO] projectM nativo: "
|
||||||
f"{'disponible' if state['projectm']['available'] else 'no instalado'}")
|
f"{'disponible' if state['projectm']['available'] else 'no instalado'}")
|
||||||
print(f"[FOSFENO] Videos encontrados: {len(state['meta']['videos'])}")
|
print(f"[FOSFENO] Videos encontrados: {len(state['meta']['videos'])}")
|
||||||
|
|
@ -486,6 +943,7 @@ def main():
|
||||||
if not NO_KIOSK:
|
if not NO_KIOSK:
|
||||||
threading.Timer(3.0, start_chromium).start()
|
threading.Timer(3.0, start_chromium).start()
|
||||||
threading.Thread(target=watchdog, daemon=True).start()
|
threading.Thread(target=watchdog, daemon=True).start()
|
||||||
|
threading.Thread(target=watch_projectm_log, daemon=True).start()
|
||||||
|
|
||||||
net = state["network"]
|
net = state["network"]
|
||||||
suffix = "" if PORT == 80 else f":{PORT}"
|
suffix = "" if PORT == 80 else f":{PORT}"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,16 @@
|
||||||
"Para apagar la Raspberry de verdad, usa los botones de Reiniciar y Apagar del final del panel."
|
"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": {
|
"engines": {
|
||||||
"title": "Motor de visuales",
|
"title": "Motor de visuales",
|
||||||
"body": [
|
"body": [
|
||||||
|
|
@ -20,9 +30,15 @@
|
||||||
"projectm": {
|
"projectm": {
|
||||||
"title": "Motor projectM",
|
"title": "Motor projectM",
|
||||||
"body": [
|
"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.",
|
"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.",
|
||||||
"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.",
|
"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).",
|
||||||
"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."
|
"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": {
|
"butterchurn": {
|
||||||
|
|
@ -53,8 +69,12 @@
|
||||||
"title": "Motor Mezclador VJ",
|
"title": "Motor Mezclador VJ",
|
||||||
"body": [
|
"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.",
|
"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.",
|
"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."
|
"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",
|
"title": "Audio y BPM",
|
||||||
"body": [
|
"body": [
|
||||||
"Que es: aqui se elige por que entrada escucha FOSFENO la musica, y se ve el BPM que detecta.",
|
"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.",
|
"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.",
|
||||||
"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.",
|
"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 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."
|
"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": {
|
"sensibilidad": {
|
||||||
|
|
@ -83,4 +105,4 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -18,5 +18,9 @@
|
||||||
"estrellas": {
|
"estrellas": {
|
||||||
"name": "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)"
|
"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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -37,24 +37,68 @@ El BPM en cero quiere decir que FOSFENO no está recibiendo audio, o que el
|
||||||
audio es demasiado flojo.
|
audio es demasiado flojo.
|
||||||
|
|
||||||
Comprueba que el micrófono USB está conectado. En el apartado Audio del panel,
|
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
|
pulsa «Buscar dispositivos», abre el desplegable, elige la entrada correcta y
|
||||||
aparece, conéctalo y reinicia la Raspberry, porque la lista se hace al
|
pulsa «Aplicar entrada». Ya no hace falta reiniciar para que aparezcan
|
||||||
arrancar.
|
entradas nuevas.
|
||||||
|
|
||||||
Si el audio entra pero el BPM no se estabiliza, sube la sensibilidad. El
|
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
|
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
|
sin percusión puede no encontrar el tempo, y a veces marca el doble o la
|
||||||
mitad del valor real.
|
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
|
## 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
|
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.
|
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
|
Si la conectas con el sistema ya arrancado, pulsa «Buscar dispositivos» en el
|
||||||
sistema ya arrancado, reinicia. Si en el panel sale un aviso de que la cámara
|
panel para que aparezca en la lista. Cuando la cámara falla, el panel lo dice
|
||||||
no responde, es que el sistema no la ve; pruébala en otro puerto USB o con
|
con un aviso (y el motivo: no conectada, ocupada por otro programa...); para
|
||||||
otro cable.
|
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
|
## 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
|
librería de desarrollo; vuelve a lanzar `bash install.sh` para reinstalar las
|
||||||
dependencias del sistema.
|
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
|
## Las visuales van a tirones
|
||||||
|
|
||||||
Suele ser calor. Una Raspberry sin disipador, después de un rato con visuales,
|
Suele ser calor. Una Raspberry sin disipador, después de un rato con visuales,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,29 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Lanza projectM nativo a pantalla completa. Lo invoca el servidor cuando
|
# Lanza projectM nativo a pantalla completa. Lo invoca el servidor cuando
|
||||||
# se selecciona el motor "projectM" desde el panel.
|
# 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)"
|
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
|
ARGS=()
|
||||||
# fijar tambien en ~/.config/projectMSDL/projectMSDL.properties
|
|
||||||
if [ -d "$PRESETS" ]; then
|
if [ -d "$PRESETS" ]; then
|
||||||
exec projectMSDL --presetPath "$PRESETS"
|
ARGS+=(--presetPath "$PRESETS")
|
||||||
else
|
|
||||||
exec projectMSDL
|
|
||||||
fi
|
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[@]}"
|
||||||
|
|
|
||||||
|
|
@ -40,15 +40,21 @@
|
||||||
<span class="label">Audio</span>
|
<span class="label">Audio</span>
|
||||||
<button class="info" data-help="audio">i</button>
|
<button class="info" data-help="audio">i</button>
|
||||||
</div>
|
</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>
|
<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>
|
<button id="dev-rescan" class="cmd">Buscar dispositivos de nuevo</button>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<span class="label">BPM detectado</span>
|
<span class="label">BPM detectado</span>
|
||||||
<span id="bpm" class="bpm">--</span>
|
<span id="bpm" class="bpm">--</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">Si conectas el microfono o la camara con el equipo ya
|
<p class="hint">Con <b>Audio del navegador</b> las visuales reaccionan a
|
||||||
encendido, pulsa este boton para que aparezcan.</p>
|
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>
|
</section>
|
||||||
|
|
||||||
<!-- Sensibilidad -->
|
<!-- Sensibilidad -->
|
||||||
|
|
@ -61,9 +67,9 @@
|
||||||
<input id="sens" type="range" min="0" max="4" step="0.1" value="1">
|
<input id="sens" type="range" min="0" max="4" step="0.1" value="1">
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Estado -->
|
<!-- Estado: visual que se esta proyectando ahora mismo -->
|
||||||
<section class="card" id="card-now">
|
<section class="card" id="card-now">
|
||||||
<span class="label">Ahora suena</span>
|
<span class="label">En pantalla</span>
|
||||||
<div id="now" class="now">-</div>
|
<div id="now" class="now">-</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -153,15 +159,29 @@
|
||||||
<button class="segbtn" data-src="video">Video</button>
|
<button class="segbtn" data-src="video">Video</button>
|
||||||
<button class="segbtn" data-src="mix">Mezcla</button>
|
<button class="segbtn" data-src="mix">Mezcla</button>
|
||||||
</div>
|
</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">
|
<label class="check">
|
||||||
<input type="checkbox" id="mix-cam"> Camara activada
|
<input type="checkbox" id="mix-cam"> Camara activada
|
||||||
</label>
|
</label>
|
||||||
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
|
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
|
||||||
<select id="mix-video"><option value="">-- sin video --</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">
|
<div class="row">
|
||||||
<span class="label">Modo de mezcla</span>
|
<span class="label">Modo de mezcla</span>
|
||||||
<select id="mix-blend" class="inline">
|
<select id="mix-blend" class="inline">
|
||||||
|
<option value="recorte">Recorte (personaje delante)</option>
|
||||||
<option value="blend">Fundido</option>
|
<option value="blend">Fundido</option>
|
||||||
<option value="diff">Diferencia</option>
|
<option value="diff">Diferencia</option>
|
||||||
<option value="mult">Multiplicar</option>
|
<option value="mult">Multiplicar</option>
|
||||||
|
|
@ -184,10 +204,75 @@
|
||||||
<span class="label">projectM</span>
|
<span class="label">projectM</span>
|
||||||
<button class="info" data-help="projectm">i</button>
|
<button class="info" data-help="projectm">i</button>
|
||||||
</div>
|
</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">
|
<div class="row">
|
||||||
<button class="cmd" data-action="prev">« Anterior</button>
|
<button class="cmd" data-action="prev">« Anterior</button>
|
||||||
<button class="cmd" data-action="next">Siguiente »</button>
|
<button class="cmd" data-action="next">Siguiente »</button>
|
||||||
</div>
|
</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">★ 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>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -256,3 +256,14 @@ input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.modal-box p { color: var(--txt); font-size: 14px; line-height: 1.5; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,9 @@ function render() {
|
||||||
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
|
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
|
||||||
(d) => d.id, (d) => d.name);
|
(d) => d.id, (d) => d.name);
|
||||||
if (state.audio.device) $("#audio-device").value = state.audio.device;
|
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 : "--";
|
$("#bpm").textContent = state.audio.bpm > 0 ? state.audio.bpm : "--";
|
||||||
|
|
||||||
$("#sens").value = state.sensitivity;
|
$("#sens").value = state.sensitivity;
|
||||||
|
|
@ -181,8 +184,10 @@ function render() {
|
||||||
renderButterchurn();
|
renderButterchurn();
|
||||||
renderEditor();
|
renderEditor();
|
||||||
renderMixer();
|
renderMixer();
|
||||||
|
renderProjectM();
|
||||||
|
renderMapping();
|
||||||
|
|
||||||
$("#now").textContent = state.status.label || "-";
|
$("#now").textContent = nowText() || "-";
|
||||||
|
|
||||||
const net = state.network || {};
|
const net = state.network || {};
|
||||||
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
|
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) {
|
function fillSelectOnce(sel, items, valFn, txtFn) {
|
||||||
items = items || [];
|
items = items || [];
|
||||||
if (sel.dataset.count == items.length) return;
|
if (sel.dataset.count == items.length) return;
|
||||||
|
|
@ -264,6 +284,7 @@ function renderMixer() {
|
||||||
fillSelectOnce($("#mix-video"), (state.meta.videos || []).map((v) => ({ v })),
|
fillSelectOnce($("#mix-video"), (state.meta.videos || []).map((v) => ({ v })),
|
||||||
(o) => o.v, (o) => o.v);
|
(o) => o.v, (o) => o.v);
|
||||||
$("#mix-video").value = m.video || "";
|
$("#mix-video").value = m.video || "";
|
||||||
|
$("#mix-fondo").value = m.fondo || "cam";
|
||||||
$("#mix-blend").value = m.blendMode;
|
$("#mix-blend").value = m.blendMode;
|
||||||
$("#mix-invert").checked = m.invert;
|
$("#mix-invert").checked = m.invert;
|
||||||
$("#mix-beat").checked = m.beatPulse;
|
$("#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
|
// Eventos de la interfaz
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
$("#power").addEventListener("click", () =>
|
$("#power").addEventListener("click", () =>
|
||||||
socket.emit("set_power", { on: !state.power }));
|
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) =>
|
$$(".engine").forEach((b) =>
|
||||||
b.addEventListener("click", () =>
|
b.addEventListener("click", () =>
|
||||||
socket.emit("set_engine", { engine: b.dataset.engine })));
|
socket.emit("set_engine", { engine: b.dataset.engine })));
|
||||||
|
|
@ -295,6 +647,14 @@ $("#modal").addEventListener("click", (e) => {
|
||||||
|
|
||||||
$("#notif-close").addEventListener("click", () => { $("#notif").hidden = true; });
|
$("#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) =>
|
$("#audio-device").addEventListener("change", (e) =>
|
||||||
socket.emit("update_settings",
|
socket.emit("update_settings",
|
||||||
{ engine: "audio", patch: { device: e.target.value } }));
|
{ engine: "audio", patch: { device: e.target.value } }));
|
||||||
|
|
@ -367,6 +727,9 @@ $("#dev-rescan").addEventListener("click", () =>
|
||||||
$("#mix-video").addEventListener("change", (e) =>
|
$("#mix-video").addEventListener("change", (e) =>
|
||||||
socket.emit("update_settings",
|
socket.emit("update_settings",
|
||||||
{ engine: "mixer", patch: { video: e.target.value } }));
|
{ 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) =>
|
$("#mix-blend").addEventListener("change", (e) =>
|
||||||
socket.emit("update_settings",
|
socket.emit("update_settings",
|
||||||
{ engine: "mixer", patch: { blendMode: e.target.value } }));
|
{ engine: "mixer", patch: { blendMode: e.target.value } }));
|
||||||
|
|
@ -378,6 +741,71 @@ $("#mix-beat").addEventListener("change", (e) =>
|
||||||
socket.emit("update_settings",
|
socket.emit("update_settings",
|
||||||
{ engine: "mixer", patch: { beatPulse: e.target.checked } }));
|
{ 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 ---
|
// --- Comandos siguiente/anterior ---
|
||||||
$$(".cmd[data-action]").forEach((b) =>
|
$$(".cmd[data-action]").forEach((b) =>
|
||||||
b.addEventListener("click", () =>
|
b.addEventListener("click", () =>
|
||||||
|
|
@ -402,7 +830,7 @@ socket.on("status", (s) => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
state.status.label = s.label;
|
state.status.label = s.label;
|
||||||
state.audio.bpm = s.bpm;
|
state.audio.bpm = s.bpm;
|
||||||
$("#now").textContent = s.label || "-";
|
$("#now").textContent = nowText() || "-";
|
||||||
$("#bpm").textContent = s.bpm > 0 ? s.bpm : "--";
|
$("#bpm").textContent = s.bpm > 0 ? s.bpm : "--";
|
||||||
});
|
});
|
||||||
socket.on("notify", (entry) => showNotif(entry));
|
socket.on("notify", (entry) => showNotif(entry));
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@
|
||||||
width: 100vw; height: 100vh; display: none;
|
width: 100vw; height: 100vh; display: none;
|
||||||
}
|
}
|
||||||
canvas.visible { display: block; }
|
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 {
|
#msg {
|
||||||
position: absolute; top: 50%; left: 0; right: 0;
|
position: absolute; top: 50%; left: 0; right: 0;
|
||||||
transform: translateY(-50%); text-align: center;
|
transform: translateY(-50%); text-align: center;
|
||||||
|
|
@ -41,6 +44,7 @@
|
||||||
padding: 1.6vh; border-radius: 1.4vh; image-rendering: pixelated;
|
padding: 1.6vh; border-radius: 1.4vh; image-rendering: pixelated;
|
||||||
}
|
}
|
||||||
.info-sub { color: #6f8a52; font-size: 2.3vh; font-family: monospace; }
|
.info-sub { color: #6f8a52; font-size: 2.3vh; font-family: monospace; }
|
||||||
|
#info { z-index: 20; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -58,12 +62,16 @@
|
||||||
<canvas id="butterchurn"></canvas>
|
<canvas id="butterchurn"></canvas>
|
||||||
<canvas id="hydra"></canvas>
|
<canvas id="hydra"></canvas>
|
||||||
<canvas id="shaders"></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/socket.io.min.js"></script>
|
||||||
<script src="/lib/qrcode.js"></script>
|
<script src="/lib/qrcode.js"></script>
|
||||||
<script src="/lib/butterchurn.min.js"></script>
|
<script src="/lib/butterchurn.min.js"></script>
|
||||||
<script src="/lib/butterchurn-presets.min.js"></script>
|
<script src="/lib/butterchurn-presets.min.js"></script>
|
||||||
<script src="/lib/hydra-synth.js"></script>
|
<script src="/lib/hydra-synth.js"></script>
|
||||||
|
<script src="/stage/mapper.js"></script>
|
||||||
<script src="/stage/stage.js"></script>
|
<script src="/stage/stage.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
396
web/stage/mapper.js
Normal file
396
web/stage/mapper.js
Normal 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;
|
||||||
|
})();
|
||||||
|
|
@ -17,6 +17,7 @@ const shaderCanvas = document.getElementById("shaders");
|
||||||
|
|
||||||
let audioCtx, gainNode, analyser, micSource, micStream;
|
let audioCtx, gainNode, analyser, micSource, micStream;
|
||||||
let currentDevice = "";
|
let currentDevice = "";
|
||||||
|
let currentSource = "mic"; // "mic" (micro de la sala) o "monitor" (lo que suena en el equipo)
|
||||||
let state = null;
|
let state = null;
|
||||||
let lastQrUrl = ""; // ultima URL para la que se genero el QR
|
let lastQrUrl = ""; // ultima URL para la que se genero el QR
|
||||||
|
|
||||||
|
|
@ -268,6 +269,59 @@ async function acquireMic(deviceId) {
|
||||||
currentDevice = 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() {
|
async function initAudio() {
|
||||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
await audioCtx.resume();
|
await audioCtx.resume();
|
||||||
|
|
@ -277,9 +331,18 @@ async function initAudio() {
|
||||||
analyser.fftSize = 1024;
|
analyser.fftSize = 1024;
|
||||||
analyser.smoothingTimeConstant = 0.8;
|
analyser.smoothingTimeConstant = 0.8;
|
||||||
gainNode.connect(analyser);
|
gainNode.connect(analyser);
|
||||||
await acquireMic(null);
|
|
||||||
beatDetector = new BeatDetector(analyser);
|
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
|
/* Lista los microfonos y las camaras y los envia al panel. Se llama al
|
||||||
|
|
@ -329,6 +392,11 @@ function initButterchurn() {
|
||||||
pixelRatio: window.devicePixelRatio || 1,
|
pixelRatio: window.devicePixelRatio || 1,
|
||||||
});
|
});
|
||||||
bcViz.connectAudio(gainNode);
|
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;
|
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
|
||||||
bcPresets = bp.getPresets();
|
bcPresets = bp.getPresets();
|
||||||
bcNames = Object.keys(bcPresets);
|
bcNames = Object.keys(bcPresets);
|
||||||
|
|
@ -337,10 +405,15 @@ function initButterchurn() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadButterchurnPreset(name) {
|
function loadButterchurnPreset(name) {
|
||||||
if (!bcPresets[name]) return;
|
if (!bcPresets[name] || !bcViz) return;
|
||||||
bcCurrent = name;
|
bcCurrent = name; // recuerda el intento aunque falle, para no reintentarlo en bucle
|
||||||
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
|
try {
|
||||||
socket.emit("stage_status", { label: name });
|
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) {
|
function bcStep(delta) {
|
||||||
|
|
@ -401,6 +474,13 @@ function runHydraCode(code) {
|
||||||
function buildMixerCode(m) {
|
function buildMixerCode(m) {
|
||||||
let base;
|
let base;
|
||||||
if (m.source === "video") base = "src(s1)";
|
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 if (m.source === "mix") base = `src(s0).${m.blendMode}(src(s1), ${m.mix})`;
|
||||||
else base = "src(s0)";
|
else base = "src(s0)";
|
||||||
|
|
||||||
|
|
@ -424,33 +504,174 @@ function buildMixerCode(m) {
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inicializa las fuentes (camara s0, video s1) solo cuando cambian. */
|
/* Fuentes del mezclador (camara s0, video s1). No usamos s0.initCam ni
|
||||||
function ensureMixerSources(m) {
|
s1.initVideo de Hydra porque se tragan los errores: si la camara falla o
|
||||||
const wantCam = m.camOn && (m.source === "cam" || m.source === "mix");
|
el video no carga, la pantalla se queda en negro sin decir nada. Creamos
|
||||||
if (wantCam && (mixerCam !== true || mixerCamId !== m.cameraId)) {
|
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 {
|
try {
|
||||||
s0.initCam(m.cameraId);
|
stopMixerCam();
|
||||||
|
mixerCamStream = await initCamSource(m.cameraId);
|
||||||
mixerCam = true;
|
mixerCam = true;
|
||||||
mixerCamId = m.cameraId;
|
mixerCamId = m.cameraId;
|
||||||
|
mixerCamFailed = null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
report("error", "No se pudo activar la camara. Comprueba que la "
|
stopMixerCam();
|
||||||
+ "webcam USB esta conectada, o elige otra camara en el panel.");
|
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) {
|
} else if (!wantCam && mixerCam) {
|
||||||
mixerCam = false;
|
stopMixerCam();
|
||||||
|
mixerCamFailed = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wantVideo = m.video && (m.source === "video" || m.source === "mix");
|
const wantVideo = m.video && (m.source === "video" || m.source === "mix");
|
||||||
if (wantVideo && m.video !== mixerVideo) {
|
if (wantVideo && m.video !== mixerVideo) {
|
||||||
try { s1.initVideo("/data/videos/" + encodeURIComponent(m.video)); }
|
const name = m.video;
|
||||||
catch (e) {
|
mixerVideo = name; // evita recargar el mismo clip con cada estado
|
||||||
report("error", "No se pudo cargar el video '" + m.video + "'.");
|
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) {
|
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);
|
const sig = JSON.stringify(m);
|
||||||
if (sig !== mixerSig) {
|
if (sig !== mixerSig) {
|
||||||
mixerSig = sig;
|
mixerSig = sig;
|
||||||
|
|
@ -468,6 +689,8 @@ function show(canvas) {
|
||||||
shaderCanvas.classList.toggle("visible", canvas === shaderCanvas);
|
shaderCanvas.classList.toggle("visible", canvas === shaderCanvas);
|
||||||
document.getElementById("info").style.display = "none";
|
document.getElementById("info").style.display = "none";
|
||||||
msgEl.style.display = canvas ? "none" : "block";
|
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
|
/* Pantalla de conexion: muestra la direccion del panel y un codigo QR en el
|
||||||
|
|
@ -495,13 +718,19 @@ function renderInfoScreen() {
|
||||||
|
|
||||||
function applyState(next) {
|
function applyState(next) {
|
||||||
const prevDevice = currentDevice;
|
const prevDevice = currentDevice;
|
||||||
|
const prevSource = currentSource;
|
||||||
state = next;
|
state = next;
|
||||||
|
if (window.FosMapper) FosMapper.setMapping(state.mapping || {});
|
||||||
if (gainNode) gainNode.gain.value = state.sensitivity;
|
if (gainNode) gainNode.gain.value = state.sensitivity;
|
||||||
|
|
||||||
// Cambio de tarjeta de audio
|
// Cambio de fuente (micro / audio del navegador) o de tarjeta de audio.
|
||||||
if (state.audio.device && state.audio.device !== prevDevice) {
|
const source = state.audio.source || "mic";
|
||||||
acquireMic(state.audio.device).catch(() =>
|
const sourceChanged = source !== prevSource;
|
||||||
report("error", "No se pudo cambiar a la tarjeta de audio elegida."));
|
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,
|
// Mientras nadie haya abierto el panel, o con las visuales apagadas,
|
||||||
|
|
@ -511,16 +740,23 @@ function applyState(next) {
|
||||||
stopButterchurn();
|
stopButterchurn();
|
||||||
if (shaderEngine) shaderEngine.stop();
|
if (shaderEngine) shaderEngine.stop();
|
||||||
if (hydra) hydra.hush();
|
if (hydra) hydra.hush();
|
||||||
mixerSig = "";
|
stopMixer();
|
||||||
if (showConnect) renderInfoScreen();
|
if (showConnect) {
|
||||||
else show(null);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.engine === "butterchurn") {
|
if (state.engine === "butterchurn") {
|
||||||
if (hydra) hydra.hush();
|
if (hydra) hydra.hush();
|
||||||
if (shaderEngine) shaderEngine.stop();
|
if (shaderEngine) shaderEngine.stop();
|
||||||
mixerSig = "";
|
stopMixer();
|
||||||
if (!bcViz) { show(null); return; }
|
if (!bcViz) { show(null); return; }
|
||||||
show(bcCanvas);
|
show(bcCanvas);
|
||||||
startButterchurn();
|
startButterchurn();
|
||||||
|
|
@ -532,7 +768,7 @@ function applyState(next) {
|
||||||
} else if (state.engine === "hydra") {
|
} else if (state.engine === "hydra") {
|
||||||
stopButterchurn();
|
stopButterchurn();
|
||||||
if (shaderEngine) shaderEngine.stop();
|
if (shaderEngine) shaderEngine.stop();
|
||||||
mixerSig = "";
|
stopMixer();
|
||||||
if (!hydra) { show(null); return; }
|
if (!hydra) { show(null); return; }
|
||||||
show(hydraCanvas);
|
show(hydraCanvas);
|
||||||
if (state.hydra.code !== hydraCode) runHydraCode(state.hydra.code);
|
if (state.hydra.code !== hydraCode) runHydraCode(state.hydra.code);
|
||||||
|
|
@ -540,16 +776,21 @@ function applyState(next) {
|
||||||
} else if (state.engine === "shaders") {
|
} else if (state.engine === "shaders") {
|
||||||
stopButterchurn();
|
stopButterchurn();
|
||||||
if (hydra) hydra.hush();
|
if (hydra) hydra.hush();
|
||||||
mixerSig = "";
|
stopMixer();
|
||||||
if (!shaderEngine) { show(null); return; }
|
if (!shaderEngine) { show(null); return; }
|
||||||
show(shaderCanvas);
|
show(shaderCanvas);
|
||||||
if (shaderEngine && state.shaders.code !== shaderCode) {
|
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();
|
if (shaderEngine) shaderEngine.start();
|
||||||
|
|
||||||
} else if (state.engine === "mixer") {
|
} 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 (shaderEngine) shaderEngine.stop();
|
||||||
if (!hydra) { show(null); return; }
|
if (!hydra) { show(null); return; }
|
||||||
show(hydraCanvas);
|
show(hydraCanvas);
|
||||||
|
|
@ -562,6 +803,14 @@ function applyState(next) {
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
socket.on("state", (next) => 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) => {
|
socket.on("stage_command", (cmd) => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
if (state.engine === "butterchurn") {
|
if (state.engine === "butterchurn") {
|
||||||
|
|
@ -575,12 +824,13 @@ socket.on("stage_rescan", () => {
|
||||||
report("warn", "No se pudieron volver a leer los dispositivos."));
|
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", () => {
|
socket.on("stage_reacquire", () => {
|
||||||
const dev = (state && state.audio && state.audio.device) || null;
|
applyAudioSource()
|
||||||
acquireMic(dev)
|
.then(() => report("info", (state && state.audio && state.audio.source) === "monitor"
|
||||||
.then(() => report("info", "Microfono conectado."))
|
? "Capturando el audio del navegador/sistema."
|
||||||
.catch(() => report("error", "No se pudo conectar con el microfono elegido."));
|
: "Microfono conectado."))
|
||||||
|
.catch(() => report("error", "No se pudo conectar con la entrada de audio elegida."));
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
|
|
@ -591,15 +841,14 @@ window.addEventListener("resize", () => {
|
||||||
// Arranque
|
// Arranque
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
async function boot() {
|
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 {
|
try {
|
||||||
await initAudio();
|
await initAudio();
|
||||||
beatLoop();
|
beatLoop();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
report("error", "No se pudo iniciar el audio: " + err.message
|
report("error", "No se pudo iniciar el audio: " + err.message
|
||||||
+ ". Comprueba el microfono y recarga la pagina.");
|
+ ". Las visuales funcionaran sin reaccionar a la musica.");
|
||||||
msgEl.textContent = "ERROR: audio (" + err.message + ")";
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Cada motor se inicia por separado: si una libreria falta o falla, los
|
// Cada motor se inicia por separado: si una libreria falta o falla, los
|
||||||
// demas motores siguen funcionando igual.
|
// demas motores siguen funcionando igual.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue