// CONTROL window: builds the UI and sends commands to the output window. // It also receives reports (meters, fps, device list, play state). const $ = (s) => document.querySelector(s); const send = (msg) => djv.send(msg); const filePath = (f) => djv.pathForFile(f); // ---------------------------------------------------------------- effects const EFFECTS = window.EFFECTS; let currentEffect = EFFECTS.list[0]; function applyEffect(effect) { currentEffect = effect; send({ type: 'effect', effect }); $('#scene-name').textContent = effect.name; renderLibrary(); } // Auto VJ: the output-side director picks presets in time with the music. // avMode: 'smart' = curated mood pools, 'all' = every family, 'custom' = the // user's checklist. Persisted in localStorage. let autoVjOn = false; let avCfg = { mode: 'smart', families: [] }; try { avCfg = Object.assign(avCfg, JSON.parse(localStorage.getItem('autovj') || '{}')); } catch (e) {} function avSend(on) { send({ type: 'autoVj', on, mode: avCfg.mode, families: avCfg.families }); } function avSave() { localStorage.setItem('autovj', JSON.stringify(avCfg)); if (autoVjOn) avSend(true); // re-push the new config while running } function renderAvFams() { const box = $('#av-fams'); box.classList.toggle('hidden', avCfg.mode !== 'custom'); if (avCfg.mode !== 'custom') return; box.innerHTML = ''; EFFECTS.families.forEach((name) => { const l = document.createElement('label'); l.className = 'av-fam' + (avCfg.families.includes(name) ? ' on' : ''); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = avCfg.families.includes(name); c.addEventListener('change', () => { avCfg.families = c.checked ? avCfg.families.concat(name) : avCfg.families.filter((f) => f !== name); l.classList.toggle('on', c.checked); avSave(); }); l.appendChild(c); l.appendChild(document.createTextNode(' ' + name)); box.appendChild(l); }); } $('#av-mode').value = avCfg.mode; $('#av-mode').addEventListener('change', (e) => { avCfg.mode = e.target.value; renderAvFams(); avSave(); }); renderAvFams(); $('#auto-vj').addEventListener('click', () => avSend(!autoVjOn)); // --- Library browser (hundreds of presets, filterable) --- // 3D-model families live in the Animazioni 3D tab, not in the effect library. const FAM_3D = (name) => /^Modello 3D$|^Interattivo Modello$|^Interattivo Avatar$/.test(name); const familySel = $('#effect-family'); EFFECTS.families.forEach((name, i) => { if (FAM_3D(name)) return; const o = document.createElement('option'); o.value = i; o.textContent = name; familySel.appendChild(o); }); const LIB_CAP = 300; // max items rendered at once function filteredEffects() { const fam = parseInt($('#effect-family').value, 10); const q = $('#effect-search').value.trim().toLowerCase(); const out = []; for (let i = 0; i < EFFECTS.list.length; i++) { const e = EFFECTS.list[i]; if (FAM_3D(e.familyName)) continue; if (fam >= 0 && e.family !== fam) continue; if (q && e.name.toLowerCase().indexOf(q) < 0) continue; out.push(e); if (out.length >= LIB_CAP) break; } return out; } function renderLibrary() { const list = $('#effect-list'); const items = filteredEffects(); list.innerHTML = ''; items.forEach((e) => { const row = document.createElement('div'); row.className = 'fx' + (e === currentEffect ? ' active' : ''); row.innerHTML = '' + e.name + '' + ''; row.querySelector('.fx-name').addEventListener('click', () => applyEffect(e)); row.querySelector('.fx-add').addEventListener('click', () => addToSequence(e)); list.appendChild(row); }); $('#effects-count').textContent = EFFECTS.count + ' preset' + (items.length >= LIB_CAP ? ' · mostro i primi ' + LIB_CAP : ''); } $('#effect-family').addEventListener('change', () => { renderLibrary(); // Switching to a specific family applies its first preset, so the // visualization changes category immediately (e.g. away from an SVG). const fam = parseInt($('#effect-family').value, 10); if (fam >= 0) { const first = filteredEffects()[0]; if (first) applyEffect(first); } }); $('#effect-search').addEventListener('input', renderLibrary); // --- SVG / image as a custom effect source --- function applySvgFamily() { const fam = EFFECTS.families.indexOf('SVG/Immagine'); const eff = EFFECTS.list.find(e => e.family === fam); if (eff) applyEffect(eff); } function useSvg(dataUrl) { send({ type: 'svg', dataUrl }); applySvgFamily(); } const svgSelect = $('#svg-select'); let builtinSvgs = []; (async () => { builtinSvgs = (djv.listBuiltinSvgs ? await djv.listBuiltinSvgs() : []) || []; builtinSvgs .slice() .sort((a, b) => a.name.localeCompare(b.name)) .forEach((s) => { const o = document.createElement('option'); o.value = builtinSvgs.indexOf(s); o.textContent = s.name; svgSelect.appendChild(o); }); })(); svgSelect.addEventListener('change', (e) => { const i = parseInt(e.target.value, 10); if (i >= 0 && builtinSvgs[i]) useSvg(builtinSvgs[i].dataUrl); }); $('#btn-svg-load').addEventListener('click', () => $('#svg-input').click()); $('#svg-input').addEventListener('change', (e) => { const f = e.target.files[0]; if (!f) return; const r = new FileReader(); r.onload = () => useSvg(r.result); // data: URL r.readAsDataURL(f); e.target.value = ''; }); // --- 3D model (GLB) for the 'Modello 3D' family ----------------------------- // The path persists and is re-sent when the output window (re)starts. let glbPath = localStorage.getItem('glb3d') || ''; let glbBg = localStorage.getItem('glbbg') || 'gradient'; let glbClipSel = []; try { glbClipSel = JSON.parse(localStorage.getItem('glbclips') || '[]'); } catch (e) {} let glbAnimFiles = []; try { glbAnimFiles = JSON.parse(localStorage.getItem('glbanimfiles') || '[]'); } catch (e) {} let glbBpm = parseInt(localStorage.getItem('glbbpm') || '0', 10) || 0; function glbSend() { if (glbPath) send({ type: 'glb', path: glbPath }); send({ type: 'modelBg', mode: glbBg }); glbAnimFiles.forEach(p => send({ type: 'animAdd', path: p })); send({ type: 'glbAnims', names: glbClipSel }); send({ type: 'modelBpm', bpm: glbBpm }); spreadSend(); } function renderGlbClips(names) { const box = $('#glb-anims'); box.innerHTML = ''; if (!names || !names.length) { box.innerHTML = 'Carica un modello riggato per vedere le mosse.'; return; } names.forEach((n) => { const l = document.createElement('label'); l.className = 'av-fam' + (glbClipSel.includes(n) ? ' on' : ''); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = glbClipSel.includes(n); c.addEventListener('change', () => { glbClipSel = c.checked ? glbClipSel.concat(n) : glbClipSel.filter(x => x !== n); l.classList.toggle('on', c.checked); localStorage.setItem('glbclips', JSON.stringify(glbClipSel)); send({ type: 'glbAnims', names: glbClipSel }); }); l.appendChild(c); l.appendChild(document.createTextNode(n)); box.appendChild(l); }); } // Model library: every loaded model is remembered for one-click switching. let glbModels = []; try { glbModels = JSON.parse(localStorage.getItem('glbmodels') || '[]'); } catch (e) {} function saveGlbModels() { localStorage.setItem('glbmodels', JSON.stringify(glbModels)); } function addGlbModel(name, p) { const i = glbModels.findIndex(m => m.path === p); if (i < 0) glbModels.push({ name, path: p }); else glbModels[i].name = name; saveGlbModels(); renderGlbModels(); } function renderGlbModels() { const box = $('#glb-models'); box.innerHTML = ''; if (!glbModels.length) { box.innerHTML = 'I modelli caricati appariranno qui per il cambio rapido.'; return; } glbModels.forEach((mo) => { const row = document.createElement('div'); row.className = 'glb-model' + (mo.path === glbPath ? ' on' : ''); const nm = document.createElement('span'); nm.className = 'gm-name'; nm.textContent = mo.name; nm.title = mo.path; nm.addEventListener('click', () => { glbPath = mo.path; localStorage.setItem('glb3d', glbPath); send({ type: 'glb', path: glbPath }); glbLabel(mo.name); spreadUi(); spreadSend(); renderGlbModels(); }); const del = document.createElement('button'); del.className = 'gm-del'; del.textContent = '✕'; del.title = 'Togli dalla lista (il file resta sul disco)'; del.addEventListener('click', () => { glbModels = glbModels.filter(x => x.path !== mo.path); saveGlbModels(); renderGlbModels(); }); row.appendChild(nm); row.appendChild(del); box.appendChild(row); }); } // Arm spread, saved per model path (chunky models need more room). let glbSpreadMap = {}; try { glbSpreadMap = JSON.parse(localStorage.getItem('glbspread') || '{}'); } catch (e) {} function spreadFor(p) { return glbSpreadMap[p] || 0; } function spreadUi() { const v = spreadFor(glbPath); $('#glb-spread').value = v; $('#glb-spread-val').textContent = v + '°'; } function spreadSend() { send({ type: 'modelSpread', deg: spreadFor(glbPath) }); } function glbLabel(name) { $('#glb-name').textContent = name ? '🧊 ' + name + ' — attiva la famiglia “Modello 3D” per vederlo.' : 'Il modello ruota a tempo di musica nella famiglia “Modello 3D”.'; } if (glbPath) { glbLabel(glbPath.split('/').pop()); if (!glbModels.some(m => m.path === glbPath)) addGlbModel(glbPath.split('/').pop(), glbPath); } renderGlbModels(); // every shader family can be a backdrop too (fluid/interactive/3D excluded: // fluids are the explicit options above, the others can't render as layers) EFFECTS.families.forEach((name, fi) => { if (/^Interattivo|^Modello 3D|^Fluido/.test(name)) return; const o = document.createElement('option'); o.value = 'fam:' + fi; o.textContent = name; $('#glb-bg').appendChild(o); }); $('#glb-bg').value = glbBg; if (!$('#glb-bg').value) { glbBg = 'gradient'; $('#glb-bg').value = 'gradient'; } $('#glb-bg').addEventListener('change', (e) => { glbBg = e.target.value; localStorage.setItem('glbbg', glbBg); send({ type: 'modelBg', mode: glbBg }); }); renderGlbClips([]); spreadUi(); $('#glb-spread').addEventListener('input', (e) => { const v = parseInt(e.target.value, 10) || 0; if (glbPath) { glbSpreadMap[glbPath] = v; localStorage.setItem('glbspread', JSON.stringify(glbSpreadMap)); } $('#glb-spread-val').textContent = v + '°'; send({ type: 'modelSpread', deg: v }); }); $('#btn-anim-load').addEventListener('click', () => $('#anim-input').click()); $('#anim-input').addEventListener('change', async (e) => { for (const f of [...e.target.files]) { const p = djv.pathForFile(f); if (!p) continue; if (/\.glb$/i.test(p)) { if (!glbAnimFiles.includes(p)) glbAnimFiles.push(p); send({ type: 'animAdd', path: p }); } else { glbLabel('⏳ converto ' + f.name + '…'); const res = await djv.convertAnim(p); if (res && res.ok) { if (!glbAnimFiles.includes(res.path)) glbAnimFiles.push(res.path); send({ type: 'animAdd', path: res.path }); glbLabel(glbPath ? glbPath.split('/').pop() : ''); } else { $('#glb-name').textContent = '⚠️ ' + ((res && res.error) || 'conversione fallita'); } } } localStorage.setItem('glbanimfiles', JSON.stringify(glbAnimFiles)); e.target.value = ''; }); function glbBpmLabel() { $('#glb-bpm-val').textContent = glbBpm > 0 ? glbBpm + ' BPM' : 'auto'; } $('#glb-bpm').value = glbBpm; glbBpmLabel(); $('#glb-bpm').addEventListener('change', (e) => { glbBpm = Math.max(0, Math.min(220, parseInt(e.target.value || '0', 10) || 0)); e.target.value = glbBpm; localStorage.setItem('glbbpm', String(glbBpm)); glbBpmLabel(); send({ type: 'modelBpm', bpm: glbBpm }); }); let tapTimes = []; $('#glb-tap').addEventListener('click', () => { const now = performance.now(); if (tapTimes.length && now - tapTimes[tapTimes.length-1] > 2000) tapTimes = []; tapTimes.push(now); if (tapTimes.length >= 4) { const iv = []; for (let i = Math.max(1, tapTimes.length-8); i < tapTimes.length; i++) iv.push(tapTimes[i] - tapTimes[i-1]); const avg = iv.reduce((a, b) => a + b, 0) / iv.length; glbBpm = Math.max(40, Math.min(220, Math.round(60000/avg))); $('#glb-bpm').value = glbBpm; localStorage.setItem('glbbpm', String(glbBpm)); glbBpmLabel(); send({ type: 'modelBpm', bpm: glbBpm }); } }); // ---- 3D effect activation (these families are hidden from the library) ---- const fam3dSel = $('#glb-effect-fam'); const pre3dSel = $('#glb-effect-preset'); EFFECTS.families.forEach((name, i) => { if (!FAM_3D(name)) return; const o = document.createElement('option'); o.value = i; o.textContent = name; fam3dSel.appendChild(o); }); function fill3dPresets() { const fam = parseInt(fam3dSel.value, 10); pre3dSel.innerHTML = ''; EFFECTS.list.forEach((e, i) => { if (e.family !== fam) return; const o = document.createElement('option'); o.value = i; o.textContent = e.name.replace(EFFECTS.families[fam] + ' · ', ''); pre3dSel.appendChild(o); }); } function apply3d() { const e = EFFECTS.list[parseInt(pre3dSel.value, 10)]; if (e) applyEffect(e); } fill3dPresets(); fam3dSel.addEventListener('change', () => { fill3dPresets(); apply3d(); }); pre3dSel.addEventListener('change', apply3d); $('#glb-effect-go').addEventListener('click', apply3d); $('#btn-glb-load').addEventListener('click', () => $('#glb-input').click()); $('#glb-input').addEventListener('change', async (e) => { const f = e.target.files[0]; if (!f) return; let p = djv.pathForFile(f); e.target.value = ''; if (!p) return; // FBX characters (e.g. a Mixamo T-pose) are converted to GLB by Blender if (/\.fbx$/i.test(p)) { glbLabel('⏳ converto ' + f.name + '… (qualche secondo)'); const res = await djv.convertModel(p); if (!res || !res.ok) { $('#glb-name').textContent = '⚠️ ' + ((res && res.error) || 'conversione fallita'); return; } p = res.path; } glbPath = p; localStorage.setItem('glb3d', p); glbSend(); glbLabel(f.name.replace(/\.fbx$/i, '.glb')); addGlbModel(f.name.replace(/\.fbx$/i, '.glb'), p); spreadUi(); }); // --- Effect sequence (playlist of effects with auto-cycle) --- let sequence = []; // { effect, key } let seqIndex = -1; let seqCapturing = -1; let seqDragFrom = -1; let autoCycle = false, onBeat = false, shuffle = false, seqMs = 8000; let autoTimer = null, lastBeatAdvance = 0; function addToSequence(effect) { sequence.push({ effect, key: null }); renderSequence(); } function applySeqIndex(i) { if (i < 0 || i >= sequence.length) return; seqIndex = i; applyEffect(sequence[i].effect); renderSequence(); } function nextInSequence() { if (!sequence.length) return; const n = shuffle ? Math.floor(Math.random() * sequence.length) : (seqIndex + 1) % sequence.length; applySeqIndex(n); } function removeSeq(i) { sequence.splice(i, 1); if (i === seqIndex) seqIndex = -1; else if (i < seqIndex) seqIndex--; renderSequence(); } function moveSeq(from, to) { if (to < 0 || to >= sequence.length) return; const [it] = sequence.splice(from, 1); sequence.splice(to, 0, it); if (seqIndex === from) seqIndex = to; renderSequence(); } function renderSequence() { const ol = $('#effect-seq'); ol.innerHTML = ''; sequence.forEach((s, i) => { const li = document.createElement('li'); li.className = 'track' + (i === seqIndex ? ' playing' : ''); li.draggable = true; li.innerHTML = '⠿' + '' + (i + 1) + '. ' + s.effect.name + '' + '' + '' + ''; li.querySelector('.play-btn').addEventListener('click', () => applySeqIndex(i)); li.querySelector('.del-btn').addEventListener('click', () => removeSeq(i)); li.querySelector('.key-btn').addEventListener('click', () => { seqCapturing = seqCapturing === i ? -1 : i; renderSequence(); }); li.addEventListener('dragstart', () => { seqDragFrom = i; li.classList.add('dragging'); }); li.addEventListener('dragend', () => { seqDragFrom = -1; li.classList.remove('dragging'); }); li.addEventListener('dragover', (e) => e.preventDefault()); li.addEventListener('drop', (e) => { e.preventDefault(); if (seqDragFrom >= 0 && seqDragFrom !== i) moveSeq(seqDragFrom, i); }); ol.appendChild(li); }); $('#seq-count').textContent = sequence.length + ' in sequenza'; } function restartAuto() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (autoCycle && !onBeat) autoTimer = setInterval(nextInSequence, seqMs); } $('#btn-seq-add').addEventListener('click', () => addToSequence(currentEffect)); $('#btn-seq-clear').addEventListener('click', () => { sequence = []; seqIndex = -1; renderSequence(); }); $('#seq-auto').addEventListener('change', (e) => { autoCycle = e.target.checked; restartAuto(); }); $('#seq-beat').addEventListener('change', (e) => { onBeat = e.target.checked; restartAuto(); }); $('#seq-shuffle').addEventListener('change', (e) => { shuffle = e.target.checked; }); $('#seq-interval').addEventListener('input', (e) => { seqMs = parseFloat(e.target.value) * 1000; $('#seq-interval-val').textContent = parseFloat(e.target.value).toFixed(1) + 's'; restartAuto(); }); // ---------------------------------------------------------------- audio function togglePlayPause() { // If the track finished, restart it; otherwise pause/resume. if (ended && currentIndex >= 0) { playIndex(currentIndex); return; } if (segTimer) { // a visual segment (interlude/gap) is running — pause/resume it if (segPaused) { resumeSeg(); isPlaying = true; $('#btn-play').textContent = '⏸ Pausa'; } else { pauseSeg(); isPlaying = false; $('#btn-play').textContent = '▶ Play'; } renderPlaylist(); return; } send({ type: 'togglePlay' }); } $('#btn-play').addEventListener('click', togglePlayPause); // ---------------------------------------------------------------- playlist let playlist = []; // { path, name, key } let currentIndex = -1; let repeat = false; let isPlaying = false; // whether the current track is playing let ended = false; // true when the current track reached its end let playbackOwner = 'playlist'; // 'playlist' or 'pad' — who started playback const durations = {}; // path -> seconds (probed from the output window) const peaksCache = {}; // path -> Array(N) of 0..1 peak amplitudes (or 'loading') const WAVE_BUCKETS = 400; let playCur = 0, playDur = 0; // live progress of the currently playing track // A low-rate AudioContext used only to decode files into waveform peaks. let _peakCtx = null; function peakCtx() { if (!_peakCtx) { const AC = window.AudioContext || window.webkitAudioContext; try { _peakCtx = new AC({ sampleRate: 8000 }); } catch (e) { _peakCtx = new AC(); } } return _peakCtx; } function peaksFromAudioBuffer(buf, buckets) { const ch = buf.getChannelData(0); const n = ch.length, per = Math.max(1, Math.floor(n / buckets)); const peaks = new Array(buckets).fill(0); let top = 0.0001; for (let b = 0; b < buckets; b++) { let m = 0; const s = b * per, e = Math.min(n, s + per); for (let i = s; i < e; i++) { const v = ch[i] < 0 ? -ch[i] : ch[i]; if (v > m) m = v; } peaks[b] = m; if (m > top) top = m; } for (let b = 0; b < buckets; b++) peaks[b] = Math.min(1, peaks[b] / top); return peaks; } // Decode a file's waveform in the renderer via Web Audio (no external ffmpeg). function decodePeaksWebAudio(path) { return djv.readFile(path).then((bytes) => { if (!bytes || !bytes.byteLength) throw new Error('no bytes'); const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); return peakCtx().decodeAudioData(ab); }).then((audioBuf) => peaksFromAudioBuffer(audioBuf, WAVE_BUCKETS)); } // Lazily compute the waveform peaks for a file, then redraw its card. Tries // Web Audio first (works everywhere); falls back to ffmpeg in main if present. function ensurePeaks(path) { if (!path || peaksCache[path]) return; peaksCache[path] = 'loading'; decodePeaksWebAudio(path) .then((p) => { peaksCache[path] = p; renderPlaylist(); }) .catch(() => djv.peaks(path, WAVE_BUCKETS) .then((p) => { peaksCache[path] = (p && p.length) ? p : null; renderPlaylist(); }) .catch(() => { peaksCache[path] = null; })); } // Draw a waveform on a canvas, shading the trimmed-out parts and the playhead. function drawWave(canvas, tr, isCur) { const peaks = peaksCache[tr.path]; const ctx = canvas.getContext('2d'); const w = canvas.width, h = canvas.height, mid = h / 2; ctx.clearRect(0, 0, w, h); if (!Array.isArray(peaks)) { // still loading / unavailable ctx.fillStyle = 'rgba(255,255,255,0.06)'; ctx.fillRect(0, mid - 1, w, 2); return; } const dur = durations[tr.path] || 0; const s = tr.start || 0; const e = tr.end > 0 ? tr.end : dur; const sx = dur > 0 ? (s / dur) * w : 0; const ex = dur > 0 ? (e / dur) * w : w; const N = peaks.length; for (let x = 0; x < w; x++) { const pk = peaks[Math.min(N - 1, Math.floor(x / w * N))]; const bar = Math.max(1, pk * (h * 0.46)); const inTrim = x >= sx && x <= ex; ctx.fillStyle = inTrim ? 'rgba(140,182,255,0.85)' : 'rgba(140,182,255,0.18)'; ctx.fillRect(x, mid - bar, 1, bar * 2); } // trim handles if (dur > 0) { ctx.fillStyle = '#6ee7a0'; ctx.fillRect(sx, 0, 2, h); // start (green) ctx.fillStyle = '#ff9a9a'; ctx.fillRect(ex - 2, 0, 2, h); // end (red) } // playhead if (isCur && playDur > 0) { const px = Math.min(w, (playCur / playDur) * w); ctx.fillStyle = '#ffffff'; ctx.fillRect(px, 0, 1.5, h); } } // Clamp and persist a track's start/end; push it live to the output if playing. function commitTrim(tr, i, rerender) { const dur = durations[tr.path] || 0; tr.start = Math.max(0, tr.start || 0); if (dur > 0 && tr.start > dur) tr.start = dur; if (tr.end > 0) { if (dur > 0 && tr.end > dur) tr.end = dur; if (tr.end <= tr.start + 0.2) tr.end = Math.min(dur || tr.start + 1, tr.start + 1); } savePlaylistState(); if (i === currentIndex) send({ type: 'setTrim', start: tr.start || 0, end: tr.end || 0 }); if (rerender) renderPlaylist(); } // Wire the trim inputs, the "set to current" buttons and waveform drag handles. function wireTrim(li, canvas, tr, i) { const sIn = li.querySelector('.trim-start'); const eIn = li.querySelector('.trim-end'); sIn.addEventListener('change', () => { tr.start = parseTime(sIn.value); commitTrim(tr, i, true); }); eIn.addEventListener('change', () => { tr.end = eIn.value.trim() ? parseTime(eIn.value) : 0; commitTrim(tr, i, true); }); li.querySelector('.trim-here-s').addEventListener('click', () => { if (i === currentIndex) { tr.start = Math.max(0, playCur); commitTrim(tr, i, true); } }); li.querySelector('.trim-here-e').addEventListener('click', () => { if (i === currentIndex) { tr.end = Math.max(0.5, playCur); commitTrim(tr, i, true); } }); li.querySelector('.trim-reset').addEventListener('click', () => { tr.start = 0; tr.end = 0; commitTrim(tr, i, true); }); // Pointer on the waveform: grab a trim handle if near one, otherwise SEEK — // scrub the playhead on the current track, or start a stopped track from there. const HANDLE_PX = 10; let mode = null; const dur = () => durations[tr.path] || (i === currentIndex ? playDur : 0) || 0; const xToTime = (e) => { const rect = canvas.getBoundingClientRect(); return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * dur(); }; const scrub = (t) => { if (i !== currentIndex) return; // preview seek only for the playing track playCur = t; send({ type: 'seek', time: t }); drawWave(canvas, tr, true); }; canvas.addEventListener('pointerdown', (e) => { const d = dur(); const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const sx = d ? (tr.start || 0) / d * rect.width : 0; const ex = d ? (tr.end > 0 ? tr.end : d) / d * rect.width : rect.width; if (d && Math.abs(x - sx) <= HANDLE_PX) mode = 'start'; else if (d && Math.abs(x - ex) <= HANDLE_PX) mode = 'end'; else mode = 'seek'; canvas.setPointerCapture(e.pointerId); e.preventDefault(); if (mode === 'seek') scrub(xToTime(e)); }); canvas.addEventListener('pointermove', (e) => { if (!mode) return; const t = xToTime(e); if (mode === 'start') { tr.start = Math.max(0, Math.min(t, (tr.end > 0 ? tr.end : dur()) - 0.5)); } else if (mode === 'end') { tr.end = Math.min(dur(), Math.max(t, (tr.start || 0) + 0.5)); } else { scrub(t); return; } drawWave(canvas, tr, i === currentIndex); sIn.value = fmtTime(Math.floor(tr.start || 0)); eIn.value = tr.end > 0 ? fmtTime(Math.floor(tr.end)) : ''; }); const endDrag = (e) => { if (!mode) return; if (mode === 'seek') { const t = xToTime(e); if (i === currentIndex) scrub(t); else playIndex(i, t); // start a stopped track from the clicked point } else { commitTrim(tr, i, true); } mode = null; }; canvas.addEventListener('pointerup', endDrag); canvas.addEventListener('pointercancel', () => { mode = null; }); } let padCrossfadeMs = 0; // crossfade duration for pad triggers let capturingFor = -1; // index of the track awaiting a hotkey, or -1 let sceneEditing = -1; // playlist index whose scene editor is open let sceneImgTarget = -1, sceneCueTarget = -1; // track/cue awaiting an image let activeCues = [], firedCue = -1; // cue schedule for the playing track function serializePlaylist() { return playlist.map(t => ({ path: t.path, name: t.name, key: t.key || null, cues: t.cues || [], isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0, start: t.start || 0, end: t.end || 0 })); } function normalizeTrack(t) { return { path: t.path || '', name: t.name || baseName(t.path || '') || 'Intermezzo', key: t.key || null, cues: migrateCues(Array.isArray(t.cues) ? t.cues : []), isVideo: !!t.isVideo, gap: t.gap || 0, isInterlude: !!t.isInterlude, duration: t.duration || 0, start: t.start || 0, end: t.end || 0 }; } function savePlaylistState() { if (djv.savePlaylist) djv.savePlaylist(serializePlaylist()); } function hasScene(tr) { return !!(tr.cues && tr.cues.some(c => (c.type === 'effect' && c.effectIndex != null) || (c.type === 'text' && c.text && c.text.trim()) || (c.type === 'image' && c.image))); } function parseTime(str) { str = String(str).trim(); if (str.indexOf(':') >= 0) { const p = str.split(':').map(Number); return (p[0] || 0) * 60 + (p[1] || 0); } return parseFloat(str) || 0; } // ---- Scene model ----------------------------------------------------------- // A track scene is a list of timed ELEMENTS, each of type 'effect' | 'text' | // 'image' with its OWN appearance time and optional visible duration. The three // channels are independent: an effect, a text and an image each appear/disappear // on their own schedule. dur 0 = stays until the next element of the same type. const TXT_FONTS = [ ['-apple-system, BlinkMacSystemFont, sans-serif', 'Sistema'], ["'Arial Black', Impact, sans-serif", 'Arial Black'], ['Impact, sans-serif', 'Impact'], ["Georgia, 'Times New Roman', serif", 'Georgia'], ["'Courier New', monospace", 'Monospace'], ["'Brush Script MT', 'Snell Roundhand', cursive", 'Corsivo'], ['Verdana, Geneva, sans-serif', 'Verdana'], ["'Trebuchet MS', sans-serif", 'Trebuchet'], ]; const TXT_DIRS = [['h', '➡️ Orizzontale'], ['vup', '⬆️ Vert. su'], ['vdown', '⬇️ Vert. giù'], ['sides', '↕️ Bordi laterali']]; const TXT_FX = [['none', 'Nessuno'], ['updown', 'Su e giù'], ['wave', 'Onda'], ['zoom', 'Zoom'], ['flash', 'Flash'], ['rotate', 'Rotazione']]; const TXT_POS = [['bottom', 'Basso'], ['top', 'Alto'], ['middle', 'Centro']]; const IMG_POS = { center: { x: 50, y: 50 }, top: { x: 50, y: 18 }, bottom: { x: 50, y: 82 }, left: { x: 20, y: 50 }, right: { x: 80, y: 50 } }; const IMG_POS_LABELS = { center: 'Centro', top: 'Alto', bottom: 'Basso', left: 'Sinistra', right: 'Destra' }; function optList(pairs, sel) { return pairs.map(([v, l]) => '').join(''); } function newEffectEl(time) { return { type: 'effect', time, effectIndex: EFFECTS.list.indexOf(currentEffect), effectName: currentEffect.name, 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 }; } function newImageEl(time) { return { type: 'image', time, image: null, imageSize: 60, imagePos: 'center', dur: 0 }; } // Convert legacy combined cues into the new per-element list (idempotent). function migrateCues(cues) { if (!Array.isArray(cues) || cues.length === 0) return cues || []; if (cues.every(c => c && c.type)) return cues; const out = []; cues.forEach(c => { if (c && c.type) { out.push(c); return; } const t = (c && c.time) || 0; if (c.effectIndex != null) out.push({ type: 'effect', time: t, effectIndex: c.effectIndex, effectName: c.effectName || '', dur: 0 }); if (c.text && c.text.trim()) { const s = c.textStyle || {}; out.push({ type: 'text', time: t, text: c.text, dir: s.dir || 'h', fx: s.fx || 'none', font: s.font || TXT_FONTS[0][0], size: s.size || 6, weight: s.weight != null ? s.weight : true, color: s.color || '#ffffff', pos: 'bottom', speed: 1, dur: 0 }); } if (c.image) out.push({ type: 'image', time: t, image: c.image, imageSize: c.imageSize || 60, imagePos: c.imagePos || 'center', dur: 0 }); }); return out; } // Runtime: re-evaluate which element is active per channel and diff-apply it. let lastScene = { effect: null, text: null, image: null }; let userTrackBlend = 'normal'; // playlist-video blend chosen by the user let sentTrackBlend = null; // last blend pushed to the output (auto or manual) function startCues(tr) { tr.cues = migrateCues(tr.cues || []); activeCues = tr.cues.slice().sort((a, b) => a.time - b.time); sentTrackBlend = null; // re-evaluate auto-blend for the new track // `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 }; advanceCues(0); } function sceneActive(type, t) { let best = null; for (const el of activeCues) { if (el.type !== type || el.time > t) continue; if (el.dur && el.time + el.dur <= t) continue; if (!best || el.time >= best.time) best = el; } return best; } function advanceCues(t) { const ef = sceneActive('effect', t); if (ef !== lastScene.effect) { lastScene.effect = ef; if (ef && EFFECTS.list[ef.effectIndex]) applyEffect(EFFECTS.list[ef.effectIndex]); } // Auto-blend: on a video track, when a scene effect is active, blend the video // ('screen') so the effect shows through underneath. Only when the user left // the blend at the default 'normal' — a manual choice is always respected. const curTr = playlist[currentIndex]; if (curTr && curTr.isVideo) { const effOn = !!(ef && ef.effectIndex != null && EFFECTS.list[ef.effectIndex]); 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); } } function applyTextEl(el) { const show = !!(el && el.text && el.text.trim()); send({ type: 'tickerText', text: show ? el.text : '' }); send({ type: 'tickerOn', on: show }); $('#ticker-text').value = show ? el.text : ''; $('#ticker-on').checked = show; if (show) { send({ type: 'tickerDir', value: el.dir }); send({ type: 'tickerFx', value: el.fx }); send({ type: 'tickerFont', value: el.font }); send({ type: 'tickerSize', value: el.size }); send({ type: 'tickerWeight', on: el.weight }); send({ type: 'tickerColor', value: el.color }); send({ type: 'tickerPos', pos: el.pos }); send({ type: 'tickerSpeed', mult: el.speed }); } } function applyImageEl(el) { if (el && el.image) { const p = IMG_POS[el.imagePos || 'center'] || IMG_POS.center; send({ type: 'sceneImage', path: el.image, size: el.imageSize || 60, x: p.x, y: p.y }); } else { send({ type: 'sceneImage', path: null }); } } // Live preview: when editing the scene of the current track, re-apply at the // current playback position so changes show immediately on the output. 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 }; advanceCues(playCur || 0); } const baseName = (p) => p.split('/').pop(); function probePaths(paths) { const todo = paths.filter(p => p && !(p in durations)); if (todo.length) send({ type: 'probeDurations', paths: todo }); } function addTracks(items) { // items: array of { path, name } const start = playlist.length; items.forEach(it => { if (it.path) playlist.push({ path: it.path, name: it.name || baseName(it.path), key: null, cues: [], isVideo: !!it.isVideo, gap: 0, start: 0, end: 0 }); }); renderPlaylist(); savePlaylistState(); probePaths(items.map(it => it.path)); // Auto-start the first added track if nothing is playing yet. if (currentIndex < 0 && playlist.length > start) playIndex(start); } // Add a visual-only interlude item (effects/text/image between tracks). function addInterlude() { playlist.push({ path: '', name: 'Intermezzo', key: null, cues: [], isVideo: false, isInterlude: true, duration: 15, gap: 0 }); sceneEditing = playlist.length - 1; // open its scene editor right away renderPlaylist(); savePlaylistState(); } function playIndex(i, fromTime) { if (i < 0 || i >= playlist.length) return; clearGap(); const tr = playlist[i]; currentIndex = i; isPlaying = true; ended = false; playbackOwner = 'playlist'; activePad = -1; padPlaying = false; renderPads(); $('#btn-play').disabled = false; $('#btn-play').textContent = '⏸ Pausa'; if (tr.isInterlude) { playInterlude(tr); return; } // fromTime (set by clicking the waveform) overrides the trim start for this run. const startAt = (fromTime != null) ? Math.max(0, fromTime) : (tr.start || 0); send({ type: tr.isVideo ? 'playVideoTrack' : 'playTrack', path: tr.path, start: startAt, end: tr.end || 0 }); startCues(tr); renderPlaylist(); } // Visual-only interlude: silence audio, run this item's scene for its duration. function playInterlude(tr) { send({ type: 'playSilence' }); startCues(tr); const dur = Math.max(1, tr.duration || 15); runSeg('interlude', dur, 0, () => { if (tr.gap > 0) startGap(tr, tr.gap); else if (!nextTrack()) stopPlaylist(); }); } function nextTrack() { if (!playlist.length) return false; let n = currentIndex + 1; if (n >= playlist.length) { if (!repeat) return false; n = 0; } playIndex(n); return true; } function stopPlaylist() { isPlaying = false; ended = true; $('#btn-play').textContent = '▶ Play'; renderPlaylist(); } // Timed visual segment: drives the scene timeline with no audio for `durSecs`, // then runs onDone. Used both for inter-track gaps ('gap', continuing the // previous scene from `base`) and for visual-only interlude items ('interlude', // their own scene from base 0). Supports pause/resume via the Play button. let segTimer = null, segMode = null, segRemain = 0; let segDur = 0, segBase = 0, segElapsed = 0, segT0 = 0, segPaused = false, segDone = null; function clearGap() { if (segTimer) { clearInterval(segTimer); segTimer = null; } segMode = null; segPaused = false; segRemain = 0; } const clearSeg = clearGap; function segTick() { if (segPaused) return; const elapsed = segElapsed + (Date.now() - segT0) / 1000; segRemain = Math.max(0, segDur - elapsed); playCur = Math.min(segDur, elapsed); playDur = segDur; advanceCues(segBase + elapsed); renderPlaylist(); if (elapsed >= segDur) { const d = segDone; clearSeg(); if (d) d(); } } function runSeg(mode, durSecs, base, onDone) { clearSeg(); segMode = mode; segDur = durSecs; segBase = base; segDone = onDone; segElapsed = 0; segT0 = Date.now(); segPaused = false; segTimer = setInterval(segTick, 200); segTick(); } function pauseSeg() { if (segTimer && !segPaused) { segElapsed += (Date.now() - segT0) / 1000; segPaused = true; } } function resumeSeg() { if (segTimer && segPaused) { segT0 = Date.now(); segPaused = false; } } function startGap(tr, secs) { runSeg('gap', secs, playDur || playCur || 0, () => { if (!nextTrack()) stopPlaylist(); }); } function removeTrack(i) { playlist.splice(i, 1); if (i === currentIndex) currentIndex = -1; else if (i < currentIndex) currentIndex--; if (sceneEditing === i) sceneEditing = -1; renderPlaylist(); savePlaylistState(); } function moveTrack(from, to) { if (to < 0 || to >= playlist.length) return; const [item] = playlist.splice(from, 1); playlist.splice(to, 0, item); if (currentIndex === from) currentIndex = to; else if (from < currentIndex && to >= currentIndex) currentIndex--; else if (from > currentIndex && to <= currentIndex) currentIndex++; renderPlaylist(); savePlaylistState(); } let dragFrom = -1; let cueDragging = false; // a scene-editor element is being dragged let plDirty = false; // a re-render was requested mid-drag let dragMarkEl = null, dragMarkBelow = false; function clearDropMarks() { if (dragMarkEl) { dragMarkEl.classList.remove('drop-above', 'drop-below'); dragMarkEl = null; } } // Build the '; EFFECTS.families.forEach((name, i) => { s += ''; }); return s; } // Build the '; let s = ''; for (let i = 0; i < EFFECTS.list.length; i++) { if (EFFECTS.list[i].family !== fam) continue; s += ''; } return s; } // First preset index of a family (used when a family is chosen). function firstPresetOfFamily(fam) { for (let i = 0; i < EFFECTS.list.length; i++) if (EFFECTS.list[i].family === fam) return i; return -1; } const EL_ICON = { effect: '🌀', text: '🔤', image: '🖼' }; const EL_LABEL = { effect: 'Effetto', text: 'Testo', image: 'Immagine' }; // Build the per-track scene editor: a list of independent timed elements // (effect / text / image), each with its own appearance time and duration. function buildSceneEditor(tr, i, li) { const cues = tr.cues = migrateCues(tr.cues || []); cues.sort((a, b) => a.time - b.time); const ed = document.createElement('li'); ed.className = 'scene-editor'; let html = '