Projection mapping: warped zones for images and the live visual
New Mappatura tab + mapping.js engine: each zone is a quad warped with a projective homography (square->quad closed form, inverted per fragment) on a dedicated WebGL2 canvas above the visual, black outside. Sources: the live visual (with a source sub-rect for multi-panel LED walls) or an image file (corner-pin onto physical surfaces). Edit mode draws handles ON THE OUTPUT: corners (or whole quads) are dragged while looking at the real projection, edits flow back to the panel which persists them. Zone list with name, source portion, opacity and delete; included in recordings; re-sent when the output restarts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a3991e0f92
commit
0daa89656f
6 files changed
+446
No files matched your search
@@ -18,6 +18,7 @@
|
||||
<button data-tab="playlist">🎶 Playlist</button>
|
||||
<button data-tab="effetti">🌀 Effetti</button>
|
||||
<button data-tab="anim3d">🧊 Animazioni 3D</button>
|
||||
<button data-tab="mappa">🗺 Mappatura</button>
|
||||
<button data-tab="pad">🎹 Pad</button>
|
||||
<button data-tab="immagini">🖼 Immagini</button>
|
||||
<button data-tab="testo">🔤 Testo</button>
|
||||
@@ -201,6 +202,27 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ============ MAPPATURA ============ -->
|
||||
<div class="tab-panel" data-tab="mappa">
|
||||
<section class="span2">
|
||||
<label>Mappatura proiezione</label>
|
||||
<div class="row">
|
||||
<label class="chk"><input type="checkbox" id="map-on" /> Attiva</label>
|
||||
<label class="chk"><input type="checkbox" id="map-edit" /> Modifica zone (maniglie sull'output)</label>
|
||||
</div>
|
||||
<div id="map-zones"></div>
|
||||
<div class="row">
|
||||
<button id="map-add-vis">➕ Zona Visual</button>
|
||||
<button id="map-add-img">➕ Zona Immagine…</button>
|
||||
<input type="file" id="map-img-input" accept="image/*" hidden />
|
||||
</div>
|
||||
<div class="hint-mini">Ogni zona è un quadrilatero proiettato: con “Modifica zone” trascini i
|
||||
4 angoli (o l'intera zona) direttamente sull'output guardando la superficie reale.
|
||||
Le zone <b>Visual</b> mostrano l'effetto live (anche solo una porzione, per wall multi-pannello);
|
||||
le zone <b>Immagine</b> fanno corner-pin di un'immagine su un oggetto. Fuori dalle zone: nero.</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" data-tab="pad">
|
||||
<section class="span2">
|
||||
<label>Pad — 20 brani al volo (5×4, salvati automaticamente)</label>
|
||||
|
||||
@@ -1408,6 +1408,86 @@ $('#ticker-bold').addEventListener('change', (e) => send({ type: 'tickerWeight',
|
||||
$('#ticker-color').addEventListener('input', (e) => send({ type: 'tickerColor', value: e.target.value }));
|
||||
$('#ticker-fx').addEventListener('change', (e) => send({ type: 'tickerFx', value: e.target.value }));
|
||||
|
||||
// ---------------------------------------------------------------- mapping
|
||||
// Projection mapping zones: the panel is the source of truth (persisted);
|
||||
// corner drags on the output flow back via the 'mapZones' report.
|
||||
let mapCfg = { on: false, zones: [] };
|
||||
try { mapCfg = Object.assign(mapCfg, JSON.parse(localStorage.getItem('mapcfg') || '{}')); } catch (e) {}
|
||||
let mapNextId = mapCfg.zones.reduce((m2, z) => Math.max(m2, z.id || 0), 0) + 1;
|
||||
function mapSave() { localStorage.setItem('mapcfg', JSON.stringify({ on: mapCfg.on, zones: mapCfg.zones })); }
|
||||
function mapSendZones() { send({ type: 'mapZones', zones: mapCfg.zones }); }
|
||||
function mapSendAll() {
|
||||
mapSendZones();
|
||||
send({ type: 'mapOn', on: mapCfg.on });
|
||||
send({ type: 'mapEdit', on: $('#map-edit').checked });
|
||||
}
|
||||
function mapAddZone(src) {
|
||||
const n = mapCfg.zones.length;
|
||||
const off = (n % 4) * 0.05;
|
||||
mapCfg.zones.push({ id: mapNextId++, name: 'Zona ' + mapNextId,
|
||||
src, corners: [[0.25+off, 0.25+off], [0.75+off, 0.25+off], [0.75+off, 0.75+off], [0.25+off, 0.75+off]],
|
||||
srcRect: [0, 0, 1, 1], opacity: 1 });
|
||||
mapSave(); mapSendZones(); renderMapZones();
|
||||
}
|
||||
function renderMapZones() {
|
||||
const box = $('#map-zones');
|
||||
box.innerHTML = '';
|
||||
if (!mapCfg.zones.length) {
|
||||
box.innerHTML = '<div class="hint-mini">Nessuna zona: aggiungine una qui sotto.</div>';
|
||||
return;
|
||||
}
|
||||
mapCfg.zones.forEach((z, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'map-zone';
|
||||
const isImg = z.src && z.src.type === 'image';
|
||||
row.innerHTML =
|
||||
'<span class="mz-ico">' + (isImg ? '🖼' : '🌀') + '</span>' +
|
||||
'<input class="mz-name" type="text" value="' + (z.name || 'Zona') + '" />' +
|
||||
(isImg ? '<span class="mz-src" title="' + (z.src.path || '') + '">' +
|
||||
String(z.src.path || '').split(/[\\/]/).pop() + '</span>'
|
||||
: '<span class="mz-rect">Porzione ' +
|
||||
'<input class="mz-r" data-k="0" type="number" min="0" max="100" value="' + Math.round(z.srcRect[0]*100) + '" />%,' +
|
||||
'<input class="mz-r" data-k="1" type="number" min="0" max="100" value="' + Math.round(z.srcRect[1]*100) + '" />% ' +
|
||||
'<input class="mz-r" data-k="2" type="number" min="1" max="100" value="' + Math.round(z.srcRect[2]*100) + '" />%×' +
|
||||
'<input class="mz-r" data-k="3" type="number" min="1" max="100" value="' + Math.round(z.srcRect[3]*100) + '" />%</span>') +
|
||||
'<input class="mz-op" type="range" min="0.1" max="1" step="0.05" value="' + z.opacity + '" title="Opacità" />' +
|
||||
'<button class="mz-del" title="Elimina zona">✕</button>';
|
||||
row.querySelector('.mz-name').addEventListener('change', (e) => {
|
||||
z.name = e.target.value; mapSave(); mapSendZones();
|
||||
});
|
||||
row.querySelectorAll('.mz-r').forEach(inp => inp.addEventListener('change', (e) => {
|
||||
const k = parseInt(e.target.dataset.k, 10);
|
||||
z.srcRect[k] = Math.max(0, Math.min(1, (parseInt(e.target.value, 10) || 0)/100));
|
||||
if (z.srcRect[2] <= 0) z.srcRect[2] = 0.01;
|
||||
if (z.srcRect[3] <= 0) z.srcRect[3] = 0.01;
|
||||
mapSave(); mapSendZones();
|
||||
}));
|
||||
row.querySelector('.mz-op').addEventListener('input', (e) => {
|
||||
z.opacity = parseFloat(e.target.value); mapSave(); mapSendZones();
|
||||
});
|
||||
row.querySelector('.mz-del').addEventListener('click', () => {
|
||||
mapCfg.zones.splice(i, 1); mapSave(); mapSendZones(); renderMapZones();
|
||||
});
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
$('#map-on').checked = mapCfg.on;
|
||||
$('#map-on').addEventListener('change', (e) => {
|
||||
mapCfg.on = e.target.checked; mapSave(); send({ type: 'mapOn', on: mapCfg.on });
|
||||
});
|
||||
$('#map-edit').addEventListener('change', (e) => send({ type: 'mapEdit', on: e.target.checked }));
|
||||
$('#map-add-vis').addEventListener('click', () => mapAddZone({ type: 'visual' }));
|
||||
$('#map-add-img').addEventListener('click', () => $('#map-img-input').click());
|
||||
$('#map-img-input').addEventListener('change', (e) => {
|
||||
const f = e.target.files[0];
|
||||
if (f) {
|
||||
const p = djv.pathForFile(f);
|
||||
if (p) mapAddZone({ type: 'image', path: p });
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
renderMapZones();
|
||||
|
||||
// ---------------------------------------------------------------- displays
|
||||
async function refreshDisplays() {
|
||||
const list = await djv.listDisplays();
|
||||
@@ -1729,11 +1809,18 @@ djv.onReport((m) => {
|
||||
}
|
||||
break;
|
||||
case 'glbClips': renderGlbClips(m.names); break;
|
||||
case 'mapZones':
|
||||
// corner edits made on the output: adopt and persist
|
||||
mapCfg.zones = m.zones || [];
|
||||
mapSave();
|
||||
renderMapZones();
|
||||
break;
|
||||
case 'devices': {
|
||||
// The output reports its devices once at startup: good moment to push
|
||||
// config the (possibly recreated) output window doesn't have yet.
|
||||
if (ledCfg.on) ledSend();
|
||||
glbSend();
|
||||
mapSendAll();
|
||||
const sel = $('#device-select');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">— Input live (mic/line/BlackHole) —</option>';
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
// Projection mapping: quads ("zone") warped with a projective homography,
|
||||
// sourcing either the LIVE VISUAL (the main canvas, optionally a sub-region:
|
||||
// multi-panel LED walls) or an IMAGE file (corner-pin onto physical objects).
|
||||
// Runs on its own WebGL2 canvas above the visual; editing handles are drawn
|
||||
// on the output itself so corners are dragged while looking at the real
|
||||
// projection surface.
|
||||
(function () {
|
||||
|
||||
const QUAD_VS = `#version 300 es
|
||||
in vec2 aPos; // zone-space position (0..1 output coords, y down)
|
||||
out vec2 vZ;
|
||||
void main(){
|
||||
vZ = aPos;
|
||||
gl_Position = vec4(aPos.x*2.0-1.0, 1.0-aPos.y*2.0, 0.0, 1.0);
|
||||
}`;
|
||||
const QUAD_FS = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 vZ; out vec4 frag;
|
||||
uniform mat3 uH; // output coords -> unit square of the zone
|
||||
uniform sampler2D uTex;
|
||||
uniform float uOpacity;
|
||||
uniform vec4 uSrc; // source sub-rect (x,y,w,h) in texture uv
|
||||
void main(){
|
||||
vec3 q = uH * vec3(vZ, 1.0);
|
||||
vec2 uv = q.xy / q.z;
|
||||
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard;
|
||||
frag = vec4(texture(uTex, uSrc.xy + uv*uSrc.zw).rgb, uOpacity);
|
||||
}`;
|
||||
const H_VS = `#version 300 es
|
||||
in vec2 aPos; uniform float uPt;
|
||||
void main(){
|
||||
gl_Position = vec4(aPos.x*2.0-1.0, 1.0-aPos.y*2.0, 0.0, 1.0);
|
||||
gl_PointSize = uPt;
|
||||
}`;
|
||||
const H_FS = `#version 300 es
|
||||
precision highp float; uniform vec4 uCol; out vec4 frag;
|
||||
void main(){ frag = uCol; }`;
|
||||
|
||||
// homography mapping the unit square onto quad c (TL,TR,BR,BL), column-major
|
||||
function squareToQuad(c) {
|
||||
const [p0, p1, p2, p3] = c;
|
||||
const dx1 = p1[0]-p2[0], dx2 = p3[0]-p2[0], dx3 = p0[0]-p1[0]+p2[0]-p3[0];
|
||||
const dy1 = p1[1]-p2[1], dy2 = p3[1]-p2[1], dy3 = p0[1]-p1[1]+p2[1]-p3[1];
|
||||
const den = dx1*dy2 - dy1*dx2 || 1e-9;
|
||||
const g = (dx3*dy2 - dy3*dx2)/den, h = (dx1*dy3 - dy1*dx3)/den;
|
||||
return [p1[0]-p0[0]+g*p1[0], p1[1]-p0[1]+g*p1[1], g,
|
||||
p3[0]-p0[0]+h*p3[0], p3[1]-p0[1]+h*p3[1], h,
|
||||
p0[0], p0[1], 1];
|
||||
}
|
||||
function inv3(m) {
|
||||
const [a,b,c,d,e,f,g,h,i] = [m[0],m[3],m[6], m[1],m[4],m[7], m[2],m[5],m[8]];
|
||||
const A = e*i-f*h, B = c*h-b*i, C = b*f-c*e;
|
||||
const det = a*A + d*B + g*C || 1e-12;
|
||||
return [A/det, (f*g-d*i)/det, (d*h-e*g)/det,
|
||||
B/det, (a*i-c*g)/det, (b*g-a*h)/det,
|
||||
C/det, (c*d-a*f)/det, (a*e-b*d)/det];
|
||||
}
|
||||
|
||||
class MappingSim {
|
||||
constructor(canvas, mainCanvas) {
|
||||
this.canvas = canvas;
|
||||
this.main = mainCanvas;
|
||||
const gl = canvas.getContext('webgl2', { antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||
if (!gl) throw new Error('WebGL2 non disponibile (mappatura)');
|
||||
this.gl = gl;
|
||||
const compile = (t, src) => {
|
||||
const sh = gl.createShader(t);
|
||||
gl.shaderSource(sh, src); gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS))
|
||||
throw new Error('Mapping shader: ' + gl.getShaderInfoLog(sh));
|
||||
return sh;
|
||||
};
|
||||
const prog = (v, f) => {
|
||||
const p = gl.createProgram();
|
||||
gl.attachShader(p, compile(gl.VERTEX_SHADER, v));
|
||||
gl.attachShader(p, compile(gl.FRAGMENT_SHADER, f));
|
||||
gl.linkProgram(p);
|
||||
if (!gl.getProgramParameter(p, gl.LINK_STATUS))
|
||||
throw new Error('Mapping link: ' + gl.getProgramInfoLog(p));
|
||||
return p;
|
||||
};
|
||||
this.progQ = prog(QUAD_VS, QUAD_FS);
|
||||
this.progH = prog(H_VS, H_FS);
|
||||
this.uQ = { uH: gl.getUniformLocation(this.progQ, 'uH'),
|
||||
uTex: gl.getUniformLocation(this.progQ, 'uTex'),
|
||||
uOpacity: gl.getUniformLocation(this.progQ, 'uOpacity'),
|
||||
uSrc: gl.getUniformLocation(this.progQ, 'uSrc') };
|
||||
this.uH = { uCol: gl.getUniformLocation(this.progH, 'uCol'),
|
||||
uPt: gl.getUniformLocation(this.progH, 'uPt') };
|
||||
this.aQ = gl.getAttribLocation(this.progQ, 'aPos');
|
||||
this.aH = gl.getAttribLocation(this.progH, 'aPos');
|
||||
this.vbo = gl.createBuffer();
|
||||
|
||||
// live-visual texture, refreshed from the main canvas every frame
|
||||
const mkTex = () => {
|
||||
const t = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, t);
|
||||
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);
|
||||
return t;
|
||||
};
|
||||
this.visTex = mkTex();
|
||||
this._mkTex = mkTex;
|
||||
this.imgTex = {}; // path -> {tex, ok}
|
||||
this.zones = [];
|
||||
this.editOn = false;
|
||||
this.selected = -1;
|
||||
this.onChange = null; // (zones) => {} after a drag edit
|
||||
this._drag = null;
|
||||
this._bindPointer();
|
||||
}
|
||||
|
||||
setZones(zones) {
|
||||
this.zones = (zones || []).map(z => ({
|
||||
id: z.id, name: z.name || 'Zona',
|
||||
src: z.src || { type: 'visual' },
|
||||
corners: (z.corners || [[0.25,0.25],[0.75,0.25],[0.75,0.75],[0.25,0.75]]).map(c => c.slice()),
|
||||
srcRect: z.srcRect || [0, 0, 1, 1],
|
||||
opacity: z.opacity !== undefined ? z.opacity : 1
|
||||
}));
|
||||
if (this.selected >= this.zones.length) this.selected = this.zones.length - 1;
|
||||
// preload image textures
|
||||
const gl = this.gl;
|
||||
for (const z of this.zones) {
|
||||
if (z.src.type !== 'image' || !z.src.url || this.imgTex[z.src.url]) continue;
|
||||
const entry = this.imgTex[z.src.url] = { tex: this._mkTex(), ok: false };
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
gl.bindTexture(gl.TEXTURE_2D, entry.tex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||
entry.ok = true;
|
||||
};
|
||||
img.src = z.src.url;
|
||||
}
|
||||
}
|
||||
|
||||
_bindPointer() {
|
||||
const cv = this.canvas;
|
||||
const pos = (e) => {
|
||||
const r = cv.getBoundingClientRect();
|
||||
return [(e.clientX - r.left)/Math.max(1, r.width), (e.clientY - r.top)/Math.max(1, r.height)];
|
||||
};
|
||||
cv.addEventListener('mousedown', (e) => {
|
||||
if (!this.editOn) return;
|
||||
const [x, y] = pos(e);
|
||||
const r = cv.getBoundingClientRect();
|
||||
const tol = 14/Math.max(1, r.width);
|
||||
// nearest corner of any zone
|
||||
let best = null, bd = tol;
|
||||
this.zones.forEach((z, zi) => z.corners.forEach((c, ci) => {
|
||||
const d = Math.hypot(c[0]-x, (c[1]-y)*(r.height/r.width));
|
||||
if (d < bd) { bd = d; best = { zi, ci }; }
|
||||
}));
|
||||
if (best) { this._drag = best; this.selected = best.zi; }
|
||||
else {
|
||||
// click inside a zone selects it and drags the whole quad
|
||||
for (let zi = this.zones.length-1; zi >= 0; zi--) {
|
||||
if (this._inside(this.zones[zi].corners, x, y)) {
|
||||
this._drag = { zi, ci: -1, lx: x, ly: y };
|
||||
this.selected = zi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!this._drag) return;
|
||||
const [x, y] = pos(e);
|
||||
const z = this.zones[this._drag.zi];
|
||||
if (!z) { this._drag = null; return; }
|
||||
if (this._drag.ci >= 0) {
|
||||
z.corners[this._drag.ci] = [Math.min(1.2, Math.max(-0.2, x)), Math.min(1.2, Math.max(-0.2, y))];
|
||||
} else {
|
||||
const dx = x - this._drag.lx, dy = y - this._drag.ly;
|
||||
z.corners.forEach(c => { c[0] += dx; c[1] += dy; });
|
||||
this._drag.lx = x; this._drag.ly = y;
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', () => {
|
||||
if (this._drag && this.onChange) this.onChange(this.zones);
|
||||
this._drag = null;
|
||||
});
|
||||
}
|
||||
|
||||
_inside(c, x, y) {
|
||||
let hit = false;
|
||||
for (let i = 0, j = 3; i < 4; j = i++) {
|
||||
if ((c[i][1] > y) !== (c[j][1] > y) &&
|
||||
x < (c[j][0]-c[i][0])*(y-c[i][1])/(c[j][1]-c[i][1]) + c[i][0]) hit = !hit;
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
render() {
|
||||
const gl = this.gl, cv = this.canvas;
|
||||
const w = this.main.width, h = this.main.height;
|
||||
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||||
gl.viewport(0, 0, w, h);
|
||||
gl.clearColor(0, 0, 0, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
const needVis = this.zones.some(z => z.src.type === 'visual');
|
||||
if (needVis) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.visTex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.main);
|
||||
}
|
||||
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.useProgram(this.progQ);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
|
||||
gl.enableVertexAttribArray(this.aQ);
|
||||
gl.vertexAttribPointer(this.aQ, 2, gl.FLOAT, false, 0, 0);
|
||||
for (const z of this.zones) {
|
||||
let tex = this.visTex;
|
||||
if (z.src.type === 'image') {
|
||||
const e = this.imgTex[z.src.url];
|
||||
if (!e || !e.ok) continue;
|
||||
tex = e.tex;
|
||||
}
|
||||
const c = z.corners;
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||
c[0][0], c[0][1], c[1][0], c[1][1], c[2][0], c[2][1],
|
||||
c[0][0], c[0][1], c[2][0], c[2][1], c[3][0], c[3][1]
|
||||
]), gl.DYNAMIC_DRAW);
|
||||
gl.uniformMatrix3fv(this.uQ.uH, false, inv3(squareToQuad(c)));
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.uniform1i(this.uQ.uTex, 0);
|
||||
gl.uniform1f(this.uQ.uOpacity, z.opacity);
|
||||
gl.uniform4fv(this.uQ.uSrc, z.srcRect);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
if (this.editOn) {
|
||||
gl.useProgram(this.progH);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
|
||||
gl.enableVertexAttribArray(this.aH);
|
||||
gl.vertexAttribPointer(this.aH, 2, gl.FLOAT, false, 0, 0);
|
||||
this.zones.forEach((z, zi) => {
|
||||
const c = z.corners;
|
||||
const flat = new Float32Array([c[0][0],c[0][1], c[1][0],c[1][1], c[2][0],c[2][1], c[3][0],c[3][1]]);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, flat, gl.DYNAMIC_DRAW);
|
||||
const sel = zi === this.selected;
|
||||
gl.uniform4fv(this.uH.uCol, sel ? [0.4, 0.75, 1, 0.95] : [1, 1, 1, 0.45]);
|
||||
gl.uniform1f(this.uH.uPt, 1);
|
||||
gl.drawArrays(gl.LINE_LOOP, 0, 4);
|
||||
gl.uniform1f(this.uH.uPt, sel ? 16 : 10);
|
||||
gl.drawArrays(gl.POINTS, 0, 4);
|
||||
});
|
||||
}
|
||||
gl.disable(gl.BLEND);
|
||||
}
|
||||
}
|
||||
|
||||
window.MappingSim = MappingSim;
|
||||
|
||||
})();
|
||||
@@ -12,6 +12,9 @@
|
||||
<div id="stage">
|
||||
<canvas id="gl"></canvas>
|
||||
|
||||
<!-- Projection mapping layer (zones warped over the visual) -->
|
||||
<canvas id="map-canvas"></canvas>
|
||||
|
||||
<!-- Video layer (VJ loop) that blends with the generated visuals -->
|
||||
<video id="bg-video" class="bg-video" loop muted playsinline></video>
|
||||
|
||||
@@ -50,6 +53,7 @@
|
||||
<script src="pose.js"></script>
|
||||
<script src="interactive.js"></script>
|
||||
<script src="model3d.js"></script>
|
||||
<script src="mapping.js"></script>
|
||||
<script src="visualizer.js"></script>
|
||||
<script src="audio.js"></script>
|
||||
<script src="output.js"></script>
|
||||
|
||||
@@ -89,6 +89,10 @@ function composeFrame() {
|
||||
// Draw the canvas where it actually sits (the LED area may confine it).
|
||||
const cr = canvas.getBoundingClientRect();
|
||||
recCtx.drawImage(canvas, cr.x * sx, cr.y * sy, cr.width * sx, cr.height * sy);
|
||||
if (mapOn && mapping) {
|
||||
const mr = mapping.canvas.getBoundingClientRect();
|
||||
try { recCtx.drawImage(mapping.canvas, mr.x * sx, mr.y * sy, mr.width * sx, mr.height * sy); } catch (e) {}
|
||||
}
|
||||
|
||||
if (bgVideo.classList.contains('show') && bgVideo.readyState >= 2 && bgVideo.videoWidth) {
|
||||
recCtx.globalAlpha = parseFloat(getComputedStyle(bgVideo).opacity) || 1;
|
||||
@@ -324,6 +328,22 @@ djv.onControl(async (m) => {
|
||||
break;
|
||||
case 'svg': loadCustomTexture(m.dataUrl); break;
|
||||
case 'modelBg': viz.modelBg = m.mode || 'gradient'; break;
|
||||
case 'mapZones':
|
||||
try {
|
||||
ensureMapping().setZones((m.zones || []).map(z => Object.assign({}, z,
|
||||
{ src: z.src && z.src.type === 'image'
|
||||
? { type: 'image', url: toFileURL(z.src.path), path: z.src.path }
|
||||
: { type: 'visual' } })));
|
||||
} catch (e) { djv.report({ type: 'error', message: 'Mappatura: ' + e.message }); }
|
||||
break;
|
||||
case 'mapOn':
|
||||
mapOn = !!m.on;
|
||||
$('#map-canvas').classList.toggle('show', mapOn);
|
||||
break;
|
||||
case 'mapEdit':
|
||||
if (mapping) mapping.editOn = !!m.on;
|
||||
$('#map-canvas').classList.toggle('edit', !!m.on);
|
||||
break;
|
||||
case 'glbAnims': viz.setClipFilter(m.names || []); break;
|
||||
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
|
||||
case 'modelSpread': viz.setArmSpread(m.deg || 0); break;
|
||||
@@ -574,6 +594,7 @@ function frame() {
|
||||
const a = audio.values;
|
||||
avTick(performance.now());
|
||||
viz.render(t * speed, a);
|
||||
if (mapOn && mapping) mapping.render();
|
||||
|
||||
// Interactive family: tell the control panel if the camera didn't start
|
||||
// (permission denied / missing device). Mouse interaction still works.
|
||||
@@ -632,6 +653,20 @@ function frame() {
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// ------------------------------------------------------------- mapping
|
||||
let mapping = null, mapOn = false;
|
||||
function ensureMapping() {
|
||||
if (!mapping) {
|
||||
mapping = new window.MappingSim($('#map-canvas'), canvas);
|
||||
// corner drags on the output flow back to the panel, which persists them
|
||||
mapping.onChange = (zones) => djv.report({ type: 'mapZones',
|
||||
zones: zones.map(z => ({ id: z.id, name: z.name,
|
||||
src: z.src.type === 'image' ? { type: 'image', path: z.src.path } : { type: 'visual' },
|
||||
corners: z.corners, srcRect: z.srcRect, opacity: z.opacity })) });
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
// Tell the panel which dance clips the current model can play.
|
||||
function reportClips() {
|
||||
const names = viz.model3d && viz.model3d.anims ? viz.model3d.anims.map(a => a.name) : [];
|
||||
|
||||
@@ -39,6 +39,18 @@ body.hide-cursor { cursor: none; }
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Projection-mapping layer: sits over the visual, black outside the zones */
|
||||
#map-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
#map-canvas.show { display: block; }
|
||||
#map-canvas.edit { pointer-events: auto; cursor: crosshair; }
|
||||
|
||||
/* ============ CONTROL WINDOW LAYOUT ============ */
|
||||
body.control {
|
||||
display: flex;
|
||||
@@ -882,3 +894,29 @@ kbd {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.glb-models .hint-mini { grid-column: 1 / -1; }
|
||||
|
||||
/* Projection-mapping zone list */
|
||||
.map-zone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
margin: 4px 0;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 9px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.map-zone .mz-ico { flex: 0 0 auto; }
|
||||
.map-zone .mz-name { width: 90px; flex: 0 0 auto; }
|
||||
.map-zone .mz-src { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: 0.75; }
|
||||
.map-zone .mz-rect { flex: 1; display: flex; align-items: center; gap: 2px; white-space: nowrap; opacity: 0.9; }
|
||||
.map-zone .mz-r { width: 44px; flex: 0 0 auto; }
|
||||
.map-zone .mz-op { flex: 0 0 70px; }
|
||||
.map-zone .mz-del {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ff8a8a;
|
||||
padding: 0 3px;
|
||||
}
|
||||
Reference in new issue
Block a user