Playlist scenes drive the mapping: on/off, which zones, per-zone effects

New scene element 🗺 Mappatura (➕🗺): at its time it switches mapping on or
off and, via 'Mostra su' checkboxes, picks which zones show content — the
others go black (opacity 0 on the output only). Effect elements can target
a single zone ('🗺 Zona: …') to run their own preset there; 'no effect'
gives the zone back. Both are live overrides on the output: the saved
mapping is never modified, and edit reports from the output return the
original src/opacity. Manual 'Attiva' toggle shows every zone again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc
This commit is contained in:
lucianoandClaude Opus 5.5 committed 2026-10-02 12:08:25 +02:00
1 parent e793add541
commit 22b6d966e1
3 files changed
+111 -21

No files matched your search

+7
View File
@@ -273,6 +273,13 @@
· <b>Slider</b> — opacità della zona (le zone più in basso nella lista · <b>Slider</b> — opacità della zona (le zone più in basso nella lista
coprono quelle sopra dove si sovrappongono)<br/> coprono quelle sopra dove si sovrappongono)<br/>
· <b>✕</b> — elimina la zona</p> · <b>✕</b> — elimina la zona</p>
<p><b>Dalla playlist</b> — nella 🎬 Scena di un brano aggiungi ➕🗺: accende o spegne la
mappatura in quel momento e, con le spunte <b>Mostra su</b>, sceglie su quali zone si vede
(le altre restano nere). Mettilo a 0:00 per decidere le zone di tutto il brano, o più volte
per spostare il visual da una zona all'altra. Nell'elemento 🌀 Effetto, il selettore
<b>Su</b> può puntare anche a una singola zona (🗺 Zona: …) per darle un effetto suo
in quel momento; "nessun effetto" la riporta al contenuto normale. La mappatura salvata
non viene mai modificata.</p>
<p><b>Note</b> — tutto è salvato automaticamente e rientra nelle registrazioni <p><b>Note</b> — tutto è salvato automaticamente e rientra nelle registrazioni
MP4. Le zone Visual seguono l'effetto attivo: cambi preset e cambia il MP4. Le zone Visual seguono l'effetto attivo: cambi preset e cambia il
contenuto di tutte le zone.</p> contenuto di tutte le zone.</p>
+67 -10
View File
@@ -658,7 +658,8 @@ function hasScene(tr) {
return !!(tr.cues && tr.cues.some(c => return !!(tr.cues && tr.cues.some(c =>
(c.type === 'effect' && c.effectIndex != null) || (c.type === 'effect' && c.effectIndex != null) ||
(c.type === 'text' && c.text && c.text.trim()) || (c.type === 'text' && c.text && c.text.trim()) ||
(c.type === 'image' && c.image))); (c.type === 'image' && c.image) ||
c.type === 'map'));
} }
function parseTime(str) { function parseTime(str) {
str = String(str).trim(); str = String(str).trim();
@@ -692,6 +693,8 @@ function optList(pairs, sel) {
} }
function newEffectEl(time) { return { type: 'effect', time, effectIndex: EFFECTS.list.indexOf(currentEffect), effectName: currentEffect.name, dur: 0, target: 'main' }; } function newEffectEl(time) { return { type: 'effect', time, effectIndex: EFFECTS.list.indexOf(currentEffect), effectName: currentEffect.name, dur: 0, target: 'main' }; }
function newTextEl(time) { return { type: 'text', time, text: '', dir: 'h', fx: 'none', font: TXT_FONTS[0][0], size: 6, weight: true, color: '#ffffff', pos: 'bottom', speed: 1, dur: 0 }; } function newTextEl(time) { return { type: 'text', time, text: '', dir: 'h', fx: 'none', font: TXT_FONTS[0][0], size: 6, weight: true, color: '#ffffff', pos: 'bottom', speed: 1, dur: 0 }; }
// zones: null = every zone, otherwise the ids of the zones that show content
function newMapEl(time) { return { type: 'map', time, on: true, zones: null, dur: 0 }; }
function newImageEl(time) { return { type: 'image', time, image: null, imageSize: 60, imagePos: 'center', dur: 0 }; } function newImageEl(time) { return { type: 'image', time, image: null, imageSize: 60, imagePos: 'center', dur: 0 }; }
// Convert legacy combined cues into the new per-element list (idempotent). // Convert legacy combined cues into the new per-element list (idempotent).
@@ -713,7 +716,7 @@ function migrateCues(cues) {
} }
// Runtime: re-evaluate which element is active per channel and diff-apply it. // Runtime: re-evaluate which element is active per channel and diff-apply it.
let lastScene = { effect: null, text: null, image: null }; let lastScene = { effect: null, text: null, image: null, map: null };
let userTrackBlend = 'normal'; // playlist-video blend chosen by the user let userTrackBlend = 'normal'; // playlist-video blend chosen by the user
let sentTrackBlend = null; // last blend pushed to the output (auto or manual) let sentTrackBlend = null; // last blend pushed to the output (auto or manual)
@@ -724,7 +727,7 @@ function startCues(tr) {
// `undefined` (not null) forces advanceCues to APPLY every channel once, so a // `undefined` (not null) forces advanceCues to APPLY every channel once, so a
// stale text/image from the previous track is cleared even when the new scene // stale text/image from the previous track is cleared even when the new scene
// has nothing active at t=0. // has nothing active at t=0.
lastScene = { effect: undefined, text: undefined, image: undefined }; lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined };
advanceCues(0); advanceCues(0);
} }
function sceneActive(type, t) { function sceneActive(type, t) {
@@ -750,6 +753,14 @@ function sceneActiveEffects(t) {
return best; return best;
} }
function applyEffectTo(targetKey, el) { function applyEffectTo(targetKey, el) {
if (targetKey.startsWith('zone:')) {
// Live override of ONE mapping zone (the saved mapping is untouched):
// the zone runs this preset; 'no effect' gives it back its own content.
const id = parseInt(targetKey.slice(5), 10);
const ok = el.effectIndex != null && EFFECTS.list[el.effectIndex];
send({ type: 'mapZoneFx', id, effectIndex: ok ? el.effectIndex : null });
return;
}
const e = EFFECTS.list[el.effectIndex]; const e = EFFECTS.list[el.effectIndex];
if (!e) return; if (!e) return;
if (targetKey === 'main') applyEffect(e); if (targetKey === 'main') applyEffect(e);
@@ -797,6 +808,17 @@ function advanceCues(t) {
if (tx !== lastScene.text) { lastScene.text = tx; applyTextEl(tx); } if (tx !== lastScene.text) { lastScene.text = tx; applyTextEl(tx); }
const im = sceneActive('image', t); const im = sceneActive('image', t);
if (im !== lastScene.image) { lastScene.image = im; applyImageEl(im); } if (im !== lastScene.image) { lastScene.image = im; applyImageEl(im); }
const mp = sceneActive('map', t);
if (mp !== lastScene.map) {
lastScene.map = mp;
// Apply-once like effects: when the element ends the state stays as is.
if (mp) {
mapCfg.on = !!mp.on;
$('#map-on').checked = mapCfg.on;
send({ type: 'mapShow', ids: mp.on && Array.isArray(mp.zones) ? mp.zones : null });
send({ type: 'mapOn', on: mapCfg.on });
}
}
} }
function applyTextEl(el) { function applyTextEl(el) {
const show = !!(el && el.text && el.text.trim()); const show = !!(el && el.text && el.text.trim());
@@ -828,7 +850,7 @@ function applyImageEl(el) {
function refreshScenePreview(i) { function refreshScenePreview(i) {
if (i !== currentIndex) return; if (i !== currentIndex) return;
activeCues = (playlist[i].cues || []).slice().sort((a, b) => a.time - b.time); activeCues = (playlist[i].cues || []).slice().sort((a, b) => a.time - b.time);
lastScene = { effect: undefined, text: undefined, image: undefined }; lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined };
advanceCues(playCur || 0); advanceCues(playCur || 0);
} }
@@ -985,8 +1007,8 @@ function firstPresetOfFamily(fam) {
return -1; return -1;
} }
const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼' }; const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼', map: '🗺' };
const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine' }; const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine', map: 'Mappatura' };
// Build the per-track scene editor: a list of independent timed elements // Build the per-track scene editor: a list of independent timed elements
// (effect / text / image), each with its own appearance time and duration. // (effect / text / image), each with its own appearance time and duration.
@@ -1023,7 +1045,13 @@ function buildSceneEditor(tr, i, li) {
tgtOpts += '<option value="' + ds.id + '"' + (tgt === String(ds.id) ? ' selected' : '') + '>🖥 ' + ds.label + '</option>'; tgtOpts += '<option value="' + ds.id + '"' + (tgt === String(ds.id) ? ' selected' : '') + '>🖥 ' + ds.label + '</option>';
}); });
tgtOpts += '<option value="all"' + (tgt === 'all' ? ' selected' : '') + '>🖥🖥 Tutti gli schermi</option>'; tgtOpts += '<option value="all"' + (tgt === 'all' ? ' selected' : '') + '>🖥🖥 Tutti gli schermi</option>';
if (tgt !== 'main' && tgt !== 'all' && !auxDisplays.some(ds => String(ds.id) === tgt)) { mapCfg.zones.forEach(z => {
const v = 'zone:' + z.id;
tgtOpts += '<option value="' + v + '"' + (tgt === v ? ' selected' : '') + '>🗺 Zona: ' + (z.name || 'Zona') + '</option>';
});
if (tgt.startsWith('zone:') && !mapCfg.zones.some(z => 'zone:' + z.id === tgt)) {
tgtOpts += '<option value="' + tgt + '" selected>🗺 Zona eliminata</option>';
} else if (tgt !== 'main' && tgt !== 'all' && !tgt.startsWith('zone:') && !auxDisplays.some(ds => String(ds.id) === tgt)) {
tgtOpts += '<option value="' + tgt + '" selected>🖥 Schermo scollegato</option>'; tgtOpts += '<option value="' + tgt + '" selected>🖥 Schermo scollegato</option>';
} }
html += html +=
@@ -1060,6 +1088,23 @@ function buildSceneEditor(tr, i, li) {
'</div>' + '</div>' +
'<div class="cue-row"><span class="cue-dim">Vel</span><input class="cue-tx-speed" type="range" min="0.2" max="4" step="0.1" value="' + c.speed + '" /></div>' + '<div class="cue-row"><span class="cue-dim">Vel</span><input class="cue-tx-speed" type="range" min="0.2" max="4" step="0.1" value="' + c.speed + '" /></div>' +
'</div>'; '</div>';
} else if (c.type === 'map') {
html +=
'<div class="cue-body">' +
'<div class="cue-row">' +
'<select class="cue-map-on" title="Stato della mappatura da questo momento">' +
'<option value="1"' + (c.on ? ' selected' : '') + '>🗺 Accendi la mappatura</option>' +
'<option value="0"' + (!c.on ? ' selected' : '') + '>⬜ Spegni (schermo pieno)</option>' +
'</select>' +
'</div>' +
(c.on ? '<div class="cue-row cue-map-zones"><span class="cue-dim">Mostra su</span>' +
(mapCfg.zones.length ? mapCfg.zones.map(z =>
'<label class="chk"><input type="checkbox" class="cue-map-z" data-id="' + z.id + '"' +
(!Array.isArray(c.zones) || c.zones.includes(z.id) ? ' checked' : '') + ' /> ' +
(z.name || 'Zona') + '</label>').join('')
: '<span class="cue-dim">nessuna zona: creale nel tab Mappatura</span>') +
'</div>' : '') +
'</div>';
} else { } else {
html += html +=
'<div class="cue-body">' + '<div class="cue-body">' +
@@ -1080,6 +1125,7 @@ function buildSceneEditor(tr, i, li) {
'<button class="add-eff" title="Aggiungi effetto">➕🌀</button>' + '<button class="add-eff" title="Aggiungi effetto">➕🌀</button>' +
'<button class="add-txt" title="Aggiungi testo">➕🔤</button>' + '<button class="add-txt" title="Aggiungi testo">➕🔤</button>' +
'<button class="add-img" title="Aggiungi immagine">➕🖼</button>' + '<button class="add-img" title="Aggiungi immagine">➕🖼</button>' +
'<button class="add-map" title="Accendi/spegni la mappatura a questo punto">➕🗺</button>' +
'<button class="se-clear">🗑 Scena</button>' + '<button class="se-clear">🗑 Scena</button>' +
'</div>' + '</div>' +
(tr.isInterlude (tr.isInterlude
@@ -1175,7 +1221,7 @@ function buildSceneEditor(tr, i, li) {
cueEl.querySelector('.cue-eff-cur').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; save(); }); cueEl.querySelector('.cue-eff-cur').addEventListener('click', () => { c.effectIndex = EFFECTS.list.indexOf(currentEffect); c.effectName = currentEffect.name; save(); });
cueEl.querySelector('.cue-eff-target').addEventListener('change', (e) => { cueEl.querySelector('.cue-eff-target').addEventListener('change', (e) => {
const v = e.target.value; const v = e.target.value;
c.target = (v === 'main' || v === 'all') ? v : parseInt(v, 10); c.target = (v === 'main' || v === 'all' || v.startsWith('zone:')) ? v : parseInt(v, 10);
saveLive(); saveLive();
}); });
} else if (c.type === 'text') { } else if (c.type === 'text') {
@@ -1189,6 +1235,14 @@ function buildSceneEditor(tr, i, li) {
cueEl.querySelector('.cue-tx-bold').addEventListener('change', (e) => { c.weight = e.target.checked; saveLive(); }); cueEl.querySelector('.cue-tx-bold').addEventListener('change', (e) => { c.weight = e.target.checked; saveLive(); });
cueEl.querySelector('.cue-tx-color').addEventListener('input', (e) => { c.color = e.target.value; saveLive(); }); cueEl.querySelector('.cue-tx-color').addEventListener('input', (e) => { c.color = e.target.value; saveLive(); });
cueEl.querySelector('.cue-tx-speed').addEventListener('input', (e) => { c.speed = parseFloat(e.target.value); saveLive(); }); cueEl.querySelector('.cue-tx-speed').addEventListener('input', (e) => { c.speed = parseFloat(e.target.value); saveLive(); });
} else if (c.type === 'map') {
cueEl.querySelector('.cue-map-on').addEventListener('change', (e) => { c.on = e.target.value === '1'; save(); });
cueEl.querySelectorAll('.cue-map-z').forEach(cb => cb.addEventListener('change', () => {
const ids = [...cueEl.querySelectorAll('.cue-map-z')].filter(x => x.checked).map(x => parseInt(x.dataset.id, 10));
// every zone ticked = null, so zones created later are included too
c.zones = ids.length === mapCfg.zones.length ? null : ids;
saveLive();
}));
} else { } else {
cueEl.querySelector('.cue-img').addEventListener('click', () => { sceneImgTarget = i; sceneCueTarget = ci; $('#scene-img-input').click(); }); cueEl.querySelector('.cue-img').addEventListener('click', () => { sceneImgTarget = i; sceneCueTarget = ci; $('#scene-img-input').click(); });
cueEl.querySelector('.cue-img-clear').addEventListener('click', () => { c.image = null; save(); }); cueEl.querySelector('.cue-img-clear').addEventListener('click', () => { c.image = null; save(); });
@@ -1205,6 +1259,7 @@ function buildSceneEditor(tr, i, li) {
ed.querySelector('.add-eff').addEventListener('click', () => addEl(newEffectEl)); ed.querySelector('.add-eff').addEventListener('click', () => addEl(newEffectEl));
ed.querySelector('.add-txt').addEventListener('click', () => addEl(newTextEl)); ed.querySelector('.add-txt').addEventListener('click', () => addEl(newTextEl));
ed.querySelector('.add-img').addEventListener('click', () => addEl(newImageEl)); ed.querySelector('.add-img').addEventListener('click', () => addEl(newImageEl));
ed.querySelector('.add-map').addEventListener('click', () => addEl(newMapEl));
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; save(); }); ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; save(); });
const gapInput = ed.querySelector('.cue-gap'); const gapInput = ed.querySelector('.cue-gap');
if (gapInput) gapInput.addEventListener('change', (e) => { tr.gap = Math.max(0, parseFloat(e.target.value) || 0); savePlaylistState(); renderPlaylist(); }); if (gapInput) gapInput.addEventListener('change', (e) => { tr.gap = Math.max(0, parseFloat(e.target.value) || 0); savePlaylistState(); renderPlaylist(); });
@@ -1650,7 +1705,9 @@ function renderMapZones() {
} }
$('#map-on').checked = mapCfg.on; $('#map-on').checked = mapCfg.on;
$('#map-on').addEventListener('change', (e) => { $('#map-on').addEventListener('change', (e) => {
mapCfg.on = e.target.checked; mapSave(); send({ type: 'mapOn', on: mapCfg.on }); mapCfg.on = e.target.checked; mapSave();
send({ type: 'mapShow', ids: null }); // manual toggle: every zone visible
send({ type: 'mapOn', on: mapCfg.on });
}); });
$('#map-edit').addEventListener('change', (e) => send({ type: 'mapEdit', on: e.target.checked })); $('#map-edit').addEventListener('change', (e) => send({ type: 'mapEdit', on: e.target.checked }));
$('#map-add-vis').addEventListener('click', () => mapAddZone({ type: 'visual' })); $('#map-add-vis').addEventListener('click', () => mapAddZone({ type: 'visual' }));
@@ -2267,7 +2324,7 @@ function playPad(i) {
clearGap(); clearGap();
playbackOwner = 'pad'; playbackOwner = 'pad';
activePad = i; padPlaying = true; activePad = i; padPlaying = true;
activeCues = []; firedCue = -1; lastScene = { effect: null, text: null, image: null }; // pads don't run playlist cues activeCues = []; firedCue = -1; lastScene = { effect: null, text: null, image: null, map: null }; // pads don't run playlist cues
playCur = 0; playDur = durations[p.path] || 0; playCur = 0; playDur = durations[p.path] || 0;
// The playlist is no longer the active source. // The playlist is no longer the active source.
currentIndex = -1; isPlaying = false; renderPlaylist(); currentIndex = -1; isPlaying = false; renderPlaylist();
+37 -11
View File
@@ -377,14 +377,18 @@ djv.onControl(async (m) => {
case 'svg': loadCustomTexture(m.dataUrl); break; case 'svg': loadCustomTexture(m.dataUrl); break;
case 'modelBg': viz.modelBg = m.mode || 'gradient'; break; case 'modelBg': viz.modelBg = m.mode || 'gradient'; break;
case 'mapZones': case 'mapZones':
try { lastMapZones = m.zones || [];
ensureMapping().setZones((m.zones || []).map(z => Object.assign({}, z, applyMapZones();
{ src: z.src && z.src.type === 'image' break;
? { type: 'image', url: toFileURL(z.src.path), path: z.src.path } case 'mapShow':
: (z.src && z.src.type === 'effect' mapShowIds = Array.isArray(m.ids) ? m.ids : null;
? { type: 'effect', effectIndex: z.src.effectIndex } applyMapZones();
: { type: 'visual' }) }))); break;
} catch (e) { djv.report({ type: 'error', message: 'Mappatura: ' + e.message }); } case 'mapZoneFx':
// Playlist scene cue: live override of one zone's content.
if (m.effectIndex == null) delete mapOverrides[m.id];
else mapOverrides[m.id] = m.effectIndex;
applyMapZones();
break; break;
case 'mapOn': case 'mapOn':
mapOn = !!m.on; mapOn = !!m.on;
@@ -724,6 +728,25 @@ requestAnimationFrame(frame);
// ------------------------------------------------------------- mapping // ------------------------------------------------------------- mapping
let mapping = null, mapOn = false; let mapping = null, mapOn = false;
// Zones as configured in the panel + live per-zone effect overrides set by
// playlist scene cues (never written back to the panel's saved mapping).
let lastMapZones = [], mapOverrides = {};
let mapShowIds = null; // playlist scene: only these zone ids show content (null = all)
function applyMapZones() {
try {
ensureMapping().setZones(lastMapZones.map(z => {
const ov = mapOverrides[z.id];
const src = ov != null ? { type: 'effect', effectIndex: ov }
: (z.src && z.src.type === 'image'
? { type: 'image', url: toFileURL(z.src.path), path: z.src.path }
: (z.src && z.src.type === 'effect'
? { type: 'effect', effectIndex: z.src.effectIndex }
: { type: 'visual' }));
const hidden = mapShowIds && !mapShowIds.includes(z.id);
return Object.assign({}, z, { src, opacity: hidden ? 0 : z.opacity });
}));
} catch (e) { djv.report({ type: 'error', message: 'Mappatura: ' + e.message }); }
}
// Videos (VJ loop + playlist video track) are DOM layers above the WebGL // Videos (VJ loop + playlist video track) are DOM layers above the WebGL
// canvas: with mapping on they would cover the zones entirely, and the zones // canvas: with mapping on they would cover the zones entirely, and the zones
@@ -762,10 +785,13 @@ function ensureMapping() {
// corner drags on the output flow back to the panel, which persists them // corner drags on the output flow back to the panel, which persists them
mapping.onChange = (zones) => djv.report({ type: 'mapZones', mapping.onChange = (zones) => djv.report({ type: 'mapZones',
zones: zones.map(z => ({ id: z.id, name: z.name, zones: zones.map(z => ({ id: z.id, name: z.name,
src: z.src.type === 'image' ? { type: 'image', path: z.src.path } // the panel's ORIGINAL content: a scene override must never be saved
src: ((lastMapZones.find(o => o.id === z.id) || {}).src) ||
(z.src.type === 'image' ? { type: 'image', path: z.src.path }
: (z.src.type === 'effect' ? { type: 'effect', effectIndex: z.src.effectIndex } : (z.src.type === 'effect' ? { type: 'effect', effectIndex: z.src.effectIndex }
: { type: 'visual' }), : { type: 'visual' })),
corners: z.corners, srcRect: z.srcRect, opacity: z.opacity })) }); corners: z.corners, srcRect: z.srcRect,
opacity: ((lastMapZones.find(o => o.id === z.id) || {}).opacity) ?? z.opacity })) });
} }
return mapping; return mapping;
} }