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>
1004 lines
38 KiB
JavaScript
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();
|