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
|
|
@ -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