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>
457 lines
18 KiB
JavaScript
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;
|
|
})();
|