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

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