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

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

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

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

View file

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

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

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

View file

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