From f67d853bbc4e7475e45f0ef6b2525097bbae44b0 Mon Sep 17 00:00:00 2001
From: luciano
Date: Fri, 2 Oct 2026 12:16:50 +0200
Subject: [PATCH] Playlist assigns any content to mapping zones (effect, text,
image, video)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01XVMQ2JhsJ77pGKqcvvFHqc
---
src/control.html | 15 +++----
src/control.js | 103 ++++++++++++++++++++++++++++++++++++++++++-----
src/mapping.js | 49 +++++++++++++++++++---
src/output.html | 1 +
src/output.js | 73 ++++++++++++++++++++++++++++-----
src/zonetext.js | 86 +++++++++++++++++++++++++++++++++++++++
6 files changed, 292 insertions(+), 35 deletions(-)
create mode 100644 src/zonetext.js
diff --git a/src/control.html b/src/control.html
index 5c46f25..e5876e6 100644
--- a/src/control.html
+++ b/src/control.html
@@ -273,13 +273,14 @@
Β· Slider β opacitΓ della zona (le zone piΓΉ in basso nella lista
coprono quelle sopra dove si sovrappongono)
Β· β β elimina la zona
-
Dalla playlist β nella π¬ Scena di un brano aggiungi βπΊ: accende o spegne la
- mappatura in quel momento e, con le spunte Mostra su, 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
- Su 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.
+
Dalla playlist β qui disegni solo dove sono le zone; cosa mostrano lo
+ decide la playlist. Nella π¬ Scena di un brano ogni elemento ha il selettore Su:
+ π Effetto, π€ Testo e πΌ Immagine possono andare sullo schermo principale o su una
+ zona; βπ mette un video in una zona; βπΊ accende/spegne la mappatura e
+ con Mostra su sceglie le zone visibili (le altre nere). Il testo in una zona scorre
+ sopra il suo contenuto. Il contenuto impostato qui nel tab resta il
+ predefinito: ogni nuovo brano riparte da lì, e la mappatura salvata non viene mai
+ modificata dalla playlist.
Note β 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.
diff --git a/src/control.js b/src/control.js
index 0305a07..47bba51 100644
--- a/src/control.js
+++ b/src/control.js
@@ -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 ? '' : '';
+ mapCfg.zones.forEach(z => {
+ const v = 'zone:' + z.id;
+ o += '';
+ });
+ if (tgt.startsWith('zone:') && !mapCfg.zones.some(z => 'zone:' + z.id === tgt)) o += '';
+ if (!o) o = '';
+ return '
Su
';
+}
+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 +=
'
' +
+ cueTargetRow(c, true) +
'' +
'
' +
'' +
@@ -1088,6 +1143,16 @@ function buildSceneEditor(tr, i, li) {
'
' +
'
Vel
' +
'
';
+ } else if (c.type === 'video') {
+ html +=
+ '