Capas independientes, projectM en el mapping y catalogo de la galeria

Mapping
- Los clips salian del reves: mapper.js activaba UNPACK_FLIP_Y_WEBGL, pero el
  modelo va con origen arriba-izquierda y texImage2D ya sube la primera fila
  en t=0, asi que el flip la mandaba al final. Comprobado renderizando el
  mapper real en Chromium headless con una fuente mitad roja / mitad azul.
- projectM se puede usar YA en una capa: los .milk de data/presets-projectm se
  traducen a Butterchurn en el navegador al elegirlos (milkdrop-preset-
  converter). La capa MilkDrop gana 'biblioteca' (butter | projectm) sin
  cambiar su firma, para no recrear el contexto WebGL al cambiar de una a otra.
  Medido: 100/100 presets de una muestra convierten, 84/84 de los que llevan
  shaders warp/comp; ~7 ms por preset.
- Cada capa tiene su pestana arriba y su propia vista, y '+ CAPA' crea una y
  entra en ella: con varias capas, ir y volver a MAPPING no era viable.
- Dos capas MilkDrop sin preset ya no salen identicas (cogian el indice 0):
  cada instancia elige uno al azar y lo escribe en el estado.
- Una capa nueva ya no nace en la fuente "motor", que es el lienzo del motor
  activo y hacia que dos capas ensenaran lo mismo.

Galeria de visuales
- scripts/catalogar-visuales.py: ficha de cada clip (pelicula, personajes,
  duracion) y, sobre todo, si es una silueta de verdad y cuanta figura tiene.
  Distingue silueta de corte crudo por el negro puro del fondo: 0,63-0,90
  frente a 0,04-0,06, sin zona gris.
- El panel lista los clips agrupados por pelicula, con nombre legible y aviso
  de los que casi no tienen figura, en vez del nombre del archivo.
- Soporte de siluetas con canal alfa (.webm VP9): transparencia de verdad, sin
  recorte por luminancia. El shader del mapper ya la respeta.

Documentacion
- docs/visuales.md nuevo; pendiente.md al dia con lo hecho y lo que queda.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-08-02 14:56:01 +02:00
parent 5d5223db9f
commit f017246f0e
30 changed files with 3771 additions and 292 deletions

View file

@ -8,6 +8,7 @@
"butterchurn-presets": "^2.4.7",
"codemirror": "^5.65.16",
"hydra-synth": "^1.3.29",
"milkdrop-preset-converter": "^0.1.2",
"qrcode-generator": "^1.4.4",
"socket.io-client": "^4.7.5"
}

View file

@ -9,15 +9,33 @@
<link rel="stylesheet" href="/lib/codemirror/material-darker.css">
</head>
<body>
<header>
<h1>FOSFENO</h1>
<span id="conn" class="dot off" title="Conexion"></span>
</header>
<!-- Barra superior fija: titulo + pestanas + banda de avisos.
Van juntas en un mismo bloque sticky para que el aviso quede siempre
pegado debajo del titulo, aunque las pestanas salten de linea. -->
<div class="topbar">
<header>
<h1>FOSFENO</h1>
<!-- Dos zonas del panel: MOTORES (lo de siempre) y MAPPING (aparte,
para que no se mezclen los controles de directo con los de
encaje sobre la superficie fisica). -->
<nav class="tabs" id="tabs">
<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>
<!-- Banda de avisos: aqui aparece cualquier error o aviso -->
<div id="notif" class="notif" hidden>
<span id="notif-msg"></span>
<button id="notif-close" aria-label="Cerrar aviso">&times;</button>
<!-- Banda de avisos: aqui aparece cualquier error o aviso -->
<div id="notif" class="notif" hidden>
<span id="notif-msg"></span>
<button id="notif-close" aria-label="Cerrar aviso">&times;</button>
</div>
</div>
<!-- En movil las tarjetas van en una sola columna; en pantalla ancha,
@ -148,38 +166,69 @@
<p class="hint" id="editor-hint"></p>
</section>
<!-- Mezclador VJ -->
<!-- Mezclador VJ.
Se lee de arriba abajo como dos capas: el FONDO (capa de abajo:
camara o visuales Butter) y el CLIP de la galeria (capa de
encima), mas la forma de juntarlos. El resumen de mas abajo
dice en una linea que va a salir por el proyector. -->
<section class="card" id="ctl-mixer" hidden>
<div class="cardhead">
<span class="label">Mezclador VJ</span>
<button class="info" data-help="mixer">i</button>
</div>
<!-- 1. Que se ve -->
<span class="label">1 &middot; Que se ve</span>
<div class="seg" id="mixer-source">
<button class="segbtn" data-src="cam">Camara</button>
<button class="segbtn" data-src="video">Video</button>
<button class="segbtn" data-src="mix">Mezcla</button>
<button class="segbtn" data-src="cam">Solo el fondo</button>
<button class="segbtn" data-src="video">Solo el clip</button>
<button class="segbtn" data-src="mix">Los dos</button>
</div>
<div class="row">
<span class="label">Fondo (capa de abajo)</span>
<!-- 2. Fondo: capa de abajo -->
<div class="row" id="mix-fondo-row">
<span class="label">2 &middot; Fondo (capa de abajo)</span>
<select id="mix-fondo" class="inline">
<option value="cam">Camara</option>
<option value="butter">Visuales Butter (MilkDrop)</option>
</select>
</div>
<label class="check">
<input type="checkbox" id="mix-cam"> Camara activada
</label>
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
<!-- Fondo = camara -->
<div class="stack" id="mix-cam-block">
<label class="check">
<input type="checkbox" id="mix-cam"> Camara activada
</label>
<select id="mix-camera"><option value="0">Camara por defecto</option></select>
</div>
<!-- Fondo = visuales Butter: se elige aqui mismo el preset de
MilkDrop que hace de fondo, sin salir del mezclador. -->
<div class="stack" id="mix-bg-block" hidden>
<select id="mix-bg-preset"><option>Cargando presets...</option></select>
<div class="row">
<button id="mix-bg-prev" class="cmd">&laquo; Anterior</button>
<button id="mix-bg-next" class="cmd">Siguiente &raquo;</button>
</div>
<label class="check">
<input type="checkbox" id="mix-bg-shuffle"> Cambiar el fondo solo
</label>
<p class="hint">Es el mismo preset y el mismo cambio automatico del
motor Butter: lo que elijas aqui queda elegido tambien alli.</p>
</div>
<!-- 3. Clip de la galeria: capa de encima -->
<span class="label" id="mix-clip-label">3 &middot; Clip (capa de encima)</span>
<select id="mix-video"><option value="">-- sin video --</option></select>
<div class="row">
<button id="mix-rescan" class="cmd">Actualizar lista</button>
<button id="mix-upload-btn" class="cmd">Subir video o imagen</button>
<span id="mix-upload-status" class="hint"></span>
</div>
<span id="mix-upload-status" class="hint"></span>
<input type="file" id="mix-upload" hidden
accept="video/*,image/jpeg,image/png,image/gif,image/webp">
<div class="row">
<span class="label">Modo de mezcla</span>
<!-- 4. Como se juntan las dos capas -->
<div class="row" id="mix-blend-row">
<span class="label">4 &middot; Como se juntan</span>
<select id="mix-blend" class="inline">
<option value="recorte">Recorte (personaje delante)</option>
<option value="blend">Fundido</option>
@ -189,6 +238,10 @@
<option value="layer">Capa</option>
</select>
</div>
<!-- Resumen en una linea de lo que sale por el proyector -->
<p class="hint resumen" id="mix-resumen"></p>
<div id="mixer-sliders"></div>
<label class="check">
<input type="checkbox" id="mix-invert"> Invertir colores
@ -248,37 +301,165 @@
deslizador relanza projectM (~1 s en negro).</p>
</section>
<!-- Mapping (projection mapping) -->
<section class="card" id="card-mapping">
<!-- ===================== PESTANA MAPPING ===================== -->
<!-- Salida: encendido y previsualizacion -->
<section class="card" id="card-mapping" data-vista="mapping">
<div class="cardhead">
<span class="label">Mapping</span>
<button class="info" data-help="mapping">i</button>
</div>
<label class="check">
<input type="checkbox" id="map-enabled"> Activar mapping
</label>
<label class="check">
<input type="checkbox" id="map-edit"> Modo edicion (arrastrar en el escenario)
</label>
<div class="row">
<button id="map-add" class="cmd accent">+ Superficie</button>
<label class="check">
<input type="checkbox" id="map-enabled"> Activar mapping
</label>
<label class="check">
<input type="checkbox" id="map-edit"> Editar en el escenario
</label>
</div>
<span class="label">Previsualizacion (arrastra los puntos)</span>
<canvas id="map-preview"></canvas>
<p class="hint">Arrastra los puntos para encajar cada capa en su sitio.
Toca dentro de una capa para seleccionarla. Se guarda solo.</p>
</section>
<!-- Capas: la lista, al estilo de una mesa de VJ -->
<section class="card" id="card-capas" data-vista="mapping">
<div class="cardhead">
<span class="label">Capas</span>
<button class="info" data-help="capas">i</button>
</div>
<div class="row">
<button id="map-add" class="cmd accent">+ Capa</button>
<button id="map-add-mask" class="cmd">+ Mascara</button>
<button id="map-reset" class="cmd">Reset</button>
</div>
<span class="label">Previsualizacion (arrastra los puntos)</span>
<canvas id="map-preview"
style="width:100%;aspect-ratio:16/9;background:#0a0a0a;border:1px solid #333;border-radius:6px;display:block;margin:6px 0;touch-action:none;cursor:crosshair;"></canvas>
<div class="row" id="map-selbar" hidden>
<span class="label" id="map-selinfo">Superficie seleccionada</span>
<button id="map-sub-dec" class="cmd">&minus; malla</button>
<span id="map-sub-val" class="value">1x1</span>
<button id="map-sub-inc" class="cmd">+ malla</button>
</div>
<p class="hint"><b>Capa</b> = una zona que PROYECTA algo (eliges que en
Propiedades). <b>Mascara</b> = un recuadro negro que TAPA lo que haya
debajo; no lleva visual.</p>
<div id="map-list"></div>
<p class="hint">Arrastra los puntos para deformar. Selecciona una
superficie y usa <b>+ malla</b> para subdividir y <b>curvarla</b>.
<b>+ Mascara</b> tapa zonas con un recuadro negro (recorta derrames
de luz). Se guarda solo.</p>
<p class="hint" id="map-list-vacio">Sin capas: con el mapping activado
y ninguna capa, sale el motor a pantalla completa. Pulsa
<b>+ Capa</b> para trocear la proyeccion en zonas.</p>
</section>
<!-- Propiedades de la capa seleccionada -->
<section class="card" id="card-propiedades" data-vista="mapping">
<div class="cardhead">
<span class="label" id="prop-titulo">Propiedades</span>
<button class="info" data-help="fuentes">i</button>
</div>
<p class="hint" id="prop-vacio">Selecciona una capa (en la lista o
tocando dentro de ella en la previsualizacion) para elegir que
visual se proyecta en esa zona.</p>
<div class="stack" id="prop-capa" hidden>
<div class="row">
<span class="label">Nombre</span>
<input type="text" id="prop-nombre" class="txt" maxlength="24"
placeholder="Capa">
</div>
<span class="label">Que se proyecta aqui</span>
<select id="prop-fuente">
<option value="motor">El motor elegido en MOTORES</option>
<option value="butter">Visuales MilkDrop (propias de esta capa)</option>
<option value="shader">Shader GLSL</option>
<option value="video">Clip o imagen de la galeria</option>
<option value="camara">Camara</option>
<option value="negro">Negro (tapar)</option>
</select>
<!-- Ajustes propios de cada fuente -->
<div class="stack fuente-op" id="fop-butter" hidden>
<span class="label">Biblioteca de presets</span>
<select id="prop-biblioteca">
<option value="butter">Butterchurn &mdash; ~100 presets, los mas ligeros</option>
<option value="projectm">projectM &mdash; tu biblioteca .milk (9795)</option>
</select>
<!-- Biblioteca de Butterchurn -->
<div class="stack" id="fop-bib-butter">
<select id="prop-preset"><option>Cargando presets...</option></select>
<div class="row">
<button id="prop-preset-prev" class="cmd">&laquo; Anterior</button>
<button id="prop-preset-next" class="cmd">Siguiente &raquo;</button>
</div>
</div>
<!-- Biblioteca de projectM: los .milk se traducen al vuelo -->
<div class="stack" id="fop-bib-milk" hidden>
<select id="prop-milk-cat"><option value="">-- categoria --</option></select>
<select id="prop-milk-vis"><option value="">-- visual --</option></select>
<select id="prop-milk-var"><option value="">-- variante --</option></select>
<button id="prop-milk-azar" class="cmd">Uno al azar</button>
<p class="hint" id="prop-milk-est">Los .milk se traducen a
Butterchurn en el propio navegador, al elegirlos. Alguno puede
salir distinto al original: si uno no convence, prueba otra
variante.</p>
</div>
<label class="check">
<input type="checkbox" id="prop-auto"> Cambiar de preset solo
</label>
<div class="row" id="prop-auto-row" hidden>
<span class="label">Cada <span id="prop-seg-val" class="value">20</span> s</span>
</div>
<input id="prop-seg" type="range" min="3" max="180" step="1" value="20" hidden>
<div class="row">
<span class="label">Transicion:
<span id="prop-tr-val" class="value">2.5</span> s</span>
</div>
<input id="prop-transicion" type="range" min="0" max="8" step="0.1" value="2.5">
<p class="hint">A 0 el preset cambia de golpe; subiendolo, uno se
disuelve en el siguiente sin que se note el corte.</p>
</div>
<div class="stack fuente-op" id="fop-shader" hidden>
<select id="prop-shader"><option value="">-- elige un shader --</option></select>
</div>
<div class="stack fuente-op" id="fop-video" hidden>
<select id="prop-video"><option value="">-- elige un clip --</option></select>
</div>
<div class="stack fuente-op" id="fop-camara" hidden>
<select id="prop-camara"><option value="0">Camara por defecto</option></select>
</div>
<div class="row">
<span class="label">Opacidad: <span id="prop-op-val" class="value">100</span>%</span>
</div>
<input id="prop-opacidad" type="range" min="0" max="1" step="0.02" value="1">
<div class="row">
<span class="label">Mezcla con lo de debajo</span>
<select id="prop-mezcla" class="inline">
<option value="normal">Normal (tapa)</option>
<option value="sumar">Sumar (apila luz)</option>
</select>
</div>
<div class="row">
<span class="label">Malla (para curvar)</span>
<span class="grupo">
<button id="map-sub-dec" class="cmd">&minus;</button>
<span id="map-sub-val" class="value">1x1</span>
<button id="map-sub-inc" class="cmd">+</button>
</span>
</div>
</div>
<div class="stack" id="prop-mascara" hidden>
<p class="hint">Una mascara solo TAPA: es un recuadro negro que se
pinta encima de todo, para recortar derrames de luz. No lleva
visual, por eso aqui no hay nada que elegir. Arrastra sus esquinas
en la previsualizacion.</p>
<p class="hint resumen">Si lo que querias era proyectar algo en esta
zona, eso es una <b>capa</b>, no una mascara.</p>
<button id="mask-a-capa" class="cmd accent">Convertirla en capa</button>
</div>
</section>
</div>

