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
|
· <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
|
<p><b>Dalla playlist</b> — qui disegni solo <b>dove</b> sono le zone; <b>cosa</b> mostrano lo
|
||||||
mappatura in quel momento e, con le spunte <b>Mostra su</b>, sceglie su quali zone si vede
|
decide la playlist. Nella 🎬 Scena di un brano ogni elemento ha il selettore <b>Su</b>:
|
||||||
(le altre restano nere). Mettilo a 0:00 per decidere le zone di tutto il brano, o più volte
|
🌀 Effetto, 🔤 Testo e 🖼 Immagine possono andare sullo schermo principale o su una
|
||||||
per spostare il visual da una zona all'altra. Nell'elemento 🌀 Effetto, il selettore
|
<b>zona</b>; ➕🎞 mette un <b>video</b> in una zona; ➕🗺 accende/spegne la mappatura e
|
||||||
<b>Su</b> può puntare anche a una singola zona (🗺 Zona: …) per darle un effetto suo
|
con <b>Mostra su</b> sceglie le zone visibili (le altre nere). Il testo in una zona scorre
|
||||||
in quel momento; "nessun effetto" la riporta al contenuto normale. La mappatura salvata
|
<i>sopra</i> il suo contenuto. Il contenuto impostato qui nel tab resta il
|
||||||
non viene mai modificata.</p>
|
<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
|
<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>
|
||||||
|
|||||||
+92
-11
@@ -659,6 +659,7 @@ function hasScene(tr) {
|
|||||||
(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 === 'video' && c.path) ||
|
||||||
c.type === 'map'));
|
c.type === 'map'));
|
||||||
}
|
}
|
||||||
function parseTime(str) {
|
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 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
|
// 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 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 }; }
|
||||||
|
|
||||||
@@ -716,7 +756,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, 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 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)
|
||||||
|
|
||||||
@@ -727,7 +767,8 @@ 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, 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);
|
advanceCues(0);
|
||||||
}
|
}
|
||||||
function sceneActive(type, t) {
|
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.
|
// the zone runs this preset; 'no effect' gives it back its own content.
|
||||||
const id = parseInt(targetKey.slice(5), 10);
|
const id = parseInt(targetKey.slice(5), 10);
|
||||||
const ok = el.effectIndex != null && EFFECTS.list[el.effectIndex];
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const e = EFFECTS.list[el.effectIndex];
|
const e = EFFECTS.list[el.effectIndex];
|
||||||
@@ -804,10 +845,22 @@ function advanceCues(t) {
|
|||||||
const wantBlend = (effOn && userTrackBlend === 'normal') ? 'screen' : userTrackBlend;
|
const wantBlend = (effOn && userTrackBlend === 'normal') ? 'screen' : userTrackBlend;
|
||||||
if (wantBlend !== sentTrackBlend) { sentTrackBlend = wantBlend; send({ type: 'trackVideoBlend', value: wantBlend }); }
|
if (wantBlend !== sentTrackBlend) { sentTrackBlend = wantBlend; send({ type: 'trackVideoBlend', value: wantBlend }); }
|
||||||
}
|
}
|
||||||
const tx = sceneActive('text', t);
|
// Text / image / video: one channel per destination (main or a zone);
|
||||||
if (tx !== lastScene.text) { lastScene.text = tx; applyTextEl(tx); }
|
// when an element ends, its destination is cleared (unlike effects).
|
||||||
const im = sceneActive('image', t);
|
advanceTargeted('text', t, (k, el) => {
|
||||||
if (im !== lastScene.image) { lastScene.image = im; applyImageEl(im); }
|
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);
|
const mp = sceneActive('map', t);
|
||||||
if (mp !== lastScene.map) {
|
if (mp !== lastScene.map) {
|
||||||
lastScene.map = mp;
|
lastScene.map = mp;
|
||||||
@@ -850,7 +903,8 @@ 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, 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);
|
advanceCues(playCur || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,8 +1061,8 @@ function firstPresetOfFamily(fam) {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼', map: '🗺' };
|
const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼', map: '🗺', video: '🎞' };
|
||||||
const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine', map: 'Mappatura' };
|
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
|
// 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.
|
||||||
@@ -1072,6 +1126,7 @@ function buildSceneEditor(tr, i, li) {
|
|||||||
} else if (c.type === 'text') {
|
} else if (c.type === 'text') {
|
||||||
html +=
|
html +=
|
||||||
'<div class="cue-body">' +
|
'<div class="cue-body">' +
|
||||||
|
cueTargetRow(c, true) +
|
||||||
'<input class="cue-text textfield" placeholder="Testo da mostrare" />' +
|
'<input class="cue-text textfield" placeholder="Testo da mostrare" />' +
|
||||||
'<div class="cue-row">' +
|
'<div class="cue-row">' +
|
||||||
'<select class="cue-tx-dir" title="Direzione">' + optList(TXT_DIRS, c.dir) + '</select>' +
|
'<select class="cue-tx-dir" title="Direzione">' + optList(TXT_DIRS, c.dir) + '</select>' +
|
||||||
@@ -1088,6 +1143,16 @@ 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 === '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') {
|
} else if (c.type === 'map') {
|
||||||
html +=
|
html +=
|
||||||
'<div class="cue-body">' +
|
'<div class="cue-body">' +
|
||||||
@@ -1108,6 +1173,7 @@ function buildSceneEditor(tr, i, li) {
|
|||||||
} else {
|
} else {
|
||||||
html +=
|
html +=
|
||||||
'<div class="cue-body">' +
|
'<div class="cue-body">' +
|
||||||
|
cueTargetRow(c, true) +
|
||||||
'<div class="cue-row">' +
|
'<div class="cue-row">' +
|
||||||
'<button class="cue-img">Carica…</button>' +
|
'<button class="cue-img">Carica…</button>' +
|
||||||
'<button class="cue-img-clear">✕</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-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-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="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>' +
|
||||||
@@ -1204,6 +1271,8 @@ function buildSceneEditor(tr, i, li) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
cueEl.querySelector('.cue-time').addEventListener('change', (e) => { c.time = parseTime(e.target.value); save(); });
|
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-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(); });
|
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-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 === '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') {
|
} else if (c.type === 'map') {
|
||||||
cueEl.querySelector('.cue-map-on').addEventListener('change', (e) => { c.on = e.target.value === '1'; save(); });
|
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', () => {
|
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-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('.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(); });
|
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(); });
|
||||||
@@ -2324,7 +2405,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, 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;
|
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();
|
||||||
|
|||||||
+43
-6
@@ -20,11 +20,13 @@ uniform mat3 uH; // output coords -> unit square of the zone
|
|||||||
uniform sampler2D uTex;
|
uniform sampler2D uTex;
|
||||||
uniform float uOpacity;
|
uniform float uOpacity;
|
||||||
uniform vec4 uSrc; // source sub-rect (x,y,w,h) in texture uv
|
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(){
|
void main(){
|
||||||
vec3 q = uH * vec3(vZ, 1.0);
|
vec3 q = uH * vec3(vZ, 1.0);
|
||||||
vec2 uv = q.xy / q.z;
|
vec2 uv = q.xy / q.z;
|
||||||
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) discard;
|
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
|
const H_VS = `#version 300 es
|
||||||
in vec2 aPos; uniform float uPt;
|
in vec2 aPos; uniform float uPt;
|
||||||
@@ -84,7 +86,8 @@ class MappingSim {
|
|||||||
this.uQ = { uH: gl.getUniformLocation(this.progQ, 'uH'),
|
this.uQ = { uH: gl.getUniformLocation(this.progQ, 'uH'),
|
||||||
uTex: gl.getUniformLocation(this.progQ, 'uTex'),
|
uTex: gl.getUniformLocation(this.progQ, 'uTex'),
|
||||||
uOpacity: gl.getUniformLocation(this.progQ, 'uOpacity'),
|
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'),
|
this.uH = { uCol: gl.getUniformLocation(this.progH, 'uCol'),
|
||||||
uPt: gl.getUniformLocation(this.progH, 'uPt') };
|
uPt: gl.getUniformLocation(this.progH, 'uPt') };
|
||||||
this.aQ = gl.getAttribLocation(this.progQ, 'aPos');
|
this.aQ = gl.getAttribLocation(this.progQ, 'aPos');
|
||||||
@@ -105,6 +108,8 @@ class MappingSim {
|
|||||||
this._mkTex = mkTex;
|
this._mkTex = mkTex;
|
||||||
this.imgTex = {}; // path -> {tex, ok}
|
this.imgTex = {}; // path -> {tex, ok}
|
||||||
this.fx = {}; // zoneId -> {canvas, viz, tex, effectIndex} own engine
|
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.zones = [];
|
||||||
this.editOn = false;
|
this.editOn = false;
|
||||||
this.selected = -1;
|
this.selected = -1;
|
||||||
@@ -119,7 +124,8 @@ class MappingSim {
|
|||||||
src: z.src || { type: 'visual' },
|
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()),
|
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],
|
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;
|
if (this.selected >= this.zones.length) this.selected = this.zones.length - 1;
|
||||||
// per-zone effect engines: an independent Visualizer on an offscreen
|
// 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);
|
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.viewport(0, 0, w, h);
|
||||||
gl.clearColor(0, 0, 0, 1);
|
gl.clearColor(0, 0, 0, 1);
|
||||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
@@ -261,6 +285,8 @@ class MappingSim {
|
|||||||
const f = this.fx[z.id];
|
const f = this.fx[z.id];
|
||||||
if (!f) continue;
|
if (!f) continue;
|
||||||
tex = f.tex;
|
tex = f.tex;
|
||||||
|
} else if (z.src.type === 'media') {
|
||||||
|
tex = z._mediaOk ? this.mediaTex[z.id] : null;
|
||||||
}
|
}
|
||||||
const c = z.corners;
|
const c = z.corners;
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||||||
@@ -271,9 +297,20 @@ class MappingSim {
|
|||||||
gl.activeTexture(gl.TEXTURE0);
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||||
gl.uniform1i(this.uQ.uTex, 0);
|
gl.uniform1i(this.uQ.uTex, 0);
|
||||||
gl.uniform1f(this.uQ.uOpacity, z.opacity);
|
gl.uniform1f(this.uQ.uUseAlpha, 0);
|
||||||
gl.uniform4fv(this.uQ.uSrc, z.srcRect);
|
if (tex) {
|
||||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
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) {
|
if (this.editOn) {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
<script src="interactive.js"></script>
|
<script src="interactive.js"></script>
|
||||||
<script src="model3d.js"></script>
|
<script src="model3d.js"></script>
|
||||||
<script src="mapping.js"></script>
|
<script src="mapping.js"></script>
|
||||||
|
<script src="zonetext.js"></script>
|
||||||
<script src="visualizer.js"></script>
|
<script src="visualizer.js"></script>
|
||||||
<script src="audio.js"></script>
|
<script src="audio.js"></script>
|
||||||
<script src="output.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;
|
mapShowIds = Array.isArray(m.ids) ? m.ids : null;
|
||||||
applyMapZones();
|
applyMapZones();
|
||||||
break;
|
break;
|
||||||
case 'mapZoneFx':
|
case 'mapZoneSet': mapZoneSet(m.id, m.kind, m.data); break;
|
||||||
// Playlist scene cue: live override of one zone's content.
|
case 'mapZoneReset':
|
||||||
if (m.effectIndex == null) delete mapOverrides[m.id];
|
// new playlist track: every zone falls back to its Mappatura content
|
||||||
else mapOverrides[m.id] = m.effectIndex;
|
for (const id in zoneOv) dropZoneOv(id);
|
||||||
applyMapZones();
|
applyMapZones();
|
||||||
break;
|
break;
|
||||||
case 'mapOn':
|
case 'mapOn':
|
||||||
@@ -656,6 +656,7 @@ function frame() {
|
|||||||
avTick(performance.now());
|
avTick(performance.now());
|
||||||
viz.render(t * speed, a);
|
viz.render(t * speed, a);
|
||||||
if (mapOn && mapping) {
|
if (mapOn && mapping) {
|
||||||
|
for (const id in zoneOv) if (zoneOv[id].text) zoneOv[id].text.render(a);
|
||||||
mapping.main = mapVisualSource();
|
mapping.main = mapVisualSource();
|
||||||
mapping.render(t * speed, a);
|
mapping.render(t * speed, a);
|
||||||
}
|
}
|
||||||
@@ -730,20 +731,69 @@ requestAnimationFrame(frame);
|
|||||||
let mapping = null, mapOn = false;
|
let mapping = null, mapOn = false;
|
||||||
// Zones as configured in the panel + live per-zone effect overrides set by
|
// 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).
|
// 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)
|
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() {
|
function applyMapZones() {
|
||||||
try {
|
try {
|
||||||
ensureMapping().setZones(lastMapZones.map(z => {
|
ensureMapping().setZones(lastMapZones.map(z => {
|
||||||
const ov = mapOverrides[z.id];
|
const o = zoneOv[z.id], b = o && o.base;
|
||||||
const src = ov != null ? { type: 'effect', effectIndex: ov }
|
let src, srcRect = z.srcRect;
|
||||||
: (z.src && z.src.type === 'image'
|
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 }
|
? { type: 'image', url: toFileURL(z.src.path), path: z.src.path }
|
||||||
: (z.src && z.src.type === 'effect'
|
: (z.src && z.src.type === 'effect'
|
||||||
? { type: 'effect', effectIndex: z.src.effectIndex }
|
? { type: 'effect', effectIndex: z.src.effectIndex }
|
||||||
: { type: 'visual' }));
|
: { type: 'visual' });
|
||||||
|
}
|
||||||
const hidden = mapShowIds && !mapShowIds.includes(z.id);
|
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 }); }
|
} 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 === '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,
|
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 })) });
|
opacity: ((lastMapZones.find(o => o.id === z.id) || {}).opacity) ?? z.opacity })) });
|
||||||
}
|
}
|
||||||
return mapping;
|
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