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 += + '
' + + cueTargetRow(c, false) + + '
' + + '' + + '' + + '' + (c.path ? baseName(c.path) : 'nessuno') + '' + + '
' + + '
'; } else if (c.type === 'map') { html += '
' + @@ -1108,6 +1173,7 @@ function buildSceneEditor(tr, i, li) { } else { html += '
' + + cueTargetRow(c, true) + '
' + '' + '' + @@ -1125,6 +1191,7 @@ function buildSceneEditor(tr, i, li) { '' + '' + '' + + '' + '' + '' + '
' + @@ -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(); diff --git a/src/mapping.js b/src/mapping.js index 57f0d81..0adfcc1 100644 --- a/src/mapping.js +++ b/src/mapping.js @@ -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