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:
1 parent
22b6d966e1
commit
f67d853bbc
6 files changed
+292
-35
No files matched your search
+8
-7
@@ -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
@@ -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();
|
||||
|
||||
+43
-6
@@ -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,9 +297,20 @@ class MappingSim {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.uniform1i(this.uQ.uTex, 0);
|
||||
gl.uniform1f(this.uQ.uOpacity, z.opacity);
|
||||
gl.uniform4fv(this.uQ.uSrc, z.srcRect);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
Reference in new issue
Block a user