Capas independientes, projectM en el mapping y catalogo de la galeria
Mapping - Los clips salian del reves: mapper.js activaba UNPACK_FLIP_Y_WEBGL, pero el modelo va con origen arriba-izquierda y texImage2D ya sube la primera fila en t=0, asi que el flip la mandaba al final. Comprobado renderizando el mapper real en Chromium headless con una fuente mitad roja / mitad azul. - projectM se puede usar YA en una capa: los .milk de data/presets-projectm se traducen a Butterchurn en el navegador al elegirlos (milkdrop-preset- converter). La capa MilkDrop gana 'biblioteca' (butter | projectm) sin cambiar su firma, para no recrear el contexto WebGL al cambiar de una a otra. Medido: 100/100 presets de una muestra convierten, 84/84 de los que llevan shaders warp/comp; ~7 ms por preset. - Cada capa tiene su pestana arriba y su propia vista, y '+ CAPA' crea una y entra en ella: con varias capas, ir y volver a MAPPING no era viable. - Dos capas MilkDrop sin preset ya no salen identicas (cogian el indice 0): cada instancia elige uno al azar y lo escribe en el estado. - Una capa nueva ya no nace en la fuente "motor", que es el lienzo del motor activo y hacia que dos capas ensenaran lo mismo. Galeria de visuales - scripts/catalogar-visuales.py: ficha de cada clip (pelicula, personajes, duracion) y, sobre todo, si es una silueta de verdad y cuanta figura tiene. Distingue silueta de corte crudo por el negro puro del fondo: 0,63-0,90 frente a 0,04-0,06, sin zona gris. - El panel lista los clips agrupados por pelicula, con nombre legible y aviso de los que casi no tienen figura, en vez del nombre del archivo. - Soporte de siluetas con canal alfa (.webm VP9): transparencia de verdad, sin recorte por luminancia. El shader del mapper ya la respeta. Documentacion - docs/visuales.md nuevo; pendiente.md al dia con lo hecho y lo que queda. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5d5223db9f
commit
f017246f0e
30 changed files with 3771 additions and 292 deletions
|
|
@ -70,7 +70,11 @@
|
|||
<script src="/lib/qrcode.js"></script>
|
||||
<script src="/lib/butterchurn.min.js"></script>
|
||||
<script src="/lib/butterchurn-presets.min.js"></script>
|
||||
<!-- Traduce los .milk de projectM a presets de Butterchurn, para poder
|
||||
usar la biblioteca grande dentro de una capa del mapping -->
|
||||
<script src="/lib/milkdrop-preset-converter.min.js"></script>
|
||||
<script src="/lib/hydra-synth.js"></script>
|
||||
<script src="/stage/layers.js"></script>
|
||||
<script src="/stage/mapper.js"></script>
|
||||
<script src="/stage/stage.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
457
web/stage/layers.js
Normal file
457
web/stage/layers.js
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
/*
|
||||
* FOSFENO :: Capas del mapping
|
||||
* --------------------------------------------------------------------------
|
||||
* Cada superficie del mapping puede llevar su PROPIA fuente: una instancia de
|
||||
* Butterchurn con su preset, un shader GLSL, un clip de la galeria, la camara
|
||||
* o el motor que este activo. Este modulo crea esas fuentes, las mantiene y
|
||||
* le entrega al mapper el elemento que tiene que usar como textura.
|
||||
*
|
||||
* Fuente (va dentro de cada superficie, en data/mapping.json):
|
||||
* { tipo: "motor" } el motor elegido en el panel
|
||||
* { tipo: "butter", preset, auto, segundos } instancia propia de MilkDrop
|
||||
* biblioteca: "butter" (los ~100 que trae Butterchurn) o "projectm"
|
||||
* (los .milk de data/presets-projectm); con "projectm", milk lleva la
|
||||
* ruta relativa del preset ("Dancer/Glowsticks/285.milk")
|
||||
* { tipo: "shader", shader } shader GLSL de la libreria
|
||||
* { tipo: "video", archivo } clip o imagen de data/videos
|
||||
* { tipo: "camara", camaraId } webcam
|
||||
* { tipo: "negro" } nada (tapa la zona)
|
||||
*
|
||||
* Cada capa se renderiza en un lienzo propio y PEQUENO (el mapper la estira
|
||||
* al deformarla), que es lo que hace viable tener varias a la vez en una
|
||||
* Raspberry. Las instancias con estado (Butterchurn, shaders) son una por
|
||||
* superficie; las que no lo tienen (clips, camara) se comparten.
|
||||
*
|
||||
* API global:
|
||||
* FosLayers.init({ audioCtx, gainNode, analyser, crearShader, shaders, report,
|
||||
* onPreset(surfaceId, preset) }) preset elegido al azar
|
||||
* FosLayers.setMotor(canvas) lienzo del motor activo (fuente "motor")
|
||||
* FosLayers.sync(surfaces) crea/actualiza/destruye segun el estado
|
||||
* FosLayers.fuenteDe(surface) -> { el, clave } o null (lo usa el mapper)
|
||||
* FosLayers.presets() nombres de preset disponibles
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const ANCHO = 640; // resolucion interna de cada capa (se estira luego)
|
||||
const ALTO = 360;
|
||||
const SEGUNDOS = 20; // cambio automatico de preset por defecto
|
||||
|
||||
let deps = null;
|
||||
let motorEl = null;
|
||||
|
||||
// Instancias con estado: una por superficie (id de superficie -> capa)
|
||||
const capas = new Map();
|
||||
// Fuentes compartidas sin estado: clip o camara (clave -> recurso)
|
||||
const compartidas = new Map();
|
||||
|
||||
const IMAGEN_RE = /\.(jpe?g|png|gif|webp)$/i;
|
||||
|
||||
function aviso(nivel, msg) {
|
||||
if (deps && deps.report) deps.report(nivel, msg);
|
||||
else console.warn('[FOSFENO] capas:', msg);
|
||||
}
|
||||
|
||||
function lienzo() {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = ANCHO; cv.height = ALTO;
|
||||
return cv;
|
||||
}
|
||||
|
||||
/* Suelta de verdad el contexto WebGL de un lienzo que se tira. Sin esto, el
|
||||
contexto sigue vivo hasta que pase el recolector: el navegador solo
|
||||
aguanta unos 16 a la vez y al pasarse mata EL MAS VIEJO, que es el del
|
||||
compositor de mapping. Resultado: proyector en negro para siempre. */
|
||||
function soltarGL(cv) {
|
||||
try {
|
||||
const g = cv.getContext('webgl2') || cv.getContext('webgl');
|
||||
const ext = g && g.getExtension('WEBGL_lose_context');
|
||||
if (ext) ext.loseContext();
|
||||
} catch (e) { /* el lienzo no llego a tener contexto */ }
|
||||
}
|
||||
|
||||
// La fuente de una superficie, siempre normalizada (las superficies
|
||||
// antiguas no la llevan: son del motor activo, como hasta ahora).
|
||||
function fuenteDe(s) {
|
||||
const f = (s && s.fuente) || {};
|
||||
return { tipo: f.tipo || 'motor', preset: f.preset || '', auto: !!f.auto,
|
||||
segundos: f.segundos || SEGUNDOS, shader: f.shader || '',
|
||||
archivo: f.archivo || '', camaraId: f.camaraId || 0,
|
||||
biblioteca: f.biblioteca === 'projectm' ? 'projectm' : 'butter',
|
||||
milk: f.milk || '',
|
||||
// segundos de fundido al pasar de un preset al siguiente
|
||||
transicion: typeof f.transicion === 'number' ? f.transicion : 2.5 };
|
||||
}
|
||||
|
||||
/* Firma de una fuente: si no cambia, la capa se deja viva y solo se le
|
||||
pasan los ajustes nuevos con aplicar(). Para Butterchurn la firma es el
|
||||
tipo a secas, a proposito: cambiar de preset o de cambio automatico NO
|
||||
puede recrear la instancia, porque cada instancia nueva es un contexto
|
||||
WebGL mas (ver soltarGL). */
|
||||
function firma(f) {
|
||||
if (f.tipo === 'butter') return 'butter';
|
||||
if (f.tipo === 'shader') return 'shader|' + f.shader;
|
||||
if (f.tipo === 'video') return 'video|' + f.archivo;
|
||||
if (f.tipo === 'camara') return 'camara|' + f.camaraId;
|
||||
return f.tipo;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- presets .milk (projectM)
|
||||
La biblioteca gorda son los 9795 .milk de data/presets-projectm, que
|
||||
projectM lee en nativo y Butterchurn no entiende. Se traducen aqui, en el
|
||||
navegador, con milkdrop-preset-converter: se pide el .milk al backend (ya
|
||||
los sirve tal cual), se convierte y se le pasa a viz.loadPreset como
|
||||
cualquier otro preset. Tarda unos milisegundos y no gasta disco: no hay
|
||||
que pre-convertir 9795 ficheros en la tarjeta de la Raspberry.
|
||||
|
||||
La cache guarda la promesa, no el resultado: si dos capas piden el mismo
|
||||
preset a la vez, se convierte una sola vez. */
|
||||
const milkCache = new Map();
|
||||
|
||||
function cargarMilk(ruta) {
|
||||
if (milkCache.has(ruta)) return milkCache.get(ruta);
|
||||
const conv = window.milkdropPresetConverter;
|
||||
if (!conv || !conv.convertPreset) {
|
||||
return Promise.reject(new Error(
|
||||
'falta la libreria de conversion de presets .milk'));
|
||||
}
|
||||
const p = fetch('/data/presets-projectm/'
|
||||
+ ruta.split('/').map(encodeURIComponent).join('/'))
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('no esta en la biblioteca (' + r.status + ')');
|
||||
return r.text();
|
||||
})
|
||||
.then((txt) => conv.convertPreset(txt))
|
||||
.then((preset) => {
|
||||
if (!preset || !preset.baseVals) throw new Error('conversion vacia');
|
||||
return preset;
|
||||
})
|
||||
.catch((e) => { milkCache.delete(ruta); throw e; }); // que se reintente
|
||||
milkCache.set(ruta, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Butter
|
||||
function crearButter(f) {
|
||||
if (typeof window.butterchurn === 'undefined' || !window.butterchurnPresets) {
|
||||
aviso('warn', 'No hay Butterchurn: la capa de visuales MilkDrop se queda en negro.');
|
||||
return null;
|
||||
}
|
||||
const bch = window.butterchurn.default || window.butterchurn;
|
||||
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
|
||||
const presets = bp.getPresets();
|
||||
const nombres = Object.keys(presets);
|
||||
const cv = lienzo();
|
||||
let viz;
|
||||
try {
|
||||
viz = bch.createVisualizer(deps.audioCtx, cv, {
|
||||
width: ANCHO, height: ALTO, pixelRatio: 1,
|
||||
});
|
||||
viz.connectAudio(deps.gainNode);
|
||||
} catch (e) {
|
||||
aviso('error', 'No se pudo crear la capa de visuales MilkDrop: ' + e.message);
|
||||
return null;
|
||||
}
|
||||
/* Sin preset elegido (capa recien creada antes de que la lista llegara al
|
||||
panel), indexOf devuelve -1 y el Math.max lo dejaba en 0: TODAS las
|
||||
capas asi arrancaban en el mismo preset y parecian la misma imagen.
|
||||
Ahora cada instancia coge uno al azar y lo devuelve en presetElegido,
|
||||
para que el panel lo ensene en vez de dejar el desplegable en blanco. */
|
||||
let i = nombres.indexOf(f.preset);
|
||||
let presetElegido = '';
|
||||
if (i < 0) {
|
||||
i = Math.floor(Math.random() * nombres.length);
|
||||
presetElegido = nombres[i];
|
||||
}
|
||||
let raf = null, timer = null, seg = 0, fundido = f.transicion;
|
||||
|
||||
// El fundido lo hace el propio Butterchurn al cargar: a 0 es corte seco,
|
||||
// a 6 segundos se disuelve uno en otro sin que se note el cambio.
|
||||
const cargar = (nombre) => {
|
||||
if (!presets[nombre]) return;
|
||||
try { viz.loadPreset(presets[nombre], fundido); } catch (e) { /* preset roto */ }
|
||||
};
|
||||
const paso = () => { i = (i + 1) % nombres.length; cargar(nombres[i]); };
|
||||
/* Monta o desmonta el cambio automatico sin tocar la instancia. */
|
||||
const ritmo = (auto, segundos) => {
|
||||
const s = Math.max(2, segundos || SEGUNDOS);
|
||||
if (!!timer === !!auto && (!auto || s === seg)) return;
|
||||
clearInterval(timer); timer = null; seg = s;
|
||||
if (auto) timer = setInterval(paso, s * 1000);
|
||||
};
|
||||
|
||||
/* Carga un .milk de la biblioteca de projectM. Va por promesa, asi que
|
||||
hay que descartar el resultado si mientras tanto la capa cambio de
|
||||
preset o se destruyo: si no, el que tarde mas en convertirse pisa al
|
||||
que el usuario acaba de elegir. */
|
||||
let milkActual = ''; // el que se pidio el ultimo
|
||||
let muerta = false;
|
||||
const cargarDeMilk = (ruta) => {
|
||||
milkActual = ruta;
|
||||
cargarMilk(ruta).then((preset) => {
|
||||
if (muerta || milkActual !== ruta) return;
|
||||
try { viz.loadPreset(preset, fundido); }
|
||||
catch (e) { aviso('warn', 'El preset .milk "' + ruta + '" no se pudo '
|
||||
+ 'cargar: ' + e.message); }
|
||||
}).catch((e) => {
|
||||
if (muerta || milkActual !== ruta) return;
|
||||
aviso('warn', 'No se pudo convertir el preset "' + ruta + '": '
|
||||
+ e.message + '. La capa se queda con el anterior.');
|
||||
});
|
||||
};
|
||||
|
||||
if (f.biblioteca === 'projectm' && f.milk) cargarDeMilk(f.milk);
|
||||
else cargar(nombres[i]);
|
||||
const bucle = () => { viz.render(); raf = requestAnimationFrame(bucle); };
|
||||
bucle();
|
||||
ritmo(f.auto, f.segundos);
|
||||
|
||||
return {
|
||||
el: cv,
|
||||
// Con un .milk clavado no hay preset de Butterchurn que anotar
|
||||
presetElegido: (f.biblioteca === 'projectm' && f.milk) ? '' : presetElegido,
|
||||
// Todos los ajustes se aplican en caliente: nunca hay que recrear la
|
||||
// instancia (y con ella, un contexto WebGL nuevo).
|
||||
aplicar(nf) {
|
||||
fundido = nf.transicion;
|
||||
if (nf.biblioteca === 'projectm') {
|
||||
// El cambio automatico es de la lista de Butterchurn: con un .milk
|
||||
// clavado no tiene sentido dejarlo girando por encima.
|
||||
if (nf.milk && nf.milk !== milkActual) cargarDeMilk(nf.milk);
|
||||
ritmo(false, nf.segundos);
|
||||
return;
|
||||
}
|
||||
if (milkActual) { // volvemos a la biblioteca de Butterchurn
|
||||
milkActual = '';
|
||||
cargar(nombres[i]);
|
||||
}
|
||||
if (!nf.auto && nf.preset && nf.preset !== nombres[i]) {
|
||||
const j = nombres.indexOf(nf.preset);
|
||||
if (j >= 0) { i = j; cargar(nombres[i]); }
|
||||
}
|
||||
ritmo(nf.auto, nf.segundos);
|
||||
},
|
||||
destruir() {
|
||||
muerta = true;
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
clearInterval(timer);
|
||||
// Sin desconectar, cada instancia tirada deja un analizador colgando
|
||||
// del audio compartido para siempre.
|
||||
try { viz.disconnectAudio(deps.gainNode); } catch (e) { /* ya suelto */ }
|
||||
soltarGL(cv);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Shader
|
||||
function crearShaderCapa(f, nombre) {
|
||||
const codigo = (deps.shaders && deps.shaders[f.shader]
|
||||
&& deps.shaders[f.shader].code) || '';
|
||||
if (!codigo) {
|
||||
aviso('warn', 'La capa "' + nombre + '" es de shader pero no tiene '
|
||||
+ 'ninguno elegido: seleccionalo en Propiedades.');
|
||||
return null;
|
||||
}
|
||||
const cv = lienzo();
|
||||
let eng;
|
||||
try {
|
||||
eng = deps.crearShader(cv, { width: ANCHO, height: ALTO });
|
||||
// Que no compile no puede dejar el contexto colgando (ver soltarGL)
|
||||
if (!eng.load(codigo)) {
|
||||
aviso('warn', 'El shader de la capa "' + nombre + '" no compila.');
|
||||
soltarGL(cv);
|
||||
return null;
|
||||
}
|
||||
eng.start();
|
||||
} catch (e) {
|
||||
aviso('error', 'La capa de shader "' + nombre + '" fallo: ' + e.message);
|
||||
soltarGL(cv);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
el: cv, aplicar() {},
|
||||
destruir() {
|
||||
try { eng.stop(); } catch (e) { /* ya parado */ }
|
||||
soltarGL(cv);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Clip / imagen / camara
|
||||
// Sin estado propio: varias superficies pueden compartir el mismo recurso.
|
||||
function recursoCompartido(clave, crear) {
|
||||
let r = compartidas.get(clave);
|
||||
if (!r) { r = crear(); if (!r) return null; compartidas.set(clave, r); }
|
||||
r.usos = (r.usos || 0) + 1;
|
||||
return r;
|
||||
}
|
||||
|
||||
/* Ojo al soltar: vaciar el src de un <video> o de una <img> dispara su
|
||||
evento 'error', y sin la bandera saldria un aviso falso de "no pudo
|
||||
cargar" cada vez que cambias de clip o quitas la capa. */
|
||||
function crearVideo(f) {
|
||||
if (!f.archivo) return null;
|
||||
const url = '/data/videos/' + encodeURIComponent(f.archivo);
|
||||
let soltado = false;
|
||||
if (IMAGEN_RE.test(f.archivo)) {
|
||||
const img = new Image();
|
||||
img.onerror = () => {
|
||||
if (!soltado) aviso('warn', "La capa no pudo cargar la imagen '" + f.archivo + "'.");
|
||||
};
|
||||
img.src = url;
|
||||
return { el: img, soltar() { soltado = true; img.src = ''; } };
|
||||
}
|
||||
const vid = document.createElement('video');
|
||||
vid.muted = true; vid.loop = true; vid.playsInline = true;
|
||||
vid.addEventListener('error', () => {
|
||||
if (soltado) return;
|
||||
aviso('warn', "La capa no pudo cargar '" + f.archivo
|
||||
+ "': prueba con .mp4 (H.264) o .webm.");
|
||||
});
|
||||
vid.addEventListener('loadeddata', () => vid.play().catch(() => {}));
|
||||
vid.src = url;
|
||||
return {
|
||||
el: vid,
|
||||
soltar() {
|
||||
soltado = true;
|
||||
vid.pause();
|
||||
vid.removeAttribute('src'); // mas limpio que src="" para soltarlo
|
||||
vid.load();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function crearCamara(f) {
|
||||
const vid = document.createElement('video');
|
||||
vid.muted = true; vid.playsInline = true;
|
||||
let stream = null, muerto = false;
|
||||
navigator.mediaDevices.enumerateDevices().then((devs) => {
|
||||
const cams = devs.filter((d) => d.kind === 'videoinput');
|
||||
if (!cams.length) throw new Error('no hay ninguna camara conectada');
|
||||
const pick = cams[f.camaraId] || cams[0];
|
||||
return navigator.mediaDevices.getUserMedia({
|
||||
audio: false, video: { deviceId: { exact: pick.deviceId } },
|
||||
});
|
||||
}).then((st) => {
|
||||
// La capa pudo borrarse o cambiar de camara mientras esta tardaba en
|
||||
// abrirse (1-3 s por USB): si no se corta aqui, el piloto se queda
|
||||
// encendido hasta recargar el escenario.
|
||||
if (muerto) { st.getTracks().forEach((t) => t.stop()); return null; }
|
||||
stream = st; vid.srcObject = st;
|
||||
return vid.play();
|
||||
}).catch((e) => {
|
||||
if (!muerto) aviso('error', 'La capa de camara fallo: ' + e.message);
|
||||
});
|
||||
return {
|
||||
el: vid,
|
||||
soltar() {
|
||||
muerto = true;
|
||||
if (stream) stream.getTracks().forEach((t) => t.stop());
|
||||
vid.srcObject = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ ciclo de vida
|
||||
function crear(f, nombre) {
|
||||
if (f.tipo === 'butter') return crearButter(f);
|
||||
if (f.tipo === 'shader') return crearShaderCapa(f, nombre);
|
||||
if (f.tipo === 'video') {
|
||||
if (!f.archivo) {
|
||||
aviso('warn', 'La capa "' + nombre + '" no tiene clip elegido: se ve '
|
||||
+ 'negra hasta que selecciones uno en Propiedades.');
|
||||
return null;
|
||||
}
|
||||
const r = recursoCompartido('video|' + f.archivo, () => crearVideo(f));
|
||||
return r ? { el: r.el, compartida: 'video|' + f.archivo, aplicar() {}, destruir() {} } : null;
|
||||
}
|
||||
if (f.tipo === 'camara') {
|
||||
const r = recursoCompartido('camara|' + f.camaraId, () => crearCamara(f));
|
||||
return r ? { el: r.el, compartida: 'camara|' + f.camaraId, aplicar() {}, destruir() {} } : null;
|
||||
}
|
||||
// "motor" y "negro" no necesitan instancia; cualquier otro tipo es de una
|
||||
// version distinta y hay que decirlo en vez de dejar la zona en negro.
|
||||
if (f.tipo !== 'motor' && f.tipo !== 'negro') {
|
||||
aviso('warn', 'La capa "' + nombre + '" usa una fuente desconocida ("'
|
||||
+ f.tipo + '"): elige otra en Propiedades.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function soltarCompartida(clave) {
|
||||
const r = compartidas.get(clave);
|
||||
if (!r) return;
|
||||
r.usos--;
|
||||
if (r.usos <= 0) { try { r.soltar(); } catch (e) { /* ya suelto */ } compartidas.delete(clave); }
|
||||
}
|
||||
|
||||
function destruirCapa(id) {
|
||||
const c = capas.get(id);
|
||||
if (!c) return;
|
||||
if (c.inst) {
|
||||
try { c.inst.destruir(); } catch (e) { /* ya destruida */ }
|
||||
if (c.inst.compartida) soltarCompartida(c.inst.compartida);
|
||||
}
|
||||
capas.delete(id);
|
||||
}
|
||||
|
||||
const API = {
|
||||
init(opciones) { deps = opciones || {}; },
|
||||
|
||||
setMotor(canvas) { motorEl = canvas || null; },
|
||||
|
||||
/* Pone las capas al dia con las superficies del estado: crea las nuevas,
|
||||
actualiza las que solo han cambiado de ajuste y tira las que sobran. */
|
||||
sync(surfaces) {
|
||||
if (!deps) return;
|
||||
const vivos = new Set();
|
||||
(surfaces || []).forEach((s, i) => {
|
||||
if (!s || !s.id) return;
|
||||
vivos.add(s.id);
|
||||
const f = fuenteDe(s);
|
||||
// Una capa apagada o a cero no gasta nada: ni motores renderizando ni
|
||||
// la camara encendida por algo que no se ve. Mismo criterio que usa
|
||||
// el mapper para no pintarla.
|
||||
const oculta = s.visible === false
|
||||
|| (typeof s.opacidad === 'number' && s.opacidad <= 0);
|
||||
const sig = oculta ? 'oculta' : firma(f);
|
||||
const c = capas.get(s.id);
|
||||
if (c && c.sig === sig) {
|
||||
if (c.inst && c.inst.aplicar) c.inst.aplicar(f);
|
||||
return;
|
||||
}
|
||||
if (c) destruirCapa(s.id);
|
||||
if (oculta || f.tipo === 'motor' || f.tipo === 'negro') {
|
||||
capas.set(s.id, { sig: sig, inst: null });
|
||||
return;
|
||||
}
|
||||
const inst = crear(f, s.nombre || 'Capa ' + (i + 1));
|
||||
capas.set(s.id, { sig: sig, inst: inst });
|
||||
// Si la capa tuvo que elegir preset por su cuenta, que el estado se
|
||||
// entere: asi el desplegable de Propiedades deja de salir en blanco.
|
||||
if (inst && inst.presetElegido && deps.onPreset)
|
||||
deps.onPreset(s.id, inst.presetElegido);
|
||||
});
|
||||
Array.from(capas.keys()).forEach((id) => { if (!vivos.has(id)) destruirCapa(id); });
|
||||
},
|
||||
|
||||
/* Que elemento tiene que usar el mapper como textura de esta superficie.
|
||||
La clave sirve para no subir la misma imagen dos veces por fotograma. */
|
||||
fuenteDe(s) {
|
||||
const f = fuenteDe(s);
|
||||
if (f.tipo === 'negro') return null;
|
||||
if (f.tipo === 'motor') return motorEl ? { el: motorEl, clave: 'motor' } : null;
|
||||
const c = capas.get(s.id);
|
||||
if (!c || !c.inst || !c.inst.el) return null;
|
||||
return { el: c.inst.el, clave: c.inst.compartida || ('capa:' + s.id) };
|
||||
},
|
||||
|
||||
presets() {
|
||||
if (!window.butterchurnPresets) return [];
|
||||
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
|
||||
return Object.keys(bp.getPresets());
|
||||
},
|
||||
};
|
||||
|
||||
window.FosLayers = API;
|
||||
})();
|
||||
|
|
@ -1,26 +1,34 @@
|
|||
/*
|
||||
* FOSFENO :: Mapper (projection mapping en el navegador) — Fase 2
|
||||
* FOSFENO :: Mapper (projection mapping en el navegador) — Fase 3
|
||||
* --------------------------------------------------------------------------
|
||||
* Coge el canvas del motor activo como TEXTURA y lo pinta deformado sobre
|
||||
* SUPERFICIES con MALLA (rejilla n x n deformable => superficies curvas, no
|
||||
* solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que
|
||||
* Pinta CAPAS deformadas sobre superficies con MALLA (rejilla n x n => curvas,
|
||||
* no solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que
|
||||
* tapan zonas / recortan derrames de luz).
|
||||
*
|
||||
* Cada superficie es una CAPA con su propia fuente: el motor activo, una
|
||||
* instancia propia de MilkDrop, un shader, un clip o la camara (las crea
|
||||
* layers.js). Asi se puede tener MilkDrop a la derecha, otro preset a la
|
||||
* izquierda y un video en el centro, cada uno encajado en su sitio.
|
||||
*
|
||||
* Modelo (0..1, origen arriba-izquierda; se guarda en data/mapping.json):
|
||||
* surface = { id, n, points:[[x,y] x (n+1)^2] } // rejilla n x n celdas
|
||||
* mask = { id, corners:[[x,y] x4] } // TL,TR,BR,BL, negro
|
||||
* surface = { id, n, points:[[x,y] x (n+1)^2],
|
||||
* nombre, visible, opacidad, mezcla:"normal"|"sumar",
|
||||
* fuente:{ tipo, ... } } // ver layers.js
|
||||
* mask = { id, corners:[[x,y] x4] } // TL,TR,BR,BL, negro
|
||||
* mapping = { enabled, edit, surfaces:[...], masks:[...] }
|
||||
*
|
||||
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1.
|
||||
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1, y
|
||||
* las que no llevan fuente se pintan con el motor activo, como siempre.
|
||||
*
|
||||
* Render: cada celda de la malla se dibuja como un parche con homografia
|
||||
* (cuadrado unidad -> celda) sobre una sub-malla fina => perspectiva correcta.
|
||||
*
|
||||
* API global:
|
||||
* FosMapper.init({ onChange }) arranca
|
||||
* FosMapper.setSource(canvas) motor activo (o null)
|
||||
* FosMapper.setMapping(m) estado de mapping
|
||||
* onChange({surfaces, masks}) al editar (arrastrar), para guardar
|
||||
* FosMapper.init({ onChange, resolver }) arranca
|
||||
* FosMapper.setSource(canvas) lienzo del motor activo (fuente "motor")
|
||||
* FosMapper.setMapping(m) estado de mapping
|
||||
* onChange({surfaces, masks}) al editar (arrastrar), para guardar
|
||||
* resolver(surface) -> { el, clave } con la textura de la capa
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
|
@ -32,11 +40,15 @@
|
|||
const M = { enabled: false, edit: false, surfaces: [], masks: [] };
|
||||
let source = null;
|
||||
let onChange = null;
|
||||
let resolver = null; // surface -> { el, clave } (lo pone layers.js)
|
||||
let aviso = function () {}; // report() del escenario: nada se queda callado
|
||||
|
||||
let outputEl, editEl, gl, ectx;
|
||||
let prog, aUV, uH, uTex, uOff, uScale; // programa de textura
|
||||
let progF, aPos, uColor, fillBuf; // programa de relleno (mascaras)
|
||||
let tex, gridBuf, gridIdx, gridCount, dpr = 1;
|
||||
let prog, aUV, uH, uTex, uOff, uScale, uAlpha; // programa de textura
|
||||
let progF, aPos, uColor, fillBuf; // programa de relleno (mascaras)
|
||||
let gridBuf, gridIdx, gridCount, dpr = 1;
|
||||
const texturas = new Map(); // clave de fuente -> { tex, subida, visto }
|
||||
let frame = 0;
|
||||
|
||||
let selected = null; // { type:'surface'|'mask', id }
|
||||
let drag = null; // { type, id, pt|null, sx, sy, orig }
|
||||
|
|
@ -46,16 +58,39 @@
|
|||
const sidx = (c, r, n) => r * (n + 1) + c;
|
||||
|
||||
// ------------------------------------------------------------------ modelo
|
||||
/* Normaliza una superficie: geometria a formato malla y ajustes de capa con
|
||||
sus valores por defecto. Lo que no venga (superficies de antes de las
|
||||
capas) se rellena para que el resto del codigo no tenga que preguntar. */
|
||||
function migrate(s) {
|
||||
const capa = {
|
||||
id: s.id,
|
||||
nombre: s.nombre || '',
|
||||
visible: s.visible !== false,
|
||||
opacidad: typeof s.opacidad === 'number' ? s.opacidad : 1,
|
||||
mezcla: s.mezcla === 'sumar' ? 'sumar' : 'normal',
|
||||
fuente: s.fuente || { tipo: 'motor' },
|
||||
};
|
||||
if (Array.isArray(s.points) && s.n) {
|
||||
return { id: s.id, n: s.n, points: s.points.map((p) => [p[0], p[1]]) };
|
||||
}
|
||||
if (Array.isArray(s.corners)) { // formato Fase 1 -> n=1
|
||||
capa.n = s.n; capa.points = s.points.map((p) => [p[0], p[1]]);
|
||||
} else if (Array.isArray(s.corners)) { // formato Fase 1 -> n=1
|
||||
const c = s.corners;
|
||||
return { id: s.id, n: 1, points: [[c[0][0], c[0][1]], [c[1][0], c[1][1]],
|
||||
[c[3][0], c[3][1]], [c[2][0], c[2][1]]] }; // TL,TR,BL,BR (row-major)
|
||||
capa.n = 1;
|
||||
capa.points = [[c[0][0], c[0][1]], [c[1][0], c[1][1]],
|
||||
[c[3][0], c[3][1]], [c[2][0], c[2][1]]]; // TL,TR,BL,BR (row-major)
|
||||
} else {
|
||||
capa.n = 1;
|
||||
capa.points = [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]];
|
||||
}
|
||||
return { id: s.id, n: 1, points: [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]] };
|
||||
return capa;
|
||||
}
|
||||
|
||||
/* Mascaras saneadas. Una sin esquinas (mapping.json a medio escribir, de
|
||||
otra version o tocado a mano) no puede tumbar el escenario entero: se
|
||||
descarta y punto. */
|
||||
function migrateMasks(lista) {
|
||||
return (Array.isArray(lista) ? lista : [])
|
||||
.filter((k) => k && Array.isArray(k.corners) && k.corners.length === 4)
|
||||
.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) }));
|
||||
}
|
||||
|
||||
// punto [x,y] a coordenada normalizada (u,v) de la superficie (bilineal por celda)
|
||||
|
|
@ -74,7 +109,11 @@
|
|||
const pts = [];
|
||||
for (let r = 0; r <= newN; r++)
|
||||
for (let c = 0; c <= newN; c++) pts.push(sampleSurf(s, c / newN, r / newN));
|
||||
return { id: s.id, n: newN, points: pts };
|
||||
// Solo cambia la malla: los ajustes de la capa (fuente, nombre, opacidad)
|
||||
// se conservan tal cual.
|
||||
const out = migrate(s);
|
||||
out.n = newN; out.points = pts;
|
||||
return out;
|
||||
}
|
||||
|
||||
// homografia cuadrado unidad -> cuadrilatero (Heckbert), mat3 por columnas
|
||||
|
|
@ -117,7 +156,11 @@
|
|||
function initGL() {
|
||||
gl = outputEl.getContext('webgl', { alpha: false, antialias: true })
|
||||
|| outputEl.getContext('experimental-webgl');
|
||||
if (!gl) { console.error('[FOSFENO] mapper: sin WebGL'); return false; }
|
||||
if (!gl) {
|
||||
aviso('error', 'El mapping necesita WebGL y este navegador no lo tiene: '
|
||||
+ 'las visuales saldran sin deformar.');
|
||||
return false;
|
||||
}
|
||||
|
||||
prog = linkProg(
|
||||
`attribute vec2 aUV; uniform mat3 uH; uniform vec2 uOff; uniform vec2 uScale;
|
||||
|
|
@ -126,12 +169,15 @@
|
|||
gl_Position = vec4(d.x*2.0-1.0, 1.0-d.y*2.0, 0.0, 1.0);
|
||||
vUV = uOff + aUV*uScale; }`,
|
||||
`precision mediump float; varying vec2 vUV; uniform sampler2D uTex;
|
||||
void main(){ gl_FragColor = texture2D(uTex, vUV); }`);
|
||||
uniform float uAlpha;
|
||||
void main(){ vec4 c = texture2D(uTex, vUV);
|
||||
gl_FragColor = vec4(c.rgb, c.a * uAlpha); }`);
|
||||
aUV = gl.getAttribLocation(prog, 'aUV');
|
||||
uH = gl.getUniformLocation(prog, 'uH');
|
||||
uTex = gl.getUniformLocation(prog, 'uTex');
|
||||
uOff = gl.getUniformLocation(prog, 'uOff');
|
||||
uScale = gl.getUniformLocation(prog, 'uScale');
|
||||
uAlpha = gl.getUniformLocation(prog, 'uAlpha');
|
||||
|
||||
progF = linkProg(
|
||||
`attribute vec2 aPos; void main(){ gl_Position = vec4(aPos,0.0,1.0); }`,
|
||||
|
|
@ -158,16 +204,53 @@
|
|||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
|
||||
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);
|
||||
/* SIN voltear la textura al subirla. El modelo va con el origen
|
||||
arriba-izquierda (vUV.y = 0 es el borde de arriba de la superficie) y
|
||||
texImage2D ya sube la primera fila de la imagen en t = 0. Poniendo
|
||||
UNPACK_FLIP_Y a true, t = 0 pasaba a ser la ultima fila y todo salia
|
||||
del reves — se notaba sobre todo con los clips de video. */
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
gl.enable(gl.BLEND);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Una textura por fuente, subida una sola vez por fotograma: si dos
|
||||
superficies comparten el mismo clip, la imagen no viaja dos veces. */
|
||||
function texturaDe(src) {
|
||||
const el = src.el;
|
||||
if (!el) return null;
|
||||
const w = el.videoWidth || el.naturalWidth || el.width;
|
||||
const h = el.videoHeight || el.naturalHeight || el.height;
|
||||
if (!w || !h) return null;
|
||||
if (el.tagName === 'VIDEO' && el.readyState < 2) return null;
|
||||
let e = texturas.get(src.clave);
|
||||
if (!e) {
|
||||
e = { tex: gl.createTexture(), subida: -1, visto: -1 };
|
||||
gl.bindTexture(gl.TEXTURE_2D, e.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);
|
||||
texturas.set(src.clave, e);
|
||||
}
|
||||
e.visto = frame;
|
||||
if (e.subida !== frame) {
|
||||
e.subida = frame;
|
||||
gl.bindTexture(gl.TEXTURE_2D, e.tex);
|
||||
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el); }
|
||||
catch (err) { return null; }
|
||||
}
|
||||
return e.tex;
|
||||
}
|
||||
|
||||
/* Tira las texturas de fuentes que ya no se usan (capas borradas). */
|
||||
function limpiarTexturas() {
|
||||
if (frame % 300) return;
|
||||
texturas.forEach((e, clave) => {
|
||||
if (frame - e.visto > 120) { gl.deleteTexture(e.tex); texturas.delete(clave); }
|
||||
});
|
||||
}
|
||||
|
||||
function drawSurfaceGL(s) {
|
||||
const n = s.n;
|
||||
const P = (c, r) => s.points[sidx(c, r, n)];
|
||||
|
|
@ -182,32 +265,63 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Pantalla entera: lo que se ve cuando el mapping esta activo pero aun no
|
||||
// hay ninguna superficie creada (pasada directa del motor).
|
||||
const PANTALLA = { id: '__pantalla__', n: 1, visible: true, opacidad: 1,
|
||||
mezcla: 'normal', fuente: { tipo: 'motor' },
|
||||
points: [[0, 0], [1, 0], [0, 1], [1, 1]] };
|
||||
|
||||
// La fuente de una capa: la resuelve layers.js; si no hay resolver (o la
|
||||
// capa es del motor), tira del lienzo del motor activo.
|
||||
function fuenteDeCapa(capa) {
|
||||
if (resolver) {
|
||||
const r = resolver(capa);
|
||||
if (r && r.el) return r;
|
||||
if (capa.fuente && capa.fuente.tipo !== 'motor') return null;
|
||||
}
|
||||
return (source && source.width) ? { el: source, clave: 'motor' } : null;
|
||||
}
|
||||
|
||||
function renderGL() {
|
||||
frame++;
|
||||
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;
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); }
|
||||
catch (e) { return; }
|
||||
|
||||
// superficies
|
||||
// superficies (cada una con su propia fuente)
|
||||
gl.useProgram(prog);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
|
||||
gl.enableVertexAttribArray(aUV);
|
||||
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.uniform1i(uTex, 0);
|
||||
if (!M.surfaces.length) {
|
||||
drawSurfaceGL({ n: 1, points: [[0, 0], [1, 0], [0, 1], [1, 1]] }); // pasada directa
|
||||
} else {
|
||||
for (const s of M.surfaces) drawSurfaceGL(migrate(s));
|
||||
}
|
||||
|
||||
// mascaras (cuadrilateros negros encima)
|
||||
// M.surfaces ya viene normalizado de setMapping / onDown / onDblClick:
|
||||
// no hace falta migrar otra vez en cada fotograma (eran cientos de
|
||||
// arrays nuevos por segundo, justo en la maquina que menos lo aguanta).
|
||||
const lista = M.surfaces.length ? M.surfaces : [PANTALLA];
|
||||
for (const capa of lista) {
|
||||
if (!capa.visible || capa.opacidad <= 0) continue;
|
||||
if (capa.fuente.tipo === 'negro') continue;
|
||||
const src = fuenteDeCapa(capa);
|
||||
if (!src) continue;
|
||||
const t = texturaDe(src);
|
||||
if (!t) continue;
|
||||
gl.bindTexture(gl.TEXTURE_2D, t);
|
||||
gl.uniform1f(uAlpha, capa.opacidad);
|
||||
// "sumar" apila luz (las zonas oscuras dejan ver la capa de abajo);
|
||||
// "normal" tapa lo que haya debajo.
|
||||
if (capa.mezcla === 'sumar') gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
|
||||
else gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
drawSurfaceGL(capa);
|
||||
}
|
||||
limpiarTexturas();
|
||||
|
||||
// mascaras (cuadrilateros negros encima). Vuelve a mezcla normal: si la
|
||||
// ultima capa iba en "sumar", un negro sumado no taparia nada.
|
||||
if (M.masks.length) {
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.useProgram(progF);
|
||||
gl.uniform4f(uColor, 0, 0, 0, 1);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, fillBuf);
|
||||
|
|
@ -225,6 +339,28 @@
|
|||
}
|
||||
|
||||
// ------------------------------------------------------------------ editor
|
||||
const NOMBRE_FUENTE = {
|
||||
motor: 'motor activo', butter: 'MilkDrop', shader: 'shader',
|
||||
video: 'clip', camara: 'camara', negro: 'negro',
|
||||
};
|
||||
function etiquetaFuente(f) {
|
||||
f = f || {};
|
||||
const base = NOMBRE_FUENTE[f.tipo] || 'motor activo';
|
||||
if (f.tipo === 'butter' && f.preset) return base + ' · ' + f.preset;
|
||||
if (f.tipo === 'video' && f.archivo) return base + ' · ' + f.archivo;
|
||||
return base;
|
||||
}
|
||||
|
||||
/* Rotulo centrado que no se sale de la pantalla: una capa pegada al borde
|
||||
tendria el nombre cortado por la mitad. */
|
||||
function rotulo(txt, x, y, w) {
|
||||
let t = txt;
|
||||
while (t.length > 6 && ectx.measureText(t).width > w - 24) t = t.slice(0, -2);
|
||||
if (t !== txt) t = t.slice(0, -1) + '…';
|
||||
const media = ectx.measureText(t).width / 2;
|
||||
ectx.fillText(t, Math.min(Math.max(x, media + 10), w - media - 10), y);
|
||||
}
|
||||
|
||||
function drawEditor() {
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
ectx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
|
@ -232,9 +368,9 @@
|
|||
ectx.font = '600 14px system-ui, sans-serif';
|
||||
ectx.textAlign = 'center'; ectx.textBaseline = 'middle';
|
||||
|
||||
// superficies (malla)
|
||||
M.surfaces.forEach((raw, i) => {
|
||||
const s = migrate(raw); const n = s.n;
|
||||
// superficies (malla). Ya vienen normalizadas de setMapping.
|
||||
M.surfaces.forEach((s, i) => {
|
||||
const n = s.n;
|
||||
const sel = selected && selected.type === 'surface' && selected.id === s.id;
|
||||
const col = sel ? '#00e5ff' : '#b4ff00';
|
||||
const P = (c, r) => { const p = s.points[sidx(c, r, n)]; return [p[0] * w, p[1] * h]; };
|
||||
|
|
@ -242,9 +378,16 @@
|
|||
ectx.strokeStyle = col; ectx.lineWidth = sel ? 2 : 1.3;
|
||||
for (let r = 0; r <= n; r++) { ectx.beginPath(); for (let c = 0; c <= n; c++) { const p = P(c, r); c ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
|
||||
for (let c = 0; c <= n; c++) { ectx.beginPath(); for (let r = 0; r <= n; r++) { const p = P(c, r); r ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
|
||||
// numero
|
||||
const cc = P(n / 2 | 0, n / 2 | 0);
|
||||
ectx.fillStyle = col; ectx.fillText(String(i + 1), cc[0], cc[1]);
|
||||
// nombre de la capa y su fuente, en el centro de verdad: el medio de
|
||||
// las cuatro esquinas (con n=1, P(n/2|0, n/2|0) caia en la esquina).
|
||||
const esq = [P(0, 0), P(n, 0), P(n, n), P(0, n)];
|
||||
const cc = [(esq[0][0] + esq[1][0] + esq[2][0] + esq[3][0]) / 4,
|
||||
(esq[0][1] + esq[1][1] + esq[2][1] + esq[3][1]) / 4];
|
||||
ectx.fillStyle = col;
|
||||
rotulo(s.nombre || ('Capa ' + (i + 1)), cc[0], cc[1] - 9, w);
|
||||
ectx.font = '12px system-ui, sans-serif';
|
||||
rotulo(etiquetaFuente(s.fuente), cc[0], cc[1] + 10, w);
|
||||
ectx.font = '600 14px system-ui, sans-serif';
|
||||
// tiradores (todos los puntos)
|
||||
for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) {
|
||||
const p = P(c, r);
|
||||
|
|
@ -364,7 +507,9 @@
|
|||
function onUp() { if (drag && dragged) emitNow(); drag = null; }
|
||||
|
||||
function defaultSurface(cx, cy, s) {
|
||||
return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4), n: 1,
|
||||
return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4),
|
||||
n: 1, nombre: '', visible: true, opacidad: 1, mezcla: 'normal',
|
||||
fuente: { tipo: 'motor' },
|
||||
points: [[clamp01(cx - s), clamp01(cy - s)], [clamp01(cx + s), clamp01(cy - s)],
|
||||
[clamp01(cx - s), clamp01(cy + s)], [clamp01(cx + s), clamp01(cy + s)]] };
|
||||
}
|
||||
|
|
@ -394,7 +539,7 @@
|
|||
function emitNow() {
|
||||
if (emitTimer) { clearTimeout(emitTimer); emitTimer = null; }
|
||||
if (onChange) onChange({
|
||||
surfaces: M.surfaces.map((s) => { const m = migrate(s); return { id: m.id, n: m.n, points: m.points }; }),
|
||||
surfaces: M.surfaces.map(migrate), // geometria + ajustes de la capa
|
||||
masks: M.masks.map((m) => ({ id: m.id, corners: m.corners })),
|
||||
});
|
||||
}
|
||||
|
|
@ -422,10 +567,24 @@
|
|||
init(opts) {
|
||||
opts = opts || {};
|
||||
onChange = opts.onChange || null;
|
||||
resolver = opts.resolver || null;
|
||||
aviso = opts.report || function () {};
|
||||
outputEl = document.getElementById('output');
|
||||
editEl = document.getElementById('mapedit');
|
||||
if (!outputEl || !editEl) { console.error('[FOSFENO] mapper: faltan #output/#mapedit'); return; }
|
||||
if (!outputEl || !editEl) {
|
||||
aviso('error', 'Falta el lienzo del mapping en la pagina del '
|
||||
+ 'escenario: recarga el escenario.');
|
||||
return;
|
||||
}
|
||||
ectx = editEl.getContext('2d');
|
||||
// Si el navegador tira este contexto (demasiadas capas a la vez), el
|
||||
// mapping se queda negro en silencio: que al menos se sepa por que.
|
||||
outputEl.addEventListener('webglcontextlost', (e) => {
|
||||
e.preventDefault();
|
||||
aviso('error', 'El navegador ha cerrado el contexto grafico del '
|
||||
+ 'mapping (demasiadas capas a la vez). Quita alguna capa de '
|
||||
+ 'MilkDrop o de shader y recarga el escenario.');
|
||||
});
|
||||
if (!initGL()) return;
|
||||
editEl.addEventListener('mousedown', onDown);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
|
|
@ -440,8 +599,10 @@
|
|||
M.enabled = !!m.enabled;
|
||||
M.edit = !!m.edit;
|
||||
if (!drag) { // no piso la geometria mientras arrastro aqui
|
||||
if (Array.isArray(m.surfaces)) M.surfaces = m.surfaces.map(migrate);
|
||||
M.masks = Array.isArray(m.masks) ? m.masks.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) })) : [];
|
||||
if (Array.isArray(m.surfaces)) {
|
||||
M.surfaces = m.surfaces.filter((s) => s && s.id).map(migrate);
|
||||
}
|
||||
M.masks = migrateMasks(m.masks);
|
||||
if (selected) {
|
||||
const ok = selected.type === 'surface' ? findSurf(selected.id) >= 0 : findMask(selected.id) >= 0;
|
||||
if (!ok) selected = null;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@
|
|||
* Incluye deteccion de BPM en vivo y seleccion de tarjeta de audio.
|
||||
*/
|
||||
|
||||
const socket = io();
|
||||
// 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");
|
||||
|
|
@ -120,8 +124,12 @@ let beatDetector = null;
|
|||
// u_bpm, u_beat (fase 0..1), u_fft (sampler2D)
|
||||
// ==========================================================================
|
||||
class ShaderEngine {
|
||||
constructor(canvas, analyserNode) {
|
||||
/* 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;
|
||||
|
|
@ -220,8 +228,8 @@ class ShaderEngine {
|
|||
|
||||
_frame() {
|
||||
const gl = this.gl;
|
||||
const w = this.canvas.width = window.innerWidth;
|
||||
const h = this.canvas.height = window.innerHeight;
|
||||
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();
|
||||
|
|
@ -366,8 +374,9 @@ function beatLoop() {
|
|||
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
|
||||
if (isBeat && state && state.engine === "butterchurn" && state.power
|
||||
// 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)) {
|
||||
|
|
@ -407,9 +416,24 @@ function initButterchurn() {
|
|||
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: name });
|
||||
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.");
|
||||
|
|
@ -421,6 +445,17 @@ function bcStep(delta) {
|
|||
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();
|
||||
|
|
@ -429,21 +464,30 @@ function 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 (state && state.engine === "butterchurn" && state.power
|
||||
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "seconds") {
|
||||
bcTimer = setInterval(() => bcStep(1),
|
||||
(state.butterchurn.interval || 20) * 1000);
|
||||
if (butterLive() && b.shuffle && b.intervalMode === "seconds") {
|
||||
bcTimer = setInterval(() => bcStep(1), (b.interval || 20) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -515,6 +559,13 @@ 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());
|
||||
|
|
@ -530,10 +581,21 @@ function stopMixer() {
|
|||
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");
|
||||
|
|
@ -560,6 +622,7 @@ function initVideoSource(name) {
|
|||
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", () =>
|
||||
|
|
@ -576,6 +639,7 @@ function initImageSource(name) {
|
|||
const img = new Image();
|
||||
img.onload = () => {
|
||||
s1.init({ src: img, dynamic: false });
|
||||
mixerS1 = { el: img, dynamic: false };
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () =>
|
||||
|
|
@ -585,6 +649,9 @@ function initImageSource(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.
|
||||
|
|
@ -592,11 +659,13 @@ async function ensureMixerSources(m) {
|
|||
&& (m.source === "cam" || m.source === "mix");
|
||||
if (wantButter && bcViz) {
|
||||
stopMixerCam();
|
||||
if (!bcCurrent) {
|
||||
loadButterchurnPreset((state && state.butterchurn.preset)
|
||||
|| bcNames[bcIndex]);
|
||||
}
|
||||
// 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 });
|
||||
|
|
@ -691,6 +760,7 @@ function show(canvas) {
|
|||
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
|
||||
|
|
@ -700,8 +770,9 @@ function renderInfoScreen() {
|
|||
show(null);
|
||||
const net = state.network || {};
|
||||
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
|
||||
const ipUrl = "http://" + (net.ip || "...") + port + "/";
|
||||
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + 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) {
|
||||
|
|
@ -716,11 +787,39 @@ function renderInfoScreen() {
|
|||
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;
|
||||
if (window.FosMapper) FosMapper.setMapping(state.mapping || {});
|
||||
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.
|
||||
|
|
@ -803,17 +902,30 @@ function applyState(next) {
|
|||
// ==========================================================================
|
||||
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: (surfaces) => socket.emit("set_mapping", { surfaces: surfaces }),
|
||||
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 (state.engine === "butterchurn") {
|
||||
if (butterLive()) {
|
||||
if (cmd.action === "next") bcStep(1);
|
||||
else if (cmd.action === "prev") bcStep(-1);
|
||||
}
|
||||
|
|
@ -863,6 +975,29 @@ async function boot() {
|
|||
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";
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue