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

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

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

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

457 lines
18 KiB
JavaScript

/*
* 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;
})();