Mapping: mini-editor in the control panel + keyboard nudge
Editing corners with handles on the output is unmanageable on a large LED wall (7x4 m): the hand has to work on the giant screen. The Mappatura tab now has a scaled replica of the output (aspect from the output display or the LED area) where zones are dragged by corner or whole, with live sync to the output (one IPC per frame). Keyboard on the focused editor: arrows nudge 1 output pixel, Shift 10 px, Tab cycles corners, Esc = whole zone; keys never leak to the global hotkeys. Selection is shared with the zone list and highlighted on the output handles; a status line shows pixel coordinates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc
This commit is contained in:
1 parent
c2709ff158
commit
e754656585
4 files changed
+237
-7
No files matched your search
+16
-5
@@ -213,7 +213,14 @@
|
||||
<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>
|
||||
<label class="chk"><input type="checkbox" id="map-edit" /> Mostra maniglie anche sull'output</label>
|
||||
</div>
|
||||
<div class="map-ed-wrap">
|
||||
<canvas id="map-editor" tabindex="0"></canvas>
|
||||
</div>
|
||||
<div class="map-ed-info">
|
||||
<span id="map-ed-status">Clicca una zona o un angolo per selezionarlo</span>
|
||||
<span class="map-ed-keys">Frecce = sposta 1px · Shift+frecce = 10px · Tab = angolo successivo · Esc = zona intera</span>
|
||||
</div>
|
||||
<div id="map-zones"></div>
|
||||
<div class="row">
|
||||
@@ -234,10 +241,14 @@
|
||||
nella zona un <b>preset indipendente</b> (ogni zona il suo effetto, tutti a tempo
|
||||
con la stessa musica — ma ogni zona costa un rendering in più); <b>➕ Zona
|
||||
Immagine</b> incolla un'immagine fissa su una superficie (corner-pin).</p>
|
||||
<p><b>3. Posiziona</b> — spunta <b>Modifica zone</b>: sull'output compaiono le
|
||||
maniglie. Trascina i <b>4 angoli</b> per adattare la zona alla superficie reale
|
||||
(guardando la proiezione!), oppure clicca <b>dentro</b> la zona per spostarla
|
||||
intera. La zona selezionata ha le maniglie azzurre. Finito? Togli la spunta.</p>
|
||||
<p><b>3. Posiziona</b> — usa l'<b>editor qui sopra</b>: è una replica in scala
|
||||
dell'output, così non devi lavorare sullo schermo grande. Trascina un <b>angolo</b>
|
||||
per deformare la zona, o clicca <b>dentro</b> per spostarla intera; il risultato si
|
||||
vede subito sul ledwall/proiettore. Per la rifinitura usa la <b>tastiera</b>
|
||||
(clicca prima sull'editor): <b>frecce</b> = 1 pixel, <b>Shift+frecce</b> = 10 pixel,
|
||||
<b>Tab</b> = passa all'angolo successivo, <b>Esc</b> = torna a muovere la zona intera.
|
||||
La riga sotto l'editor mostra la posizione in pixel. Se vuoi vedere i contorni anche
|
||||
sullo schermo, spunta <b>Mostra maniglie anche sull'output</b>.</p>
|
||||
<p><b>Opzioni per zona</b>:</p>
|
||||
<p>· <b>Nome</b> — etichetta libera ("Pannello SX", "Cubo"…)<br/>
|
||||
· <b>Porzione</b> (solo Visual) — quale ritaglio del visual mostrare:
|
||||
|
||||
+207
-2
@@ -1552,11 +1552,15 @@ function mapAddZone(src) {
|
||||
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 });
|
||||
mapSel = mapCfg.zones.length - 1; mapSelC = -1;
|
||||
mapSave(); mapSendZones(); renderMapZones();
|
||||
send({ type: 'mapSel', index: mapSel });
|
||||
}
|
||||
function renderMapZones() {
|
||||
const box = $('#map-zones');
|
||||
box.innerHTML = '';
|
||||
if (mapSel >= mapCfg.zones.length) { mapSel = mapCfg.zones.length - 1; mapSelC = -1; }
|
||||
mapEdDraw();
|
||||
if (!mapCfg.zones.length) {
|
||||
box.innerHTML = '<div class="hint-mini">Nessuna zona: aggiungine una qui sotto.</div>';
|
||||
return;
|
||||
@@ -1631,7 +1635,15 @@ function renderMapZones() {
|
||||
z.opacity = parseFloat(e.target.value); mapSave(); mapSendZones();
|
||||
});
|
||||
row.querySelector('.mz-del').addEventListener('click', () => {
|
||||
mapCfg.zones.splice(i, 1); mapSave(); mapSendZones(); renderMapZones();
|
||||
mapCfg.zones.splice(i, 1);
|
||||
if (mapSel === i) mapSel = -1; else if (mapSel > i) mapSel--;
|
||||
mapSave(); mapSendZones(); renderMapZones();
|
||||
});
|
||||
if (i === mapSel) row.classList.add('sel');
|
||||
// Clicking the row (not its controls) selects the zone in the editor.
|
||||
row.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('input, select, button')) return;
|
||||
mapSelect(i, -1);
|
||||
});
|
||||
box.appendChild(row);
|
||||
});
|
||||
@@ -1653,6 +1665,196 @@ $('#map-img-input').addEventListener('change', (e) => {
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
// ---- Mapping mini-editor: edit zones on a scaled replica of the output ----
|
||||
// On a huge LED wall the on-output handles are unusable; here the hand stays
|
||||
// on the laptop and the wall only shows the result. Coordinates are the same
|
||||
// normalized 0..1 (y-down) corners the output uses.
|
||||
let mapSel = -1; // selected zone index
|
||||
let mapSelC = -1; // selected corner (0 TL,1 TR,2 BR,3 BL) or -1 = whole zone
|
||||
let mapEdDrag = null; // { ci, lx, ly }
|
||||
let outBounds = null; // main output display size (set by refreshDisplays)
|
||||
const MAP_CORNER = ['alto-sx', 'alto-dx', 'basso-dx', 'basso-sx'];
|
||||
|
||||
// Size the editor works in = the area the map canvas covers on the output.
|
||||
function mapEdSize() {
|
||||
// ledCfg is declared further down: guard the first draw at startup (TDZ).
|
||||
try { if (ledCfg.on && ledCfg.w > 0 && ledCfg.h > 0) return [ledCfg.w, ledCfg.h]; } catch (e) {}
|
||||
if (outBounds) return [outBounds.width, outBounds.height];
|
||||
return [1920, 1080];
|
||||
}
|
||||
|
||||
let mapEdSendPending = false;
|
||||
function mapEdSync() {
|
||||
// Coalesce drag/nudge bursts to one IPC per frame.
|
||||
if (mapEdSendPending) return;
|
||||
mapEdSendPending = true;
|
||||
requestAnimationFrame(() => { mapEdSendPending = false; mapSendZones(); });
|
||||
}
|
||||
let mapEdSaveT = null;
|
||||
function mapEdSaveSoon() { clearTimeout(mapEdSaveT); mapEdSaveT = setTimeout(mapSave, 300); }
|
||||
|
||||
function mapSelect(zi, ci) {
|
||||
mapSel = zi; mapSelC = zi < 0 ? -1 : ci;
|
||||
send({ type: 'mapSel', index: mapSel });
|
||||
document.querySelectorAll('#map-zones .map-zone').forEach((r, k) => r.classList.toggle('sel', k === mapSel));
|
||||
mapEdDraw();
|
||||
}
|
||||
|
||||
function mapEdDraw() {
|
||||
const cv = $('#map-editor');
|
||||
if (!cv || !cv.clientWidth) return;
|
||||
const [ow, oh] = mapEdSize();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = cv.clientWidth;
|
||||
const cssH = Math.min(440, cssW * oh / ow);
|
||||
// Keep the output aspect even when max-height clamps the box.
|
||||
const drawW = cssH * ow / oh < cssW ? cssH * ow / oh : cssW;
|
||||
cv.style.height = cssH + 'px';
|
||||
if (cv.width !== Math.round(cssW * dpr) || cv.height !== Math.round(cssH * dpr)) {
|
||||
cv.width = Math.round(cssW * dpr); cv.height = Math.round(cssH * dpr);
|
||||
}
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, cssW, cssH);
|
||||
const ox = (cssW - drawW) / 2, W = drawW, H = cssH;
|
||||
cv._geo = { ox, W, H };
|
||||
// output frame + 10% grid
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(ox, 0, W, H);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
|
||||
for (let k = 1; k < 10; k++) {
|
||||
ctx.beginPath(); ctx.moveTo(ox + W * k / 10, 0); ctx.lineTo(ox + W * k / 10, H); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(ox, H * k / 10); ctx.lineTo(ox + W, H * k / 10); ctx.stroke();
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.strokeRect(ox + 0.5, 0.5, W - 1, H - 1);
|
||||
const P = (c) => [ox + c[0] * W, c[1] * H];
|
||||
mapCfg.zones.forEach((z, zi) => {
|
||||
const sel = zi === mapSel;
|
||||
const pts = z.corners.map(P);
|
||||
ctx.beginPath();
|
||||
pts.forEach((p, k) => k ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]));
|
||||
ctx.closePath();
|
||||
const t = z.src && z.src.type;
|
||||
ctx.fillStyle = sel ? 'rgba(95,140,255,0.28)'
|
||||
: (t === 'image' ? 'rgba(255,190,90,0.14)' : t === 'effect' ? 'rgba(190,120,255,0.14)' : 'rgba(120,220,200,0.14)');
|
||||
ctx.fill();
|
||||
ctx.lineWidth = sel ? 2 : 1.2;
|
||||
ctx.strokeStyle = sel ? '#7fa8ff' : 'rgba(255,255,255,0.55)';
|
||||
ctx.stroke();
|
||||
// label at the centroid
|
||||
const cx = pts.reduce((s, p) => s + p[0], 0) / 4, cy = pts.reduce((s, p) => s + p[1], 0) / 4;
|
||||
ctx.font = (sel ? 'bold ' : '') + '11px -apple-system, sans-serif';
|
||||
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = sel ? '#fff' : 'rgba(255,255,255,0.75)';
|
||||
ctx.fillText(z.name || 'Zona', cx, cy);
|
||||
pts.forEach((p, k) => {
|
||||
const hot = sel && (mapSelC === k);
|
||||
ctx.beginPath();
|
||||
ctx.arc(p[0], p[1], hot ? 7 : (sel ? 5 : 3.5), 0, Math.PI * 2);
|
||||
ctx.fillStyle = hot ? '#ffd24a' : (sel ? '#7fa8ff' : 'rgba(255,255,255,0.7)');
|
||||
ctx.fill();
|
||||
});
|
||||
});
|
||||
// status line
|
||||
const st = $('#map-ed-status');
|
||||
if (mapSel < 0 || !mapCfg.zones[mapSel]) { st.textContent = 'Clicca una zona o un angolo per selezionarlo'; return; }
|
||||
const z = mapCfg.zones[mapSel];
|
||||
const fmt = (c) => Math.round(c[0] * ow) + ', ' + Math.round(c[1] * oh) + ' px';
|
||||
st.textContent = mapSelC >= 0
|
||||
? '● ' + z.name + ' · angolo ' + MAP_CORNER[mapSelC] + ': ' + fmt(z.corners[mapSelC])
|
||||
: '● ' + z.name + ' · zona intera · angolo alto-sx ' + fmt(z.corners[0]);
|
||||
}
|
||||
|
||||
(function mapEdInit() {
|
||||
const cv = $('#map-editor');
|
||||
const toN = (e) => {
|
||||
const r = cv.getBoundingClientRect(), g = cv._geo || { ox: 0, W: r.width, H: r.height };
|
||||
return [(e.clientX - r.left - g.ox) / g.W, (e.clientY - r.top) / g.H];
|
||||
};
|
||||
const clampC = (v) => Math.min(1.2, Math.max(-0.2, v));
|
||||
const inside = (c, x, y) => { // even-odd point-in-polygon
|
||||
let inn = false;
|
||||
for (let i = 0, j = 3; i < 4; j = i++) {
|
||||
const xi = c[i][0], yi = c[i][1], xj = c[j][0], yj = c[j][1];
|
||||
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-9) + xi)) inn = !inn;
|
||||
}
|
||||
return inn;
|
||||
};
|
||||
cv.addEventListener('mousedown', (e) => {
|
||||
cv.focus();
|
||||
const [x, y] = toN(e);
|
||||
const g = cv._geo || { W: cv.clientWidth, H: cv.clientHeight };
|
||||
// nearest corner within 10 css px — the selected zone wins ties
|
||||
let best = null, bd = 10;
|
||||
const order = mapCfg.zones.map((_, i) => i).sort((a, b) => (b === mapSel) - (a === mapSel));
|
||||
for (const zi of order) {
|
||||
mapCfg.zones[zi].corners.forEach((c, ci) => {
|
||||
const d = Math.hypot((c[0] - x) * g.W, (c[1] - y) * g.H);
|
||||
if (d < bd) { bd = d; best = { zi, ci }; }
|
||||
});
|
||||
}
|
||||
if (best) {
|
||||
mapSelect(best.zi, best.ci);
|
||||
mapEdDrag = { ci: best.ci, lx: x, ly: y };
|
||||
return;
|
||||
}
|
||||
for (let zi = mapCfg.zones.length - 1; zi >= 0; zi--) {
|
||||
if (inside(mapCfg.zones[zi].corners, x, y)) {
|
||||
mapSelect(zi, -1);
|
||||
mapEdDrag = { ci: -1, lx: x, ly: y };
|
||||
return;
|
||||
}
|
||||
}
|
||||
mapSelect(-1, -1);
|
||||
});
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!mapEdDrag || mapSel < 0) return;
|
||||
const z = mapCfg.zones[mapSel];
|
||||
if (!z) { mapEdDrag = null; return; }
|
||||
const [x, y] = toN(e);
|
||||
if (mapEdDrag.ci >= 0) {
|
||||
z.corners[mapEdDrag.ci] = [clampC(x), clampC(y)];
|
||||
} else {
|
||||
const dx = x - mapEdDrag.lx, dy = y - mapEdDrag.ly;
|
||||
z.corners.forEach(c => { c[0] = clampC(c[0] + dx); c[1] = clampC(c[1] + dy); });
|
||||
mapEdDrag.lx = x; mapEdDrag.ly = y;
|
||||
}
|
||||
mapEdDraw(); mapEdSync();
|
||||
});
|
||||
window.addEventListener('mouseup', () => {
|
||||
if (mapEdDrag) { mapEdDrag = null; mapSave(); }
|
||||
});
|
||||
cv.addEventListener('keydown', (e) => {
|
||||
// Keys typed while the editor has focus never reach the global hotkeys
|
||||
// (arrows = images, 1-9 = effects, F, space…).
|
||||
e.stopPropagation();
|
||||
const z = mapCfg.zones[mapSel];
|
||||
if (e.key === 'Tab') {
|
||||
if (!z) return;
|
||||
e.preventDefault();
|
||||
// TL → TR → BR → BL → whole zone → TL …
|
||||
const nxt = mapSelC >= 3 ? -1 : mapSelC + 1;
|
||||
mapSelect(mapSel, e.shiftKey ? (mapSelC <= -1 ? 3 : mapSelC - 1) : nxt);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { if (z) mapSelect(mapSel, -1); return; }
|
||||
const dir = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
|
||||
if (!dir || !z) return;
|
||||
e.preventDefault();
|
||||
const [ow, oh] = mapEdSize();
|
||||
const step = e.shiftKey ? 10 : 1; // output pixels
|
||||
const dx = dir[0] * step / ow, dy = dir[1] * step / oh;
|
||||
const pts = mapSelC >= 0 ? [z.corners[mapSelC]] : z.corners;
|
||||
pts.forEach(c => { c[0] = clampC(c[0] + dx); c[1] = clampC(c[1] + dy); });
|
||||
mapEdDraw(); mapEdSync(); mapEdSaveSoon();
|
||||
});
|
||||
window.addEventListener('resize', mapEdDraw);
|
||||
// The canvas has no width while its tab is hidden: draw when shown.
|
||||
document.querySelectorAll('#tabs button').forEach(b => b.addEventListener('click', () => {
|
||||
if (b.dataset.tab === 'mappa') requestAnimationFrame(mapEdDraw);
|
||||
}));
|
||||
})();
|
||||
|
||||
renderMapZones();
|
||||
|
||||
// ---------------------------------------------------------------- displays
|
||||
@@ -1668,6 +1870,9 @@ async function refreshDisplays() {
|
||||
});
|
||||
const ext = list.find(d => !d.isPrimary);
|
||||
if (ext) sel.value = ext.id;
|
||||
const outD = list.find(d => d.hasOutput);
|
||||
outBounds = outD ? outD.bounds : null;
|
||||
mapEdDraw();
|
||||
// Aux candidates: every display that hosts neither the panel nor the output.
|
||||
auxDisplays = list.filter(d => !d.isPrimary && !d.hasOutput);
|
||||
renderAuxList();
|
||||
@@ -1843,7 +2048,7 @@ $('#btn-fullscreen').addEventListener('click', () => djv.toggleOutputFullscreen(
|
||||
let ledCfg = { on: false, w: 1024, h: 576, x: 0, y: 0 };
|
||||
try { ledCfg = Object.assign(ledCfg, JSON.parse(localStorage.getItem('ledarea') || '{}')); } catch (e) {}
|
||||
function ledSend() { send({ type: 'ledArea', on: ledCfg.on, w: ledCfg.w, h: ledCfg.h, x: ledCfg.x, y: ledCfg.y }); }
|
||||
function ledSave() { localStorage.setItem('ledarea', JSON.stringify(ledCfg)); ledSend(); }
|
||||
function ledSave() { localStorage.setItem('ledarea', JSON.stringify(ledCfg)); ledSend(); if (window.mapEdDraw) mapEdDraw(); }
|
||||
(function ledInit() {
|
||||
$('#led-on').checked = ledCfg.on;
|
||||
const fields = [['#led-w', 'w'], ['#led-h', 'h'], ['#led-x', 'x'], ['#led-y', 'y']];
|
||||
|
||||
@@ -393,6 +393,7 @@ djv.onControl(async (m) => {
|
||||
if (mapping) mapping.editOn = !!m.on;
|
||||
$('#map-canvas').classList.toggle('edit', !!m.on);
|
||||
break;
|
||||
case 'mapSel': if (mapping) mapping.selected = m.index; break;
|
||||
case 'auxActive': auxActive = !!m.on; break;
|
||||
case 'glbAnims': viz.setClipFilter(m.names || []); break;
|
||||
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
|
||||
|
||||
@@ -944,6 +944,19 @@ kbd {
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
/* Mapping mini-editor (control panel): scaled replica of the output */
|
||||
.map-ed-wrap { margin: 8px 0 4px; display: flex; justify-content: center; }
|
||||
#map-editor {
|
||||
display: block; width: 100%; max-height: 440px;
|
||||
background: #07070c; border: 1px solid rgba(255,255,255,0.14); border-radius: 8px;
|
||||
cursor: crosshair; outline: none;
|
||||
}
|
||||
#map-editor:focus { border-color: #5f7fe8; box-shadow: 0 0 0 2px rgba(95,127,232,0.35); }
|
||||
.map-ed-info { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 11px; margin-bottom: 6px; }
|
||||
.map-ed-info #map-ed-status { color: #9fc0ff; }
|
||||
.map-ed-info .map-ed-keys { opacity: 0.55; }
|
||||
.map-zone.sel { border-color: #5f7fe8; background: rgba(95,127,232,0.10); }
|
||||
|
||||
/* Recording crop guide on the output (overlay only, never recorded) */
|
||||
#rec-guide {
|
||||
position: fixed;
|
||||
|
||||
Reference in new issue
Block a user