Playlist assigns any content to mapping zones (effect, text, image, video)

Mappatura = where (zone geometry + default content), playlist = what.
Text and image scene elements get the 'Su' destination (main output or a
zone), and a new 🎞 Video element plays a looping clip inside a zone. Each
destination is its own cue channel; text/image/video clear when their
element ends, effects stay (apply-once). On the output, zoneOv holds the
per-zone base content (effect/image/video, via a new 'media' source type
in the mapping engine) plus an animated text overlay (zonetext.js: canvas
sized to the zone's aspect, marquee/vertical, letter effects, audio glow)
warped into the zone with alpha. New track or live scene edit sends
mapZoneReset, so zones fall back to their Mappatura content; the saved
mapping is never touched (edit reports return original src/srcRect/opacity).

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:16:50 +02:00
1 parent 22b6d966e1
commit f67d853bbc
6 files changed
+292 -35

No files matched your search

+92 -11
View File
@@ -659,6 +659,7 @@ function hasScene(tr) {
(c.type === 'effect' && c.effectIndex != null) ||
(c.type === 'text' && c.text && c.text.trim()) ||
(c.type === 'image' && c.image) ||
(c.type === 'video' && c.path) ||
c.type === 'map'));
}
function parseTime(str) {
@@ -694,6 +695,45 @@ function optList(pairs, sel) {
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 }; }
// zones: null = every zone, otherwise the ids of the zones that show content
function newVideoEl(time) {
return { type: 'video', time, path: null, dur: 0, target: mapCfg.zones.length ? 'zone:' + mapCfg.zones[0].id : 'main' };
}
// "Su" selector for text / image / video elements: main output or one zone.
function cueTargetRow(c, allowMain) {
const tgt = String(c.target || 'main');
let o = allowMain ? '<option value="main"' + (tgt === 'main' ? ' selected' : '') + '>🖥 Principale (schermo pieno)</option>' : '';
mapCfg.zones.forEach(z => {
const v = 'zone:' + z.id;
o += '<option value="' + v + '"' + (tgt === v ? ' selected' : '') + '>🗺 Zona: ' + (z.name || 'Zona') + '</option>';
});
if (tgt.startsWith('zone:') && !mapCfg.zones.some(z => 'zone:' + z.id === tgt)) o += '<option value="' + tgt + '" selected>🗺 Zona eliminata</option>';
if (!o) o = '<option value="">— crea prima una zona nel tab Mappatura —</option>';
return '<div class="cue-row"><span class="cue-dim">Su</span><select class="cue-target" title="Dove mostrare questo elemento">' + o + '</select></div>';
}
const zoneIdOf = (k) => parseInt(String(k).slice(5), 10);
function sceneActiveBy(type, t) {
const best = {};
for (const el of activeCues) {
if (el.type !== type || el.time > t) continue;
if (el.dur && el.time + el.dur <= t) continue;
const k = String(el.target || 'main');
if (!best[k] || el.time >= best[k].time) best[k] = el;
}
return best;
}
function advanceTargeted(type, t, apply) {
const cur = sceneActiveBy(type, t);
const force = lastScene[type] === undefined;
const prev = (lastScene[type] && typeof lastScene[type] === 'object' && !lastScene[type].type) ? lastScene[type] : {};
const keys = new Set([...Object.keys(cur), ...Object.keys(prev)]);
if (force && type !== 'video') keys.add('main'); // clear a stale full-screen text/image
keys.forEach(k => {
const el = cur[k] || null;
if (!force && prev[k] === el) return;
apply(k, el);
});
lastScene[type] = cur;
}
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 }; }
@@ -716,7 +756,7 @@ function migrateCues(cues) {
}
// Runtime: re-evaluate which element is active per channel and diff-apply it.
let lastScene = { effect: null, text: null, image: null, map: null };
let lastScene = { effect: null, text: null, image: null, map: null, video: null };
let userTrackBlend = 'normal'; // playlist-video blend chosen by the user
let sentTrackBlend = null; // last blend pushed to the output (auto or manual)
@@ -727,7 +767,8 @@ function startCues(tr) {
// `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
// has nothing active at t=0.
lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined };
lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined, video: undefined };
send({ type: 'mapZoneReset' }); // zones fall back to their Mappatura content
advanceCues(0);
}
function sceneActive(type, t) {
@@ -758,7 +799,7 @@ function applyEffectTo(targetKey, el) {
// 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 });
send({ type: 'mapZoneSet', id, kind: 'effect', data: ok ? { effectIndex: el.effectIndex } : null });
return;
}
const e = EFFECTS.list[el.effectIndex];
@@ -804,10 +845,22 @@ function advanceCues(t) {
const wantBlend = (effOn && userTrackBlend === 'normal') ? 'screen' : userTrackBlend;
if (wantBlend !== sentTrackBlend) { sentTrackBlend = wantBlend; send({ type: 'trackVideoBlend', value: wantBlend }); }
}
const tx = sceneActive('text', t);
if (tx !== lastScene.text) { lastScene.text = tx; applyTextEl(tx); }
const im = sceneActive('image', t);
if (im !== lastScene.image) { lastScene.image = im; applyImageEl(im); }
// Text / image / video: one channel per destination (main or a zone);
// when an element ends, its destination is cleared (unlike effects).
advanceTargeted('text', t, (k, el) => {
if (k === 'main') return applyTextEl(el);
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'text',
data: el && el.text && el.text.trim() ? { text: el.text, dir: el.dir, fx: el.fx, font: el.font,
size: el.size, weight: el.weight, color: el.color, pos: el.pos, speed: el.speed } : null });
});
advanceTargeted('image', t, (k, el) => {
if (k === 'main') return applyImageEl(el);
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'image', data: el && el.image ? { path: el.image } : null });
});
advanceTargeted('video', t, (k, el) => {
if (k === 'main') return; // videos are zone-only
send({ type: 'mapZoneSet', id: zoneIdOf(k), kind: 'video', data: el && el.path ? { path: el.path } : null });
});
const mp = sceneActive('map', t);
if (mp !== lastScene.map) {
lastScene.map = mp;
@@ -850,7 +903,8 @@ function applyImageEl(el) {
function refreshScenePreview(i) {
if (i !== currentIndex) return;
activeCues = (playlist[i].cues || []).slice().sort((a, b) => a.time - b.time);
lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined };
lastScene = { effect: undefined, text: undefined, image: undefined, map: undefined, video: undefined };
send({ type: 'mapZoneReset' }); // re-applied below from the edited scene
advanceCues(playCur || 0);
}
@@ -1007,8 +1061,8 @@ function firstPresetOfFamily(fam) {
return -1;
}
const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼', map: '🗺' };
const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine', map: 'Mappatura' };
const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼', map: '🗺', video: '🎞' };
const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine', map: 'Mappatura', video: 'Video' };
// Build the per-track scene editor: a list of independent timed elements
// (effect / text / image), each with its own appearance time and duration.
@@ -1072,6 +1126,7 @@ function buildSceneEditor(tr, i, li) {
} else if (c.type === 'text') {
html +=
'<div class="cue-body">' +
cueTargetRow(c, true) +
'<input class="cue-text textfield" placeholder="Testo da mostrare" />' +
'<div class="cue-row">' +
'<select class="cue-tx-dir" title="Direzione">' + optList(TXT_DIRS, c.dir) + '</select>' +
@@ -1088,6 +1143,16 @@ function buildSceneEditor(tr, i, li) {
'</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>';
} else if (c.type === 'video') {
html +=
'<div class="cue-body">' +
cueTargetRow(c, false) +
'<div class="cue-row">' +
'<button class="cue-vid">Carica video…</button>' +
'<button class="cue-vid-clear">✕</button>' +
'<span class="cue-name">' + (c.path ? baseName(c.path) : 'nessuno') + '</span>' +
'</div>' +
'</div>';
} else if (c.type === 'map') {
html +=
'<div class="cue-body">' +
@@ -1108,6 +1173,7 @@ function buildSceneEditor(tr, i, li) {
} else {
html +=
'<div class="cue-body">' +
cueTargetRow(c, true) +
'<div class="cue-row">' +
'<button class="cue-img">Carica…</button>' +
'<button class="cue-img-clear">✕</button>' +
@@ -1125,6 +1191,7 @@ function buildSceneEditor(tr, i, li) {
'<button class="add-eff" title="Aggiungi effetto">➕🌀</button>' +
'<button class="add-txt" title="Aggiungi testo">➕🔤</button>' +
'<button class="add-img" title="Aggiungi immagine">➕🖼</button>' +
'<button class="add-vid" title="Aggiungi un video in una zona della mappatura">➕🎞</button>' +
'<button class="add-map" title="Accendi/spegni la mappatura a questo punto">➕🗺</button>' +
'<button class="se-clear">🗑 Scena</button>' +
'</div>' +
@@ -1204,6 +1271,8 @@ function buildSceneEditor(tr, i, li) {
});
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); save(); });
const tSel = cueEl.querySelector('.cue-target');
if (tSel) tSel.addEventListener('change', (e) => { if (e.target.value) { c.target = e.target.value; saveLive(); } });
cueEl.querySelector('.cue-dur').addEventListener('change', (e) => { c.dur = Math.max(0, parseFloat(e.target.value) || 0); saveLive(); });
cueEl.querySelector('.cue-del').addEventListener('click', () => { cues.splice(ci, 1); save(); });
@@ -1235,6 +1304,17 @@ function buildSceneEditor(tr, i, li) {
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-speed').addEventListener('input', (e) => { c.speed = parseFloat(e.target.value); saveLive(); });
} else if (c.type === 'video') {
cueEl.querySelector('.cue-vid').addEventListener('click', () => {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'video/*';
inp.addEventListener('change', () => {
const f = inp.files[0], p2 = f ? djv.pathForFile(f) : null;
if (p2) { c.path = p2; save(); }
});
inp.click();
});
cueEl.querySelector('.cue-vid-clear').addEventListener('click', () => { c.path = null; save(); });
} 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', () => {
@@ -1260,6 +1340,7 @@ function buildSceneEditor(tr, i, li) {
ed.querySelector('.add-txt').addEventListener('click', () => addEl(newTextEl));
ed.querySelector('.add-img').addEventListener('click', () => addEl(newImageEl));
ed.querySelector('.add-map').addEventListener('click', () => addEl(newMapEl));
ed.querySelector('.add-vid').addEventListener('click', () => addEl(newVideoEl));
ed.querySelector('.se-clear').addEventListener('click', () => { tr.cues = []; sceneEditing = -1; save(); });
const gapInput = ed.querySelector('.cue-gap');
if (gapInput) gapInput.addEventListener('change', (e) => { tr.gap = Math.max(0, parseFloat(e.target.value) || 0); savePlaylistState(); renderPlaylist(); });
@@ -2324,7 +2405,7 @@ function playPad(i) {
clearGap();
playbackOwner = 'pad';
activePad = i; padPlaying = true;
activeCues = []; firedCue = -1; lastScene = { effect: null, text: null, image: null, map: null }; // pads don't run playlist cues
activeCues = []; firedCue = -1; lastScene = { effect: null, text: null, image: null, map: null, video: null }; // pads don't run playlist cues
playCur = 0; playDur = durations[p.path] || 0;
// The playlist is no longer the active source.
currentIndex = -1; isPlaying = false; renderPlaylist();