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:
lucianoandClaude Fable 5 committed 2026-07-31 16:22:43 +02:00
1 parent a3991e0f92
commit 0daa89656f
6 files changed
+446

No files matched your search

+87
View File
@@ -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>';