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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 14:56:01 +02:00

1004 lines
38 KiB
JavaScript

/*
* FOSFENO :: Escenario
* Renderiza las visuales en el proyector. Motores:
* - butterchurn : presets MilkDrop (WebGL)
* - hydra : codigo Hydra en vivo (editor / libreria)
* - shaders : shaders GLSL en vivo (editor)
* - mixer : mezclador VJ (camara + video + efectos, via Hydra)
* - projectm : nativo (esta pagina se queda en negro y se superpone)
* Incluye deteccion de BPM en vivo y seleccion de tarjeta de audio.
*/
// Clave compartida (config.json -> auth.token). Viene en la propia direccion
// del escenario; si no hay clave configurada, queda vacia y todo va como
// siempre. Se reenvia en el QR para que el movil entre de un escaneo.
const CLAVE = new URLSearchParams(location.search).get("k") || "";
const socket = io({ auth: { token: CLAVE } });
const msgEl = document.getElementById("msg");
const bcCanvas = document.getElementById("butterchurn");
const hydraCanvas = document.getElementById("hydra");
const shaderCanvas = document.getElementById("shaders");
let audioCtx, gainNode, analyser, micSource, micStream;
let currentDevice = "";
let currentSource = "mic"; // "mic" (micro de la sala) o "monitor" (lo que suena en el equipo)
let state = null;
let lastQrUrl = ""; // ultima URL para la que se genero el QR
// Estado del detector de ritmo, compartido por todos los motores
const fosBeat = { bpm: 0, phase: 0, level: 0, isBeat: false };
// --- Butterchurn ---
let bcViz = null, bcRAF = null, bcTimer = null;
let bcPresets = {}, bcNames = [], bcIndex = 0, bcCurrent = "";
let bcBeatCount = 0;
// --- Hydra / Mixer ---
let hydra = null, hydraCode = "", mixerSig = "";
let mixerVideo = "", mixerCam = null, mixerCamId = null;
// --- Shaders ---
let shaderEngine = null, shaderCode = "";
/* Comunica un aviso al servidor para que aparezca en el panel de control.
Asi el usuario siempre ve que ha pasado, no se queda el sistema mudo. */
function report(level, message) {
if (level === "error") console.error("[FOSFENO]", message);
else console.warn("[FOSFENO]", message);
try { socket.emit("stage_notify", { level: level, message: message }); }
catch (e) { /* sin conexion */ }
}
// ==========================================================================
// Detector de BPM (analisis de energia de graves en tiempo real)
// ==========================================================================
class BeatDetector {
constructor(analyserNode) {
this.analyser = analyserNode;
this.freq = new Uint8Array(analyserNode.frequencyBinCount);
this.history = [];
this.HISTORY = 55;
this.lastBeat = 0;
this.intervals = [];
this.bpm = 0;
}
update(now) {
this.analyser.getByteFrequencyData(this.freq);
const n = this.freq.length;
const hi = Math.max(2, Math.floor(n * 0.08));
let e = 0;
for (let i = 0; i < hi; i++) e += this.freq[i];
e /= hi;
this.history.push(e);
if (this.history.length > this.HISTORY) this.history.shift();
const avg = this.history.reduce((s, v) => s + v, 0) / this.history.length;
let isBeat = false;
const minGap = 60000 / 210; // tope de 210 BPM
if (e > avg * 1.35 && e > 24 && now - this.lastBeat > minGap) {
if (this.lastBeat > 0) {
const dt = now - this.lastBeat;
if (dt > 200 && dt < 2000) {
this.intervals.push(dt);
if (this.intervals.length > 40) this.intervals.shift();
this._estimate();
}
}
this.lastBeat = now;
isBeat = true;
}
fosBeat.level = e / 255;
fosBeat.bpm = this.bpm;
fosBeat.isBeat = isBeat;
if (this.bpm > 0) {
const beatMs = 60000 / this.bpm;
fosBeat.phase = ((now - this.lastBeat) % beatMs) / beatMs;
}
return isBeat;
}
_estimate() {
if (this.intervals.length < 6) return;
// Agrupa intervalos parecidos (tolerancia 8%) y elige el grupo mayoritario
const groups = [];
for (const iv of this.intervals) {
const g = groups.find((x) => Math.abs(x.mean - iv) / x.mean < 0.08);
if (g) { g.sum += iv; g.count++; g.mean = g.sum / g.count; }
else groups.push({ sum: iv, count: 1, mean: iv });
}
groups.sort((a, b) => b.count - a.count);
let bpm = 60000 / groups[0].mean;
while (bpm < 70) bpm *= 2;
while (bpm > 180) bpm /= 2;
this.bpm = Math.round(bpm);
}
}
let beatDetector = null;
// ==========================================================================
// Motor de shaders GLSL (estilo Shadertoy, reactivo al audio y al BPM)
// Uniforms: u_resolution, u_time, u_bass, u_mid, u_treble, u_level,
// u_bpm, u_beat (fase 0..1), u_fft (sampler2D)
// ==========================================================================
class ShaderEngine {
/* size: {width, height} fija el lienzo (capas del mapping, que se
renderizan pequenas y las estira el mapper). Sin size, el shader ocupa
la pantalla entera, que es como va el motor de shaders normal. */
constructor(canvas, analyserNode, size) {
this.canvas = canvas;
this.size = size || null;
this.analyser = analyserNode;
this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
this.raf = null;
this.program = null;
this.loc = {};
this.startTime = performance.now();
this.fftData = new Uint8Array(analyserNode.frequencyBinCount);
this._initQuad();
this._initFFTTexture();
}
_initQuad() {
const gl = this.gl;
this.quadBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
}
_initFFTTexture() {
const gl = this.gl;
this.fftTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.fftTex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
}
_compile(type, src) {
const gl = this.gl;
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
report("error", "El shader no compila. Revisa el codigo GLSL "
+ "(el detalle tecnico esta en la consola del navegador).");
gl.deleteShader(sh);
return null;
}
return sh;
}
load(fragBody) {
const gl = this.gl;
const vs = "attribute vec2 p; void main(){ gl_Position = vec4(p,0.0,1.0); }";
const header =
"precision highp float;\n" +
"uniform vec2 u_resolution;\n" +
"uniform float u_time;\n" +
"uniform float u_bass;\n" +
"uniform float u_mid;\n" +
"uniform float u_treble;\n" +
"uniform float u_level;\n" +
"uniform float u_bpm;\n" +
"uniform float u_beat;\n" +
"uniform sampler2D u_fft;\n";
const vsh = this._compile(gl.VERTEX_SHADER, vs);
const fsh = this._compile(gl.FRAGMENT_SHADER, header + fragBody);
if (!vsh || !fsh) return false;
const prog = gl.createProgram();
gl.attachShader(prog, vsh);
gl.attachShader(prog, fsh);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error("[FOSFENO] link:", gl.getProgramInfoLog(prog));
return false;
}
if (this.program) gl.deleteProgram(this.program);
this.program = prog;
const u = (name) => gl.getUniformLocation(prog, name);
this.loc = {
p: gl.getAttribLocation(prog, "p"),
res: u("u_resolution"), time: u("u_time"),
bass: u("u_bass"), mid: u("u_mid"), treble: u("u_treble"),
level: u("u_level"), bpm: u("u_bpm"), beat: u("u_beat"), fft: u("u_fft"),
};
return true;
}
_bands() {
this.analyser.getByteFrequencyData(this.fftData);
const n = this.fftData.length;
const avg = (lo, hi) => {
let s = 0;
for (let i = lo; i < hi; i++) s += this.fftData[i];
return s / Math.max(1, (hi - lo) * 255);
};
return [
avg(1, Math.floor(n * 0.06)),
avg(Math.floor(n * 0.06), Math.floor(n * 0.30)),
avg(Math.floor(n * 0.30), Math.floor(n * 0.75)),
avg(1, n),
];
}
_frame() {
const gl = this.gl;
const w = this.canvas.width = this.size ? this.size.width : window.innerWidth;
const h = this.canvas.height = this.size ? this.size.height : window.innerHeight;
gl.viewport(0, 0, w, h);
if (this.program) {
const b = this._bands();
gl.useProgram(this.program);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.fftTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE,
this.fftData.length, 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, this.fftData);
gl.uniform1i(this.loc.fft, 0);
gl.uniform2f(this.loc.res, w, h);
gl.uniform1f(this.loc.time, (performance.now() - this.startTime) / 1000);
gl.uniform1f(this.loc.bass, b[0]);
gl.uniform1f(this.loc.mid, b[1]);
gl.uniform1f(this.loc.treble, b[2]);
gl.uniform1f(this.loc.level, b[3]);
gl.uniform1f(this.loc.bpm, fosBeat.bpm);
gl.uniform1f(this.loc.beat, fosBeat.phase);
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
gl.enableVertexAttribArray(this.loc.p);
gl.vertexAttribPointer(this.loc.p, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
this.raf = requestAnimationFrame(() => this._frame());
}
start() { if (!this.raf) this._frame(); }
stop() { if (this.raf) cancelAnimationFrame(this.raf); this.raf = null; }
}
// ==========================================================================
// Audio: captura del microfono + seleccion de tarjeta
// ==========================================================================
async function acquireMic(deviceId) {
if (micStream) micStream.getTracks().forEach((t) => t.stop());
const constraints = {
audio: {
echoCancellation: false, noiseSuppression: false, autoGainControl: false,
},
};
if (deviceId) constraints.audio.deviceId = { exact: deviceId };
micStream = await navigator.mediaDevices.getUserMedia(constraints);
if (micSource) micSource.disconnect();
micSource = audioCtx.createMediaStreamSource(micStream);
micSource.connect(gainNode);
currentDevice = deviceId || "";
}
/* Busca la fuente "monitor" del sistema: en Linux, PulseAudio/PipeWire expone
por cada salida de audio una entrada "Monitor of ..." que contiene
exactamente lo que se esta reproduciendo (navegador, Spotify...), aunque
suene por los cascos. Capturarla permite visuales reactivas sin micro.
Si el usuario ya eligio un monitor concreto en la lista, se respeta. */
async function resolveMonitorId() {
const devs = await navigator.mediaDevices.enumerateDevices();
const inputs = devs.filter((d) => d.kind === "audioinput");
const isMonitor = (d) =>
/monitor|loopback|what.?u.?hear|stereo mix/i.test(d.label || "");
const chosen = inputs.find((d) =>
d.deviceId === (state && state.audio && state.audio.device) && isMonitor(d));
if (chosen) return chosen.deviceId;
const mon = inputs.find(isMonitor);
return mon ? mon.deviceId : null;
}
/* Conecta la captura segun la fuente elegida en el panel. Las llamadas se
encadenan una detras de otra: dos cambios rapidos en el panel ya no pueden
solaparse y dejar streams huerfanos capturando. */
let audioChain = Promise.resolve();
function applyAudioSource() {
const run = audioChain.catch(() => {}).then(() => doApplyAudioSource());
audioChain = run.catch(() => {});
return run;
}
async function doApplyAudioSource() {
const src = (state && state.audio && state.audio.source) || "mic";
if (src === "monitor") {
const id = await resolveMonitorId();
if (id) {
await acquireMic(id);
} else {
/* Chromium oculta los monitores de PulseAudio/PipeWire al enumerar
dispositivos (Firefox si los muestra). Plan B: capturamos la entrada
por defecto y el servidor mueve este stream al monitor de la salida
con pactl. Solo funciona con escenario y servidor en la misma
maquina, que es el caso del kiosko y del modo portatil. */
await acquireMic(null);
socket.emit("audio_route", { target: "monitor" });
}
} else {
const dev = (state && state.audio && state.audio.device) || null;
await acquireMic(dev);
/* Sin tarjeta concreta elegida, PipeWire puede "recordar" que el
navegador capturaba el monitor (stream-restore) y dejarlo alli:
pedimos al servidor que devuelva la captura a la entrada por defecto. */
if (!dev) socket.emit("audio_route", { target: "mic" });
}
currentSource = src;
}
async function initAudio() {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
await audioCtx.resume();
gainNode = audioCtx.createGain();
gainNode.gain.value = 1.0;
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.8;
gainNode.connect(analyser);
beatDetector = new BeatDetector(analyser);
// 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
arrancar y cada vez que el panel pide volver a buscar dispositivos. */
async function enumerateInputs() {
const devs = await navigator.mediaDevices.enumerateDevices();
const audioInputs = devs.filter((d) => d.kind === "audioinput")
.map((d, i) => ({ id: d.deviceId,
name: d.label || ("Entrada de audio " + (i + 1)) }));
const cameraInputs = devs.filter((d) => d.kind === "videoinput")
.map((d, i) => ({ id: i, name: d.label || ("Camara " + (i + 1)) }));
socket.emit("stage_meta", {
audioDevices: audioInputs, cameraDevices: cameraInputs,
});
}
// Bucle permanente del detector de ritmo (independiente del motor activo)
let lastBpmSent = 0;
function beatLoop() {
const now = performance.now();
const isBeat = beatDetector ? beatDetector.update(now) : false;
try { window.bpm = fosBeat.bpm || 30; } catch (e) { /* hydra no cargado */ }
// Cambio de preset de Butterchurn sincronizado al compas (tanto si es el
// motor elegido como si hace de fondo del mezclador)
if (isBeat && butterLive()
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "beats") {
bcBeatCount++;
if (bcBeatCount >= (state.butterchurn.interval || 16)) {
bcBeatCount = 0;
bcStep(1);
}
}
if (now - lastBpmSent > 2000) {
lastBpmSent = now;
socket.emit("stage_status", { bpm: fosBeat.bpm });
}
requestAnimationFrame(beatLoop);
}
// ==========================================================================
// Butterchurn
// ==========================================================================
function initButterchurn() {
const bch = window.butterchurn.default || window.butterchurn;
bcViz = bch.createVisualizer(audioCtx, bcCanvas, {
width: window.innerWidth, height: window.innerHeight,
pixelRatio: window.devicePixelRatio || 1,
});
bcViz.connectAudio(gainNode);
if (!window.butterchurnPresets) {
report("error", "Faltan los presets de Butterchurn (libreria no "
+ "copiada). Vuelve a ejecutar install.sh.");
return;
}
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
bcPresets = bp.getPresets();
bcNames = Object.keys(bcPresets);
bcIndex = Math.floor(Math.random() * bcNames.length);
socket.emit("stage_meta", { butterchurnPresets: bcNames });
}
function loadButterchurnPreset(name) {
if (!bcPresets[name] || !bcViz) return;
bcCurrent = name; // recuerda el intento aunque falle, para no reintentarlo en bucle
// El indice sigue al preset que suena: asi 'siguiente' continua desde el
// que se ve, tanto si se eligio en la lista como si vino del cambio
// automatico.
const i = bcNames.indexOf(name);
if (i >= 0) bcIndex = i;
// El estado guarda siempre el preset que esta en pantalla. Sin esto, el
// cambio automatico volvia atras en cuanto llegaba cualquier estado
// nuevo (el escenario recargaba el ultimo preset elegido a mano).
if (!state || state.butterchurn.preset !== name) {
socket.emit("update_settings",
{ engine: "butterchurn", patch: { preset: name } });
}
try {
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
socket.emit("stage_status", {
label: (state && state.engine === "mixer")
? "Mezclador VJ · fondo: " + name : name,
});
} catch (e) {
report("warn", "El preset '" + name + "' fallo al cargar en esta "
+ "tarjeta grafica. Pasa a otro con 'siguiente' o eligelo en la lista.");
}
}
function bcStep(delta) {
bcIndex = (bcIndex + delta + bcNames.length) % bcNames.length;
loadButterchurnPreset(bcNames[bcIndex]);
}
/* Butterchurn esta pintando ahora mismo? Puede ser el motor elegido o el
fondo del mezclador (la capa de abajo). En los dos casos manda el mismo
preset y el mismo cambio automatico, para que el fondo del mezclador se
maneje igual que el motor Butter. */
function butterLive() {
if (!state || !state.power) return false;
if (state.engine === "butterchurn") return true;
return state.engine === "mixer" && state.mixer.fondo === "butter"
&& (state.mixer.source === "cam" || state.mixer.source === "mix");
}
function bcLoop() {
bcViz.setRendererSize(window.innerWidth, window.innerHeight);
bcViz.render();
bcRAF = requestAnimationFrame(bcLoop);
}
function startButterchurn() { if (!bcRAF) bcLoop(); }
/* Ajustes con los que se monto el temporizador del cambio automatico. Sin
esta firma, cada estado nuevo (mover un deslizador, subir un clip...)
reiniciaba la cuenta y con intervalos largos el preset no cambiaba nunca. */
let bcShuffleSig = "";
function stopButterchurn() {
if (bcRAF) cancelAnimationFrame(bcRAF);
bcRAF = null;
clearInterval(bcTimer);
bcTimer = null;
bcShuffleSig = "";
}
function refreshShuffle() {
const b = (state && state.butterchurn) || {};
const sig = butterLive()
? [b.shuffle, b.intervalMode, b.interval].join("|") : "off";
if (sig === bcShuffleSig) return;
bcShuffleSig = sig;
clearInterval(bcTimer);
bcTimer = null;
bcBeatCount = 0;
if (butterLive() && b.shuffle && b.intervalMode === "seconds") {
bcTimer = setInterval(() => bcStep(1), (b.interval || 20) * 1000);
}
}
// ==========================================================================
// Hydra (codigo en vivo) + Mixer (mezclador VJ)
// ==========================================================================
function initHydra() {
hydra = new Hydra({
canvas: hydraCanvas, detectAudio: true, makeGlobal: true,
width: window.innerWidth, height: window.innerHeight,
});
a.setSmooth(0.85);
a.setBins(5);
}
function runHydraCode(code) {
if (!code) return;
hydraCode = code;
try {
// eslint-disable-next-line no-eval
eval(code);
} catch (err) {
report("error", "El codigo de Hydra ha fallado: " + err.message);
}
}
/* Construye una cadena de codigo Hydra a partir de los ajustes del mezclador. */
function buildMixerCode(m) {
let base;
if (m.source === "video") base = "src(s1)";
else if (m.source === "mix" && m.blendMode === "recorte") {
// Recorte: el negro del clip se hace transparente (luma key) y la
// figura queda DELANTE del fondo. El deslizador de mezcla ajusta el
// umbral del recorte (mas alto = se come mas borde oscuro).
const umbral = (0.05 + 0.30 * Number(m.mix || 0.5)).toFixed(3);
base = `src(s0).layer(src(s1).luma(${umbral}, 0.15))`;
}
else if (m.source === "mix") base = `src(s0).${m.blendMode}(src(s1), ${m.mix})`;
else base = "src(s0)";
let c = base;
if (m.kaleid > 1) c += `.kaleid(${m.kaleid})`;
if (m.rotate) c += `.rotate(${m.rotate})`;
if (m.pixelate > 0) {
const px = Math.round(220 - m.pixelate * 210);
c += `.pixelate(${px}, ${px})`;
}
if (m.hue) c += `.hue(${m.hue})`;
if (m.saturate !== 1) c += `.saturate(${m.saturate})`;
if (m.contrast !== 1) c += `.contrast(${m.contrast})`;
if (m.brightness) c += `.brightness(${m.brightness})`;
if (m.colorama > 0) c += `.colorama(${m.colorama})`;
if (m.posterize > 0) c += `.posterize(${m.posterize}, 0.5)`;
if (m.invert) c += ".invert()";
if (m.beatPulse) c += ".scale(() => 1.0 + a.fft[0]*0.3)";
if (m.feedback > 0) c += `.blend(o0, ${m.feedback})`;
c += ".out(o0)";
return c;
}
/* Fuentes del mezclador (camara s0, video s1). No usamos s0.initCam ni
s1.initVideo de Hydra porque se tragan los errores: si la camara falla o
el video no carga, la pantalla se queda en negro sin decir nada. Creamos
nosotros la captura y el <video>, avisamos al panel de cualquier fallo y
solo cuando estan listos se los pasamos a Hydra con init({src}). */
let mixerCamStream = null; // stream de la webcam, para poder apagarla
let mixerCamPending = false; // evita dos aperturas de camara a la vez
let mixerCamFailed = null; // cameraId que fallo (no reintentar en bucle)
let mixerVideoEl = null; // elemento <video> del clip actual
let mixerWarned = ""; // ultimo aviso de fuente no visible mostrado
let mixerButter = false; // s0 es el lienzo de Butterchurn (fondo butter)
/* Que hay enganchado a s1 (el clip o la imagen de encima). Hydra.hush() borra
TODAS las fuentes al salir del mezclador, asi que hay que recordarlo para
volver a engancharlo al entrar: sin esto, al ir al motor Butter (o a
projectM, o a Shaders) y volver, el clip desaparecia y la mezcla salia sin
la capa de encima. */
let mixerS1 = null; // { el, dynamic }
let mixerSueltas = false; // hubo hush(): s0/s1 estan vacias
function stopMixerCam() {
if (mixerCamStream) mixerCamStream.getTracks().forEach((t) => t.stop());
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;
mixerSueltas = true;
stopMixerCam();
if (mixerVideoEl) mixerVideoEl.pause();
}
/* Vuelve a enganchar a Hydra lo que ya teniamos cargado, sin recargarlo. */
function reengancharMixer() {
if (!mixerSueltas) return;
mixerSueltas = false;
if (mixerS1 && mixerS1.el) {
try { s1.init({ src: mixerS1.el, dynamic: mixerS1.dynamic }); }
catch (e) { mixerS1 = null; }
}
}
async function initCamSource(index) {
const devs = await navigator.mediaDevices.enumerateDevices();
const cams = devs.filter((d) => d.kind === "videoinput");
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 });
mixerS1 = { el: 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 });
mixerS1 = { el: 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) {
// Al volver de otro motor, Hydra tiene las fuentes vacias (hush): lo
// primero es reengancharlas antes de decidir que falta.
reengancharMixer();
// Fondo "butter": el lienzo de Butterchurn sigue renderizando (oculto,
// el visible es el de Hydra) y hace de capa de abajo s0 en lugar de la
// camara. Asi los recortes de personajes van DELANTE de MilkDrop.
const wantButter = m.fondo === "butter"
&& (m.source === "cam" || m.source === "mix");
if (wantButter && bcViz) {
stopMixerCam();
// Mismo criterio que en el motor Butter: manda el preset del estado
// (elegido en el panel, en el mezclador o por el cambio automatico).
const quiere = (state && state.butterchurn.preset) || "";
if (quiere && quiere !== bcCurrent) loadButterchurnPreset(quiere);
else if (!bcCurrent) loadButterchurnPreset(bcNames[bcIndex]);
startButterchurn();
refreshShuffle();
if (!mixerButter) {
mixerButter = true;
s0.init({ src: bcCanvas, dynamic: true });
}
} 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 {
stopMixerCam();
mixerCamStream = await initCamSource(m.cameraId);
mixerCam = true;
mixerCamId = m.cameraId;
mixerCamFailed = null;
} catch (e) {
stopMixerCam();
mixerCamFailed = m.cameraId;
report("error", "No se pudo activar la camara (" + e.message + "). "
+ "Comprueba que esta conectada, pulsa 'Buscar dispositivos' y "
+ "apaga y enciende la casilla 'Camara' para reintentar.");
} finally {
mixerCamPending = false;
}
} else if (!wantCam && mixerCam) {
stopMixerCam();
mixerCamFailed = null;
}
const wantVideo = m.video && (m.source === "video" || m.source === "mix");
if (wantVideo && m.video !== mixerVideo) {
const name = m.video;
mixerVideo = name; // evita recargar el mismo clip con cada estado
if (mixerVideoEl) { mixerVideoEl.pause(); mixerVideoEl.src = ""; mixerVideoEl = null; }
try {
if (IMAGE_RE.test(name)) {
await initImageSource(name); // imagen fija: sin elemento video
} else {
mixerVideoEl = await initVideoSource(name);
}
} catch (e) {
mixerVideo = ""; // deja reintentar eligiendolo otra vez en la lista
report("error", "No se pudo cargar '" + name + "': "
+ e.message + ". Prueba con un .mp4 (H.264), .webm, .jpg o .png.");
}
} else if (wantVideo && mixerVideoEl && mixerVideoEl.paused) {
// Al volver al mezclador el clip estaba pausado por stopMixer()
mixerVideoEl.play().catch(() => {});
}
}
/* Texto de aviso si la fuente elegida no puede verse todavia. */
function mixerWarnings(m) {
const w = [];
if ((m.source === "cam" || m.source === "mix") && !m.camOn
&& m.fondo !== "butter")
w.push("La camara esta apagada: activa la casilla 'Camara'.");
if ((m.source === "video" || m.source === "mix") && !m.video)
w.push(((state && state.meta.videos) || []).length
? "No hay video elegido: selecciona uno en la lista."
: "No hay videos: copia archivos a data/videos y pulsa el boton de recargar.");
return w.join(" ");
}
function applyMixer(m) {
ensureMixerSources(m).catch((e) =>
report("error", "El mezclador fallo preparando las fuentes: " + e.message));
const warn = mixerWarnings(m);
if (warn && warn !== mixerWarned) report("warn", warn);
mixerWarned = warn;
const sig = JSON.stringify(m);
if (sig !== mixerSig) {
mixerSig = sig;
runHydraCode(buildMixerCode(m));
socket.emit("stage_status", { label: "Mezclador VJ" });
}
}
// ==========================================================================
// Conmutacion de motores
// ==========================================================================
function show(canvas) {
bcCanvas.classList.toggle("visible", canvas === bcCanvas);
hydraCanvas.classList.toggle("visible", canvas === hydraCanvas);
shaderCanvas.classList.toggle("visible", canvas === shaderCanvas);
document.getElementById("info").style.display = "none";
msgEl.style.display = canvas ? "none" : "block";
// el mapper (si el mapping está activo) usa este canvas como textura
if (window.FosMapper) FosMapper.setSource(canvas);
if (window.FosLayers) FosLayers.setMotor(canvas);
}
/* Pantalla de conexion: muestra la direccion del panel y un codigo QR en el
proyector. Se ve al arrancar (hasta que alguien abre el panel) y cuando las
visuales estan apagadas, para que conectarse sea inmediato. */
function renderInfoScreen() {
show(null);
const net = state.network || {};
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
const k = CLAVE ? "?k=" + encodeURIComponent(CLAVE) : "";
const ipUrl = "http://" + (net.ip || "...") + port + "/" + k;
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + port + "/" + k;
document.getElementById("info-url").textContent = ipUrl;
document.getElementById("info-sub").textContent = "o tambien: " + nameUrl;
if (typeof qrcode !== "undefined" && lastQrUrl !== ipUrl && net.ip) {
lastQrUrl = ipUrl;
try {
const qr = qrcode(0, "M");
qr.addData(ipUrl);
qr.make();
document.getElementById("info-qr").src = qr.createDataURL(7, 8);
} catch (e) { /* sin QR: queda la direccion en texto */ }
}
document.getElementById("info").style.display = "flex";
}
/* Una capa MilkDrop sin preset elegido coge uno al azar (para que dos capas
nuevas no salgan identicas). Lo guardamos en el estado para que el panel
ensene cual le toco en vez de dejar el desplegable en blanco. No recrea la
instancia: la firma de una capa butter es el tipo a secas. */
function anotarPresetDeCapa(surfaceId, preset) {
const map = (state && state.mapping) || {};
const surf = (map.surfaces || []).find((s) => s.id === surfaceId);
if (!surf || (surf.fuente && surf.fuente.preset)) return;
surf.fuente = Object.assign({}, surf.fuente, { preset: preset });
socket.emit("set_mapping", { surfaces: map.surfaces, masks: map.masks });
}
function applyState(next) {
const prevDevice = currentDevice;
const prevSource = currentSource;
state = next;
const map = state.mapping || {};
// El mapping nunca puede tumbar el escenario: un mapping.json raro dejaria
// el proyector en negro y, de paso, sin poder contarlo (los avisos se
// pintan despues). Va aislado y con su propio aviso.
try {
if (window.FosMapper) FosMapper.setMapping(map);
// Las capas se ponen al dia con el estado: crea las nuevas, cambia el
// preset de las que lo hayan cambiado y tira las borradas. Solo con el
// mapping encendido y las visuales dadas: apagado no queremos motores
// extra quemando GPU ni la camara con el piloto puesto.
if (window.FosLayers) {
FosLayers.sync(map.enabled && state.power ? (map.surfaces || []) : []);
}
} catch (e) {
report("error", "El mapping no se pudo aplicar (" + e.message + "). "
+ "Pulsa Reset en la pestana MAPPING del panel.");
}
if (gainNode) gainNode.gain.value = state.sensitivity;
// Cambio de fuente (micro / audio del navegador) o de tarjeta de audio.
const source = state.audio.source || "mic";
const sourceChanged = source !== prevSource;
const deviceChanged = source === "mic"
&& state.audio.device && state.audio.device !== prevDevice;
if (audioCtx && (sourceChanged || deviceChanged)) {
applyAudioSource().catch(() =>
report("error", "No se pudo cambiar la fuente de audio elegida."));
}
// Mientras nadie haya abierto el panel, o con las visuales apagadas,
// el proyector muestra como conectarse (direccion + codigo QR).
const showConnect = !(state.network && state.network.panelSeen) || !state.power;
if (showConnect || state.engine === "projectm") {
stopButterchurn();
if (shaderEngine) shaderEngine.stop();
if (hydra) hydra.hush();
stopMixer();
if (showConnect) {
renderInfoScreen();
} else {
// Motor projectM: esta pagina queda en negro debajo de su ventana.
msgEl.textContent = (state.projectm && state.projectm.available)
? "projectM nativo en marcha (en su propia ventana)"
: "projectM no esta instalado: elige otro motor en el panel";
show(null);
}
return;
}
if (state.engine === "butterchurn") {
if (hydra) hydra.hush();
if (shaderEngine) shaderEngine.stop();
stopMixer();
if (!bcViz) { show(null); return; }
show(bcCanvas);
startButterchurn();
const wanted = state.butterchurn.preset;
if (wanted && wanted !== bcCurrent) loadButterchurnPreset(wanted);
else if (!bcCurrent) loadButterchurnPreset(bcNames[bcIndex]);
refreshShuffle();
} else if (state.engine === "hydra") {
stopButterchurn();
if (shaderEngine) shaderEngine.stop();
stopMixer();
if (!hydra) { show(null); return; }
show(hydraCanvas);
if (state.hydra.code !== hydraCode) runHydraCode(state.hydra.code);
} else if (state.engine === "shaders") {
stopButterchurn();
if (hydra) hydra.hush();
stopMixer();
if (!shaderEngine) { show(null); return; }
show(shaderCanvas);
if (shaderEngine && state.shaders.code !== shaderCode) {
// Recuerda el intento aunque no compile: asi el aviso de error sale
// una sola vez y no se repite con cada actualizacion del estado.
shaderCode = state.shaders.code;
shaderEngine.load(state.shaders.code);
}
if (shaderEngine) shaderEngine.start();
} else if (state.engine === "mixer") {
// Con fondo "butter" el lienzo de Butterchurn debe seguir renderizando
// (oculto): es la capa de abajo del mezclador.
if (state.mixer.fondo !== "butter") stopButterchurn();
if (shaderEngine) shaderEngine.stop();
if (!hydra) { show(null); return; }
show(hydraCanvas);
applyMixer(state.mixer);
}
}
// ==========================================================================
// WebSocket
// ==========================================================================
socket.on("state", (next) => applyState(next));
/* Al (re)conectar, el escenario le devuelve al servidor lo que solo sabe el
navegador: la lista de presets y los dispositivos. Si el servidor se ha
reiniciado, su estado nace vacio y sin esto el panel se queda sin presets
ni camaras hasta recargar el escenario a mano. */
socket.on("connect", () => {
if (bcNames.length) socket.emit("stage_meta", { butterchurnPresets: bcNames });
enumerateInputs().catch(() => { /* aun sin permisos: ya se reintenta */ });
});
// --- Mapper (projection mapping en el navegador) ---
// Arranca el compositor/editor; al editar (arrastrar), guarda en el servidor.
// El resolver es lo que hace que cada superficie pinte SU fuente: se la pide
// a layers.js, que mantiene una instancia por capa.
if (window.FosMapper) {
FosMapper.init({
onChange: (m) => socket.emit("set_mapping", m),
resolver: (s) => (window.FosLayers ? FosLayers.fuenteDe(s) : null),
report: report,
});
}
socket.on("stage_command", (cmd) => {
if (!state) return;
if (butterLive()) {
if (cmd.action === "next") bcStep(1);
else if (cmd.action === "prev") bcStep(-1);
}
});
socket.on("stage_rescan", () => {
enumerateInputs().catch((e) =>
report("warn", "No se pudieron volver a leer los dispositivos."));
});
// El panel pide reconectar la captura de audio (boton "Aplicar entrada").
socket.on("stage_reacquire", () => {
applyAudioSource()
.then(() => report("info", (state && state.audio && state.audio.source) === "monitor"
? "Capturando el audio del navegador/sistema."
: "Microfono conectado."))
.catch(() => report("error", "No se pudo conectar con la entrada de audio elegida."));
});
window.addEventListener("resize", () => {
if (hydra) hydra.setResolution(window.innerWidth, window.innerHeight);
});
// ==========================================================================
// Arranque
// ==========================================================================
async function boot() {
// El audio va primero, pero si falla las visuales siguen adelante:
// un escenario sin reactividad es mejor que un proyector en negro.
try {
await initAudio();
beatLoop();
} catch (err) {
report("error", "No se pudo iniciar el audio: " + err.message
+ ". Las visuales funcionaran sin reaccionar a la musica.");
}
// Cada motor se inicia por separado: si una libreria falta o falla, los
// demas motores siguen funcionando igual.
try {
if (typeof butterchurn !== "undefined") initButterchurn();
else report("warn", "Butterchurn no esta disponible (libreria no copiada).");
} catch (e) { report("error", "Butterchurn fallo al iniciar: " + e.message); }
try {
if (typeof Hydra !== "undefined") initHydra();
else report("error", "Hydra no esta disponible (libreria no copiada).");
} catch (e) { report("error", "Hydra fallo al iniciar: " + e.message); }
try {
shaderEngine = new ShaderEngine(shaderCanvas, analyser);
} catch (e) { report("error", "El motor de shaders fallo: " + e.message); }
// Capas del mapping: cada superficie puede tener su propia fuente. Necesita
// el audio compartido (para que MilkDrop y los shaders de cada capa
// reaccionen a la misma musica) y la libreria de shaders.
let shaders = {};
try {
shaders = await fetch("/data/shaders.json").then((r) => r.json());
} catch (e) {
report("warn", "No se pudo cargar la libreria de shaders: las capas de "
+ "shader del mapping no estaran disponibles.");
}
if (window.FosLayers) {
FosLayers.init({
audioCtx: audioCtx, gainNode: gainNode, analyser: analyser,
shaders: shaders, report: report,
crearShader: (canvas, size) => new ShaderEngine(canvas, analyser, size),
onPreset: anotarPresetDeCapa,
});
// El estado pudo llegar antes de que las capas estuvieran listas
if (state && state.mapping && state.mapping.enabled) {
FosLayers.sync(state.mapping.surfaces || []);
}
}
msgEl.textContent = "FOSFENO listo";
}
boot();