MOTORES: pastillas configurables y destino (salida o capa)

Las pestanas de capa de la barra superior no valian: mezclaban navegar por el
panel con elegir sobre que se trabaja, y sacaban del panel de directo. Se
quitan y todo pasa a MOTORES, con dos decisiones separadas:

  DONDE  -> fila 'Donde se ve': SALIDA o una capa del mapping (+ crea una).
  QUE    -> las pastillas: combinaciones guardadas por el usuario.

Pinchar una pastilla la manda al destino elegido: con SALIDA cambia el motor
global, con una capa cambia SOLO esa capa y el resto sigue igual. Con una capa
elegida sus ajustes salen en MOTORES (la tarjeta de propiedades se mueve, no
se duplica: dos formularios para lo mismo se desincronizan a la primera).

Las pastillas son configurables: motor + preset + ajustes y su nombre, con
'Configurar pastillas'. Persisten en data/slots.json (gitignorado, es de cada
instalacion). De fabrica vienen los cinco motores de siempre, asi que quien
actualice se encuentra lo mismo que tenia. Hydra, Mezcla y projectM nativo
solo pueden ir a la salida, y al intentar mandarlos a una capa se dice.

Comprobado en Chromium por CDP, no con --dump-dom: ese fotografia el DOM al
cargar, antes de que llegue el estado por socket, y daba falsos "esta vacio".
Verificado que disparar sobre una capa deja el motor global intacto, que la
tarjeta de propiedades va y vuelve de su sitio, y que las pastillas nuevas se
guardan en disco.

De paso, dos fallos que aparecieron al probarlo:
- Restaurar la vista guardada corria al principio del fichero y llamaba a
  codigo que usa variables declaradas con let mas abajo: ReferenceError que
  se llevaba por delante el resto del script (socket incluido) y dejaba el
  panel en blanco. Ahora se restaura al final.
- #card-destino no estaba en la lista de 'order' del CSS, asi que caia a 0 y
  se colaba encima de las pastillas pese a ir despues en el HTML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-08-02 16:47:01 +02:00
parent f017246f0e
commit 61543e1987
8 changed files with 602 additions and 146 deletions

2
.gitignore vendored
View file

@ -22,6 +22,8 @@ data/miniaturas/
# Config de runtime (por instalacion): no va al repo
data/mapping.json
# Pastillas del panel: son las combinaciones de cada uno, no del proyecto
data/slots.json
data/pm-favoritos.json
data/pm-fijado/

View file

