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>
1651 lines
67 KiB
JavaScript
1651 lines
67 KiB
JavaScript
/*
|
|
* FOSFENO :: Panel de control
|
|
* Conecta con el servidor, refleja el estado y envia ordenes.
|
|
* Incluye editor de codigo, mezclador VJ, ventana de ayuda y avisos.
|
|
*/
|
|
|
|
// Clave compartida (config.json -> auth.token). Llega en la direccion del
|
|
// panel, que es la que trae el codigo QR del proyector. Vacia = sin clave.
|
|
const CLAVE = new URLSearchParams(location.search).get("k") || "";
|
|
const socket = io({ auth: { token: CLAVE } });
|
|
let state = null;
|
|
let editorEngine = ""; // que motor tiene cargado el editor
|
|
let helpData = {}; // textos de ayuda
|
|
let lastNotifT = 0; // marca de tiempo del ultimo aviso mostrado
|
|
|
|
const $ = (s) => document.querySelector(s);
|
|
const $$ = (s) => document.querySelectorAll(s);
|
|
|
|
// ==========================================================================
|
|
// Vistas: MOTORES (lo de siempre) y MAPPING (encaje sobre la superficie)
|
|
// Se separan para no mezclar los controles de directo con los de montaje.
|
|
// La eleccion se recuerda en el propio navegador.
|
|
// ==========================================================================
|
|
/* Ademas de MOTORES y MAPPING hay una vista por CAPA ("capa:<id>"): ensena
|
|
solo la previsualizacion y las propiedades de esa capa, para poder trastear
|
|
con su efecto sin el resto del panel delante. Es lo que hace usable tener
|
|
varias capas: sin esto habia que ir a MAPPING, buscarla en la lista y
|
|
volver. */
|
|
/* mapSurfaces se declara mas abajo con let: al restaurar la vista guardada
|
|
esta funcion corre ANTES que esa linea, y tocarla daria ReferenceError. */
|
|
function capasHoy() {
|
|
try { return mapSurfaces || []; } catch (e) { return []; }
|
|
}
|
|
|
|
function setView(view) {
|
|
const esCapa = String(view).startsWith("capa:");
|
|
if (!esCapa && view !== "mapping") view = "motores";
|
|
if (esCapa) {
|
|
const id = view.slice(5);
|
|
// Si la capa ya no existe (borrada desde otro movil), no dejamos el panel
|
|
// en una vista fantasma
|
|
if (!capasHoy().some((s) => s.id === id)) return setView("mapping");
|
|
mapSel = { type: "surface", id: id };
|
|
document.body.dataset.capa = id;
|
|
} else {
|
|
delete document.body.dataset.capa;
|
|
}
|
|
document.body.dataset.view = esCapa ? "capa" : view;
|
|
vistaActual = view;
|
|
$$(".tab").forEach((t) =>
|
|
t.classList.toggle("active", t.dataset.view === view));
|
|
try { localStorage.setItem("fosfeno.view", view); } catch (e) { /* privado */ }
|
|
// El lienzo del mapping mide 0 mientras esta oculto: hay que repintarlo
|
|
// al entrar en su vista, ya con su tamano real.
|
|
if (state && (view === "mapping" || esCapa)) {
|
|
renderPropiedades();
|
|
drawMapPreview();
|
|
}
|
|
}
|
|
let vistaActual = "motores";
|
|
|
|
/* Rehace las pestanas de capa. Solo toca el DOM si algo cambio: si no, cada
|
|
estado que llega (y llegan muchos) tiraria la pestana que el usuario esta
|
|
pulsando. */
|
|
function renderTabsCapas() {
|
|
const cont = $("#tabs-capas");
|
|
if (!cont) return;
|
|
const capas = capasHoy();
|
|
const sig = capas.map((s, i) => s.id + ":" + (s.nombre || i + 1)).join("|");
|
|
if (cont.dataset.sig !== sig) {
|
|
cont.dataset.sig = sig;
|
|
cont.innerHTML = "";
|
|
capas.forEach((s, i) => {
|
|
const b = document.createElement("button");
|
|
b.className = "tab tabcapa";
|
|
b.dataset.view = "capa:" + s.id;
|
|
b.textContent = (s.nombre || "CAPA " + (i + 1)).toUpperCase();
|
|
b.title = "Editar esta capa";
|
|
b.addEventListener("click", () => setView("capa:" + s.id));
|
|
cont.appendChild(b);
|
|
});
|
|
}
|
|
// La capa activa pudo desaparecer: volver a MAPPING antes de quedarnos en
|
|
// una vista sin contenido
|
|
if (vistaActual.startsWith("capa:")
|
|
&& !capas.some((s) => s.id === vistaActual.slice(5))) {
|
|
setView("mapping");
|
|
return;
|
|
}
|
|
$$(".tab").forEach((t) =>
|
|
t.classList.toggle("active", t.dataset.view === vistaActual));
|
|
}
|
|
|
|
$$(".tab[data-view]").forEach((t) =>
|
|
t.addEventListener("click", () => setView(t.dataset.view)));
|
|
|
|
(function () {
|
|
let saved = "motores";
|
|
try { saved = localStorage.getItem("fosfeno.view") || "motores"; }
|
|
catch (e) { /* almacenamiento no disponible */ }
|
|
setView(saved);
|
|
})();
|
|
|
|
// Sliders del mezclador VJ (se generan dinamicamente)
|
|
const MIXER_SLIDERS = [
|
|
{ key: "mix", label: "Mezcla A/B", min: 0, max: 1, step: 0.01 },
|
|
{ key: "hue", label: "Tono (hue)", min: 0, max: 1, step: 0.01 },
|
|
{ key: "saturate", label: "Saturacion", min: 0, max: 3, step: 0.05 },
|
|
{ key: "contrast", label: "Contraste", min: 0, max: 3, step: 0.05 },
|
|
{ key: "brightness", label: "Brillo", min: -0.5, max: 0.5, step: 0.02 },
|
|
{ key: "colorama", label: "Colorama", min: 0, max: 1, step: 0.01 },
|
|
{ key: "posterize", label: "Posterizar", min: 0, max: 8, step: 1 },
|
|
{ key: "pixelate", label: "Pixelado", min: 0, max: 1, step: 0.02 },
|
|
{ key: "kaleid", label: "Caleidoscopio", min: 0, max: 12, step: 1 },
|
|
{ key: "rotate", label: "Rotacion", min: -3, max: 3, step: 0.05 },
|
|
{ key: "feedback", label: "Feedback", min: 0, max: 0.95, step: 0.01 },
|
|
];
|
|
|
|
// ==========================================================================
|
|
// Editor de codigo (CodeMirror)
|
|
// ==========================================================================
|
|
let editor = null;
|
|
if (typeof CodeMirror !== "undefined") {
|
|
editor = CodeMirror.fromTextArea($("#code"), {
|
|
lineNumbers: true, theme: "material-darker",
|
|
mode: "javascript", lineWrapping: true,
|
|
});
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Librerias de codigo y de ayuda
|
|
// ==========================================================================
|
|
let hydraLib = [];
|
|
let shaderLib = [];
|
|
|
|
async function loadData() {
|
|
try {
|
|
const sketches = await fetch("/data/hydra-sketches.json").then((r) => r.json());
|
|
const snippets = await fetch("/data/hydra-snippets.json").then((r) => r.json());
|
|
const shaders = await fetch("/data/shaders.json").then((r) => r.json());
|
|
hydraLib = [];
|
|
for (const [id, v] of Object.entries(sketches)) {
|
|
hydraLib.push({ id: "s:" + id, name: "* " + (v.name || id), code: v.code });
|
|
}
|
|
for (const [id, v] of Object.entries(snippets.snippets || {})) {
|
|
hydraLib.push({ id: "x:" + id, name: v.name || id, code: v.code });
|
|
}
|
|
shaderLib = Object.entries(shaders).map(
|
|
([id, v]) => ({ id: id, name: v.name || id, code: v.code }));
|
|
} catch (err) {
|
|
console.error("[FOSFENO] No se pudieron cargar las librerias:", err);
|
|
}
|
|
try {
|
|
const ayuda = await fetch("/data/ayuda.json").then((r) => r.json());
|
|
helpData = ayuda.ayuda || {};
|
|
} catch (err) {
|
|
console.error("[FOSFENO] No se pudo cargar la ayuda:", err);
|
|
}
|
|
// Si el estado llego antes que las librerias, repinta para que aparezcan
|
|
if (state) { editorEngine = ""; render(); }
|
|
}
|
|
|
|
function fillLibrarySelect(lib) {
|
|
const sel = $("#lib-select");
|
|
sel.innerHTML = '<option value="">-- Cargar de la libreria --</option>';
|
|
lib.forEach((it, i) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = String(i);
|
|
opt.textContent = it.name;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Ventana de informacion
|
|
// ==========================================================================
|
|
function openHelp(key) {
|
|
const h = helpData[key];
|
|
if (!h) return;
|
|
$("#modal-title").textContent = h.title || "Informacion";
|
|
const body = $("#modal-body");
|
|
body.innerHTML = "";
|
|
(h.body || []).forEach((par) => {
|
|
const p = document.createElement("p");
|
|
p.textContent = par;
|
|
body.appendChild(p);
|
|
});
|
|
$("#modal").hidden = false;
|
|
}
|
|
|
|
function closeHelp() { $("#modal").hidden = true; }
|
|
|
|
// ==========================================================================
|
|
// Avisos (banda superior)
|
|
// ==========================================================================
|
|
let notifTimer = null;
|
|
function showNotif(entry) {
|
|
if (!entry || !entry.message) return;
|
|
const n = $("#notif");
|
|
// El nivel va con prefijo 'lv-': sin el, un aviso 'info' cogia tambien el
|
|
// estilo del boton redondo de ayuda (.info) y salia como una pelotita.
|
|
n.className = "notif lv-" + (entry.level || "info");
|
|
$("#notif-msg").textContent = entry.message;
|
|
n.hidden = false;
|
|
clearTimeout(notifTimer);
|
|
if (entry.level === "info") {
|
|
notifTimer = setTimeout(() => { n.hidden = true; }, 6000);
|
|
}
|
|
if (entry.t) lastNotifT = entry.t;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Sliders del mezclador
|
|
// ==========================================================================
|
|
function fmt(v, step) {
|
|
return step >= 1 ? String(Math.round(v)) : Number(v).toFixed(2);
|
|
}
|
|
|
|
function buildMixerSliders() {
|
|
const cont = $("#mixer-sliders");
|
|
MIXER_SLIDERS.forEach((cfg) => {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "slider";
|
|
wrap.innerHTML =
|
|
`<div class="head"><span id="ml-${cfg.key}">${cfg.label}</span>` +
|
|
`<b id="mv-${cfg.key}">-</b></div>`;
|
|
const input = document.createElement("input");
|
|
input.type = "range";
|
|
input.min = cfg.min; input.max = cfg.max; input.step = cfg.step;
|
|
input.id = "ms-" + cfg.key;
|
|
input.addEventListener("input", () => {
|
|
$("#mv-" + cfg.key).textContent = fmt(input.value, cfg.step);
|
|
});
|
|
input.addEventListener("change", () => {
|
|
const patch = {};
|
|
patch[cfg.key] = parseFloat(input.value);
|
|
socket.emit("update_settings", { engine: "mixer", patch: patch });
|
|
});
|
|
wrap.appendChild(input);
|
|
cont.appendChild(wrap);
|
|
});
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Render
|
|
// ==========================================================================
|
|
function render() {
|
|
if (!state) return;
|
|
$("#conn").className = "dot on";
|
|
|
|
const power = $("#power");
|
|
power.textContent = state.power ? "ON" : "OFF";
|
|
power.classList.toggle("off", !state.power);
|
|
|
|
$$(".engine").forEach((b) =>
|
|
b.classList.toggle("active", b.dataset.engine === state.engine));
|
|
|
|
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
|
|
(d) => d.id, (d) => d.name);
|
|
if (state.audio.device) $("#audio-device").value = state.audio.device;
|
|
const source = state.audio.source || "mic";
|
|
$("#audio-src-mic").classList.toggle("active", source === "mic");
|
|
$("#audio-src-monitor").classList.toggle("active", source === "monitor");
|
|
$("#bpm").textContent = state.audio.bpm > 0 ? state.audio.bpm : "--";
|
|
|
|
$("#sens").value = state.sensitivity;
|
|
$("#sens-val").textContent = Number(state.sensitivity).toFixed(1);
|
|
|
|
$("#ctl-butterchurn").hidden = state.engine !== "butterchurn";
|
|
$("#ctl-editor").hidden = !(state.engine === "hydra" || state.engine === "shaders");
|
|
$("#ctl-mixer").hidden = state.engine !== "mixer";
|
|
$("#ctl-projectm").hidden = state.engine !== "projectm";
|
|
|
|
renderButterchurn();
|
|
renderEditor();
|
|
renderMixer();
|
|
renderProjectM();
|
|
renderMapping();
|
|
|
|
// Aviso en la pestana: el mapping esta deformando la salida aunque
|
|
// estemos mirando la pestana de motores.
|
|
$("#tab-map-dot").hidden = !(state.mapping && state.mapping.enabled);
|
|
|
|
$("#now").textContent = nowText() || "-";
|
|
|
|
const net = state.network || {};
|
|
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
|
|
const k = CLAVE ? "?k=" + encodeURIComponent(CLAVE) : "";
|
|
$("#net-foot").textContent = "Panel: http://" +
|
|
(net.hostname || "fosfeno") + ".local" + port + "/" + k +
|
|
(net.ip ? " (" + net.ip + ")" : "");
|
|
|
|
// Aviso pendiente recibido antes de abrir el panel
|
|
const notes = state.notifications || [];
|
|
if (notes.length) {
|
|
const last = notes[notes.length - 1];
|
|
if (last.t !== lastNotifT) showNotif(last);
|
|
}
|
|
}
|
|
|
|
/* Texto unico de "que se esta viendo". Lo usan la tarjeta En pantalla y la
|
|
tarjeta de projectM, para que ambas cuenten exactamente lo mismo. Con
|
|
projectM se ensena con la misma taxonomia de las listas (Categoria /
|
|
Visual) y despues la variante concreta que rota dentro de ese look. */
|
|
function nowText() {
|
|
if (state.engine === "projectm") {
|
|
const pm = state.projectm || {};
|
|
if (!pm.nowPlaying) return "";
|
|
const sitio = (pm.nowCategory && pm.nowVisual)
|
|
? pm.nowCategory + " / " + pm.nowVisual + " · " : "";
|
|
return sitio + pm.nowPlaying + (pm.locked ? " [congelado]" : "");
|
|
}
|
|
return state.status.label || "";
|
|
}
|
|
|
|
// Opciones fijas que sobreviven al rellenar cada lista: el "-- elige ... --"
|
|
// de arriba, y lo que se ensena cuando no hay nada que listar.
|
|
const SELECT_FIJA = {
|
|
"mix-video": '<option value="">-- sin video --</option>',
|
|
"prop-video": '<option value="">-- elige un clip --</option>',
|
|
"prop-shader": '<option value="">-- elige un shader --</option>',
|
|
};
|
|
const SELECT_VACIA = {
|
|
"mix-camera": '<option value="0">Camara por defecto</option>',
|
|
"prop-camara": '<option value="0">Camara por defecto</option>',
|
|
"prop-video": '<option value="">-- no hay clips: subelos en MOTORES --</option>',
|
|
"prop-preset": '<option value="">-- presets no disponibles --</option>',
|
|
};
|
|
|
|
function fillSelectOnce(sel, items, valFn, txtFn) {
|
|
items = items || [];
|
|
if (sel.dataset.count == items.length) return;
|
|
sel.dataset.count = items.length;
|
|
let keep = SELECT_FIJA[sel.id] || "";
|
|
if (!items.length && SELECT_VACIA[sel.id]) keep = SELECT_VACIA[sel.id];
|
|
sel.innerHTML = keep;
|
|
items.forEach((it) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = valFn(it);
|
|
opt.textContent = txtFn(it);
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
/* Lista de clips con la ficha del catalogo (data/visuales.json, que genera
|
|
scripts/catalogar-visuales.py): agrupada por pelicula y con el nombre del
|
|
personaje en vez del nombre del archivo. Los clips que casi no tienen
|
|
figura salen marcados, que es lo que evita elegir a ciegas uno que sobre el
|
|
proyector se ve negro. Sin catalogo cae en la lista de siempre. */
|
|
const MARCA_CALIDAD = { floja: " · poca figura", vacia: " · casi vacio" };
|
|
|
|
function fichaClip(archivo) {
|
|
return ((state.meta && state.meta.visuales) || {})[archivo] || null;
|
|
}
|
|
|
|
/* Nombre corto para las frases ("Jack Sparrow y Will Turner") */
|
|
function tituloClip(archivo) {
|
|
const f = fichaClip(archivo);
|
|
return (f && f.titulo) || archivo;
|
|
}
|
|
|
|
/* Nombre largo para las listas: titulo + duracion + aviso de calidad */
|
|
function etiquetaClip(archivo) {
|
|
const f = fichaClip(archivo);
|
|
if (!f) return archivo;
|
|
const dur = f.duracion ? " " + Math.round(f.duracion) + "s" : "";
|
|
return f.titulo + dur + (MARCA_CALIDAD[f.calidad] || "");
|
|
}
|
|
|
|
function fillClipSelect(sel) {
|
|
const vs = (state.meta && state.meta.videos) || [];
|
|
const cat = (state.meta && state.meta.visuales) || {};
|
|
// La firma incluye el catalogo: si se recataloga sin cambiar el numero de
|
|
// archivos, la lista tambien tiene que rehacerse.
|
|
const sig = vs.length + "|" + Object.keys(cat).length;
|
|
if (sel.dataset.sig === sig) return;
|
|
sel.dataset.sig = sig;
|
|
delete sel.dataset.count; // no dejar que fillSelectOnce lo de por hecho
|
|
|
|
sel.innerHTML = (vs.length ? SELECT_FIJA[sel.id] : SELECT_VACIA[sel.id])
|
|
|| SELECT_FIJA[sel.id] || "";
|
|
|
|
// Agrupadas por obra, en el orden en que vienen los archivos (p1, p2...);
|
|
// lo que no tiene ficha va al final, en "Sin catalogar".
|
|
const grupos = new Map();
|
|
vs.forEach((v) => {
|
|
const f = cat[v];
|
|
const obra = (f && f.obra) || "Sin catalogar";
|
|
if (!grupos.has(obra)) grupos.set(obra, []);
|
|
grupos.get(obra).push(v);
|
|
});
|
|
|
|
grupos.forEach((lista, obra) => {
|
|
const destino = grupos.size > 1
|
|
? sel.appendChild(Object.assign(document.createElement("optgroup"),
|
|
{ label: obra }))
|
|
: sel;
|
|
lista.forEach((v) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = v;
|
|
opt.textContent = etiquetaClip(v);
|
|
const f = cat[v];
|
|
if (f && f.aviso) opt.title = f.aviso;
|
|
destino.appendChild(opt);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderButterchurn() {
|
|
if (state.engine !== "butterchurn") return;
|
|
const b = state.butterchurn;
|
|
fillSelectOnce($("#bc-preset"), state.meta.butterchurnPresets,
|
|
(n) => n, (n) => n);
|
|
// El preset se sigue solo con el cambio automatico: no lo movemos mientras
|
|
// el usuario tiene la lista abierta eligiendo.
|
|
if (b.preset && document.activeElement !== $("#bc-preset")) {
|
|
$("#bc-preset").value = b.preset;
|
|
}
|
|
$("#bc-shuffle").checked = b.shuffle;
|
|
$("#bc-mode-seconds").classList.toggle("active", b.intervalMode === "seconds");
|
|
$("#bc-mode-beats").classList.toggle("active", b.intervalMode === "beats");
|
|
$("#bc-interval").value = b.interval;
|
|
$("#bc-int-val").textContent = b.interval;
|
|
$("#bc-int-unit").textContent = b.intervalMode === "beats" ? "comp." : "s";
|
|
$("#bc-blend").value = b.blendTime;
|
|
$("#bc-blend-val").textContent = Number(b.blendTime).toFixed(1);
|
|
}
|
|
|
|
function renderEditor() {
|
|
if (state.engine !== "hydra" && state.engine !== "shaders") return;
|
|
$("#editor-info").dataset.help = state.engine;
|
|
if (state.engine !== editorEngine) {
|
|
editorEngine = state.engine;
|
|
const isHydra = state.engine === "hydra";
|
|
$("#editor-title").textContent = isHydra ? "Editor Hydra" : "Editor Shaders";
|
|
$("#editor-hint").textContent = isHydra
|
|
? "Variables: time, a.fft[0..4], bpm. Escribe o pega codigo Hydra."
|
|
: "Uniforms: u_time, u_bass, u_mid, u_treble, u_bpm, u_beat, u_fft.";
|
|
fillLibrarySelect(isHydra ? hydraLib : shaderLib);
|
|
if (editor) {
|
|
editor.setOption("mode", isHydra ? "javascript" : "text/x-csrc");
|
|
editor.setValue(state[state.engine].code || "");
|
|
setTimeout(() => editor.refresh(), 30);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Frase de una linea que dice exactamente que va a salir por el proyector
|
|
con los ajustes actuales. Es la respuesta a "y esto ahora que hace": el
|
|
mezclador tiene dos capas (fondo + clip) y tres formas de combinarlas. */
|
|
function mixerResumen(m) {
|
|
const fondo = m.fondo === "butter"
|
|
? "las visuales Butter (MilkDrop)" : "la camara";
|
|
const clip = m.video ? tituloClip(m.video) : "el clip";
|
|
if (m.source === "video") {
|
|
return m.video
|
|
? "En pantalla: solo " + clip + ", a pantalla completa. El fondo no se usa."
|
|
: "En pantalla: nada todavia, elige un clip en la lista.";
|
|
}
|
|
if (m.source === "cam") {
|
|
return "En pantalla: solo " + fondo + ", a pantalla completa. "
|
|
+ "El clip no se usa.";
|
|
}
|
|
if (m.blendMode === "recorte") {
|
|
return "En pantalla: " + clip + " recortado (su negro se vuelve "
|
|
+ "transparente) DELANTE de " + fondo + ". El deslizador de arriba "
|
|
+ "ajusta el umbral del recorte.";
|
|
}
|
|
const modo = $("#mix-blend").selectedOptions[0];
|
|
return "En pantalla: " + clip + " y " + fondo + " combinados en modo "
|
|
+ ((modo && modo.textContent) || m.blendMode).toLowerCase() + ".";
|
|
}
|
|
|
|
function renderMixer() {
|
|
if (state.engine !== "mixer") return;
|
|
const m = state.mixer;
|
|
const esButter = m.fondo === "butter";
|
|
const usaFondo = m.source === "cam" || m.source === "mix";
|
|
const usaClip = m.source === "video" || m.source === "mix";
|
|
|
|
$$("#mixer-source .segbtn").forEach((b) =>
|
|
b.classList.toggle("active", b.dataset.src === m.source));
|
|
$("#mix-cam").checked = m.camOn;
|
|
fillSelectOnce($("#mix-camera"), state.meta.cameraDevices,
|
|
(d) => d.id, (d) => d.name);
|
|
$("#mix-camera").value = m.cameraId;
|
|
fillClipSelect($("#mix-video"));
|
|
$("#mix-video").value = m.video || "";
|
|
$("#mix-fondo").value = m.fondo || "cam";
|
|
$("#mix-blend").value = m.blendMode;
|
|
$("#mix-invert").checked = m.invert;
|
|
$("#mix-beat").checked = m.beatPulse;
|
|
|
|
// Cada capa ensena solo sus propios controles: con fondo Butter la camara
|
|
// no pinta nada (se apaga de verdad en el escenario) y al reves.
|
|
$("#mix-cam-block").hidden = esButter;
|
|
$("#mix-bg-block").hidden = !esButter;
|
|
// El modo de mezcla solo tiene sentido si se ven las dos capas a la vez
|
|
$("#mix-blend-row").hidden = m.source !== "mix";
|
|
// Lo que no se esta usando queda atenuado, no escondido
|
|
$("#mix-fondo-row").classList.toggle("dim", !usaFondo);
|
|
$("#mix-cam-block").classList.toggle("dim", !usaFondo);
|
|
$("#mix-bg-block").classList.toggle("dim", !usaFondo);
|
|
$("#mix-clip-label").classList.toggle("dim", !usaClip);
|
|
$("#mix-video").classList.toggle("dim", !usaClip);
|
|
|
|
// Preset de MilkDrop que hace de fondo (el mismo del motor Butter)
|
|
if (esButter) {
|
|
const b = state.butterchurn;
|
|
fillSelectOnce($("#mix-bg-preset"), state.meta.butterchurnPresets,
|
|
(n) => n, (n) => n);
|
|
if (b.preset && document.activeElement !== $("#mix-bg-preset")) {
|
|
$("#mix-bg-preset").value = b.preset;
|
|
}
|
|
$("#mix-bg-shuffle").checked = b.shuffle;
|
|
}
|
|
|
|
MIXER_SLIDERS.forEach((cfg) => {
|
|
const input = $("#ms-" + cfg.key);
|
|
if (input && document.activeElement !== input) {
|
|
input.value = m[cfg.key];
|
|
$("#mv-" + cfg.key).textContent = fmt(m[cfg.key], cfg.step);
|
|
}
|
|
});
|
|
// En modo recorte el deslizador de mezcla no funde: fija el umbral
|
|
$("#ml-mix").textContent = (m.source === "mix" && m.blendMode === "recorte")
|
|
? "Umbral del recorte" : "Mezcla A/B";
|
|
$("#mv-mix").parentElement.parentElement.classList.toggle("dim",
|
|
m.source !== "mix");
|
|
|
|
$("#mix-resumen").textContent = mixerResumen(m);
|
|
}
|
|
|
|
function renderProjectM() {
|
|
if (state.engine !== "projectm") return;
|
|
const pm = state.projectm || {};
|
|
const cats = state.meta.projectmPresets || {};
|
|
const catSel = $("#pm-category");
|
|
const catSig = Object.keys(cats).join("|");
|
|
if (catSel.dataset.sig !== catSig) {
|
|
catSel.dataset.sig = catSig;
|
|
catSel.innerHTML = '<option value="">Todas las categorias</option>';
|
|
Object.keys(cats).forEach((c) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = c;
|
|
opt.textContent = c + " (" + cats[c].length + ")";
|
|
catSel.appendChild(opt);
|
|
});
|
|
}
|
|
catSel.value = pm.category || "";
|
|
// La lista de presets depende de la categoria elegida
|
|
const preSel = $("#pm-preset");
|
|
const presets = cats[pm.category] || [];
|
|
const preSig = (pm.category || "") + "|" + presets.length;
|
|
if (preSel.dataset.sig !== preSig) {
|
|
preSel.dataset.sig = preSig;
|
|
preSel.innerHTML = presets.length
|
|
? '<option value="">-- rotando (elige uno para fijarlo) --</option>'
|
|
: '<option value="">-- elige antes una categoria --</option>';
|
|
presets.forEach((p) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = p;
|
|
opt.textContent = p.replace(/\.milk$/i, "");
|
|
preSel.appendChild(opt);
|
|
});
|
|
}
|
|
preSel.value = pm.preset || "";
|
|
// Tercer nivel: variantes (.milk) del visual elegido, para clavar una
|
|
// a dedo. La lista se pide al servidor al cambiar de visual.
|
|
const varSel = $("#pm-variante");
|
|
const varSig = (pm.category || "") + "|" + (pm.preset || "");
|
|
if (varSel.dataset.sig !== varSig) {
|
|
varSel.dataset.sig = varSig;
|
|
varSel.innerHTML = pm.category && pm.preset
|
|
? '<option value="">-- variantes: rotando (elige una a dedo) --</option>'
|
|
: '<option value="">-- elige antes un visual --</option>';
|
|
if (pm.category && pm.preset) {
|
|
fetch("/pm/variantes?categoria=" + encodeURIComponent(pm.category)
|
|
+ "&visual=" + encodeURIComponent(pm.preset))
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
if (varSel.dataset.sig !== varSig) return; // cambio a medias
|
|
(d.variantes || []).forEach((v) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = v;
|
|
opt.textContent = v;
|
|
varSel.appendChild(opt);
|
|
});
|
|
varSel.value = (state.projectm && state.projectm.variante) || "";
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
} else {
|
|
varSel.value = pm.variante || "";
|
|
}
|
|
// Favoritos: una fila por visual guardado, para lanzarlo de un toque
|
|
const favList = $("#pm-fav-list");
|
|
const favs = pm.favoritos || [];
|
|
const favSig = favs.map((f) => f.categoria + "/" + f.visual + "/"
|
|
+ f.variante).join("|");
|
|
if (favList.dataset.sig !== favSig) {
|
|
favList.dataset.sig = favSig;
|
|
favList.innerHTML = "";
|
|
favs.forEach((f) => {
|
|
const row = document.createElement("div");
|
|
row.className = "row";
|
|
const play = document.createElement("button");
|
|
play.className = "cmd favplay";
|
|
play.textContent = "▶ " + f.variante;
|
|
play.title = f.categoria + " / " + f.visual;
|
|
play.addEventListener("click", () => socket.emit("pm_favorito", {
|
|
action: "play", categoria: f.categoria,
|
|
visual: f.visual, variante: f.variante,
|
|
}));
|
|
const del = document.createElement("button");
|
|
del.className = "cmd favdel";
|
|
del.textContent = "✕";
|
|
del.title = "Quitar de favoritos";
|
|
del.addEventListener("click", () => socket.emit("pm_favorito", {
|
|
action: "del", categoria: f.categoria,
|
|
visual: f.visual, variante: f.variante,
|
|
}));
|
|
row.appendChild(play);
|
|
row.appendChild(del);
|
|
favList.appendChild(row);
|
|
});
|
|
}
|
|
$("#pm-shuffle").checked = pm.shuffle !== false;
|
|
// Selector de pantalla: Auto + monitores detectados por el servidor
|
|
const monSel = $("#pm-monitor");
|
|
const mons = state.meta.monitors || [];
|
|
const monSig = mons.map((m) => m.id + m.name).join("|");
|
|
if (monSel.dataset.sig !== monSig) {
|
|
monSel.dataset.sig = monSig;
|
|
monSel.innerHTML = '<option value="auto">Auto (la del escenario)</option>';
|
|
mons.forEach((m) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = m.id;
|
|
opt.textContent = "Pantalla " + m.id + " - " + m.name;
|
|
monSel.appendChild(opt);
|
|
});
|
|
}
|
|
monSel.value = String(pm.monitor || "auto");
|
|
const dur = $("#pm-duration");
|
|
if (document.activeElement !== dur) dur.value = pm.duration || 60;
|
|
$("#pm-dur-val").textContent = pm.duration || 60;
|
|
const lockBtn = $("#pm-lock");
|
|
lockBtn.classList.toggle("accent", !!pm.locked);
|
|
lockBtn.textContent = pm.locked ? "Congelado (soltar)" : "Congelar visual";
|
|
$("#pm-now").textContent = "En pantalla: " + (nowText() || "...");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Projection mapping (Fase 2): previsualizacion con malla + mascaras
|
|
// ==========================================================================
|
|
let mapSurfaces = []; // superficies (malla) — copia local para editar
|
|
let mapMasks = []; // mascaras (cuadrilateros negros)
|
|
let mapSel = null; // { type:'surface'|'mask', id }
|
|
let mapDrag = null; // { type, id, pt|null, sx, sy, orig }
|
|
let mapEmitTimer = null;
|
|
const MAP_H = 13; // radio del tirador (px)
|
|
const MAP_MAXN = 6;
|
|
const sidx = (c, r, n) => r * (n + 1) + c;
|
|
const cl01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
|
|
/* Normaliza una capa: geometria a formato malla y ajustes con sus valores por
|
|
defecto. Tiene que coincidir con migrate() de stage/mapper.js. */
|
|
function mapMigrate(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: Object.assign({ tipo: "motor" }, s.fuente || {}),
|
|
};
|
|
if (Array.isArray(s.points) && s.n) {
|
|
capa.n = s.n; capa.points = s.points.map((p) => [p[0], p[1]]);
|
|
} else if (Array.isArray(s.corners)) {
|
|
const c = s.corners;
|
|
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
|
|
} else {
|
|
capa.n = 1;
|
|
capa.points = [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]];
|
|
}
|
|
return capa;
|
|
}
|
|
|
|
// Etiqueta corta de la fuente de una capa (la que sale en la lista)
|
|
const FUENTE_NOMBRE = {
|
|
motor: "El motor de MOTORES", butter: "MilkDrop propio",
|
|
shader: "Shader GLSL", video: "Clip de la galeria",
|
|
camara: "Camara", negro: "Negro (tapa)",
|
|
};
|
|
function fuenteTexto(f) {
|
|
f = f || {};
|
|
const base = FUENTE_NOMBRE[f.tipo] || FUENTE_NOMBRE.motor;
|
|
if (f.tipo === "butter") {
|
|
if (f.biblioteca === "projectm") {
|
|
return "projectM" + (f.milk
|
|
? " · " + f.milk.split("/").pop().replace(/\.milk$/i, "")
|
|
: " · sin preset elegido");
|
|
}
|
|
return base + (f.auto ? " · cambio automatico"
|
|
: (f.preset ? " · " + f.preset : ""));
|
|
}
|
|
if (f.tipo === "video" && f.archivo) return base + " · " + etiquetaClip(f.archivo);
|
|
if (f.tipo === "shader" && f.shader) return base + " · " + f.shader;
|
|
return base;
|
|
}
|
|
function mapSample(s, u, v) {
|
|
const n = s.n;
|
|
let ci = Math.min(n - 1, Math.floor(u * n)); if (ci < 0) ci = 0;
|
|
let cj = Math.min(n - 1, Math.floor(v * n)); if (cj < 0) cj = 0;
|
|
const lu = u * n - ci, lv = v * n - cj;
|
|
const P = (c, r) => s.points[sidx(c, r, n)];
|
|
const tl = P(ci, cj), tr = P(ci + 1, cj), bl = P(ci, cj + 1), br = P(ci + 1, cj + 1);
|
|
const ax = tl[0] + (tr[0] - tl[0]) * lu, ay = tl[1] + (tr[1] - tl[1]) * lu;
|
|
const bx = bl[0] + (br[0] - bl[0]) * lu, by = bl[1] + (br[1] - bl[1]) * lu;
|
|
return [ax + (bx - ax) * lv, ay + (by - ay) * lv];
|
|
}
|
|
function mapResample(s, newN) {
|
|
const pts = [];
|
|
for (let r = 0; r <= newN; r++)
|
|
for (let c = 0; c <= newN; c++) pts.push(mapSample(s, c / newN, r / newN));
|
|
// Solo cambia la malla: los ajustes de la capa se conservan
|
|
return Object.assign(mapMigrate(s), { n: newN, points: pts });
|
|
}
|
|
function mapPointInQuad(x, y, c) {
|
|
let inside = false;
|
|
for (let i = 0, j = 3; i < 4; j = i++) {
|
|
const xi = c[i][0], yi = c[i][1], xj = c[j][0], yj = c[j][1];
|
|
if (((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / ((yj - yi) || 1e-9) + xi)) inside = !inside;
|
|
}
|
|
return inside;
|
|
}
|
|
function mapOutline(s) {
|
|
const n = s.n;
|
|
return [s.points[sidx(0, 0, n)], s.points[sidx(n, 0, n)], s.points[sidx(n, n, n)], s.points[sidx(0, n, n)]];
|
|
}
|
|
const mapSelSurf = () => (mapSel && mapSel.type === "surface") ? mapSurfaces.find((s) => s.id === mapSel.id) : null;
|
|
|
|
/* Rotulo centrado que no se sale del lienzo: si la capa esta pegada a un
|
|
borde, el texto se recorta y se empuja hacia dentro. */
|
|
function rotulo(ctx, txt, x, y, w) {
|
|
let t = txt;
|
|
while (t.length > 6 && ctx.measureText(t).width > w - 12) {
|
|
t = t.slice(0, -2);
|
|
}
|
|
if (t !== txt) t = t.slice(0, -1) + "…";
|
|
const media = ctx.measureText(t).width / 2;
|
|
ctx.fillText(t, Math.min(Math.max(x, media + 5), w - media - 5), y);
|
|
}
|
|
|
|
function drawMapPreview() {
|
|
const cv = $("#map-preview");
|
|
if (!cv) return;
|
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
const w = cv.clientWidth, h = cv.clientHeight;
|
|
if (!w || !h) return;
|
|
const W = Math.round(w * dpr), H = Math.round(h * dpr);
|
|
if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; }
|
|
const ctx = cv.getContext("2d");
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.strokeStyle = "#333"; ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
|
|
ctx.font = "600 13px system-ui, sans-serif";
|
|
ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
|
if (!mapSurfaces.length && !mapMasks.length) {
|
|
ctx.fillStyle = "rgba(255,255,255,0.4)";
|
|
ctx.fillText("Pulsa '+ Capa'", w / 2, h / 2);
|
|
return;
|
|
}
|
|
// superficies (malla n x n)
|
|
mapSurfaces.forEach((s, i) => {
|
|
const n = s.n;
|
|
const sel = mapSel && mapSel.type === "surface" && mapSel.id === s.id;
|
|
const col = sel ? "#00e5ff" : "#b4ff00";
|
|
ctx.globalAlpha = s.visible ? 1 : 0.35;
|
|
const P = (c, r) => { const p = s.points[sidx(c, r, n)]; return [p[0] * w, p[1] * h]; };
|
|
ctx.strokeStyle = sel ? "rgba(0,229,255,0.45)" : "rgba(180,255,0,0.3)";
|
|
ctx.lineWidth = 1;
|
|
for (let r = 0; r <= n; r++) { ctx.beginPath(); for (let c = 0; c <= n; c++) { const p = P(c, r); c ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); }
|
|
for (let c = 0; c <= n; c++) { ctx.beginPath(); for (let r = 0; r <= n; r++) { const p = P(c, r); r ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]); } ctx.stroke(); }
|
|
// Nombre en el centro de verdad (el medio de las cuatro esquinas). La
|
|
// linea de la fuente solo en la capa seleccionada: con varias capas
|
|
// solapadas, los rotulos se pisaban unos a otros y no se leia nada.
|
|
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];
|
|
ctx.shadowColor = "#000"; ctx.shadowBlur = 5; // legible sobre la malla
|
|
ctx.fillStyle = col;
|
|
rotulo(ctx, s.nombre || "Capa " + (i + 1), cc[0], sel ? cc[1] - 8 : cc[1], w);
|
|
if (sel) {
|
|
ctx.font = "11px system-ui, sans-serif";
|
|
ctx.fillStyle = "rgba(0,229,255,0.85)";
|
|
rotulo(ctx, fuenteTexto(s.fuente), cc[0], cc[1] + 9, w);
|
|
ctx.font = "600 13px system-ui, sans-serif";
|
|
}
|
|
ctx.shadowBlur = 0;
|
|
for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) {
|
|
const p = P(c, r);
|
|
ctx.beginPath(); ctx.arc(p[0], p[1], MAP_H, 0, Math.PI * 2);
|
|
ctx.fillStyle = sel ? "#00e5ff" : "#fff"; ctx.fill();
|
|
ctx.lineWidth = 2; ctx.strokeStyle = "#000"; ctx.stroke();
|
|
}
|
|
});
|
|
// mascaras (rojo)
|
|
mapMasks.forEach((m, i) => {
|
|
const sel = mapSel && mapSel.type === "mask" && mapSel.id === m.id;
|
|
const pts = m.corners.map(([x, y]) => [x * w, y * h]);
|
|
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);
|
|
for (let k = 1; k < 4; k++) ctx.lineTo(pts[k][0], pts[k][1]);
|
|
ctx.closePath();
|
|
ctx.fillStyle = "rgba(255,60,60,0.3)"; ctx.fill();
|
|
ctx.lineWidth = sel ? 3 : 2; ctx.strokeStyle = sel ? "#ff2d2d" : "#ff6b6b";
|
|
ctx.setLineDash([6, 4]); ctx.stroke(); ctx.setLineDash([]);
|
|
const cx = (pts[0][0] + pts[1][0] + pts[2][0] + pts[3][0]) / 4;
|
|
const cy = (pts[0][1] + pts[1][1] + pts[2][1] + pts[3][1]) / 4;
|
|
ctx.fillStyle = "#ff6b6b"; ctx.fillText("M" + (i + 1), cx, cy);
|
|
pts.forEach(([px, py]) => {
|
|
ctx.beginPath(); ctx.arc(px, py, MAP_H, 0, Math.PI * 2);
|
|
ctx.fillStyle = "#ff6b6b"; ctx.fill();
|
|
ctx.lineWidth = 2; ctx.strokeStyle = "#000"; ctx.stroke();
|
|
});
|
|
});
|
|
}
|
|
|
|
function mapLocalPt(ev) {
|
|
const r = $("#map-preview").getBoundingClientRect();
|
|
return { x: (ev.clientX - r.left) / r.width, y: (ev.clientY - r.top) / r.height, w: r.width, h: r.height };
|
|
}
|
|
function mapPick(p) {
|
|
const rx = (MAP_H + 8) / p.w, ry = (MAP_H + 8) / p.h;
|
|
for (let i = mapSurfaces.length - 1; i >= 0; i--) {
|
|
const s = mapSurfaces[i];
|
|
for (let k = 0; k < s.points.length; k++) {
|
|
const dx = (s.points[k][0] - p.x) / rx, dy = (s.points[k][1] - p.y) / ry;
|
|
if (dx * dx + dy * dy <= 1) return { type: "surface", id: s.id, pt: k };
|
|
}
|
|
}
|
|
for (let i = mapMasks.length - 1; i >= 0; i--) {
|
|
const c = mapMasks[i].corners;
|
|
for (let k = 0; k < 4; k++) {
|
|
const dx = (c[k][0] - p.x) / rx, dy = (c[k][1] - p.y) / ry;
|
|
if (dx * dx + dy * dy <= 1) return { type: "mask", id: mapMasks[i].id, pt: k };
|
|
}
|
|
}
|
|
for (let i = mapMasks.length - 1; i >= 0; i--)
|
|
if (mapPointInQuad(p.x, p.y, mapMasks[i].corners)) return { type: "mask", id: mapMasks[i].id, pt: null };
|
|
for (let i = mapSurfaces.length - 1; i >= 0; i--)
|
|
if (mapPointInQuad(p.x, p.y, mapOutline(mapSurfaces[i]))) return { type: "surface", id: mapSurfaces[i].id, pt: null };
|
|
return null;
|
|
}
|
|
function onMapDown(ev) {
|
|
ev.preventDefault();
|
|
const p = mapLocalPt(ev);
|
|
const hit = mapPick(p);
|
|
// Tocar fuera deselecciona; tocar dentro de una capa la selecciona y
|
|
// ensena sus propiedades (asi se elige la fuente sin buscarla en la lista).
|
|
if (!hit) { mapSel = null; mapDrag = null; drawMapPreview(); renderCapas(); renderPropiedades(); return; }
|
|
mapSel = { type: hit.type, id: hit.id };
|
|
const arr = hit.type === "surface"
|
|
? mapSurfaces.find((s) => s.id === hit.id).points
|
|
: mapMasks.find((m) => m.id === hit.id).corners;
|
|
mapDrag = { type: hit.type, id: hit.id, pt: hit.pt, sx: p.x, sy: p.y, orig: arr.map((q) => q.slice()) };
|
|
drawMapPreview(); renderCapas(); renderPropiedades();
|
|
}
|
|
function onMapMove(ev) {
|
|
if (!mapDrag) return;
|
|
ev.preventDefault();
|
|
const p = mapLocalPt(ev);
|
|
const dx = p.x - mapDrag.sx, dy = p.y - mapDrag.sy;
|
|
const arr = mapDrag.type === "surface"
|
|
? (mapSurfaces.find((s) => s.id === mapDrag.id) || {}).points
|
|
: (mapMasks.find((m) => m.id === mapDrag.id) || {}).corners;
|
|
if (!arr) return;
|
|
if (mapDrag.pt == null) {
|
|
for (let k = 0; k < arr.length; k++) { arr[k][0] = cl01(mapDrag.orig[k][0] + dx); arr[k][1] = cl01(mapDrag.orig[k][1] + dy); }
|
|
} else {
|
|
arr[mapDrag.pt][0] = cl01(mapDrag.orig[mapDrag.pt][0] + dx);
|
|
arr[mapDrag.pt][1] = cl01(mapDrag.orig[mapDrag.pt][1] + dy);
|
|
}
|
|
drawMapPreview(); scheduleMapEmit();
|
|
}
|
|
function onMapUp() { if (mapDrag) { emitMapNow(); mapDrag = null; } }
|
|
function scheduleMapEmit() { if (!mapEmitTimer) mapEmitTimer = setTimeout(() => { mapEmitTimer = null; emitMapNow(); }, 60); }
|
|
function emitMapNow() {
|
|
if (mapEmitTimer) { clearTimeout(mapEmitTimer); mapEmitTimer = null; }
|
|
socket.emit("set_mapping", { surfaces: mapSurfaces, masks: mapMasks });
|
|
}
|
|
|
|
/* Cambia ajustes de una capa y lo manda. Todo el panel de propiedades pasa
|
|
por aqui: modifica solo la capa seleccionada y deja el resto igual. */
|
|
function patchCapa(id, patch) {
|
|
mapSurfaces = mapSurfaces.map((s) =>
|
|
(s.id === id ? Object.assign({}, s, patch) : s));
|
|
emitMapNow();
|
|
}
|
|
function patchFuente(id, patch) {
|
|
const s = mapSurfaces.find((x) => x.id === id);
|
|
if (!s) return;
|
|
patchCapa(id, { fuente: Object.assign({}, s.fuente, patch) });
|
|
}
|
|
function moverCapa(id, delta) {
|
|
const i = mapSurfaces.findIndex((s) => s.id === id);
|
|
const j = i + delta;
|
|
if (i < 0 || j < 0 || j >= mapSurfaces.length) return;
|
|
const arr = mapSurfaces.slice();
|
|
arr.splice(j, 0, arr.splice(i, 1)[0]);
|
|
mapSurfaces = arr;
|
|
emitMapNow();
|
|
}
|
|
|
|
// ---------------------------------------------------------------- la lista
|
|
function renderCapas() {
|
|
const list = $("#map-list");
|
|
const sig = mapSurfaces.map((s) => [s.id, s.nombre, s.visible, s.n,
|
|
fuenteTexto(s.fuente)].join(",")).join("|")
|
|
+ "##" + mapMasks.map((k) => k.id).join("|")
|
|
+ "##" + (mapSel ? mapSel.type + mapSel.id : "");
|
|
$("#map-list-vacio").hidden = !!(mapSurfaces.length || mapMasks.length);
|
|
if (list.dataset.sig === sig) return;
|
|
list.dataset.sig = sig;
|
|
list.innerHTML = "";
|
|
|
|
const fila = (opciones) => {
|
|
const row = document.createElement("div");
|
|
row.className = "capa" + (opciones.sel ? " sel" : "")
|
|
+ (opciones.oculta ? " oculta" : "")
|
|
+ (opciones.mascara ? " mascara" : "");
|
|
row.addEventListener("click", opciones.onSel);
|
|
const txt = document.createElement("div");
|
|
txt.className = "txt";
|
|
txt.innerHTML = '<span class="nom"></span><span class="src"></span>';
|
|
txt.querySelector(".nom").textContent = opciones.nombre;
|
|
txt.querySelector(".src").textContent = opciones.sub;
|
|
row.appendChild(txt);
|
|
(opciones.botones || []).forEach((b) => {
|
|
const el = document.createElement("button");
|
|
el.textContent = b.txt;
|
|
el.title = b.titulo || "";
|
|
if (b.clase) el.className = b.clase;
|
|
if (b.off) el.disabled = true;
|
|
el.addEventListener("click", (e) => { e.stopPropagation(); b.fn(); });
|
|
row.appendChild(el);
|
|
});
|
|
list.appendChild(row);
|
|
};
|
|
|
|
// Las capas se pintan de arriba abajo: la ultima de la lista queda encima
|
|
// en el proyector, asi que la ensenamos al reves (como en cualquier VJ).
|
|
mapSurfaces.slice().reverse().forEach((s, ri) => {
|
|
const i = mapSurfaces.length - 1 - ri;
|
|
fila({
|
|
nombre: s.nombre || "Capa " + (i + 1),
|
|
sub: fuenteTexto(s.fuente) + " · malla " + s.n + "x" + s.n,
|
|
sel: mapSel && mapSel.type === "surface" && mapSel.id === s.id,
|
|
oculta: !s.visible,
|
|
onSel: () => { mapSel = { type: "surface", id: s.id }; renderMapping(); },
|
|
botones: [
|
|
{ txt: s.visible ? "◉" : "○", titulo: "Ver / ocultar",
|
|
fn: () => patchCapa(s.id, { visible: !s.visible }) },
|
|
{ txt: "▲", titulo: "Subir (mas al frente)", off: i === mapSurfaces.length - 1,
|
|
fn: () => moverCapa(s.id, 1) },
|
|
{ txt: "▼", titulo: "Bajar (mas al fondo)", off: i === 0,
|
|
fn: () => moverCapa(s.id, -1) },
|
|
{ txt: "✕", titulo: "Borrar capa", clase: "del", fn: () => {
|
|
mapSurfaces = mapSurfaces.filter((x) => x.id !== s.id);
|
|
if (mapSel && mapSel.id === s.id) mapSel = null;
|
|
emitMapNow();
|
|
} },
|
|
],
|
|
});
|
|
});
|
|
|
|
mapMasks.forEach((k, i) => fila({
|
|
nombre: "Mascara " + (i + 1),
|
|
sub: "Recuadro negro que tapa",
|
|
sel: mapSel && mapSel.type === "mask" && mapSel.id === k.id,
|
|
onSel: () => { mapSel = { type: "mask", id: k.id }; renderMapping(); },
|
|
mascara: true,
|
|
botones: [{ txt: "✕", titulo: "Borrar mascara", clase: "del", fn: () => {
|
|
mapMasks = mapMasks.filter((x) => x.id !== k.id);
|
|
if (mapSel && mapSel.id === k.id) mapSel = null;
|
|
emitMapNow();
|
|
} }],
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------- las propiedades
|
|
function renderPropiedades() {
|
|
const s = mapSelSurf();
|
|
const esMascara = mapSel && mapSel.type === "mask";
|
|
$("#prop-vacio").hidden = !!(s || esMascara);
|
|
$("#prop-capa").hidden = !s;
|
|
$("#prop-mascara").hidden = !esMascara;
|
|
const i = s ? mapSurfaces.findIndex((x) => x.id === s.id) : -1;
|
|
$("#prop-titulo").textContent = s
|
|
? (s.nombre || "Capa " + (i + 1))
|
|
: (esMascara ? "Mascara" : "Propiedades");
|
|
if (!s) return;
|
|
|
|
const f = s.fuente;
|
|
if (document.activeElement !== $("#prop-nombre")) {
|
|
$("#prop-nombre").value = s.nombre;
|
|
$("#prop-nombre").placeholder = "Capa " + (i + 1);
|
|
}
|
|
$("#prop-fuente").value = f.tipo;
|
|
$$(".fuente-op").forEach((el) => { el.hidden = true; });
|
|
const bloque = $("#fop-" + f.tipo);
|
|
if (bloque) bloque.hidden = false;
|
|
|
|
if (f.tipo === "butter") {
|
|
const esMilk = f.biblioteca === "projectm";
|
|
$("#prop-biblioteca").value = esMilk ? "projectm" : "butter";
|
|
$("#fop-bib-butter").hidden = esMilk;
|
|
$("#fop-bib-milk").hidden = !esMilk;
|
|
if (esMilk) renderMilkCapa(f);
|
|
|
|
fillSelectOnce($("#prop-preset"), state.meta.butterchurnPresets,
|
|
(n) => n, (n) => n);
|
|
if (f.preset && document.activeElement !== $("#prop-preset")) {
|
|
$("#prop-preset").value = f.preset;
|
|
}
|
|
$("#prop-auto").checked = !!f.auto;
|
|
$("#prop-auto-row").hidden = !f.auto;
|
|
$("#prop-seg").hidden = !f.auto;
|
|
$("#prop-seg").value = f.segundos || 20;
|
|
$("#prop-seg-val").textContent = f.segundos || 20;
|
|
// Con el cambio automatico puesto manda el reloj: elegir preset a mano
|
|
// no haria nada, asi que se apaga en vez de mentir. Con la biblioteca de
|
|
// projectM el preset va clavado, asi que el automatico tampoco aplica.
|
|
$("#prop-auto").disabled = esMilk;
|
|
$("#prop-preset").disabled = !!f.auto;
|
|
$("#prop-preset-prev").disabled = !!f.auto;
|
|
$("#prop-preset-next").disabled = !!f.auto;
|
|
const tr = typeof f.transicion === "number" ? f.transicion : 2.5;
|
|
if (document.activeElement !== $("#prop-transicion")) {
|
|
$("#prop-transicion").value = tr;
|
|
}
|
|
$("#prop-tr-val").textContent = Number(tr).toFixed(1);
|
|
} else if (f.tipo === "shader") {
|
|
fillSelectOnce($("#prop-shader"), shaderLib, (x) => x.id, (x) => x.name);
|
|
$("#prop-shader").value = f.shader || "";
|
|
} else if (f.tipo === "video") {
|
|
fillClipSelect($("#prop-video"));
|
|
$("#prop-video").value = f.archivo || "";
|
|
} else if (f.tipo === "camara") {
|
|
fillSelectOnce($("#prop-camara"), state.meta.cameraDevices,
|
|
(d) => d.id, (d) => d.name);
|
|
$("#prop-camara").value = f.camaraId || 0;
|
|
}
|
|
|
|
if (document.activeElement !== $("#prop-opacidad")) {
|
|
$("#prop-opacidad").value = s.opacidad;
|
|
}
|
|
$("#prop-op-val").textContent = Math.round(s.opacidad * 100);
|
|
$("#prop-mezcla").value = s.mezcla;
|
|
$("#map-sub-val").textContent = s.n + "x" + s.n;
|
|
}
|
|
|
|
// refleja estado, dibuja previsualizacion, lista de capas y propiedades
|
|
function renderMapping() {
|
|
const m = state.mapping || { enabled: false, edit: false, surfaces: [], masks: [] };
|
|
const en = $("#map-enabled"), ed = $("#map-edit");
|
|
if (document.activeElement !== en) en.checked = !!m.enabled;
|
|
if (document.activeElement !== ed) ed.checked = !!m.edit;
|
|
ed.disabled = !m.enabled;
|
|
|
|
// Mientras se arrastra, se escribe un nombre o queda un envio de nombre
|
|
// pendiente, no pisamos la copia local: el estado que llega todavia es el
|
|
// de antes y borraria lo que se acaba de escribir.
|
|
const escribiendo = document.activeElement === $("#prop-nombre") || !!nombreTimer;
|
|
if (!mapDrag && !escribiendo) {
|
|
mapSurfaces = (m.surfaces || []).filter((s) => s && s.id).map(mapMigrate);
|
|
// Una mascara sin esquinas (mapping.json raro) se descarta en vez de
|
|
// reventar el render del panel entero.
|
|
mapMasks = (m.masks || [])
|
|
.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]]) }));
|
|
if (mapSel) {
|
|
const ok = mapSel.type === "surface"
|
|
? mapSurfaces.some((s) => s.id === mapSel.id)
|
|
: mapMasks.some((k) => k.id === mapSel.id);
|
|
if (!ok) mapSel = null;
|
|
}
|
|
}
|
|
drawMapPreview();
|
|
renderCapas();
|
|
renderTabsCapas();
|
|
renderPropiedades();
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Eventos de la interfaz
|
|
// ==========================================================================
|
|
$("#power").addEventListener("click", () =>
|
|
socket.emit("set_power", { on: !state.power }));
|
|
|
|
// --- Mapping ---
|
|
$("#map-enabled").addEventListener("change", (e) =>
|
|
socket.emit("set_mapping", { enabled: e.target.checked }));
|
|
$("#map-edit").addEventListener("change", (e) =>
|
|
socket.emit("set_mapping", { edit: e.target.checked }));
|
|
/* Capa nueva: cae escalonada para que dos seguidas no se solapen del todo,
|
|
y queda seleccionada para poder elegir su fuente al momento. */
|
|
/* Crea una capa y devuelve su id. Cae escalonada para que dos seguidas no se
|
|
solapen del todo. */
|
|
function nuevaCapa() {
|
|
const n = mapSurfaces.length;
|
|
const s = 0.18;
|
|
const cx = cl01(0.3 + (n % 3) * 0.2), cy = cl01(0.35 + ((n / 3) | 0) * 0.2);
|
|
const nueva = {
|
|
id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4), n: 1,
|
|
nombre: "", visible: true, opacidad: 1, mezcla: "normal",
|
|
fuente: fuenteDeCapaNueva(),
|
|
points: [[cx - s, cy - s], [cx + s, cy - s], [cx - s, cy + s], [cx + s, cy + s]], // TL,TR,BL,BR
|
|
};
|
|
mapSurfaces = mapSurfaces.concat([nueva]);
|
|
mapSel = { type: "surface", id: nueva.id };
|
|
socket.emit("set_mapping",
|
|
{ enabled: true, surfaces: mapSurfaces, masks: mapMasks });
|
|
return nueva.id;
|
|
}
|
|
|
|
$("#map-add").addEventListener("click", () => {
|
|
nuevaCapa();
|
|
renderCapas(); renderTabsCapas(); renderPropiedades(); drawMapPreview();
|
|
});
|
|
|
|
/* "+ CAPA" de la barra de arriba: crea la capa y te deja YA dentro de su
|
|
pestana, que es lo que hace que trabajar con varias capas no sea un
|
|
suplicio de ir y volver a MAPPING. */
|
|
$("#tab-nueva-capa").addEventListener("click", () => {
|
|
const id = nuevaCapa();
|
|
renderCapas(); renderTabsCapas();
|
|
setView("capa:" + id);
|
|
});
|
|
$("#map-add-mask").addEventListener("click", () => {
|
|
const s = 0.15, cx = 0.5, cy = 0.5;
|
|
const nueva = {
|
|
id: "m" + Date.now() + "_" + Math.floor(Math.random() * 1e4),
|
|
corners: [[cx - s, cy - s], [cx + s, cy - s], [cx + s, cy + s], [cx - s, cy + s]], // TL,TR,BR,BL
|
|
};
|
|
mapMasks = mapMasks.concat([nueva]);
|
|
mapSel = { type: "mask", id: nueva.id };
|
|
socket.emit("set_mapping",
|
|
{ enabled: true, surfaces: mapSurfaces, masks: mapMasks });
|
|
renderCapas(); renderPropiedades(); drawMapPreview();
|
|
});
|
|
$("#map-reset").addEventListener("click", () => {
|
|
if (confirm("Borrar todas las capas y mascaras del mapping?")) {
|
|
mapSurfaces = []; mapMasks = []; mapSel = null;
|
|
socket.emit("set_mapping", { surfaces: [], masks: [] });
|
|
}
|
|
});
|
|
$("#map-sub-inc").addEventListener("click", () => {
|
|
const s = mapSelSurf(); if (!s || s.n >= MAP_MAXN) return;
|
|
mapSurfaces = mapSurfaces.map((x) => (x.id === s.id ? mapResample(s, s.n + 1) : x));
|
|
emitMapNow();
|
|
});
|
|
$("#map-sub-dec").addEventListener("click", () => {
|
|
const s = mapSelSurf(); if (!s || s.n <= 1) return;
|
|
mapSurfaces = mapSurfaces.map((x) => (x.id === s.id ? mapResample(s, s.n - 1) : x));
|
|
emitMapNow();
|
|
});
|
|
|
|
// --- Propiedades de la capa seleccionada ---
|
|
const selId = () => (mapSel && mapSel.type === "surface" ? mapSel.id : null);
|
|
|
|
// El nombre se guarda con calma: cada pulsacion escribe mapping.json en el
|
|
// disco, asi que no mandamos una por tecla.
|
|
let nombreTimer = null;
|
|
$("#prop-nombre").addEventListener("input", (e) => {
|
|
const id = selId(); if (!id) return;
|
|
mapSurfaces = mapSurfaces.map((s) =>
|
|
(s.id === id ? Object.assign({}, s, { nombre: e.target.value }) : s));
|
|
clearTimeout(nombreTimer);
|
|
nombreTimer = setTimeout(() => { nombreTimer = null; emitMapNow(); }, 400);
|
|
});
|
|
$("#prop-nombre").addEventListener("change", () => { renderCapas(); drawMapPreview(); });
|
|
|
|
/* Al cambiar de fuente estrenamos ajustes con algo que se vea ya: un preset al
|
|
azar, el primer shader o el primer clip de la lista. Elegir "MilkDrop" y
|
|
quedarse en negro esperando a rellenar otro desplegable es de las cosas que
|
|
mas despistan. */
|
|
function fuenteEstrenada(tipo) {
|
|
const f = { tipo: tipo };
|
|
if (tipo === "butter") {
|
|
const ps = (state.meta && state.meta.butterchurnPresets) || [];
|
|
f.preset = ps.length ? ps[Math.floor(Math.random() * ps.length)] : "";
|
|
f.biblioteca = "butter"; f.milk = "";
|
|
f.auto = false; f.segundos = 20; f.transicion = 2.5;
|
|
} else if (tipo === "shader") {
|
|
f.shader = shaderLib.length ? shaderLib[0].id : "";
|
|
} else if (tipo === "video") {
|
|
const vs = (state.meta && state.meta.videos) || [];
|
|
f.archivo = vs.length ? vs[0] : "";
|
|
} else if (tipo === "camara") {
|
|
f.camaraId = 0;
|
|
}
|
|
return f;
|
|
}
|
|
|
|
/* Fuente con la que nace una capa nueva. NO puede ser "motor": esa fuente es
|
|
literalmente el lienzo del motor activo, asi que dos capas recien creadas
|
|
ensenaban la misma imagen y parecia que las capas no fueran independientes.
|
|
Nace en MilkDrop con preset al azar — contenido distinto desde el primer
|
|
clic — y solo cae en "motor" si Butterchurn aun no ha dado su lista. */
|
|
function fuenteDeCapaNueva() {
|
|
const ps = (state.meta && state.meta.butterchurnPresets) || [];
|
|
return ps.length ? fuenteEstrenada("butter") : { tipo: "motor" };
|
|
}
|
|
|
|
$("#prop-fuente").addEventListener("change", (e) => {
|
|
const id = selId(); if (!id) return;
|
|
patchCapa(id, { fuente: fuenteEstrenada(e.target.value) });
|
|
renderCapas(); renderPropiedades(); drawMapPreview();
|
|
});
|
|
|
|
/* --- Biblioteca de projectM dentro de una capa ---
|
|
La ruta de un preset es "Categoria/Visual/Variante.milk"; el escenario la
|
|
pide al backend y la traduce a Butterchurn al vuelo (ver layers.js). Aqui
|
|
solo se elige. Las categorias y visuales ya vienen en el estado; las
|
|
variantes se piden al servidor al cambiar de visual, igual que hace el
|
|
motor projectM. */
|
|
function partesMilk(ruta) {
|
|
const p = String(ruta || "").split("/");
|
|
return { cat: p[0] || "", vis: p[1] || "",
|
|
vari: (p[2] || "").replace(/\.milk$/i, "") };
|
|
}
|
|
|
|
function rellena(sel, items, vacio) {
|
|
const sig = vacio + "|" + items.join("|");
|
|
if (sel.dataset.sig === sig) return false;
|
|
sel.dataset.sig = sig;
|
|
sel.innerHTML = "";
|
|
const o0 = document.createElement("option");
|
|
o0.value = ""; o0.textContent = vacio;
|
|
sel.appendChild(o0);
|
|
items.forEach((v) => {
|
|
const o = document.createElement("option");
|
|
o.value = v; o.textContent = v;
|
|
sel.appendChild(o);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function renderMilkCapa(f) {
|
|
const lib = (state.meta && state.meta.projectmPresets) || {};
|
|
const { cat, vis, vari } = partesMilk(f.milk);
|
|
const cats = Object.keys(lib);
|
|
|
|
rellena($("#prop-milk-cat"), cats, "-- categoria --");
|
|
$("#prop-milk-cat").value = cat;
|
|
|
|
rellena($("#prop-milk-vis"), cat ? (lib[cat] || []) : [],
|
|
cat ? "-- visual --" : "-- elige antes una categoria --");
|
|
$("#prop-milk-vis").value = vis;
|
|
|
|
const varSel = $("#prop-milk-var");
|
|
const sig = cat + "|" + vis;
|
|
if (varSel.dataset.pedido !== sig) {
|
|
varSel.dataset.pedido = sig;
|
|
rellena(varSel, [], cat && vis ? "cargando variantes..."
|
|
: "-- elige antes un visual --");
|
|
if (cat && vis) {
|
|
fetch("/pm/variantes?categoria=" + encodeURIComponent(cat)
|
|
+ "&visual=" + encodeURIComponent(vis))
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
if (varSel.dataset.pedido !== sig) return; // cambio a medias
|
|
varSel.dataset.sig = ""; // forzar repintado
|
|
rellena(varSel, d.variantes || [], "-- variante --");
|
|
const s = mapSelSurf();
|
|
varSel.value = partesMilk(s && s.fuente && s.fuente.milk).vari;
|
|
})
|
|
.catch(() => rellena(varSel, [], "-- no se pudieron leer --"));
|
|
}
|
|
} else if (document.activeElement !== varSel) {
|
|
varSel.value = vari;
|
|
}
|
|
|
|
const est = $("#prop-milk-est");
|
|
if (est) {
|
|
est.textContent = f.milk
|
|
? "En esta capa: " + f.milk.replace(/\.milk$/i, "")
|
|
: "Elige categoria, visual y variante — o pulsa 'Uno al azar'.";
|
|
}
|
|
}
|
|
|
|
/* Pone en la capa el .milk que toque, con lo que haya elegido de cada nivel.
|
|
Sin variante no hay preset que cargar: se coge la primera del visual. */
|
|
function ponMilk(cat, vis, vari) {
|
|
const id = selId(); if (!id) return;
|
|
if (!cat || !vis) { patchFuente(id, { biblioteca: "projectm", milk: "" }); return; }
|
|
if (vari) {
|
|
patchFuente(id, { biblioteca: "projectm", milk: `${cat}/${vis}/${vari}.milk` });
|
|
return;
|
|
}
|
|
fetch("/pm/variantes?categoria=" + encodeURIComponent(cat)
|
|
+ "&visual=" + encodeURIComponent(vis))
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
const v = (d.variantes || [])[0];
|
|
patchFuente(id, { biblioteca: "projectm",
|
|
milk: v ? `${cat}/${vis}/${v}.milk` : "" });
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
$("#prop-biblioteca").addEventListener("change", (e) => {
|
|
const id = selId(); if (!id) return;
|
|
if (e.target.value === "projectm") {
|
|
// Estrenar con algo que se vea: si no habia .milk, uno al azar
|
|
const s = mapSelSurf();
|
|
if (s && s.fuente && s.fuente.milk) patchFuente(id, { biblioteca: "projectm" });
|
|
else { patchFuente(id, { biblioteca: "projectm", auto: false }); milkAzar(); }
|
|
} else {
|
|
patchFuente(id, { biblioteca: "butter" });
|
|
}
|
|
renderPropiedades();
|
|
});
|
|
|
|
$("#prop-milk-cat").addEventListener("change", (e) =>
|
|
ponMilk(e.target.value, "", ""));
|
|
$("#prop-milk-vis").addEventListener("change", (e) =>
|
|
ponMilk($("#prop-milk-cat").value, e.target.value, ""));
|
|
$("#prop-milk-var").addEventListener("change", (e) =>
|
|
ponMilk($("#prop-milk-cat").value, $("#prop-milk-vis").value, e.target.value));
|
|
|
|
function milkAzar() {
|
|
const lib = (state.meta && state.meta.projectmPresets) || {};
|
|
const cats = Object.keys(lib).filter((c) => (lib[c] || []).length);
|
|
if (!cats.length) {
|
|
showNotif({ level: "warn", message: "No hay biblioteca de projectM "
|
|
+ "instalada: data/presets-projectm esta vacia." });
|
|
return;
|
|
}
|
|
const cat = cats[Math.floor(Math.random() * cats.length)];
|
|
const viss = lib[cat];
|
|
ponMilk(cat, viss[Math.floor(Math.random() * viss.length)], "");
|
|
}
|
|
$("#prop-milk-azar").addEventListener("click", milkAzar);
|
|
|
|
$("#prop-preset").addEventListener("change", (e) => {
|
|
const id = selId(); if (id) patchFuente(id, { preset: e.target.value });
|
|
});
|
|
function pasoPresetCapa(delta) {
|
|
const id = selId(); const s = mapSelSurf();
|
|
const ps = (state && state.meta.butterchurnPresets) || [];
|
|
if (!id || !s || !ps.length) return;
|
|
const i = ps.indexOf(s.fuente.preset);
|
|
patchFuente(id, { preset: ps[((i < 0 ? 0 : i) + delta + ps.length) % ps.length] });
|
|
}
|
|
$("#prop-preset-prev").addEventListener("click", () => pasoPresetCapa(-1));
|
|
$("#prop-preset-next").addEventListener("click", () => pasoPresetCapa(1));
|
|
$("#prop-auto").addEventListener("change", (e) => {
|
|
const id = selId(); if (id) patchFuente(id, { auto: e.target.checked });
|
|
});
|
|
$("#prop-seg").addEventListener("input", (e) =>
|
|
$("#prop-seg-val").textContent = e.target.value);
|
|
$("#prop-seg").addEventListener("change", (e) => {
|
|
const id = selId();
|
|
if (id) patchFuente(id, { segundos: parseInt(e.target.value, 10) });
|
|
});
|
|
$("#prop-transicion").addEventListener("input", (e) =>
|
|
$("#prop-tr-val").textContent = Number(e.target.value).toFixed(1));
|
|
$("#prop-transicion").addEventListener("change", (e) => {
|
|
const id = selId();
|
|
if (id) patchFuente(id, { transicion: parseFloat(e.target.value) });
|
|
});
|
|
$("#prop-shader").addEventListener("change", (e) => {
|
|
const id = selId(); if (id) patchFuente(id, { shader: e.target.value });
|
|
});
|
|
$("#prop-video").addEventListener("change", (e) => {
|
|
const id = selId(); if (id) patchFuente(id, { archivo: e.target.value });
|
|
});
|
|
$("#prop-camara").addEventListener("change", (e) => {
|
|
const id = selId(); if (id) patchFuente(id, { camaraId: parseInt(e.target.value, 10) });
|
|
});
|
|
$("#prop-opacidad").addEventListener("input", (e) =>
|
|
$("#prop-op-val").textContent = Math.round(parseFloat(e.target.value) * 100));
|
|
$("#prop-opacidad").addEventListener("change", (e) => {
|
|
const id = selId();
|
|
if (id) patchCapa(id, { opacidad: parseFloat(e.target.value) });
|
|
});
|
|
$("#prop-mezcla").addEventListener("change", (e) => {
|
|
const id = selId(); if (id) patchCapa(id, { mezcla: e.target.value });
|
|
});
|
|
|
|
/* Convierte la mascara seleccionada en una capa en el mismo sitio. Es el
|
|
error mas facil de cometer: pulsar "+ Mascara" buscando donde poner un
|
|
visual. En vez de dejar el callejon sin salida, se sale de el. */
|
|
$("#mask-a-capa").addEventListener("click", () => {
|
|
if (!mapSel || mapSel.type !== "mask") return;
|
|
const m = mapMasks.find((k) => k.id === mapSel.id);
|
|
if (!m) return;
|
|
const c = m.corners; // TL,TR,BR,BL -> la capa los quiere TL,TR,BL,BR
|
|
const nueva = {
|
|
id: "s" + Date.now() + "_" + Math.floor(Math.random() * 1e4), n: 1,
|
|
nombre: "", visible: true, opacidad: 1, mezcla: "normal",
|
|
fuente: fuenteDeCapaNueva(),
|
|
points: [c[0].slice(), c[1].slice(), c[3].slice(), c[2].slice()],
|
|
};
|
|
mapMasks = mapMasks.filter((k) => k.id !== m.id);
|
|
mapSurfaces = mapSurfaces.concat([nueva]);
|
|
mapSel = { type: "surface", id: nueva.id };
|
|
emitMapNow();
|
|
renderCapas(); renderPropiedades(); drawMapPreview();
|
|
});
|
|
// Previsualizacion arrastrable (raton + tactil via pointer events)
|
|
(function () {
|
|
const mp = $("#map-preview");
|
|
if (!mp) return;
|
|
mp.addEventListener("pointerdown", (e) => { mp.setPointerCapture(e.pointerId); onMapDown(e); });
|
|
mp.addEventListener("pointermove", onMapMove);
|
|
mp.addEventListener("pointerup", onMapUp);
|
|
mp.addEventListener("pointercancel", onMapUp);
|
|
window.addEventListener("resize", () => { if (state) drawMapPreview(); });
|
|
})();
|
|
|
|
$$(".engine").forEach((b) =>
|
|
b.addEventListener("click", () =>
|
|
socket.emit("set_engine", { engine: b.dataset.engine })));
|
|
|
|
$$(".info").forEach((b) =>
|
|
b.addEventListener("click", () => openHelp(b.dataset.help)));
|
|
$("#modal-close").addEventListener("click", closeHelp);
|
|
$("#modal").addEventListener("click", (e) => {
|
|
if (e.target.id === "modal") closeHelp();
|
|
});
|
|
|
|
$("#notif-close").addEventListener("click", () => { $("#notif").hidden = true; });
|
|
|
|
// Eleccion de fuente: micro de la sala o audio del propio equipo (monitor).
|
|
// El cambio de 'source' ya hace que el escenario reconecte la captura; el
|
|
// boton "Aplicar entrada" queda como reintento manual.
|
|
$$("#audio-source .segbtn").forEach((b) =>
|
|
b.addEventListener("click", () =>
|
|
socket.emit("update_settings",
|
|
{ engine: "audio", patch: { source: b.dataset.src } })));
|
|
|
|
$("#audio-device").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "audio", patch: { device: e.target.value } }));
|
|
// Boton "Aplicar microfono": fija la entrada elegida y obliga al escenario
|
|
// a re-conectar la captura de audio (util tras enchufar o cambiar el micro).
|
|
$("#audio-apply").addEventListener("click", () => {
|
|
socket.emit("update_settings",
|
|
{ engine: "audio", patch: { device: $("#audio-device").value } });
|
|
socket.emit("reacquire_audio");
|
|
});
|
|
|
|
$("#sens").addEventListener("input", (e) =>
|
|
$("#sens-val").textContent = Number(e.target.value).toFixed(1));
|
|
$("#sens").addEventListener("change", (e) =>
|
|
socket.emit("set_sensitivity", { value: parseFloat(e.target.value) }));
|
|
|
|
// --- Butterchurn ---
|
|
$("#bc-preset").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "butterchurn", patch: { preset: e.target.value } }));
|
|
$("#bc-shuffle").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "butterchurn", patch: { shuffle: e.target.checked } }));
|
|
$("#bc-mode-seconds").addEventListener("click", () =>
|
|
socket.emit("update_settings",
|
|
{ engine: "butterchurn", patch: { intervalMode: "seconds" } }));
|
|
$("#bc-mode-beats").addEventListener("click", () =>
|
|
socket.emit("update_settings",
|
|
{ engine: "butterchurn", patch: { intervalMode: "beats" } }));
|
|
$("#bc-interval").addEventListener("input", (e) =>
|
|
$("#bc-int-val").textContent = e.target.value);
|
|
$("#bc-interval").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "butterchurn", patch: { interval: parseInt(e.target.value, 10) } }));
|
|
$("#bc-blend").addEventListener("input", (e) =>
|
|
$("#bc-blend-val").textContent = Number(e.target.value).toFixed(1));
|
|
$("#bc-blend").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "butterchurn", patch: { blendTime: parseFloat(e.target.value) } }));
|
|
|
|
// --- Editor de codigo ---
|
|
$("#run-code").addEventListener("click", () => {
|
|
if (!editor || !editorEngine) return;
|
|
socket.emit("run_code",
|
|
{ engine: editorEngine, code: editor.getValue(), label: "codigo personalizado" });
|
|
});
|
|
$("#clear-code").addEventListener("click", () => editor && editor.setValue(""));
|
|
$("#lib-select").addEventListener("change", (e) => {
|
|
const lib = editorEngine === "hydra" ? hydraLib : shaderLib;
|
|
const item = lib[parseInt(e.target.value, 10)];
|
|
if (!item || !editor) return;
|
|
editor.setValue(item.code);
|
|
socket.emit("run_code",
|
|
{ engine: editorEngine, code: item.code, label: item.name });
|
|
});
|
|
|
|
// --- Mezclador ---
|
|
$$("#mixer-source .segbtn").forEach((b) =>
|
|
b.addEventListener("click", () =>
|
|
socket.emit("update_settings",
|
|
{ engine: "mixer", patch: { source: b.dataset.src } })));
|
|
$("#mix-cam").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "mixer", patch: { camOn: e.target.checked } }));
|
|
$("#mix-camera").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "mixer", patch: { cameraId: parseInt(e.target.value, 10) } }));
|
|
$("#dev-rescan").addEventListener("click", () =>
|
|
socket.emit("rescan_devices"));
|
|
$("#mix-video").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "mixer", patch: { video: e.target.value } }));
|
|
$("#mix-fondo").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "mixer", patch: { fondo: e.target.value } }));
|
|
$("#mix-blend").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "mixer", patch: { blendMode: e.target.value } }));
|
|
$("#mix-rescan").addEventListener("click", () => socket.emit("rescan_videos"));
|
|
// Fondo de visuales Butter: es el mismo preset del motor Butter, asi que se
|
|
// cambia por el estado (no con un comando al escenario) y queda elegido en
|
|
// los dos sitios.
|
|
$("#mix-bg-preset").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "butterchurn", patch: { preset: e.target.value } }));
|
|
$("#mix-bg-shuffle").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "butterchurn", patch: { shuffle: e.target.checked } }));
|
|
function pasoFondo(delta) {
|
|
const names = (state && state.meta.butterchurnPresets) || [];
|
|
if (!names.length) return;
|
|
const i = names.indexOf(state.butterchurn.preset);
|
|
const next = ((i < 0 ? 0 : i) + delta + names.length) % names.length;
|
|
socket.emit("update_settings",
|
|
{ engine: "butterchurn", patch: { preset: names[next] } });
|
|
}
|
|
$("#mix-bg-prev").addEventListener("click", () => pasoFondo(-1));
|
|
$("#mix-bg-next").addEventListener("click", () => pasoFondo(1));
|
|
$("#mix-invert").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "mixer", patch: { invert: e.target.checked } }));
|
|
$("#mix-beat").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "mixer", patch: { beatPulse: e.target.checked } }));
|
|
|
|
// --- projectM ---
|
|
$("#pm-category").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "projectm",
|
|
patch: { category: e.target.value, preset: "", variante: "" } }));
|
|
$("#pm-preset").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "projectm", patch: { preset: e.target.value, variante: "" } }));
|
|
$("#pm-variante").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "projectm", patch: { variante: e.target.value } }));
|
|
$("#pm-fav-add").addEventListener("click", () =>
|
|
socket.emit("pm_favorito", { action: "add" }));
|
|
$("#pm-shuffle").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "projectm", patch: { shuffle: e.target.checked } }));
|
|
$("#pm-duration").addEventListener("input", (e) =>
|
|
$("#pm-dur-val").textContent = e.target.value);
|
|
$("#pm-duration").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "projectm", patch: { duration: parseInt(e.target.value, 10) } }));
|
|
$("#pm-monitor").addEventListener("change", (e) =>
|
|
socket.emit("update_settings",
|
|
{ engine: "projectm", patch: { monitor: e.target.value } }));
|
|
$("#pm-lock").addEventListener("click", () =>
|
|
socket.emit("engine_command", { action: "lock" }));
|
|
$("#pm-fix").addEventListener("click", () =>
|
|
socket.emit("engine_command", { action: "fix_current" }));
|
|
|
|
// --- Subida a la galeria del mezclador ---
|
|
$("#mix-upload-btn").addEventListener("click", () => $("#mix-upload").click());
|
|
$("#mix-upload").addEventListener("change", () => {
|
|
const f = $("#mix-upload").files[0];
|
|
if (!f) return;
|
|
const status = $("#mix-upload-status");
|
|
const fd = new FormData();
|
|
fd.append("file", f);
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("POST", "/upload" + (CLAVE ? "?k=" + encodeURIComponent(CLAVE) : ""));
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) {
|
|
status.textContent = "subiendo " + Math.round(e.loaded / e.total * 100) + "%";
|
|
}
|
|
};
|
|
xhr.onload = () => {
|
|
let r = {};
|
|
try { r = JSON.parse(xhr.responseText); } catch (e) { /* respuesta rara */ }
|
|
status.textContent = r.ok ? "subido ✓" : "";
|
|
if (!r.ok) {
|
|
showNotif({ level: "error",
|
|
message: r.error || "La subida fallo (" + xhr.status + ")." });
|
|
}
|
|
$("#mix-upload").value = "";
|
|
setTimeout(() => { status.textContent = ""; }, 5000);
|
|
};
|
|
xhr.onerror = () => {
|
|
status.textContent = "";
|
|
$("#mix-upload").value = "";
|
|
showNotif({ level: "error", message: "La subida fallo: no hay conexion "
|
|
+ "con el servidor de FOSFENO." });
|
|
};
|
|
status.textContent = "subiendo 0%";
|
|
xhr.send(fd);
|
|
});
|
|
|
|
// --- Comandos siguiente/anterior ---
|
|
$$(".cmd[data-action]").forEach((b) =>
|
|
b.addEventListener("click", () =>
|
|
socket.emit("engine_command", { action: b.dataset.action })));
|
|
|
|
// --- Sistema ---
|
|
$("#reboot").addEventListener("click", () => {
|
|
if (confirm("Reiniciar la Raspberry Pi?")) socket.emit("system", { action: "reboot" });
|
|
});
|
|
$("#shutdown").addEventListener("click", () => {
|
|
if (confirm("Apagar la Raspberry Pi?")) socket.emit("system", { action: "shutdown" });
|
|
});
|
|
|
|
// ==========================================================================
|
|
// WebSocket
|
|
// ==========================================================================
|
|
// Al conectar, el panel se presenta para que el proyector deje de mostrar
|
|
// la pantalla del codigo QR.
|
|
socket.on("connect", () => socket.emit("hello", { role: "panel" }));
|
|
socket.on("state", (next) => { state = next; render(); });
|
|
socket.on("status", (s) => {
|
|
if (!state) return;
|
|
state.status.label = s.label;
|
|
state.audio.bpm = s.bpm;
|
|
$("#now").textContent = nowText() || "-";
|
|
$("#bpm").textContent = s.bpm > 0 ? s.bpm : "--";
|
|
});
|
|
socket.on("notify", (entry) => showNotif(entry));
|
|
socket.on("disconnect", () => { $("#conn").className = "dot off"; });
|
|
|
|
// ==========================================================================
|
|
// Arranque
|
|
// ==========================================================================
|
|
buildMixerSliders();
|
|
loadData();
|