View file

@ -35,11 +35,17 @@ body {
padding-bottom: 48px;
}
/* --- Cabecera con el titulo centrado --- */
/* --- Barra superior fija: titulo + pestanas + avisos --- */
.topbar {
position: sticky; top: 0; z-index: 10; background: var(--bg);
}
/* --- Cabecera con el titulo centrado y las pestanas a su derecha --- */
header {
position: sticky; top: 0; z-index: 10;
position: relative;
display: flex; align-items: center; justify-content: center;
padding: 16px 20px; border-bottom: 2px solid var(--green-d);
flex-wrap: wrap; gap: 10px 20px;
padding: 14px 46px; border-bottom: 2px solid var(--green-d);
background: var(--bg);
}
header h1 {
@ -50,6 +56,35 @@ header h1 {
#conn {
position: absolute; right: 20px; top: 50%; transform: translateY(-50%);
}
/* --- Pestanas MOTORES / MAPPING --- */
.tabs { display: flex; gap: 8px; }
.tab {
position: relative; cursor: pointer;
font-family: "Xirod", "Arial Black", sans-serif;
font-size: 12px; font-weight: 400; letter-spacing: 0.07em;
padding: 10px 15px; border-radius: 10px;
background: #161d10; border: 2px solid var(--line); color: var(--dim);
}
.tab.active {
border-color: var(--orange); color: var(--orange-n);
box-shadow: 0 0 12px rgba(255, 122, 0, 0.35);
}
/* Punto verde en la pestana MAPPING cuando el mapping esta activado, para
saber desde MOTORES que la salida se esta deformando. */
.tabdot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--green); box-shadow: 0 0 8px var(--green);
margin-left: 7px; vertical-align: middle;
}
/* En movil el titulo baja de tamano para que las pestanas quepan al lado */
@media (max-width: 560px) {
header { padding: 12px 34px; gap: 8px 12px; }
header h1 { font-size: 23px; }
.tab { padding: 9px 11px; font-size: 11px; }
#conn { right: 12px; }
}
.dot { width: 13px; height: 13px; border-radius: 50%; display: inline-block; }
.dot.on { background: var(--green); box-shadow: 0 0 9px var(--green); }
.dot.off { background: var(--red); box-shadow: 0 0 9px var(--red); }
@ -82,6 +117,61 @@ 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. */
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"]) {
display: none !important;
}
body[data-view="capa"] #card-capas { display: none !important; }
body[data-view="mapping"] main,
body[data-view="capa"] 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; }
/* 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
derecha, sin tener que bajar y subir todo el rato. */
@media (min-width: 1000px) {
body[data-view="mapping"] main {
display: grid; width: 96%; max-width: 1700px;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
grid-template-areas: "previa capas" "previa props" "previa .";
align-items: start; align-content: start; gap: 16px;
}
body[data-view="mapping"] #card-mapping { grid-area: previa; }
body[data-view="mapping"] #card-capas { grid-area: capas; }
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 --- */
.card {
background: var(--card); border: 1px solid var(--line);
@ -90,11 +180,12 @@ main {
}
.card.center { align-items: center; }
/* Cabecera de tarjeta: titulo centrado, boton de info a la derecha */
/* Cabecera de tarjeta: titulo centrado, boton de info a la derecha.
El hueco lateral evita que un titulo largo se meta debajo del boton. */
.cardhead {
position: relative; width: 100%;
display: flex; align-items: center; justify-content: center;
min-height: 30px;
min-height: 30px; padding: 0 34px;
}
/* Titulo de cada tarjeta: mas grande y en la fuente Xirod */
.cardhead .label {
@ -108,8 +199,12 @@ main {
.hint { color: var(--dim); font-size: 12px; line-height: 1.45; }
.row { display: flex; align-items: center; justify-content: space-between;
gap: 10px; }
gap: 10px; flex-wrap: wrap; }
.row .cmd { flex: 1; }
/* En una fila de etiqueta + desplegable, el desplegable no baja de un ancho
usable: si no cabe, la fila parte en dos lineas en vez de estrujarlo. */
.row .label { flex: 1 1 auto; }
.row select.inline { flex: 0 1 auto; min-width: 148px; max-width: 100%; }
/* --- Boton de informacion (circular naranja, esquina de la tarjeta) --- */
.info {
@ -139,11 +234,18 @@ main {
display: flex; flex-wrap: wrap; gap: 12px; justify-content: center;
}
.engine {
width: 96px; height: 96px; border-radius: 50%;
width: 96px; aspect-ratio: 1 / 1; border-radius: 50%;
display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 2px; cursor: pointer;
background: var(--card); border: 3px solid var(--line); color: var(--txt);
}
/* En movil, algo mas pequenos para que entren TRES por fila: con 96 px se
quedaban en un 2+2+1 descuadrado (flex parte la linea antes de encoger). */
@media (max-width: 560px) {
.engines { gap: 10px; }
.engine { width: 86px; }
.engine strong { font-size: 12px; }
}
.engine strong { font-size: 13px; }
.engine small { font-size: 9px; color: var(--dim); }
.engine.active {
@ -222,15 +324,14 @@ input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
font-size: 12px; font-family: monospace; padding: 4px 0 8px;
}
/* --- Banda de avisos --- */
/* --- Banda de avisos (dentro de la barra superior fija) --- */
.notif {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; font-size: 14px; font-weight: 600;
position: sticky; top: 66px; z-index: 9;
}
.notif.info { background: var(--green); color: var(--ink); }
.notif.warn { background: var(--orange); color: var(--ink); }
.notif.error { background: var(--red); color: #fff; }
.notif.lv-info { background: var(--green); color: var(--ink); }
.notif.lv-warn { background: var(--orange); color: var(--ink); }
.notif.lv-error { background: var(--red); color: #fff; }
.notif span { flex: 1; }
.notif button {
background: transparent; border: none; color: inherit;
@ -257,6 +358,70 @@ input[type=range] { width: 100%; accent-color: var(--green); height: 30px; }
}
.modal-box p { color: var(--txt); font-size: 14px; line-height: 1.5; }
/* --- Bloques agrupados dentro de una tarjeta ---
Un grupo de controles que aparece o desaparece junto (por ejemplo los de
la camara o los del fondo Butter en el mezclador). */
.stack { display: flex; flex-direction: column; gap: 12px; }
/* Controles que ahora mismo no pintan nada: siguen visibles pero apagados,
asi se ve que existen sin que despisten. */
.dim { opacity: 0.42; }
/* Resumen del mezclador: la linea que dice que va a salir por el proyector */
.hint.resumen {
border-left: 3px solid var(--orange); padding: 6px 0 6px 10px;
color: var(--txt); font-size: 13px;
}
/* --- Previsualizacion del mapping --- */
#map-preview {
width: 100%; aspect-ratio: 16 / 9; display: block; margin: 6px 0;
background: #0a0a0a; border: 1px solid var(--line); border-radius: 8px;
touch-action: none; cursor: crosshair;
}
/* --- Lista de capas del mapping ---
Una fila por capa: nombre + fuente a la izquierda, botones a la derecha.
La fila entera selecciona; los botones no la seleccionan. */
#map-list { display: flex; flex-direction: column; gap: 8px; }
.capa {
display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 9px 10px; border-radius: 11px;
background: #161d10; border: 2px solid var(--line);
}
.capa.sel { border-color: var(--orange); box-shadow: 0 0 12px rgba(255,122,0,0.3); }
.capa.oculta { opacity: 0.45; }
.capa .txt { flex: 1; min-width: 0; }
.capa .nom {
display: block; color: var(--txt); font-weight: 700; font-size: 14px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.capa .src {
display: block; color: var(--dim); font-size: 11px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.capa.mascara .nom { color: #ff6b6b; }
.capa button {
flex: 0 0 auto; width: 34px; height: 34px; border-radius: 9px; cursor: pointer;
background: #0d120c; border: 1px solid var(--line); color: var(--txt);
font-size: 14px; font-weight: 700; line-height: 1; padding: 0;
}
.capa button:disabled { opacity: 0.3; }
.capa .del { color: var(--red); }
/* Malla: los +/- son botones pequenos y van juntos, no barras a lo ancho */
.grupo { display: inline-flex; align-items: center; gap: 8px; flex: 0 0 auto; }
#map-sub-dec, #map-sub-inc {
flex: 0 0 auto; width: 46px; padding: 10px 0; font-size: 17px;
}
#map-sub-val { min-width: 40px; text-align: center; }
/* Campo de texto (nombre de la capa) */
.txt[type="text"] {
flex: 1; padding: 10px; border-radius: 10px; font-size: 14px;
background: #161d10; color: var(--txt); border: 1px solid var(--line);
}
/* Favoritos de projectM: lista de visuales para pinchar en directo */
#pm-fav-list {
display: flex; flex-direction: column; gap: 8px;

File diff suppressed because it is too large Load diff

View file

@ -70,7 +70,11 @@
<script src="/lib/qrcode.js"></script>
<script src="/lib/butterchurn.min.js"></script>
<script src="/lib/butterchurn-presets.min.js"></script>
<!-- Traduce los .milk de projectM a presets de Butterchurn, para poder
usar la biblioteca grande dentro de una capa del mapping -->
<script src="/lib/milkdrop-preset-converter.min.js"></script>
<script src="/lib/hydra-synth.js"></script>
<script src="/stage/layers.js"></script>
<script src="/stage/mapper.js"></script>
<script src="/stage/stage.js"></script>
</body>

457
web/stage/layers.js Normal file
View file

@ -0,0 +1,457 @@
/*
* FOSFENO :: Capas del mapping
* --------------------------------------------------------------------------
* Cada superficie del mapping puede llevar su PROPIA fuente: una instancia de
* Butterchurn con su preset, un shader GLSL, un clip de la galeria, la camara
* o el motor que este activo. Este modulo crea esas fuentes, las mantiene y
* le entrega al mapper el elemento que tiene que usar como textura.
*
* Fuente (va dentro de cada superficie, en data/mapping.json):
* { tipo: "motor" } el motor elegido en el panel
* { tipo: "butter", preset, auto, segundos } instancia propia de MilkDrop
* biblioteca: "butter" (los ~100 que trae Butterchurn) o "projectm"
* (los .milk de data/presets-projectm); con "projectm", milk lleva la
* ruta relativa del preset ("Dancer/Glowsticks/285.milk")
* { tipo: "shader", shader } shader GLSL de la libreria
* { tipo: "video", archivo } clip o imagen de data/videos
* { tipo: "camara", camaraId } webcam
* { tipo: "negro" } nada (tapa la zona)
*
* Cada capa se renderiza en un lienzo propio y PEQUENO (el mapper la estira
* al deformarla), que es lo que hace viable tener varias a la vez en una
* Raspberry. Las instancias con estado (Butterchurn, shaders) son una por
* superficie; las que no lo tienen (clips, camara) se comparten.
*
* API global:
* FosLayers.init({ audioCtx, gainNode, analyser, crearShader, shaders, report,
* onPreset(surfaceId, preset) }) preset elegido al azar
* FosLayers.setMotor(canvas) lienzo del motor activo (fuente "motor")
* FosLayers.sync(surfaces) crea/actualiza/destruye segun el estado
* FosLayers.fuenteDe(surface) -> { el, clave } o null (lo usa el mapper)
* FosLayers.presets() nombres de preset disponibles
*/
(function () {
'use strict';
const ANCHO = 640; // resolucion interna de cada capa (se estira luego)
const ALTO = 360;
const SEGUNDOS = 20; // cambio automatico de preset por defecto
let deps = null;
let motorEl = null;
// Instancias con estado: una por superficie (id de superficie -> capa)
const capas = new Map();
// Fuentes compartidas sin estado: clip o camara (clave -> recurso)
const compartidas = new Map();
const IMAGEN_RE = /\.(jpe?g|png|gif|webp)$/i;
function aviso(nivel, msg) {
if (deps && deps.report) deps.report(nivel, msg);
else console.warn('[FOSFENO] capas:', msg);
}
function lienzo() {
const cv = document.createElement('canvas');
cv.width = ANCHO; cv.height = ALTO;
return cv;
}
/* Suelta de verdad el contexto WebGL de un lienzo que se tira. Sin esto, el
contexto sigue vivo hasta que pase el recolector: el navegador solo
aguanta unos 16 a la vez y al pasarse mata EL MAS VIEJO, que es el del
compositor de mapping. Resultado: proyector en negro para siempre. */
function soltarGL(cv) {
try {
const g = cv.getContext('webgl2') || cv.getContext('webgl');
const ext = g && g.getExtension('WEBGL_lose_context');
if (ext) ext.loseContext();
} catch (e) { /* el lienzo no llego a tener contexto */ }
}
// La fuente de una superficie, siempre normalizada (las superficies
// antiguas no la llevan: son del motor activo, como hasta ahora).
function fuenteDe(s) {
const f = (s && s.fuente) || {};
return { tipo: f.tipo || 'motor', preset: f.preset || '', auto: !!f.auto,
segundos: f.segundos || SEGUNDOS, shader: f.shader || '',
archivo: f.archivo || '', camaraId: f.camaraId || 0,
biblioteca: f.biblioteca === 'projectm' ? 'projectm' : 'butter',
milk: f.milk || '',
// segundos de fundido al pasar de un preset al siguiente
transicion: typeof f.transicion === 'number' ? f.transicion : 2.5 };
}
/* Firma de una fuente: si no cambia, la capa se deja viva y solo se le
pasan los ajustes nuevos con aplicar(). Para Butterchurn la firma es el
tipo a secas, a proposito: cambiar de preset o de cambio automatico NO
puede recrear la instancia, porque cada instancia nueva es un contexto
WebGL mas (ver soltarGL). */
function firma(f) {
if (f.tipo === 'butter') return 'butter';
if (f.tipo === 'shader') return 'shader|' + f.shader;
if (f.tipo === 'video') return 'video|' + f.archivo;
if (f.tipo === 'camara') return 'camara|' + f.camaraId;
return f.tipo;
}
/* ------------------------------------------------- presets .milk (projectM)
La biblioteca gorda son los 9795 .milk de data/presets-projectm, que
projectM lee en nativo y Butterchurn no entiende. Se traducen aqui, en el
navegador, con milkdrop-preset-converter: se pide el .milk al backend (ya
los sirve tal cual), se convierte y se le pasa a viz.loadPreset como
cualquier otro preset. Tarda unos milisegundos y no gasta disco: no hay
que pre-convertir 9795 ficheros en la tarjeta de la Raspberry.
La cache guarda la promesa, no el resultado: si dos capas piden el mismo
preset a la vez, se convierte una sola vez. */
const milkCache = new Map();
function cargarMilk(ruta) {
if (milkCache.has(ruta)) return milkCache.get(ruta);
const conv = window.milkdropPresetConverter;
if (!conv || !conv.convertPreset) {
return Promise.reject(new Error(
'falta la libreria de conversion de presets .milk'));
}
const p = fetch('/data/presets-projectm/'
+ ruta.split('/').map(encodeURIComponent).join('/'))
.then((r) => {
if (!r.ok) throw new Error('no esta en la biblioteca (' + r.status + ')');
return r.text();
})
.then((txt) => conv.convertPreset(txt))
.then((preset) => {
if (!preset || !preset.baseVals) throw new Error('conversion vacia');
return preset;
})
.catch((e) => { milkCache.delete(ruta); throw e; }); // que se reintente
milkCache.set(ruta, p);
return p;
}
// ---------------------------------------------------------------- Butter
function crearButter(f) {
if (typeof window.butterchurn === 'undefined' || !window.butterchurnPresets) {
aviso('warn', 'No hay Butterchurn: la capa de visuales MilkDrop se queda en negro.');
return null;
}
const bch = window.butterchurn.default || window.butterchurn;
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
const presets = bp.getPresets();
const nombres = Object.keys(presets);
const cv = lienzo();
let viz;
try {
viz = bch.createVisualizer(deps.audioCtx, cv, {
width: ANCHO, height: ALTO, pixelRatio: 1,
});
viz.connectAudio(deps.gainNode);
} catch (e) {
aviso('error', 'No se pudo crear la capa de visuales MilkDrop: ' + e.message);
return null;
}
/* Sin preset elegido (capa recien creada antes de que la lista llegara al
panel), indexOf devuelve -1 y el Math.max lo dejaba en 0: TODAS las
capas asi arrancaban en el mismo preset y parecian la misma imagen.
Ahora cada instancia coge uno al azar y lo devuelve en presetElegido,
para que el panel lo ensene en vez de dejar el desplegable en blanco. */
let i = nombres.indexOf(f.preset);
let presetElegido = '';
if (i < 0) {
i = Math.floor(Math.random() * nombres.length);
presetElegido = nombres[i];
}
let raf = null, timer = null, seg = 0, fundido = f.transicion;
// El fundido lo hace el propio Butterchurn al cargar: a 0 es corte seco,
// a 6 segundos se disuelve uno en otro sin que se note el cambio.
const cargar = (nombre) => {
if (!presets[nombre]) return;
try { viz.loadPreset(presets[nombre], fundido); } catch (e) { /* preset roto */ }
};
const paso = () => { i = (i + 1) % nombres.length; cargar(nombres[i]); };
/* Monta o desmonta el cambio automatico sin tocar la instancia. */
const ritmo = (auto, segundos) => {
const s = Math.max(2, segundos || SEGUNDOS);
if (!!timer === !!auto && (!auto || s === seg)) return;
clearInterval(timer); timer = null; seg = s;
if (auto) timer = setInterval(paso, s * 1000);
};
/* Carga un .milk de la biblioteca de projectM. Va por promesa, asi que
hay que descartar el resultado si mientras tanto la capa cambio de
preset o se destruyo: si no, el que tarde mas en convertirse pisa al
que el usuario acaba de elegir. */
let milkActual = ''; // el que se pidio el ultimo
let muerta = false;
const cargarDeMilk = (ruta) => {
milkActual = ruta;
cargarMilk(ruta).then((preset) => {
if (muerta || milkActual !== ruta) return;
try { viz.loadPreset(preset, fundido); }
catch (e) { aviso('warn', 'El preset .milk "' + ruta + '" no se pudo '
+ 'cargar: ' + e.message); }
}).catch((e) => {
if (muerta || milkActual !== ruta) return;
aviso('warn', 'No se pudo convertir el preset "' + ruta + '": '
+ e.message + '. La capa se queda con el anterior.');
});
};
if (f.biblioteca === 'projectm' && f.milk) cargarDeMilk(f.milk);
else cargar(nombres[i]);
const bucle = () => { viz.render(); raf = requestAnimationFrame(bucle); };
bucle();
ritmo(f.auto, f.segundos);
return {
el: cv,
// Con un .milk clavado no hay preset de Butterchurn que anotar
presetElegido: (f.biblioteca === 'projectm' && f.milk) ? '' : presetElegido,
// Todos los ajustes se aplican en caliente: nunca hay que recrear la
// instancia (y con ella, un contexto WebGL nuevo).
aplicar(nf) {
fundido = nf.transicion;
if (nf.biblioteca === 'projectm') {
// El cambio automatico es de la lista de Butterchurn: con un .milk
// clavado no tiene sentido dejarlo girando por encima.
if (nf.milk && nf.milk !== milkActual) cargarDeMilk(nf.milk);
ritmo(false, nf.segundos);
return;
}
if (milkActual) { // volvemos a la biblioteca de Butterchurn
milkActual = '';
cargar(nombres[i]);
}
if (!nf.auto && nf.preset && nf.preset !== nombres[i]) {
const j = nombres.indexOf(nf.preset);
if (j >= 0) { i = j; cargar(nombres[i]); }
}
ritmo(nf.auto, nf.segundos);
},
destruir() {
muerta = true;
if (raf) cancelAnimationFrame(raf);
clearInterval(timer);
// Sin desconectar, cada instancia tirada deja un analizador colgando
// del audio compartido para siempre.
try { viz.disconnectAudio(deps.gainNode); } catch (e) { /* ya suelto */ }
soltarGL(cv);
},
};
}
// ---------------------------------------------------------------- Shader
function crearShaderCapa(f, nombre) {
const codigo = (deps.shaders && deps.shaders[f.shader]
&& deps.shaders[f.shader].code) || '';
if (!codigo) {
aviso('warn', 'La capa "' + nombre + '" es de shader pero no tiene '
+ 'ninguno elegido: seleccionalo en Propiedades.');
return null;
}
const cv = lienzo();
let eng;
try {
eng = deps.crearShader(cv, { width: ANCHO, height: ALTO });
// Que no compile no puede dejar el contexto colgando (ver soltarGL)
if (!eng.load(codigo)) {
aviso('warn', 'El shader de la capa "' + nombre + '" no compila.');
soltarGL(cv);
return null;
}
eng.start();
} catch (e) {
aviso('error', 'La capa de shader "' + nombre + '" fallo: ' + e.message);
soltarGL(cv);
return null;
}
return {
el: cv, aplicar() {},
destruir() {
try { eng.stop(); } catch (e) { /* ya parado */ }
soltarGL(cv);
},
};
}
// ------------------------------------------------- Clip / imagen / camara
// Sin estado propio: varias superficies pueden compartir el mismo recurso.
function recursoCompartido(clave, crear) {
let r = compartidas.get(clave);
if (!r) { r = crear(); if (!r) return null; compartidas.set(clave, r); }
r.usos = (r.usos || 0) + 1;
return r;
}
/* Ojo al soltar: vaciar el src de un <video> o de una <img> dispara su
evento 'error', y sin la bandera saldria un aviso falso de "no pudo
cargar" cada vez que cambias de clip o quitas la capa. */
function crearVideo(f) {
if (!f.archivo) return null;
const url = '/data/videos/' + encodeURIComponent(f.archivo);
let soltado = false;
if (IMAGEN_RE.test(f.archivo)) {
const img = new Image();
img.onerror = () => {
if (!soltado) aviso('warn', "La capa no pudo cargar la imagen '" + f.archivo + "'.");
};
img.src = url;
return { el: img, soltar() { soltado = true; img.src = ''; } };
}
const vid = document.createElement('video');
vid.muted = true; vid.loop = true; vid.playsInline = true;
vid.addEventListener('error', () => {
if (soltado) return;
aviso('warn', "La capa no pudo cargar '" + f.archivo
+ "': prueba con .mp4 (H.264) o .webm.");
});
vid.addEventListener('loadeddata', () => vid.play().catch(() => {}));
vid.src = url;
return {
el: vid,
soltar() {
soltado = true;
vid.pause();
vid.removeAttribute('src'); // mas limpio que src="" para soltarlo
vid.load();
},
};
}
function crearCamara(f) {
const vid = document.createElement('video');
vid.muted = true; vid.playsInline = true;
let stream = null, muerto = false;
navigator.mediaDevices.enumerateDevices().then((devs) => {
const cams = devs.filter((d) => d.kind === 'videoinput');
if (!cams.length) throw new Error('no hay ninguna camara conectada');
const pick = cams[f.camaraId] || cams[0];
return navigator.mediaDevices.getUserMedia({
audio: false, video: { deviceId: { exact: pick.deviceId } },
});
}).then((st) => {
// La capa pudo borrarse o cambiar de camara mientras esta tardaba en
// abrirse (1-3 s por USB): si no se corta aqui, el piloto se queda
// encendido hasta recargar el escenario.
if (muerto) { st.getTracks().forEach((t) => t.stop()); return null; }
stream = st; vid.srcObject = st;
return vid.play();
}).catch((e) => {
if (!muerto) aviso('error', 'La capa de camara fallo: ' + e.message);
});
return {
el: vid,
soltar() {
muerto = true;
if (stream) stream.getTracks().forEach((t) => t.stop());
vid.srcObject = null;
},
};
}
// ------------------------------------------------------------ ciclo de vida
function crear(f, nombre) {
if (f.tipo === 'butter') return crearButter(f);
if (f.tipo === 'shader') return crearShaderCapa(f, nombre);
if (f.tipo === 'video') {
if (!f.archivo) {
aviso('warn', 'La capa "' + nombre + '" no tiene clip elegido: se ve '
+ 'negra hasta que selecciones uno en Propiedades.');
return null;
}
const r = recursoCompartido('video|' + f.archivo, () => crearVideo(f));
return r ? { el: r.el, compartida: 'video|' + f.archivo, aplicar() {}, destruir() {} } : null;
}
if (f.tipo === 'camara') {
const r = recursoCompartido('camara|' + f.camaraId, () => crearCamara(f));
return r ? { el: r.el, compartida: 'camara|' + f.camaraId, aplicar() {}, destruir() {} } : null;
}
// "motor" y "negro" no necesitan instancia; cualquier otro tipo es de una
// version distinta y hay que decirlo en vez de dejar la zona en negro.
if (f.tipo !== 'motor' && f.tipo !== 'negro') {
aviso('warn', 'La capa "' + nombre + '" usa una fuente desconocida ("'
+ f.tipo + '"): elige otra en Propiedades.');
}
return null;
}
function soltarCompartida(clave) {
const r = compartidas.get(clave);
if (!r) return;
r.usos--;
if (r.usos <= 0) { try { r.soltar(); } catch (e) { /* ya suelto */ } compartidas.delete(clave); }
}
function destruirCapa(id) {
const c = capas.get(id);
if (!c) return;
if (c.inst) {
try { c.inst.destruir(); } catch (e) { /* ya destruida */ }
if (c.inst.compartida) soltarCompartida(c.inst.compartida);
}
capas.delete(id);
}
const API = {
init(opciones) { deps = opciones || {}; },
setMotor(canvas) { motorEl = canvas || null; },
/* Pone las capas al dia con las superficies del estado: crea las nuevas,
actualiza las que solo han cambiado de ajuste y tira las que sobran. */
sync(surfaces) {
if (!deps) return;
const vivos = new Set();
(surfaces || []).forEach((s, i) => {
if (!s || !s.id) return;
vivos.add(s.id);
const f = fuenteDe(s);
// Una capa apagada o a cero no gasta nada: ni motores renderizando ni
// la camara encendida por algo que no se ve. Mismo criterio que usa
// el mapper para no pintarla.
const oculta = s.visible === false
|| (typeof s.opacidad === 'number' && s.opacidad <= 0);
const sig = oculta ? 'oculta' : firma(f);
const c = capas.get(s.id);
if (c && c.sig === sig) {
if (c.inst && c.inst.aplicar) c.inst.aplicar(f);
return;
}
if (c) destruirCapa(s.id);
if (oculta || f.tipo === 'motor' || f.tipo === 'negro') {
capas.set(s.id, { sig: sig, inst: null });
return;
}
const inst = crear(f, s.nombre || 'Capa ' + (i + 1));
capas.set(s.id, { sig: sig, inst: inst });
// Si la capa tuvo que elegir preset por su cuenta, que el estado se
// entere: asi el desplegable de Propiedades deja de salir en blanco.
if (inst && inst.presetElegido && deps.onPreset)
deps.onPreset(s.id, inst.presetElegido);
});
Array.from(capas.keys()).forEach((id) => { if (!vivos.has(id)) destruirCapa(id); });
},
/* Que elemento tiene que usar el mapper como textura de esta superficie.
La clave sirve para no subir la misma imagen dos veces por fotograma. */
fuenteDe(s) {
const f = fuenteDe(s);
if (f.tipo === 'negro') return null;
if (f.tipo === 'motor') return motorEl ? { el: motorEl, clave: 'motor' } : null;
const c = capas.get(s.id);
if (!c || !c.inst || !c.inst.el) return null;
return { el: c.inst.el, clave: c.inst.compartida || ('capa:' + s.id) };
},
presets() {
if (!window.butterchurnPresets) return [];
const bp = window.butterchurnPresets.default || window.butterchurnPresets;
return Object.keys(bp.getPresets());
},
};
window.FosLayers = API;
})();

View file

@ -1,26 +1,34 @@
/*
* FOSFENO :: Mapper (projection mapping en el navegador) Fase 2
* FOSFENO :: Mapper (projection mapping en el navegador) Fase 3
* --------------------------------------------------------------------------
* Coge el canvas del motor activo como TEXTURA y lo pinta deformado sobre
* SUPERFICIES con MALLA (rejilla n x n deformable => superficies curvas, no
* solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que
* Pinta CAPAS deformadas sobre superficies con MALLA (rejilla n x n => curvas,
* no solo keystone de 4 esquinas) y aplica MASCARAS (cuadrilateros negros que
* tapan zonas / recortan derrames de luz).
*
* Cada superficie es una CAPA con su propia fuente: el motor activo, una
* instancia propia de MilkDrop, un shader, un clip o la camara (las crea
* layers.js). Asi se puede tener MilkDrop a la derecha, otro preset a la
* izquierda y un video en el centro, cada uno encajado en su sitio.
*
* Modelo (0..1, origen arriba-izquierda; se guarda en data/mapping.json):
* surface = { id, n, points:[[x,y] x (n+1)^2] } // rejilla n x n celdas
* mask = { id, corners:[[x,y] x4] } // TL,TR,BR,BL, negro
* surface = { id, n, points:[[x,y] x (n+1)^2],
* nombre, visible, opacidad, mezcla:"normal"|"sumar",
* fuente:{ tipo, ... } } // ver layers.js
* mask = { id, corners:[[x,y] x4] } // TL,TR,BR,BL, negro
* mapping = { enabled, edit, surfaces:[...], masks:[...] }
*
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1.
* Compatibilidad: superficies antiguas { corners:[...] } se migran a n=1, y
* las que no llevan fuente se pintan con el motor activo, como siempre.
*
* Render: cada celda de la malla se dibuja como un parche con homografia
* (cuadrado unidad -> celda) sobre una sub-malla fina => perspectiva correcta.
*
* API global:
* FosMapper.init({ onChange }) arranca
* FosMapper.setSource(canvas) motor activo (o null)
* FosMapper.setMapping(m) estado de mapping
* onChange({surfaces, masks}) al editar (arrastrar), para guardar
* FosMapper.init({ onChange, resolver }) arranca
* FosMapper.setSource(canvas) lienzo del motor activo (fuente "motor")
* FosMapper.setMapping(m) estado de mapping
* onChange({surfaces, masks}) al editar (arrastrar), para guardar
* resolver(surface) -> { el, clave } con la textura de la capa
*/
(function () {
'use strict';
@ -32,11 +40,15 @@
const M = { enabled: false, edit: false, surfaces: [], masks: [] };
let source = null;
let onChange = null;
let resolver = null; // surface -> { el, clave } (lo pone layers.js)
let aviso = function () {}; // report() del escenario: nada se queda callado
let outputEl, editEl, gl, ectx;
let prog, aUV, uH, uTex, uOff, uScale; // programa de textura
let progF, aPos, uColor, fillBuf; // programa de relleno (mascaras)
let tex, gridBuf, gridIdx, gridCount, dpr = 1;
let prog, aUV, uH, uTex, uOff, uScale, uAlpha; // programa de textura
let progF, aPos, uColor, fillBuf; // programa de relleno (mascaras)
let gridBuf, gridIdx, gridCount, dpr = 1;
const texturas = new Map(); // clave de fuente -> { tex, subida, visto }
let frame = 0;
let selected = null; // { type:'surface'|'mask', id }
let drag = null; // { type, id, pt|null, sx, sy, orig }
@ -46,16 +58,39 @@
const sidx = (c, r, n) => r * (n + 1) + c;
// ------------------------------------------------------------------ modelo
/* Normaliza una superficie: geometria a formato malla y ajustes de capa con
sus valores por defecto. Lo que no venga (superficies de antes de las
capas) se rellena para que el resto del codigo no tenga que preguntar. */
function migrate(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: s.fuente || { tipo: 'motor' },
};
if (Array.isArray(s.points) && s.n) {
return { id: s.id, n: s.n, points: s.points.map((p) => [p[0], p[1]]) };
}
if (Array.isArray(s.corners)) { // formato Fase 1 -> n=1
capa.n = s.n; capa.points = s.points.map((p) => [p[0], p[1]]);
} else if (Array.isArray(s.corners)) { // formato Fase 1 -> n=1
const c = s.corners;
return { id: s.id, n: 1, 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 (row-major)
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 (row-major)
} else {
capa.n = 1;
capa.points = [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]];
}
return { id: s.id, n: 1, points: [[0.3, 0.3], [0.7, 0.3], [0.3, 0.7], [0.7, 0.7]] };
return capa;
}
/* Mascaras saneadas. Una sin esquinas (mapping.json a medio escribir, de
otra version o tocado a mano) no puede tumbar el escenario entero: se
descarta y punto. */
function migrateMasks(lista) {
return (Array.isArray(lista) ? lista : [])
.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]]) }));
}
// punto [x,y] a coordenada normalizada (u,v) de la superficie (bilineal por celda)
@ -74,7 +109,11 @@
const pts = [];
for (let r = 0; r <= newN; r++)
for (let c = 0; c <= newN; c++) pts.push(sampleSurf(s, c / newN, r / newN));
return { id: s.id, n: newN, points: pts };
// Solo cambia la malla: los ajustes de la capa (fuente, nombre, opacidad)
// se conservan tal cual.
const out = migrate(s);
out.n = newN; out.points = pts;
return out;
}
// homografia cuadrado unidad -> cuadrilatero (Heckbert), mat3 por columnas
@ -117,7 +156,11 @@
function initGL() {
gl = outputEl.getContext('webgl', { alpha: false, antialias: true })
|| outputEl.getContext('experimental-webgl');
if (!gl) { console.error('[FOSFENO] mapper: sin WebGL'); return false; }
if (!gl) {
aviso('error', 'El mapping necesita WebGL y este navegador no lo tiene: '
+ 'las visuales saldran sin deformar.');
return false;
}
prog = linkProg(
`attribute vec2 aUV; uniform mat3 uH; uniform vec2 uOff; uniform vec2 uScale;
@ -126,12 +169,15 @@
gl_Position = vec4(d.x*2.0-1.0, 1.0-d.y*2.0, 0.0, 1.0);
vUV = uOff + aUV*uScale; }`,
`precision mediump float; varying vec2 vUV; uniform sampler2D uTex;
void main(){ gl_FragColor = texture2D(uTex, vUV); }`);
uniform float uAlpha;
void main(){ vec4 c = texture2D(uTex, vUV);
gl_FragColor = vec4(c.rgb, c.a * uAlpha); }`);
aUV = gl.getAttribLocation(prog, 'aUV');
uH = gl.getUniformLocation(prog, 'uH');
uTex = gl.getUniformLocation(prog, 'uTex');
uOff = gl.getUniformLocation(prog, 'uOff');
uScale = gl.getUniformLocation(prog, 'uScale');
uAlpha = gl.getUniformLocation(prog, 'uAlpha');
progF = linkProg(
`attribute vec2 aPos; void main(){ gl_Position = vec4(aPos,0.0,1.0); }`,
@ -158,16 +204,53 @@
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(idx), gl.STATIC_DRAW);
tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
/* SIN voltear la textura al subirla. El modelo va con el origen
arriba-izquierda (vUV.y = 0 es el borde de arriba de la superficie) y
texImage2D ya sube la primera fila de la imagen en t = 0. Poniendo
UNPACK_FLIP_Y a true, t = 0 pasaba a ser la ultima fila y todo salia
del reves se notaba sobre todo con los clips de video. */
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.enable(gl.BLEND);
return true;
}
/* Una textura por fuente, subida una sola vez por fotograma: si dos
superficies comparten el mismo clip, la imagen no viaja dos veces. */
function texturaDe(src) {
const el = src.el;
if (!el) return null;
const w = el.videoWidth || el.naturalWidth || el.width;
const h = el.videoHeight || el.naturalHeight || el.height;
if (!w || !h) return null;
if (el.tagName === 'VIDEO' && el.readyState < 2) return null;
let e = texturas.get(src.clave);
if (!e) {
e = { tex: gl.createTexture(), subida: -1, visto: -1 };
gl.bindTexture(gl.TEXTURE_2D, e.tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
texturas.set(src.clave, e);
}
e.visto = frame;
if (e.subida !== frame) {
e.subida = frame;
gl.bindTexture(gl.TEXTURE_2D, e.tex);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el); }
catch (err) { return null; }
}
return e.tex;
}
/* Tira las texturas de fuentes que ya no se usan (capas borradas). */
function limpiarTexturas() {
if (frame % 300) return;
texturas.forEach((e, clave) => {
if (frame - e.visto > 120) { gl.deleteTexture(e.tex); texturas.delete(clave); }
});
}
function drawSurfaceGL(s) {
const n = s.n;
const P = (c, r) => s.points[sidx(c, r, n)];
@ -182,32 +265,63 @@
}
}
// Pantalla entera: lo que se ve cuando el mapping esta activo pero aun no
// hay ninguna superficie creada (pasada directa del motor).
const PANTALLA = { id: '__pantalla__', n: 1, visible: true, opacidad: 1,
mezcla: 'normal', fuente: { tipo: 'motor' },
points: [[0, 0], [1, 0], [0, 1], [1, 1]] };
// La fuente de una capa: la resuelve layers.js; si no hay resolver (o la
// capa es del motor), tira del lienzo del motor activo.
function fuenteDeCapa(capa) {
if (resolver) {
const r = resolver(capa);
if (r && r.el) return r;
if (capa.fuente && capa.fuente.tipo !== 'motor') return null;
}
return (source && source.width) ? { el: source, clave: 'motor' } : null;
}
function renderGL() {
frame++;
gl.viewport(0, 0, outputEl.width, outputEl.height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
if (!source || !source.width) return;
gl.bindTexture(gl.TEXTURE_2D, tex);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); }
catch (e) { return; }
// superficies
// superficies (cada una con su propia fuente)
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gridBuf);
gl.enableVertexAttribArray(aUV);
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gridIdx);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.uniform1i(uTex, 0);
if (!M.surfaces.length) {
drawSurfaceGL({ n: 1, points: [[0, 0], [1, 0], [0, 1], [1, 1]] }); // pasada directa
} else {
for (const s of M.surfaces) drawSurfaceGL(migrate(s));
}
// mascaras (cuadrilateros negros encima)
// M.surfaces ya viene normalizado de setMapping / onDown / onDblClick:
// no hace falta migrar otra vez en cada fotograma (eran cientos de
// arrays nuevos por segundo, justo en la maquina que menos lo aguanta).
const lista = M.surfaces.length ? M.surfaces : [PANTALLA];
for (const capa of lista) {
if (!capa.visible || capa.opacidad <= 0) continue;
if (capa.fuente.tipo === 'negro') continue;
const src = fuenteDeCapa(capa);
if (!src) continue;
const t = texturaDe(src);
if (!t) continue;
gl.bindTexture(gl.TEXTURE_2D, t);
gl.uniform1f(uAlpha, capa.opacidad);
// "sumar" apila luz (las zonas oscuras dejan ver la capa de abajo);
// "normal" tapa lo que haya debajo.
if (capa.mezcla === 'sumar') gl.blendFunc(gl.SRC_ALPHA, gl.ONE);
else gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
drawSurfaceGL(capa);
}
limpiarTexturas();
// mascaras (cuadrilateros negros encima). Vuelve a mezcla normal: si la
// ultima capa iba en "sumar", un negro sumado no taparia nada.
if (M.masks.length) {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(progF);
gl.uniform4f(uColor, 0, 0, 0, 1);
gl.bindBuffer(gl.ARRAY_BUFFER, fillBuf);
@ -225,6 +339,28 @@
}
// ------------------------------------------------------------------ editor
const NOMBRE_FUENTE = {
motor: 'motor activo', butter: 'MilkDrop', shader: 'shader',
video: 'clip', camara: 'camara', negro: 'negro',
};
function etiquetaFuente(f) {
f = f || {};
const base = NOMBRE_FUENTE[f.tipo] || 'motor activo';
if (f.tipo === 'butter' && f.preset) return base + ' · ' + f.preset;
if (f.tipo === 'video' && f.archivo) return base + ' · ' + f.archivo;
return base;
}
/* Rotulo centrado que no se sale de la pantalla: una capa pegada al borde
tendria el nombre cortado por la mitad. */
function rotulo(txt, x, y, w) {
let t = txt;
while (t.length > 6 && ectx.measureText(t).width > w - 24) t = t.slice(0, -2);
if (t !== txt) t = t.slice(0, -1) + '…';
const media = ectx.measureText(t).width / 2;
ectx.fillText(t, Math.min(Math.max(x, media + 10), w - media - 10), y);
}
function drawEditor() {
const w = window.innerWidth, h = window.innerHeight;
ectx.setTransform(dpr, 0, 0, dpr, 0, 0);
@ -232,9 +368,9 @@
ectx.font = '600 14px system-ui, sans-serif';
ectx.textAlign = 'center'; ectx.textBaseline = 'middle';
// superficies (malla)
M.surfaces.forEach((raw, i) => {
const s = migrate(raw); const n = s.n;
// superficies (malla). Ya vienen normalizadas de setMapping.
M.surfaces.forEach((s, i) => {
const n = s.n;
const sel = selected && selected.type === 'surface' && selected.id === s.id;
const col = sel ? '#00e5ff' : '#b4ff00';
const P = (c, r) => { const p = s.points[sidx(c, r, n)]; return [p[0] * w, p[1] * h]; };
@ -242,9 +378,16 @@
ectx.strokeStyle = col; ectx.lineWidth = sel ? 2 : 1.3;
for (let r = 0; r <= n; r++) { ectx.beginPath(); for (let c = 0; c <= n; c++) { const p = P(c, r); c ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
for (let c = 0; c <= n; c++) { ectx.beginPath(); for (let r = 0; r <= n; r++) { const p = P(c, r); r ? ectx.lineTo(p[0], p[1]) : ectx.moveTo(p[0], p[1]); } ectx.stroke(); }
// numero
const cc = P(n / 2 | 0, n / 2 | 0);
ectx.fillStyle = col; ectx.fillText(String(i + 1), cc[0], cc[1]);
// nombre de la capa y su fuente, en el centro de verdad: el medio de
// las cuatro esquinas (con n=1, P(n/2|0, n/2|0) caia en la esquina).
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];
ectx.fillStyle = col;
rotulo(s.nombre || ('Capa ' + (i + 1)), cc[0], cc[1] - 9, w);
ectx.font = '12px system-ui, sans-serif';
rotulo(etiquetaFuente(s.fuente), cc[0], cc[1] + 10, w);
ectx.font = '600 14px system-ui, sans-serif';
// tiradores (todos los puntos)
for (let r = 0; r <= n; r++) for (let c = 0; c <= n; c++) {
const p = P(c, r);
@ -364,7 +507,9 @@
function onUp() { if (drag && dragged) emitNow(); drag = null; }
function defaultSurface(cx, cy, s) {
return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4), n: 1,
return { id: 's' + Math.floor(performance.now()) + '_' + Math.floor(Math.random() * 1e4),
n: 1, nombre: '', visible: true, opacidad: 1, mezcla: 'normal',
fuente: { tipo: 'motor' },
points: [[clamp01(cx - s), clamp01(cy - s)], [clamp01(cx + s), clamp01(cy - s)],
[clamp01(cx - s), clamp01(cy + s)], [clamp01(cx + s), clamp01(cy + s)]] };
}
@ -394,7 +539,7 @@
function emitNow() {
if (emitTimer) { clearTimeout(emitTimer); emitTimer = null; }
if (onChange) onChange({
surfaces: M.surfaces.map((s) => { const m = migrate(s); return { id: m.id, n: m.n, points: m.points }; }),
surfaces: M.surfaces.map(migrate), // geometria + ajustes de la capa
masks: M.masks.map((m) => ({ id: m.id, corners: m.corners })),
});
}
@ -422,10 +567,24 @@
init(opts) {
opts = opts || {};
onChange = opts.onChange || null;
resolver = opts.resolver || null;
aviso = opts.report || function () {};
outputEl = document.getElementById('output');
editEl = document.getElementById('mapedit');
if (!outputEl || !editEl) { console.error('[FOSFENO] mapper: faltan #output/#mapedit'); return; }
if (!outputEl || !editEl) {
aviso('error', 'Falta el lienzo del mapping en la pagina del '
+ 'escenario: recarga el escenario.');
return;
}
ectx = editEl.getContext('2d');
// Si el navegador tira este contexto (demasiadas capas a la vez), el
// mapping se queda negro en silencio: que al menos se sepa por que.
outputEl.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
aviso('error', 'El navegador ha cerrado el contexto grafico del '
+ 'mapping (demasiadas capas a la vez). Quita alguna capa de '
+ 'MilkDrop o de shader y recarga el escenario.');
});
if (!initGL()) return;
editEl.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
@ -440,8 +599,10 @@
M.enabled = !!m.enabled;
M.edit = !!m.edit;
if (!drag) { // no piso la geometria mientras arrastro aqui
if (Array.isArray(m.surfaces)) M.surfaces = m.surfaces.map(migrate);
M.masks = Array.isArray(m.masks) ? m.masks.map((k) => ({ id: k.id, corners: k.corners.map((p) => [p[0], p[1]]) })) : [];
if (Array.isArray(m.surfaces)) {
M.surfaces = m.surfaces.filter((s) => s && s.id).map(migrate);
}
M.masks = migrateMasks(m.masks);
if (selected) {
const ok = selected.type === 'surface' ? findSurf(selected.id) >= 0 : findMask(selected.id) >= 0;
if (!ok) selected = null;

View file

@ -9,7 +9,11 @@
* Incluye deteccion de BPM en vivo y seleccion de tarjeta de audio.
*/
const socket = io();
// Clave compartida (config.json -> auth.token). Viene en la propia direccion
// del escenario; si no hay clave configurada, queda vacia y todo va como
// siempre. Se reenvia en el QR para que el movil entre de un escaneo.
const CLAVE = new URLSearchParams(location.search).get("k") || "";
const socket = io({ auth: { token: CLAVE } });
const msgEl = document.getElementById("msg");
const bcCanvas = document.getElementById("butterchurn");
const hydraCanvas = document.getElementById("hydra");
@ -120,8 +124,12 @@ let beatDetector = null;
// u_bpm, u_beat (fase 0..1), u_fft (sampler2D)
// ==========================================================================
class ShaderEngine {
constructor(canvas, analyserNode) {
/* size: {width, height} fija el lienzo (capas del mapping, que se
renderizan pequenas y las estira el mapper). Sin size, el shader ocupa
la pantalla entera, que es como va el motor de shaders normal. */
constructor(canvas, analyserNode, size) {
this.canvas = canvas;
this.size = size || null;
this.analyser = analyserNode;
this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
this.raf = null;
@ -220,8 +228,8 @@ class ShaderEngine {
_frame() {
const gl = this.gl;
const w = this.canvas.width = window.innerWidth;
const h = this.canvas.height = window.innerHeight;
const w = this.canvas.width = this.size ? this.size.width : window.innerWidth;
const h = this.canvas.height = this.size ? this.size.height : window.innerHeight;
gl.viewport(0, 0, w, h);
if (this.program) {
const b = this._bands();
@ -366,8 +374,9 @@ function beatLoop() {
const isBeat = beatDetector ? beatDetector.update(now) : false;
try { window.bpm = fosBeat.bpm || 30; } catch (e) { /* hydra no cargado */ }
// Cambio de preset de Butterchurn sincronizado al compas
if (isBeat && state && state.engine === "butterchurn" && state.power
// Cambio de preset de Butterchurn sincronizado al compas (tanto si es el
// motor elegido como si hace de fondo del mezclador)
if (isBeat && butterLive()
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "beats") {
bcBeatCount++;
if (bcBeatCount >= (state.butterchurn.interval || 16)) {
@ -407,9 +416,24 @@ function initButterchurn() {
function loadButterchurnPreset(name) {
if (!bcPresets[name] || !bcViz) return;
bcCurrent = name; // recuerda el intento aunque falle, para no reintentarlo en bucle
// El indice sigue al preset que suena: asi 'siguiente' continua desde el
// que se ve, tanto si se eligio en la lista como si vino del cambio
// automatico.
const i = bcNames.indexOf(name);
if (i >= 0) bcIndex = i;
// El estado guarda siempre el preset que esta en pantalla. Sin esto, el
// cambio automatico volvia atras en cuanto llegaba cualquier estado
// nuevo (el escenario recargaba el ultimo preset elegido a mano).
if (!state || state.butterchurn.preset !== name) {
socket.emit("update_settings",
{ engine: "butterchurn", patch: { preset: name } });
}
try {
bcViz.loadPreset(bcPresets[name], (state && state.butterchurn.blendTime) || 2.7);
socket.emit("stage_status", { label: name });
socket.emit("stage_status", {
label: (state && state.engine === "mixer")
? "Mezclador VJ · fondo: " + name : name,
});
} catch (e) {
report("warn", "El preset '" + name + "' fallo al cargar en esta "
+ "tarjeta grafica. Pasa a otro con 'siguiente' o eligelo en la lista.");
@ -421,6 +445,17 @@ function bcStep(delta) {
loadButterchurnPreset(bcNames[bcIndex]);
}
/* Butterchurn esta pintando ahora mismo? Puede ser el motor elegido o el
fondo del mezclador (la capa de abajo). En los dos casos manda el mismo
preset y el mismo cambio automatico, para que el fondo del mezclador se
maneje igual que el motor Butter. */
function butterLive() {
if (!state || !state.power) return false;
if (state.engine === "butterchurn") return true;
return state.engine === "mixer" && state.mixer.fondo === "butter"
&& (state.mixer.source === "cam" || state.mixer.source === "mix");
}
function bcLoop() {
bcViz.setRendererSize(window.innerWidth, window.innerHeight);
bcViz.render();
@ -429,21 +464,30 @@ function bcLoop() {
function startButterchurn() { if (!bcRAF) bcLoop(); }
/* Ajustes con los que se monto el temporizador del cambio automatico. Sin
esta firma, cada estado nuevo (mover un deslizador, subir un clip...)
reiniciaba la cuenta y con intervalos largos el preset no cambiaba nunca. */
let bcShuffleSig = "";
function stopButterchurn() {
if (bcRAF) cancelAnimationFrame(bcRAF);
bcRAF = null;
clearInterval(bcTimer);
bcTimer = null;
bcShuffleSig = "";
}
function refreshShuffle() {
const b = (state && state.butterchurn) || {};
const sig = butterLive()
? [b.shuffle, b.intervalMode, b.interval].join("|") : "off";
if (sig === bcShuffleSig) return;
bcShuffleSig = sig;
clearInterval(bcTimer);
bcTimer = null;
bcBeatCount = 0;
if (state && state.engine === "butterchurn" && state.power
&& state.butterchurn.shuffle && state.butterchurn.intervalMode === "seconds") {
bcTimer = setInterval(() => bcStep(1),
(state.butterchurn.interval || 20) * 1000);
if (butterLive() && b.shuffle && b.intervalMode === "seconds") {
bcTimer = setInterval(() => bcStep(1), (b.interval || 20) * 1000);
}
}
@ -515,6 +559,13 @@ let mixerCamFailed = null; // cameraId que fallo (no reintentar en bucle)
let mixerVideoEl = null; // elemento <video> del clip actual
let mixerWarned = ""; // ultimo aviso de fuente no visible mostrado
let mixerButter = false; // s0 es el lienzo de Butterchurn (fondo butter)
/* Que hay enganchado a s1 (el clip o la imagen de encima). Hydra.hush() borra
TODAS las fuentes al salir del mezclador, asi que hay que recordarlo para
volver a engancharlo al entrar: sin esto, al ir al motor Butter (o a
projectM, o a Shaders) y volver, el clip desaparecia y la mezcla salia sin
la capa de encima. */
let mixerS1 = null; // { el, dynamic }
let mixerSueltas = false; // hubo hush(): s0/s1 estan vacias
function stopMixerCam() {
if (mixerCamStream) mixerCamStream.getTracks().forEach((t) => t.stop());
@ -530,10 +581,21 @@ function stopMixer() {
mixerWarned = "";
mixerCamFailed = null;
mixerButter = false;
mixerSueltas = true;
stopMixerCam();
if (mixerVideoEl) mixerVideoEl.pause();
}
/* Vuelve a enganchar a Hydra lo que ya teniamos cargado, sin recargarlo. */
function reengancharMixer() {
if (!mixerSueltas) return;
mixerSueltas = false;
if (mixerS1 && mixerS1.el) {
try { s1.init({ src: mixerS1.el, dynamic: mixerS1.dynamic }); }
catch (e) { mixerS1 = null; }
}
}
async function initCamSource(index) {
const devs = await navigator.mediaDevices.enumerateDevices();
const cams = devs.filter((d) => d.kind === "videoinput");
@ -560,6 +622,7 @@ function initVideoSource(name) {
vid.addEventListener("loadeddata", () => {
vid.play().catch(() => { /* el flag de autoplay del kiosko lo permite */ });
s1.init({ src: vid, dynamic: true });
mixerS1 = { el: vid, dynamic: true };
resolve(vid);
});
vid.addEventListener("error", () =>
@ -576,6 +639,7 @@ function initImageSource(name) {
const img = new Image();
img.onload = () => {
s1.init({ src: img, dynamic: false });
mixerS1 = { el: img, dynamic: false };
resolve(img);
};
img.onerror = () =>
@ -585,6 +649,9 @@ function initImageSource(name) {
}
async function ensureMixerSources(m) {
// Al volver de otro motor, Hydra tiene las fuentes vacias (hush): lo
// primero es reengancharlas antes de decidir que falta.
reengancharMixer();
// Fondo "butter": el lienzo de Butterchurn sigue renderizando (oculto,
// el visible es el de Hydra) y hace de capa de abajo s0 en lugar de la
// camara. Asi los recortes de personajes van DELANTE de MilkDrop.
@ -592,11 +659,13 @@ async function ensureMixerSources(m) {
&& (m.source === "cam" || m.source === "mix");
if (wantButter && bcViz) {
stopMixerCam();
if (!bcCurrent) {
loadButterchurnPreset((state && state.butterchurn.preset)
|| bcNames[bcIndex]);
}
// Mismo criterio que en el motor Butter: manda el preset del estado
// (elegido en el panel, en el mezclador o por el cambio automatico).
const quiere = (state && state.butterchurn.preset) || "";
if (quiere && quiere !== bcCurrent) loadButterchurnPreset(quiere);
else if (!bcCurrent) loadButterchurnPreset(bcNames[bcIndex]);
startButterchurn();
refreshShuffle();
if (!mixerButter) {
mixerButter = true;
s0.init({ src: bcCanvas, dynamic: true });
@ -691,6 +760,7 @@ function show(canvas) {
msgEl.style.display = canvas ? "none" : "block";
// el mapper (si el mapping está activo) usa este canvas como textura
if (window.FosMapper) FosMapper.setSource(canvas);
if (window.FosLayers) FosLayers.setMotor(canvas);
}
/* Pantalla de conexion: muestra la direccion del panel y un codigo QR en el
@ -700,8 +770,9 @@ function renderInfoScreen() {
show(null);
const net = state.network || {};
const port = (net.port && net.port !== 80) ? ":" + net.port : "";
const ipUrl = "http://" + (net.ip || "...") + port + "/";
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + port + "/";
const k = CLAVE ? "?k=" + encodeURIComponent(CLAVE) : "";
const ipUrl = "http://" + (net.ip || "...") + port + "/" + k;
const nameUrl = "http://" + (net.hostname || "fosfeno") + ".local" + port + "/" + k;
document.getElementById("info-url").textContent = ipUrl;
document.getElementById("info-sub").textContent = "o tambien: " + nameUrl;
if (typeof qrcode !== "undefined" && lastQrUrl !== ipUrl && net.ip) {
@ -716,11 +787,39 @@ function renderInfoScreen() {
document.getElementById("info").style.display = "flex";
}
/* Una capa MilkDrop sin preset elegido coge uno al azar (para que dos capas
nuevas no salgan identicas). Lo guardamos en el estado para que el panel
ensene cual le toco en vez de dejar el desplegable en blanco. No recrea la
instancia: la firma de una capa butter es el tipo a secas. */
function anotarPresetDeCapa(surfaceId, preset) {
const map = (state && state.mapping) || {};
const surf = (map.surfaces || []).find((s) => s.id === surfaceId);
if (!surf || (surf.fuente && surf.fuente.preset)) return;
surf.fuente = Object.assign({}, surf.fuente, { preset: preset });
socket.emit("set_mapping", { surfaces: map.surfaces, masks: map.masks });
}
function applyState(next) {
const prevDevice = currentDevice;
const prevSource = currentSource;
state = next;
if (window.FosMapper) FosMapper.setMapping(state.mapping || {});
const map = state.mapping || {};
// El mapping nunca puede tumbar el escenario: un mapping.json raro dejaria
// el proyector en negro y, de paso, sin poder contarlo (los avisos se
// pintan despues). Va aislado y con su propio aviso.
try {
if (window.FosMapper) FosMapper.setMapping(map);
// Las capas se ponen al dia con el estado: crea las nuevas, cambia el
// preset de las que lo hayan cambiado y tira las borradas. Solo con el
// mapping encendido y las visuales dadas: apagado no queremos motores
// extra quemando GPU ni la camara con el piloto puesto.
if (window.FosLayers) {
FosLayers.sync(map.enabled && state.power ? (map.surfaces || []) : []);
}
} catch (e) {
report("error", "El mapping no se pudo aplicar (" + e.message + "). "
+ "Pulsa Reset en la pestana MAPPING del panel.");
}
if (gainNode) gainNode.gain.value = state.sensitivity;
// Cambio de fuente (micro / audio del navegador) o de tarjeta de audio.
@ -803,17 +902,30 @@ function applyState(next) {
// ==========================================================================
socket.on("state", (next) => applyState(next));
/* Al (re)conectar, el escenario le devuelve al servidor lo que solo sabe el
navegador: la lista de presets y los dispositivos. Si el servidor se ha
reiniciado, su estado nace vacio y sin esto el panel se queda sin presets
ni camaras hasta recargar el escenario a mano. */
socket.on("connect", () => {
if (bcNames.length) socket.emit("stage_meta", { butterchurnPresets: bcNames });
enumerateInputs().catch(() => { /* aun sin permisos: ya se reintenta */ });
});
// --- Mapper (projection mapping en el navegador) ---
// Arranca el compositor/editor; al editar (arrastrar), guarda en el servidor.
// El resolver es lo que hace que cada superficie pinte SU fuente: se la pide
// a layers.js, que mantiene una instancia por capa.
if (window.FosMapper) {
FosMapper.init({
onChange: (surfaces) => socket.emit("set_mapping", { surfaces: surfaces }),
onChange: (m) => socket.emit("set_mapping", m),
resolver: (s) => (window.FosLayers ? FosLayers.fuenteDe(s) : null),
report: report,
});
}
socket.on("stage_command", (cmd) => {
if (!state) return;
if (state.engine === "butterchurn") {
if (butterLive()) {
if (cmd.action === "next") bcStep(1);
else if (cmd.action === "prev") bcStep(-1);
}
@ -863,6 +975,29 @@ async function boot() {
try {
shaderEngine = new ShaderEngine(shaderCanvas, analyser);
} catch (e) { report("error", "El motor de shaders fallo: " + e.message); }
// Capas del mapping: cada superficie puede tener su propia fuente. Necesita
// el audio compartido (para que MilkDrop y los shaders de cada capa
// reaccionen a la misma musica) y la libreria de shaders.
let shaders = {};
try {
shaders = await fetch("/data/shaders.json").then((r) => r.json());
} catch (e) {
report("warn", "No se pudo cargar la libreria de shaders: las capas de "
+ "shader del mapping no estaran disponibles.");
}
if (window.FosLayers) {
FosLayers.init({
audioCtx: audioCtx, gainNode: gainNode, analyser: analyser,
shaders: shaders, report: report,
crearShader: (canvas, size) => new ShaderEngine(canvas, analyser, size),
onPreset: anotarPresetDeCapa,
});
// El estado pudo llegar antes de que las capas estuvieran listas
if (state && state.mapping && state.mapping.enabled) {
FosLayers.sync(state.mapping.surfaces || []);
}
}
msgEl.textContent = "FOSFENO listo";
}