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
+289 -32

No files matched your search

+8 -7
View File
@@ -273,13 +273,14 @@
· <b>Slider</b> — opacità della zona (le zone più in basso nella lista
coprono quelle sopra dove si sovrappongono)<br/>
· <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>Dalla playlist</b> — qui disegni solo <b>dove</b> sono le zone; <b>cosa</b> mostrano lo
decide la playlist. Nella 🎬 Scena di un brano ogni elemento ha il selettore <b>Su</b>:
🌀 Effetto, 🔤 Testo e 🖼 Immagine possono andare sullo schermo principale o su una
<b>zona</b>; ➕🎞 mette un <b>video</b> in una zona; ➕🗺 accende/spegne la mappatura e
con <b>Mostra su</b> sceglie le zone visibili (le altre nere). Il testo in una zona scorre
<i>sopra</i> il suo contenuto. Il contenuto impostato qui nel tab resta il
<b>predefinito</b>: ogni nuovo brano riparte da lì, e la mappatura salvata non viene mai
modificata dalla playlist.</p>
<p><b>Note</b> — tutto è salvato automaticamente e rientra nelle registrazioni
MP4. Le zone Visual seguono l'effetto attivo: cambi preset e cambia il
contenuto di tutte le zone.</p>
+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();
+40 -3
View File
@@ -20,11 +20,13 @@ 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
uniform float uUseAlpha; // 1 = honour the texture's alpha (text overlays)
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);
vec4 t = texture(uTex, uSrc.xy + uv*uSrc.zw);
frag = vec4(t.rgb, uOpacity * mix(1.0, t.a, uUseAlpha));
}`;
const H_VS = `#version 300 es
in vec2 aPos; uniform float uPt;
@@ -84,7 +86,8 @@ class MappingSim {
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') };
uSrc: gl.getUniformLocation(this.progQ, 'uSrc'),
uUseAlpha: gl.getUniformLocation(this.progQ, 'uUseAlpha') };
this.uH = { uCol: gl.getUniformLocation(this.progH, 'uCol'),
uPt: gl.getUniformLocation(this.progH, 'uPt') };
this.aQ = gl.getAttribLocation(this.progQ, 'aPos');
@@ -105,6 +108,8 @@ class MappingSim {
this._mkTex = mkTex;
this.imgTex = {}; // path -> {tex, ok}
this.fx = {}; // zoneId -> {canvas, viz, tex, effectIndex} own engine
this.mediaTex = {}; // zoneId -> texture for src.type 'media' (a <video>)
this.ovTex = {}; // zoneId -> texture for the zone's text overlay canvas
this.zones = [];
this.editOn = false;
this.selected = -1;
@@ -119,7 +124,8 @@ class MappingSim {
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
opacity: z.opacity !== undefined ? z.opacity : 1,
overlay: z.overlay || null // optional canvas drawn on top (alpha)
}));
if (this.selected >= this.zones.length) this.selected = this.zones.length - 1;
// per-zone effect engines: an independent Visualizer on an offscreen
@@ -235,6 +241,24 @@ class MappingSim {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, f.canvas);
}
// live media (videos) and text overlays refresh every frame
for (const z of this.zones) {
if (z.src.type === 'media' && z.src.el) {
const el = z.src.el;
if (el.readyState >= 2 && el.videoWidth) {
const t = this.mediaTex[z.id] || (this.mediaTex[z.id] = this._mkTex());
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, el);
z._mediaOk = true;
}
}
if (z.overlay) {
const t = this.ovTex[z.id] || (this.ovTex[z.id] = this._mkTex());
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, z.overlay);
}
}
gl.viewport(0, 0, w, h);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
@@ -261,6 +285,8 @@ class MappingSim {
const f = this.fx[z.id];
if (!f) continue;
tex = f.tex;
} else if (z.src.type === 'media') {
tex = z._mediaOk ? this.mediaTex[z.id] : null;
}
const c = z.corners;
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
@@ -271,10 +297,21 @@ class MappingSim {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.uniform1i(this.uQ.uTex, 0);
gl.uniform1f(this.uQ.uUseAlpha, 0);
if (tex) {
gl.uniform1f(this.uQ.uOpacity, z.opacity);
gl.uniform4fv(this.uQ.uSrc, z.srcRect);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
// text overlay: same quad, full texture, alpha-blended on top
if (z.overlay && this.ovTex[z.id] && z.opacity > 0) {
gl.bindTexture(gl.TEXTURE_2D, this.ovTex[z.id]);
gl.uniform1f(this.uQ.uUseAlpha, 1);
gl.uniform1f(this.uQ.uOpacity, z.opacity);
gl.uniform4fv(this.uQ.uSrc, [0, 0, 1, 1]);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
}
if (this.editOn) {
gl.useProgram(this.progH);
+1
View File
@@ -59,6 +59,7 @@
<script src="interactive.js"></script>
<script src="model3d.js"></script>
<script src="mapping.js"></script>
<script src="zonetext.js"></script>
<script src="visualizer.js"></script>
<script src="audio.js"></script>
<script src="output.js"></script>
+62 -11
View File
@@ -384,10 +384,10 @@ djv.onControl(async (m) => {
mapShowIds = Array.isArray(m.ids) ? m.ids : null;
applyMapZones();
break;
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;
case 'mapZoneSet': mapZoneSet(m.id, m.kind, m.data); break;
case 'mapZoneReset':
// new playlist track: every zone falls back to its Mappatura content
for (const id in zoneOv) dropZoneOv(id);
applyMapZones();
break;
case 'mapOn':
@@ -656,6 +656,7 @@ function frame() {
avTick(performance.now());
viz.render(t * speed, a);
if (mapOn && mapping) {
for (const id in zoneOv) if (zoneOv[id].text) zoneOv[id].text.render(a);
mapping.main = mapVisualSource();
mapping.render(t * speed, a);
}
@@ -730,20 +731,69 @@ requestAnimationFrame(frame);
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 lastMapZones = [];
let mapShowIds = null; // playlist scene: only these zone ids show content (null = all)
// Per-zone content assigned by playlist scene elements:
// zoneOv[id] = { base: {kind:'effect'|'image'|'video', ...} | null,
// text: ZoneText | null, video: <video> | null }
// The base replaces the zone's own content; the text is an overlay on top.
// The Mappatura tab content stays the default when nothing is assigned.
const zoneOv = {};
function zoneAspect(z) {
const c = z.corners, W = canvas.width || 1920, H = canvas.height || 1080;
const w = (Math.hypot((c[1][0]-c[0][0])*W, (c[1][1]-c[0][1])*H) + Math.hypot((c[2][0]-c[3][0])*W, (c[2][1]-c[3][1])*H)) / 2;
const h = (Math.hypot((c[3][0]-c[0][0])*W, (c[3][1]-c[0][1])*H) + Math.hypot((c[2][0]-c[1][0])*W, (c[2][1]-c[1][1])*H)) / 2;
return w / Math.max(1, h);
}
function stopZoneVideo(o) {
if (o && o.video) { try { o.video.pause(); o.video.removeAttribute('src'); o.video.load(); } catch (e) {} o.video = null; }
}
function dropZoneOv(id) { stopZoneVideo(zoneOv[id]); delete zoneOv[id]; }
function mapZoneSet(id, kind, data) {
const o = zoneOv[id] || (zoneOv[id] = { base: null, text: null, video: null });
if (kind === 'text') {
if (!data) o.text = null;
else {
const z = lastMapZones.find(x => x.id === id);
(o.text = o.text || new window.ZoneText()).set(data, z ? zoneAspect(z) : 16 / 9);
}
} else if (!data) {
// clear only if the zone currently shows THIS kind of content
if (o.base && o.base.kind === kind) { if (kind === 'video') stopZoneVideo(o); o.base = null; }
} else {
if (o.base && o.base.kind === 'video' && !(kind === 'video' && o.base.path === data.path)) stopZoneVideo(o);
o.base = Object.assign({ kind }, data);
if (kind === 'video' && !o.video) {
const v = document.createElement('video');
v.muted = true; v.loop = true; v.playsInline = true;
v.src = toFileURL(data.path);
v.play().catch(() => {});
o.video = v;
}
}
if (!o.base && !o.text) dropZoneOv(id);
applyMapZones();
}
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'
const o = zoneOv[z.id], b = o && o.base;
let src, srcRect = z.srcRect;
if (b) {
srcRect = [0, 0, 1, 1]; // the 'Porzione' crop is for the live visual only
if (b.kind === 'effect') src = { type: 'effect', effectIndex: b.effectIndex };
else if (b.kind === 'image') src = { type: 'image', url: toFileURL(b.path), path: b.path };
else src = { type: 'media', el: o.video };
} else {
src = (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' }));
: { type: 'visual' });
}
const hidden = mapShowIds && !mapShowIds.includes(z.id);
return Object.assign({}, z, { src, opacity: hidden ? 0 : z.opacity });
return Object.assign({}, z, { src, srcRect, opacity: hidden ? 0 : z.opacity,
overlay: o && o.text ? o.text.canvas : null });
}));
} catch (e) { djv.report({ type: 'error', message: 'Mappatura: ' + e.message }); }
}
@@ -790,7 +840,8 @@ function ensureMapping() {
(z.src.type === 'image' ? { type: 'image', path: z.src.path }
: (z.src.type === 'effect' ? { type: 'effect', effectIndex: z.src.effectIndex }
: { type: 'visual' })),
corners: z.corners, srcRect: z.srcRect,
corners: z.corners,
srcRect: ((lastMapZones.find(o => o.id === z.id) || {}).srcRect) || z.srcRect,
opacity: ((lastMapZones.find(o => o.id === z.id) || {}).opacity) ?? z.opacity })) });
}
return mapping;
+86
View File
@@ -0,0 +1,86 @@
// Animated text for ONE mapping zone: drawn every frame on a transparent 2D
// canvas sized to the zone's on-screen proportions, then warped into the zone
// by the mapping engine as an alpha overlay. Mirrors the options of the
// playlist text element (direction, letter effect, font, position, size,
// weight, colour, speed) and glows with the music like the main ticker.
(function () {
class ZoneText {
constructor() {
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
this.cfg = null;
this.t0 = performance.now();
}
// cfg: the scene text element; aspect: zone width/height in output pixels
set(cfg, aspect) {
const restart = !this.cfg || this.cfg.text !== cfg.text || this.cfg.dir !== cfg.dir;
this.cfg = cfg;
const a = Math.max(0.15, Math.min(8, aspect || 16 / 9));
const W = a >= 1 ? 1024 : Math.round(1024 * a);
const H = a >= 1 ? Math.round(1024 / a) : 1024;
if (this.canvas.width !== W || this.canvas.height !== H) { this.canvas.width = W; this.canvas.height = H; }
if (restart) this.t0 = performance.now();
}
render(audio) {
const c = this.cfg, ctx = this.ctx, W = this.canvas.width, H = this.canvas.height;
ctx.clearRect(0, 0, W, H);
if (!c || !c.text || !c.text.trim()) return;
const t = (performance.now() - this.t0) / 1000;
const beat = (audio && audio.beat) || 0, level = (audio && audio.level) || 0;
const sp = c.speed || 1;
// size 2..18 (vh on the main ticker) -> share of the zone height
const fs = Math.max(8, H * (c.size || 6) * 0.03);
ctx.font = (c.weight === false ? '400 ' : '800 ') + fs + 'px ' + (c.font || 'sans-serif');
ctx.textBaseline = 'middle';
ctx.fillStyle = c.color || '#ffffff';
ctx.shadowColor = c.color || '#ffffff';
ctx.shadowBlur = 6 + level * 24 + beat * 20;
const txt = c.text + ' • ';
const tw = ctx.measureText(txt).width;
const fx = c.fx || 'none';
let alpha = 1, scale = 1, bob = 0;
if (fx === 'flash') alpha = 0.35 + 0.65 * Math.min(1, beat + 0.2 * Math.sin(t * 6) + 0.2);
if (fx === 'zoom') scale = 1 + beat * 0.25;
if (fx === 'updown') bob = Math.sin(t * 3 * sp) * fs * 0.35;
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
const dir = c.dir || 'h';
if (dir === 'vup' || dir === 'vdown') {
// the line travels vertically through the zone, centred horizontally
const span = H + fs * 2, v = (t * sp * H / 4) % span;
const y = dir === 'vup' ? H + fs - v : -fs + v;
this._line(c.text, W / 2 - ctx.measureText(c.text).width * scale / 2, y + bob, fs, scale, fx, t, sp);
} else {
// horizontal marquee (also used for 'sides'): two copies for a seamless loop
const y = (c.pos === 'top' ? H * 0.2 : c.pos === 'middle' ? H * 0.5 : H * 0.8) + bob;
const off = (t * sp * W / 6) % (tw * scale);
for (let x = -off; x < W; x += tw * scale) this._line(txt, x, y, fs, scale, fx, t, sp);
}
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
}
// one run of text, letter by letter when the effect moves single letters
_line(s, x, y, fs, scale, fx, t, sp) {
const ctx = this.ctx;
if (fx !== 'wave' && fx !== 'rotate' && scale === 1) { ctx.fillText(s, x, y); return; }
let cx = x;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
const w = ctx.measureText(ch).width * scale;
ctx.save();
ctx.translate(cx + w / 2, y + (fx === 'wave' ? Math.sin(t * 4 * sp + i * 0.6) * fs * 0.25 : 0));
if (fx === 'rotate') ctx.rotate(Math.sin(t * 3 * sp + i * 0.5) * 0.35);
ctx.scale(scale, scale);
ctx.fillText(ch, -w / scale / 2, 0);
ctx.restore();
cx += w;
}
}
}
window.ZoneText = ZoneText;
})();