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