@ -79,6 +79,19 @@ socketio = SocketIO(app, async_mode="threading", cors_allowed_origins=None)
HYDRA_SKETCHES = load_json(DATA / "hydra-sketches.json", {})
SHADERS = load_json(DATA / "shaders.json", {})
# Pastillas: combinaciones guardadas (motor + preset + ajustes) que el panel
# dispara de un toque sobre el destino elegido (la salida o una capa del
# mapping). Las de fabrica son los cinco motores de siempre, para que quien
# actualice se encuentre lo mismo que tenia.
SLOTS_FABRICA = [
{"id": "sl-projectm", "nombre": "projectM", "tipo": "projectm-nativo"},
{"id": "sl-butter", "nombre": "Butter", "tipo": "butter",
"biblioteca": "butter", "preset": ""},
{"id": "sl-hydra", "nombre": "Hydra", "tipo": "hydra"},
{"id": "sl-shaders", "nombre": "Shaders", "tipo": "shader", "shader": ""},
{"id": "sl-mezcla", "nombre": "Mezcla", "tipo": "mixer"},
]
DEFAULTS = CFG.get("defaults", {})
ENGINES = ("projectm", "butterchurn", "hydra", "shaders", "mixer")
@ -187,6 +200,9 @@ state = {
# apagado para que el proyector salga limpio, sin marcos
# de colores ni circulos en las esquinas.
edit=False),
# Pastillas del panel: persisten en data/slots.json
"slots": (load_json(DATA / "slots.json", None)
if (DATA / "slots.json").is_file() else None) or list(SLOTS_FABRICA),
"meta": {
"butterchurnPresets": [],
"audioDevices": [],
@ -743,6 +759,34 @@ def capas_saneadas(lista, clave_geo):
return limpio
def slots_saneadas(lista):
"""Filtra lo que no tenga forma de pastilla y limita cuantas se aceptan:
igual que con el mapping, cualquiera en la red podria llenar el disco."""
limpio = []
for it in lista[:24]:
if isinstance(it, dict) and isinstance(it.get("id"), str) \
and isinstance(it.get("tipo"), str):
it["nombre"] = str(it.get("nombre", ""))[:12]
limpio.append(it)
return limpio
@socketio.on("set_slots")
def on_set_slots(data):
"""Guarda las pastillas del panel (data/slots.json) y las difunde."""
lista = (data or {}).get("slots")
if not isinstance(lista, list):
return
with lock:
state["slots"] = slots_saneadas(lista)
try:
with open(DATA / "slots.json", "w", encoding="utf-8") as fh:
json.dump(state["slots"], fh, ensure_ascii=False, indent=2)
except OSError as exc:
notify("error", f"No se pudieron guardar las pastillas: {exc}")
broadcast()
@socketio.on("set_mapping")
def on_set_mapping(data):
"""Projection mapping: activar/editar y guardar las superficies.

View file

@ -23,11 +23,13 @@
]
},
"engines": {
"title": "Motor de visuales",
"title": "Pastillas",
"body": [
"FOSFENO tiene cinco motores de visuales distintos. Solo uno funciona a la vez.",
"Cada boton redondo cambia al motor correspondiente. Al cambiar de motor aparecen debajo sus propios controles.",
"Pulsa el boton de informacion de cada motor para saber que hace, que necesita y como se configura."
"Cada circulo es una PASTILLA: una combinacion tuya guardada (motor + preset + ajustes) con el nombre que le pongas.",
"Al pincharla, salta al DESTINO que tengas elegido abajo: la salida entera o una capa concreta del mapping. No cambia nada mas.",
"Con 'Configurar pastillas' el toque deja de lanzar y pasa a editar: eliges que hace cada una, le pones nombre, anades con el '+' o la borras. Se guardan en el equipo.",
"De fabrica vienen los cinco motores de siempre (projectM, Butter, Hydra, Shaders, Mezcla), asi que puedes seguir usandolo como antes y montarte las tuyas cuando quieras.",
"Hydra, Mezcla y projectM nativo solo pueden ir a pantalla completa: son motores globales, no fuentes que una capa sepa pintar."
]
},
"projectm": {
@ -134,6 +136,15 @@
"projectM no puede ir en una capa: es un programa nativo que corre en su propia ventana, fuera del navegador. Para el mismo tipo de visual, usa 'Visuales MilkDrop propias', que son los mismos presets.",
"Ojo con la Raspberry: cada capa de MilkDrop o de shader es un motor de verdad funcionando. Dos o tres van bien; con muchas mas la cosa se arrastra. Los clips y la camara son mucho mas baratos."
]
},
"destino": {
"title": "Donde se ve",
"body": [
"Aqui eliges a donde van las pastillas que dispares.",
"SALIDA es el proyector entero. Cada CAPA es una zona del mapping.",
"Con una capa elegida, debajo salen sus ajustes (fuente, preset, opacidad, mezcla) sin tener que ir a la pestana MAPPING.",
"El '+' crea una capa nueva y te deja ya sobre ella. Para colocarla y deformarla sobre la superficie fisica, ve a MAPPING."
]
}
}
}

View file

@ -1,9 +1,10 @@
# Projection mapping
> **Pestañas de capa.** Cada capa tiene su propia pestaña arriba, junto a
> MOTORES y MAPPING, y el botón **+ CAPA** crea una y te deja ya dentro de
> ella. Esa vista enseña solo la previsualización y los ajustes de *esa* capa:
> es la forma de trabajar cuando hay varias, sin ir y volver a la lista.
> **Las capas se manejan desde MOTORES.** La fila *Dónde se ve* de la pestaña
> MOTORES lista `SALIDA` y una entrada por capa; eligiendo una, sus ajustes
> salen ahí mismo y las pastillas la apuntan a ella. MAPPING queda para lo
> suyo: **colocar y deformar** las capas sobre la superficie física. Ver
> `uso.md`.
>
> **Visuales de projectM en una capa.** La fuente "Visuales MilkDrop" tiene
> ahora un selector de **biblioteca**: los ~100 presets de Butterchurn, o los

View file

@ -50,9 +50,36 @@ para que no se te escapen.
El botón grande de encendido pone las visuales en marcha o las apaga. Apagar
deja el proyector en negro sin tener que apagar la Raspberry.
## Pastillas y destino
La pestaña MOTORES se maneja con **dos decisiones separadas**, y esa
separación es lo que la hace usable en directo:
- **Dónde se ve** — la fila de arriba: `SALIDA` (el proyector entero) o una
`CAPA` del mapping. El `+` crea una capa nueva y te deja ya sobre ella.
- **Pastillas** — los círculos: combinaciones tuyas guardadas (motor + preset
+ ajustes) con el nombre que les pongas.
Pinchar una pastilla la manda **al destino que tengas elegido**. Con `SALIDA`
cambia todo el proyector; con una capa elegida cambia solo esa zona y el resto
sigue igual. Con una capa elegida, además, sus ajustes (fuente, preset,
opacidad, mezcla) salen ahí mismo: no hace falta ir a MAPPING.
Con **Configurar pastillas** el toque deja de lanzar y pasa a editar: decides
qué hace cada una, la renombras, añades con el `+` o la borras. Se guardan en
el equipo (`data/slots.json`), así que al volver siguen ahí.
De fábrica vienen los cinco motores de siempre, así que puedes seguir usándolo
como antes y montarte las tuyas cuando quieras.
> **Hydra, Mezcla y projectM nativo solo van a pantalla completa.** Son
> motores globales, no fuentes que una capa sepa pintar. Si intentas mandarlos
> a una capa, el panel te lo dice en vez de no hacer nada.
## Los cinco motores
Se eligen con los botones redondos. Solo uno está activo a la vez.
Son los que vienen de fábrica en las pastillas. Solo uno está activo a la vez
en la salida.
![Visuales estilo MilkDrop](assets/milkdrop.jpg)

View file

@ -22,11 +22,6 @@
<button class="tab active" data-view="motores">MOTORES</button>
<button class="tab" data-view="mapping">MAPPING<span
id="tab-map-dot" class="tabdot" hidden></span></button>
<!-- Una pestana por capa del mapping: se generan solas (renderTabsCapas)
para poder saltar a editar cada capa sin pasar por MAPPING -->
<span id="tabs-capas"></span>
<button class="tab tabnew" id="tab-nueva-capa"
title="Crear una capa nueva y abrirla">+ CAPA</button>
</nav>
<span id="conn" class="dot off" title="Conexion"></span>
</header>
@ -102,24 +97,75 @@
<div class="column">
<!-- Selector de motor -->
<!-- Pastillas: cada circulo es una combinacion guardada (motor +
preset + ajustes) con su nombre. Se disparan sobre el DESTINO que
este elegido abajo: la salida entera o una capa del mapping. -->
<section class="card" id="card-engines">
<div class="cardhead">
<span class="label">Motor de visuales</span>
<span class="label">Pastillas</span>
<button class="info" data-help="engines">i</button>
</div>
<div class="engines">
<button class="engine" data-engine="projectm">
<strong>projectM</strong><small>nativo</small></button>
<button class="engine" data-engine="butterchurn">
<strong>Butter</strong><small>MilkDrop</small></button>
<button class="engine" data-engine="hydra">
<strong>Hydra</strong><small>codigo</small></button>
<button class="engine" data-engine="shaders">
<strong>Shaders</strong><small>GLSL</small></button>
<button class="engine" data-engine="mixer">
<strong>Mezcla</strong><small>camara</small></button>
<div class="engines" id="slots"></div>
<div class="row">
<button id="slots-config" class="cmd">Configurar pastillas</button>
</div>
<!-- Editor de una pastilla (solo en modo configurar) -->
<div class="stack" id="slot-editor" hidden>
<div class="row">
<span class="label">Nombre</span>
<input type="text" id="slot-nombre" class="txt" maxlength="12"
placeholder="TUNEL">
</div>
<span class="label">Que lanza</span>
<select id="slot-tipo">
<option value="butter">Visuales MilkDrop</option>
<option value="shader">Shader GLSL</option>
<option value="video">Clip o imagen de la galeria</option>
<option value="camara">Camara</option>
<option value="hydra">Hydra (solo a la salida)</option>
<option value="mixer">Mezclador VJ (solo a la salida)</option>
<option value="projectm-nativo">projectM nativo (solo a la salida)</option>
</select>
<div class="stack slot-op" id="sop-butter" hidden>
<select id="slot-biblioteca">
<option value="butter">Butterchurn &mdash; ~100 presets</option>
<option value="projectm">projectM &mdash; biblioteca .milk</option>
</select>
<select id="slot-preset"><option value="">-- al azar --</option></select>
<div class="stack" id="sop-milk" hidden>
<select id="slot-milk-cat"><option value="">-- categoria --</option></select>
<select id="slot-milk-vis"><option value="">-- visual --</option></select>
<select id="slot-milk-var"><option value="">-- variante --</option></select>
</div>
</div>
<div class="stack slot-op" id="sop-shader" hidden>
<select id="slot-shader"><option value="">-- elige un shader --</option></select>
</div>
<div class="stack slot-op" id="sop-video" hidden>
<select id="slot-video"><option value="">-- elige un clip --</option></select>
</div>
<div class="row">
<button id="slot-borrar" class="cmd del">Borrar pastilla</button>
<button id="slot-listo" class="cmd accent">Listo</button>
</div>
<p class="hint">Las pastillas se guardan en el equipo: al volver
siguen ahi. En directo, pincha una y salta al destino elegido.</p>
</div>
</section>
<!-- Destino: a donde va lo que dispares. La salida entera, o una capa
concreta del mapping. Aqui tambien se crean capas nuevas. -->
<section class="card" id="card-destino">
<div class="cardhead">
<span class="label">Donde se ve</span>
<button class="info" data-help="destino">i</button>
</div>
<div class="subtabs" id="destinos"></div>
<p class="hint" id="destino-hint"></p>
<!-- Cuando el destino es una capa, aqui se mete su tarjeta de
propiedades (la misma de MAPPING, prestada) -->
<div id="destino-props"></div>
</section>
<!-- Butterchurn -->

View file

@ -96,15 +96,20 @@ main {
}
.column { display: contents; } /* en movil, las columnas no existen */
/* Orden de las tarjetas cuando estan todas en una sola columna (movil) */
/* Orden de las tarjetas cuando estan todas en una sola columna (movil).
Toda tarjeta de <main> tiene que estar en esta lista: la que falte se
queda en order 0 y se cuela LA PRIMERA, por mucho que en el HTML este
abajo. Primero las pastillas (lo que se toca en directo) y justo despues
el destino, que es su pareja: que lanzas / a donde va. */
#card-power { order: 1; }
#card-engines { order: 2; }
#card-audio { order: 3; }
#card-sens { order: 4; }
#ctl-butterchurn, #ctl-editor, #ctl-mixer, #ctl-projectm { order: 5; }
#card-now { order: 6; }
#card-sys { order: 7; }
#net-foot { order: 8; }
#card-destino { order: 3; }
#card-audio { order: 4; }
#card-sens { order: 5; }
#ctl-butterchurn, #ctl-editor, #ctl-mixer, #ctl-projectm { order: 6; }
#card-now { order: 7; }
#card-sys { order: 8; }
#net-foot { order: 9; }
/* --- Disposicion: pantalla ancha (portatil) en dos columnas --- */
@media (min-width: 880px) {
@ -118,31 +123,20 @@ main {
}
/* --- Vistas (pestanas) ---
MOTORES ensena todo menos las tarjetas de mapping, y al reves. La vista
"capa" es MAPPING recortado: solo la previa y las propiedades de UNA capa,
sin la lista, para poder centrarse en su efecto. */
MOTORES ensena todo menos las tarjetas de mapping, y al reves. Salvo la
tarjeta de propiedades de capa, que MOTORES se lleva prestada cuando estas
editando una capa (ver colocarPropiedades en panel.js). */
body[data-view="motores"] [data-vista="mapping"] { display: none !important; }
body[data-view="mapping"] main > .column > :not([data-vista="mapping"]),
body[data-view="capa"] main > .column > :not([data-vista="mapping"]) {
body[data-view="motores"] #destino-props #card-propiedades {
display: block !important;
}
body[data-view="mapping"] main > .column > :not([data-vista="mapping"]) {
display: none !important;
}
body[data-view="capa"] #card-capas { display: none !important; }
body[data-view="mapping"] main,
body[data-view="capa"] main {
body[data-view="mapping"] main {
flex-direction: column; width: 100%; max-width: 900px;
}
body[data-view="mapping"] .column,
body[data-view="capa"] .column { display: contents; }
/* Pestanas de capa: mas pequenas que MOTORES/MAPPING, para que se lean como
lo que son (un atajo a cada capa) y no compitan con las dos principales. */
.tab.tabcapa, .tab.tabnew {
font-size: 0.78em; padding: 6px 10px; letter-spacing: 0.06em;
max-width: 11ch; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
.tab.tabnew { border-style: dashed; opacity: 0.85; }
.tab.tabnew:hover { opacity: 1; }
body[data-view="mapping"] .column { display: contents; }
/* En pantalla ancha el mapping se reparte: la previsualizacion grande a la
izquierda (que es donde se trabaja) y las capas con sus propiedades a la
@ -159,17 +153,6 @@ body[data-view="capa"] .column { display: contents; }
body[data-view="mapping"] #card-propiedades { grid-area: props; }
/* La previsualizacion manda: que ocupe todo el alto util disponible */
body[data-view="mapping"] #map-preview { aspect-ratio: 16 / 9; }
/* La vista de una capa: previa a la izquierda, sus ajustes a la derecha */
body[data-view="capa"] main {
display: grid; width: 96%; max-width: 1700px;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
grid-template-areas: "previa props";
align-items: start; align-content: start; gap: 16px;
}
body[data-view="capa"] #card-mapping { grid-area: previa; }
body[data-view="capa"] #card-propiedades { grid-area: props; }
body[data-view="capa"] #map-preview { aspect-ratio: 16 / 9; }
}
/* --- Tarjetas --- */
@ -253,6 +236,40 @@ body[data-view="capa"] .column { display: contents; }
box-shadow: 0 0 20px rgba(180, 255, 0, 0.6);
}
.engine.active small { color: var(--ink); }
/* En modo configurar, la pastilla abierta se marca distinto de la que esta
sonando: naranja = "la estas editando", verde = "es la que se ve". */
.engine.editando {
border-color: var(--orange); border-style: dashed;
box-shadow: 0 0 16px rgba(255, 157, 42, 0.5);
}
.engine.nueva { border-style: dashed; color: var(--dim); }
.engine.nueva strong { font-size: 22px; }
/* --- Fila de destinos (salida / capas) ---
Deliberadamente distintas de las pestanas de arriba: aquellas cambian de
pantalla, estas eligen a donde va lo que dispares. */
.subtabs {
display: flex; flex-wrap: wrap; gap: 8px; justify-content: center;
margin-bottom: 4px;
}
.subtab {
padding: 8px 14px; border-radius: 999px; cursor: pointer;
background: #161d10; border: 2px solid var(--line); color: var(--dim);
font-weight: 700; font-size: 12px; letter-spacing: 0.08em;
max-width: 14ch; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap;
}
.subtab.active {
background: var(--orange); color: var(--ink); border-color: var(--orange);
}
.subtab.nueva { border-style: dashed; }
#destino-props { margin-top: 12px; }
/* Prestada de MAPPING: dentro de MOTORES no lleva su propio marco, que
quedaria como una tarjeta dentro de otra */
#destino-props #card-propiedades {
background: none; border: none; padding: 0; border-radius: 0;
}
.cmd.del { color: #ff8a7a; }
/* --- Botones de comando --- */
.cmd, .sysbtn {

View file

@ -21,85 +21,34 @@ const $$ = (s) => document.querySelectorAll(s);
// 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. */
/* mapSurfaces se declara mas abajo con let: setView puede correr ANTES de esa
linea (al restaurar la vista guardada), 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;
if (view !== "mapping") view = "motores";
document.body.dataset.view = view;
vistaActual = view;
$$(".tab").forEach((t) =>
t.classList.toggle("active", t.dataset.view === view));
$$(".tab").forEach((t) => t.classList.toggle("active", t.dataset.view === view));
try { localStorage.setItem("fosfeno.view", view); } catch (e) { /* privado */ }
// Al salir de MOTORES, las propiedades de capa vuelven a su tarjeta de
// MAPPING (ver colocarPropiedades)
colocarPropiedades();
// 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();
}
if (view === "mapping" && state) 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);
})();
/* La vista guardada se restaura al FINAL del fichero (restaurarVista), no
aqui: setView toca variables declaradas mas abajo con let, y llamarlo a
estas alturas revienta con "Cannot access ... before initialization"
matando de paso el resto del script, socket incluido. */
// Sliders del mezclador VJ (se generan dinamicamente)
const MIXER_SLIDERS = [
@ -190,6 +139,364 @@ function openHelp(key) {
function closeHelp() { $("#modal").hidden = true; }
// ==========================================================================
// Pastillas y destino
// --------------------------------------------------------------------------
// El panel se maneja con dos decisiones separadas, y esa separacion es lo que
// lo hace usable en directo:
//
// DONDE -> la fila de destinos: la salida entera, o una capa del mapping.
// QUE -> las pastillas (circulos): combinaciones guardadas por el usuario.
//
// Pinchar una pastilla la manda al destino que este elegido. Asi el mismo
// gesto sirve para cambiar el visual de toda la pantalla o el de una zona
// mapeada, sin cambiar de pestana ni de mentalidad.
// ==========================================================================
let destinoSel = "salida"; // "salida" | id de superficie
let modoConfig = false; // configurando pastillas en vez de dispararlas
let slotEditando = null; // id de la pastilla abierta en el editor
const SLOT_ETIQUETA = {
butter: "MilkDrop", shader: "GLSL", video: "clip", camara: "camara",
hydra: "codigo", mixer: "mezcla", "projectm-nativo": "nativo",
};
// Lo que solo tiene sentido a pantalla completa: son motores globales, no
// fuentes que una capa pueda pintar por su cuenta.
const SOLO_SALIDA = new Set(["hydra", "mixer", "projectm-nativo"]);
function slots() { return (state && state.slots) || []; }
function slotSub(sl) {
if (sl.tipo === "butter" && sl.biblioteca === "projectm" && sl.milk) {
return sl.milk.split("/").pop().replace(/\.milk$/i, "").slice(0, 12);
}
if (sl.tipo === "butter" && sl.preset) return sl.preset.slice(0, 12);
if (sl.tipo === "video" && sl.archivo) return tituloClip(sl.archivo).slice(0, 12);
if (sl.tipo === "shader" && sl.shader) return sl.shader.slice(0, 12);
return SLOT_ETIQUETA[sl.tipo] || "";
}
/* Que pastilla se corresponde con lo que hay ahora mismo en la salida, para
poder marcarla. No siempre hay una: si tocas el preset a mano, ya no
coincide con ninguna y no se marca ninguna, que es lo honesto. */
function slotActivaEnSalida() {
if (destinoSel !== "salida" || !state) return null;
const e = state.engine;
const cand = slots().filter((sl) => {
if (sl.tipo === "projectm-nativo") return e === "projectm";
if (sl.tipo === "hydra") return e === "hydra";
if (sl.tipo === "mixer") return e === "mixer";
if (sl.tipo === "shader") return e === "shaders";
if (sl.tipo === "butter") return e === "butterchurn";
return false;
});
if (cand.length === 1) return cand[0].id;
// Varias del mismo motor: desempata el preset
const exacto = cand.find((sl) => sl.tipo === "butter"
&& sl.preset && sl.preset === (state.butterchurn || {}).preset);
return exacto ? exacto.id : (cand.length ? cand[0].id : null);
}
function renderSlots() {
const cont = $("#slots");
if (!cont) return;
const lista = slots();
const activa = slotActivaEnSalida();
const sig = lista.map((s) => s.id + ":" + s.nombre + ":" + slotSub(s)).join("|")
+ "|" + modoConfig + "|" + activa + "|" + slotEditando;
if (cont.dataset.sig === sig) return;
cont.dataset.sig = sig;
cont.innerHTML = "";
lista.forEach((sl) => {
const b = document.createElement("button");
b.className = "engine";
if (!modoConfig && sl.id === activa) b.classList.add("active");
if (modoConfig && sl.id === slotEditando) b.classList.add("editando");
b.innerHTML = "";
const n = document.createElement("strong");
n.textContent = sl.nombre || "?";
const s = document.createElement("small");
s.textContent = slotSub(sl);
b.appendChild(n); b.appendChild(s);
b.title = modoConfig ? "Configurar esta pastilla" : "Lanzar al destino elegido";
b.addEventListener("click", () => {
if (modoConfig) { abrirEditorSlot(sl.id); return; }
dispararSlot(sl);
});
cont.appendChild(b);
});
if (modoConfig) {
const mas = document.createElement("button");
mas.className = "engine nueva";
mas.innerHTML = "<strong>+</strong><small>nueva</small>";
mas.title = "Anadir una pastilla";
mas.addEventListener("click", nuevaSlot);
cont.appendChild(mas);
}
}
/* Lanza una pastilla al destino elegido. A la salida se traduce a motor; a
una capa, a fuente de capa. Lo que no cabe en una capa se dice, en vez de
no hacer nada y dejar al usuario pensando que ha fallado el toque. */
function dispararSlot(sl) {
if (destinoSel === "salida") {
const ajustes = (engine, patch) =>
socket.emit("update_settings", { engine: engine, patch: patch });
if (sl.tipo === "projectm-nativo") { socket.emit("set_engine", { engine: "projectm" }); return; }
if (sl.tipo === "hydra") { socket.emit("set_engine", { engine: "hydra" }); return; }
if (sl.tipo === "mixer") { socket.emit("set_engine", { engine: "mixer" }); return; }
if (sl.tipo === "shader") {
socket.emit("set_engine", { engine: "shaders" });
const sh = shaderLib.find((x) => x.id === sl.shader);
if (sh) socket.emit("run_code",
{ engine: "shaders", code: sh.code, label: sh.name });
return;
}
if (sl.tipo === "butter") {
if (sl.biblioteca === "projectm" && sl.milk) {
// La biblioteca .milk se traduce en el navegador, y eso solo pasa
// dentro de una capa. A pantalla completa lo lanza projectM nativo,
// que lee el .milk tal cual: mismo preset, otro motor.
const p = sl.milk.split("/");
socket.emit("set_engine", { engine: "projectm" });
ajustes("projectm", { category: p[0], preset: p[1],
variante: (p[2] || "").replace(/\.milk$/i, "") });
return;
}
socket.emit("set_engine", { engine: "butterchurn" });
if (sl.preset) ajustes("butterchurn", { preset: sl.preset, shuffle: false });
return;
}
if (sl.tipo === "video" || sl.tipo === "camara") {
socket.emit("set_engine", { engine: "mixer" });
ajustes("mixer", sl.tipo === "video"
? { source: "video", video: sl.archivo || "" }
: { source: "cam", camOn: true, cameraId: sl.camaraId || 0 });
return;
}
return;
}
// Destino = una capa del mapping
if (SOLO_SALIDA.has(sl.tipo)) {
showNotif({ level: "warn", message: "\"" + (sl.nombre || "Esa pastilla")
+ "\" solo puede ir a pantalla completa, no a una capa. "
+ "Elige el destino SALIDA." });
return;
}
const f = { tipo: sl.tipo };
if (sl.tipo === "butter") {
f.biblioteca = sl.biblioteca === "projectm" ? "projectm" : "butter";
f.milk = sl.milk || "";
f.preset = sl.preset || "";
f.auto = false; f.segundos = 20; f.transicion = 2.5;
} else if (sl.tipo === "shader") f.shader = sl.shader || "";
else if (sl.tipo === "video") f.archivo = sl.archivo || "";
else if (sl.tipo === "camara") f.camaraId = sl.camaraId || 0;
patchCapa(destinoSel, { fuente: f });
renderCapas(); renderPropiedades(); drawMapPreview();
}
/* --- Destinos: salida entera o una capa --- */
function renderDestinos() {
const cont = $("#destinos");
if (!cont) return;
const capas = capasHoy();
// El destino elegido pudo desaparecer (capa borrada desde otro movil)
if (destinoSel !== "salida" && !capas.some((s) => s.id === destinoSel)) {
destinoSel = "salida";
}
const sig = "salida|" + capas.map((s, i) => s.id + ":" + (s.nombre || i + 1)).join("|")
+ "|" + destinoSel;
if (cont.dataset.sig !== sig) {
cont.dataset.sig = sig;
cont.innerHTML = "";
const mk = (id, txt, titulo) => {
const b = document.createElement("button");
b.className = "subtab" + (destinoSel === id ? " active" : "");
b.textContent = txt;
b.title = titulo;
b.addEventListener("click", () => { destinoSel = id; renderTodo(); });
cont.appendChild(b);
return b;
};
mk("salida", "SALIDA", "Lo que se ve en el proyector, a pantalla completa");
capas.forEach((s, i) =>
mk(s.id, (s.nombre || "CAPA " + (i + 1)).toUpperCase(),
"Una zona del mapping"));
const mas = document.createElement("button");
mas.className = "subtab nueva";
mas.textContent = "+";
mas.title = "Crear una capa nueva";
mas.addEventListener("click", () => {
const id = nuevaCapa();
destinoSel = id;
renderCapas(); renderTodo();
});
cont.appendChild(mas);
}
const hint = $("#destino-hint");
if (hint) {
hint.textContent = destinoSel === "salida"
? "Las pastillas cambian lo que se ve en todo el proyector."
: "Las pastillas cambian solo esta capa. El resto sigue igual.";
}
if (destinoSel !== "salida") mapSel = { type: "surface", id: destinoSel };
colocarPropiedades();
}
/* La tarjeta de propiedades de capa es UNA sola en todo el panel: vive en
MAPPING y MOTORES se la lleva prestada cuando el destino es una capa. Se
mueve en vez de duplicarla para no acabar con dos formularios que hay que
mantener a la vez (y que se desincronizan a la primera). */
function colocarPropiedades() {
const card = $("#card-propiedades");
const hueco = $("#destino-props");
if (!card || !hueco) return;
const enMotores = document.body.dataset.view === "motores"
&& destinoSel !== "salida";
if (enMotores && card.parentElement !== hueco) {
hueco.appendChild(card);
} else if (!enMotores && card.parentElement === hueco) {
const col = $("#card-capas");
if (col && col.parentElement) col.parentElement.insertBefore(card, col.nextSibling);
}
hueco.hidden = !enMotores;
}
function renderTodo() {
renderSlots(); renderDestinos(); renderPropiedades(); drawMapPreview();
}
/* --- Configurar pastillas --- */
$("#slots-config").addEventListener("click", () => {
modoConfig = !modoConfig;
if (!modoConfig) slotEditando = null;
$("#slots-config").textContent = modoConfig
? "Listo (volver a lanzar)" : "Configurar pastillas";
$("#slots-config").classList.toggle("accent", modoConfig);
$("#slot-editor").hidden = !modoConfig || !slotEditando;
renderSlots();
});
function nuevaSlot() {
const sl = { id: "sl" + Date.now() + "_" + Math.floor(Math.random() * 1e4),
nombre: "NUEVA", tipo: "butter", biblioteca: "butter", preset: "" };
socket.emit("set_slots", { slots: slots().concat([sl]) });
slotEditando = sl.id;
$("#slot-editor").hidden = false;
}
function abrirEditorSlot(id) {
slotEditando = (slotEditando === id) ? null : id;
$("#slot-editor").hidden = !slotEditando;
renderSlots();
if (slotEditando) renderEditorSlot();
}
function slotActual() { return slots().find((s) => s.id === slotEditando) || null; }
function guardarSlot(cambios) {
const nuevas = slots().map((s) =>
s.id === slotEditando ? Object.assign({}, s, cambios) : s);
socket.emit("set_slots", { slots: nuevas });
}
function renderEditorSlot() {
const sl = slotActual();
if (!sl) { $("#slot-editor").hidden = true; return; }
if (document.activeElement !== $("#slot-nombre")) {
$("#slot-nombre").value = sl.nombre || "";
}
$("#slot-tipo").value = sl.tipo;
$$(".slot-op").forEach((el) => { el.hidden = true; });
const bloque = $("#sop-" + (sl.tipo === "camara" ? "" : sl.tipo));
if (bloque) bloque.hidden = false;
if (sl.tipo === "butter") {
const esMilk = sl.biblioteca === "projectm";
$("#slot-biblioteca").value = esMilk ? "projectm" : "butter";
$("#sop-milk").hidden = !esMilk;
$("#slot-preset").hidden = esMilk;
fillSelectOnce($("#slot-preset"), state.meta.butterchurnPresets, (n) => n, (n) => n);
$("#slot-preset").value = sl.preset || "";
if (esMilk) {
const lib = (state.meta && state.meta.projectmPresets) || {};
const p = partesMilk(sl.milk);
rellena($("#slot-milk-cat"), Object.keys(lib), "-- categoria --");
$("#slot-milk-cat").value = p.cat;
rellena($("#slot-milk-vis"), p.cat ? (lib[p.cat] || []) : [], "-- visual --");
$("#slot-milk-vis").value = p.vis;
const vs = $("#slot-milk-var");
const sig = p.cat + "|" + p.vis;
if (vs.dataset.pedido !== sig) {
vs.dataset.pedido = sig;
rellena(vs, [], "-- variante --");
if (p.cat && p.vis) {
fetch("/pm/variantes?categoria=" + encodeURIComponent(p.cat)
+ "&visual=" + encodeURIComponent(p.vis))
.then((r) => r.json())
.then((d) => {
if (vs.dataset.pedido !== sig) return;
vs.dataset.sig = "";
rellena(vs, d.variantes || [], "-- variante --");
vs.value = partesMilk((slotActual() || {}).milk).vari;
}).catch(() => {});
}
} else if (document.activeElement !== vs) vs.value = p.vari;
}
} else if (sl.tipo === "shader") {
fillSelectOnce($("#slot-shader"), shaderLib, (x) => x.id, (x) => x.name);
$("#slot-shader").value = sl.shader || "";
} else if (sl.tipo === "video") {
fillClipSelect($("#slot-video"));
$("#slot-video").value = sl.archivo || "";
}
}
$("#slot-nombre").addEventListener("input", (e) =>
guardarSlot({ nombre: e.target.value.toUpperCase().slice(0, 12) }));
$("#slot-tipo").addEventListener("change", (e) => {
const t = e.target.value;
const base = { tipo: t };
if (t === "butter") { base.biblioteca = "butter"; base.preset = ""; base.milk = ""; }
if (t === "shader") base.shader = shaderLib.length ? shaderLib[0].id : "";
if (t === "video") {
const vs = (state.meta && state.meta.videos) || [];
base.archivo = vs.length ? vs[0] : "";
}
guardarSlot(base);
});
$("#slot-biblioteca").addEventListener("change", (e) =>
guardarSlot({ biblioteca: e.target.value }));
$("#slot-preset").addEventListener("change", (e) =>
guardarSlot({ preset: e.target.value }));
$("#slot-shader").addEventListener("change", (e) =>
guardarSlot({ shader: e.target.value }));
$("#slot-video").addEventListener("change", (e) =>
guardarSlot({ archivo: e.target.value }));
$("#slot-milk-cat").addEventListener("change", (e) =>
guardarSlot({ milk: e.target.value ? e.target.value + "/" : "" }));
$("#slot-milk-vis").addEventListener("change", (e) =>
guardarSlot({ milk: $("#slot-milk-cat").value + "/" + e.target.value + "/" }));
$("#slot-milk-var").addEventListener("change", (e) =>
guardarSlot({ milk: $("#slot-milk-cat").value + "/" + $("#slot-milk-vis").value
+ "/" + e.target.value + ".milk" }));
$("#slot-borrar").addEventListener("click", () => {
socket.emit("set_slots", { slots: slots().filter((s) => s.id !== slotEditando) });
slotEditando = null;
$("#slot-editor").hidden = true;
});
$("#slot-listo").addEventListener("click", () => {
slotEditando = null;
$("#slot-editor").hidden = true;
renderSlots();
});
// ==========================================================================
// Avisos (banda superior)
// ==========================================================================
@ -252,8 +559,9 @@ function render() {
power.textContent = state.power ? "ON" : "OFF";
power.classList.toggle("off", !state.power);
$$(".engine").forEach((b) =>
b.classList.toggle("active", b.dataset.engine === state.engine));
renderSlots();
renderDestinos();
if (slotEditando) renderEditorSlot();
fillSelectOnce($("#audio-device"), state.meta.audioDevices,
(d) => d.id, (d) => d.name);
@ -1090,7 +1398,6 @@ function renderMapping() {
}
drawMapPreview();
renderCapas();
renderTabsCapas();
renderPropiedades();
}
@ -1105,10 +1412,8 @@ $("#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. */
solapen del todo, y queda seleccionada para elegirle fuente al momento. */
function nuevaCapa() {
const n = mapSurfaces.length;
const s = 0.18;
@ -1128,16 +1433,7 @@ function nuevaCapa() {
$("#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);
renderCapas(); renderDestinos(); renderPropiedades(); drawMapPreview();
});
$("#map-add-mask").addEventListener("click", () => {
const s = 0.15, cx = 0.5, cy = 0.5;
@ -1648,4 +1944,16 @@ socket.on("disconnect", () => { $("#conn").className = "dot off"; });
// Arranque
// ==========================================================================
buildMixerSliders();
/* Restaurar la vista va AQUI, con el fichero ya entero: setView toca
variables declaradas con let a lo largo del modulo, y llamarlo antes las
pilla sin inicializar (ReferenceError que ademas se lleva por delante el
resto del script, socket incluido, y el panel se queda en blanco). */
(function restaurarVista() {
let saved = "motores";
try { saved = localStorage.getItem("fosfeno.view") || "motores"; }
catch (e) { /* almacenamiento no disponible */ }
setView(saved);
})();
loadData();