/* * 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