New 'Zona Effetto': each zone runs its own preset on an independent offscreen Visualizer (960x540, own WebGL context), stepped with the shared clock and audio so every panel dances to the same music with a different effect (fluid presets included; interactive/3D excluded). Zone row gains family/preset selectors; engines are created/destroyed as zones change and their canvases uploaded as zone textures each frame. Guide updated with the performance note. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
689 lines
29 KiB
JavaScript
689 lines
29 KiB
JavaScript
// OUTPUT window: renders the visuals + overlays and runs the audio engine.
|
||
// It receives commands from the control window via window.djv.onControl and
|
||
// sends back reports (meters, fps, device list, play state).
|
||
|
||
const $ = (s) => document.querySelector(s);
|
||
|
||
const canvas = $('#gl');
|
||
let viz;
|
||
try {
|
||
viz = new Visualizer(canvas);
|
||
} catch (e) {
|
||
document.body.innerHTML = '<div style="color:#fff;padding:40px;font-family:sans-serif">' +
|
||
'Errore grafica:<br><pre>' + e.message + '</pre></div>';
|
||
throw e;
|
||
}
|
||
|
||
const audio = new AudioEngine();
|
||
let speed = 1.0;
|
||
|
||
// Convert an absolute filesystem path into a file:// URL the renderer can
|
||
// load (Windows: backslashes -> slashes, drive letter needs a leading /).
|
||
function toFileURL(p) {
|
||
let n = String(p).replace(/\\/g, '/');
|
||
if (n[0] !== '/') n = '/' + n;
|
||
return encodeURI('file://' + n).replace(/#/g, '%23').replace(/\?/g, '%3F');
|
||
}
|
||
|
||
// ---------------------------------------------------------------- overlays
|
||
const imgLayer = $('#overlay-images');
|
||
let images = [];
|
||
let imgIndex = 0;
|
||
let slideshow = false, slideshowTimer = null, slideshowMs = 5000;
|
||
let beatPulse = true;
|
||
let imgBlend = 'screen';
|
||
|
||
function addImages(paths) {
|
||
paths.forEach(p => {
|
||
const img = document.createElement('img');
|
||
img.src = toFileURL(p);
|
||
img.style.mixBlendMode = imgBlend;
|
||
imgLayer.appendChild(img);
|
||
images.push(img);
|
||
});
|
||
if (images.length) showImage(images.length - paths.length);
|
||
hideHint();
|
||
}
|
||
function showImage(i) {
|
||
if (!images.length) return;
|
||
imgIndex = (i + images.length) % images.length;
|
||
images.forEach((im, idx) => im.classList.toggle('show', idx === imgIndex));
|
||
}
|
||
function clearImages() {
|
||
images.forEach(im => im.remove());
|
||
images = []; imgIndex = 0;
|
||
}
|
||
function restartSlideshow() {
|
||
if (slideshowTimer) { clearInterval(slideshowTimer); slideshowTimer = null; }
|
||
if (slideshow) slideshowTimer = setInterval(() => showImage(imgIndex + 1), slideshowMs);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- recording
|
||
// Records the output canvas (video) + the audio tap into a WebM stream that
|
||
// main transcodes to MP4 at the chosen aspect ratio.
|
||
let recorder = null, recording = false, pendingRecOpts = null, recAutoStop = null;
|
||
let recCanvas = null, recCtx = null, composeRAF = null;
|
||
|
||
function pickMime() {
|
||
const cands = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
|
||
for (const m of cands) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
|
||
return 'video/webm';
|
||
}
|
||
|
||
// Composite the WebGL visual + DOM overlays (images, logos, ticker) into a 2D
|
||
// canvas so the recording captures everything that's on screen, not just WebGL.
|
||
function drawMediaFit(ctx, media, W, H, fit) {
|
||
const mw = media.videoWidth || media.naturalWidth, mh = media.videoHeight || media.naturalHeight;
|
||
if (!mw || !mh) { ctx.drawImage(media, 0, 0, W, H); return; }
|
||
const scale = fit === 'contain' ? Math.min(W / mw, H / mh) : Math.max(W / mw, H / mh);
|
||
const dw = mw * scale, dh = mh * scale;
|
||
ctx.drawImage(media, (W - dw) / 2, (H - dh) / 2, dw, dh);
|
||
}
|
||
|
||
function composeFrame() {
|
||
const W = recCanvas.width, H = recCanvas.height;
|
||
const sx = W / window.innerWidth, sy = H / window.innerHeight;
|
||
recCtx.globalAlpha = 1;
|
||
recCtx.globalCompositeOperation = 'source-over';
|
||
recCtx.clearRect(0, 0, W, H);
|
||
// Draw the canvas where it actually sits (the LED area may confine it).
|
||
const cr = canvas.getBoundingClientRect();
|
||
recCtx.drawImage(canvas, cr.x * sx, cr.y * sy, cr.width * sx, cr.height * sy);
|
||
if (mapOn && mapping) {
|
||
const mr = mapping.canvas.getBoundingClientRect();
|
||
try { recCtx.drawImage(mapping.canvas, mr.x * sx, mr.y * sy, mr.width * sx, mr.height * sy); } catch (e) {}
|
||
}
|
||
|
||
if (bgVideo.classList.contains('show') && bgVideo.readyState >= 2 && bgVideo.videoWidth) {
|
||
recCtx.globalAlpha = parseFloat(getComputedStyle(bgVideo).opacity) || 1;
|
||
const bm = bgVideo.style.mixBlendMode;
|
||
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
|
||
try { drawMediaFit(recCtx, bgVideo, W, H, bgVideo.style.objectFit || 'cover'); } catch (e) {}
|
||
recCtx.globalAlpha = 1;
|
||
recCtx.globalCompositeOperation = 'source-over';
|
||
}
|
||
|
||
if (trackVideo.classList.contains('show') && trackVideo.readyState >= 2 && trackVideo.videoWidth) {
|
||
recCtx.globalAlpha = parseFloat(getComputedStyle(trackVideo).opacity) || 1;
|
||
const bm = trackVideo.style.mixBlendMode;
|
||
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
|
||
try { drawMediaFit(recCtx, trackVideo, W, H, trackVideo.style.objectFit || 'contain'); } catch (e) {}
|
||
recCtx.globalAlpha = 1;
|
||
recCtx.globalCompositeOperation = 'source-over';
|
||
}
|
||
|
||
for (const im of images) {
|
||
if (!im.classList.contains('show')) continue;
|
||
const r = im.getBoundingClientRect();
|
||
recCtx.globalAlpha = parseFloat(getComputedStyle(im).opacity) || 1;
|
||
const bm = im.style.mixBlendMode;
|
||
recCtx.globalCompositeOperation = (bm && bm !== 'normal') ? bm : 'source-over';
|
||
try { recCtx.drawImage(im, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
|
||
}
|
||
recCtx.globalAlpha = 1;
|
||
recCtx.globalCompositeOperation = 'source-over';
|
||
|
||
if (sceneImage.classList.contains('show') && sceneImage.src) {
|
||
const r = sceneImage.getBoundingClientRect();
|
||
try { recCtx.drawImage(sceneImage, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
|
||
}
|
||
|
||
for (const l of logos) {
|
||
if (!l.classList.contains('show')) continue;
|
||
const r = l.getBoundingClientRect();
|
||
recCtx.globalAlpha = parseFloat(l.style.opacity || '1');
|
||
try { recCtx.drawImage(l, r.x * sx, r.y * sy, r.width * sx, r.height * sy); } catch (e) {}
|
||
}
|
||
recCtx.globalAlpha = 1;
|
||
|
||
if (ticker.classList.contains('show') || sideText.classList.contains('show')) {
|
||
recCtx.textBaseline = 'middle';
|
||
for (const span of tickerLetters()) {
|
||
const txt = span.textContent;
|
||
if (!txt || txt === ' ') continue;
|
||
const r = span.getBoundingClientRect();
|
||
if (r.right < 0 || r.left > window.innerWidth) continue;
|
||
const cs = getComputedStyle(span);
|
||
recCtx.globalAlpha = parseFloat(cs.opacity) || 1;
|
||
recCtx.font = cs.fontWeight + ' ' + (parseFloat(cs.fontSize) * sy) + 'px ' + cs.fontFamily;
|
||
recCtx.fillStyle = cs.color;
|
||
recCtx.shadowColor = 'rgba(140,180,255,0.9)';
|
||
recCtx.shadowBlur = 14 * sy;
|
||
recCtx.fillText(txt, r.x * sx, (r.y + r.height / 2) * sy);
|
||
recCtx.shadowBlur = 0;
|
||
}
|
||
recCtx.globalAlpha = 1;
|
||
}
|
||
composeRAF = requestAnimationFrame(composeFrame);
|
||
}
|
||
|
||
function stopCompose() {
|
||
if (composeRAF) { cancelAnimationFrame(composeRAF); composeRAF = null; }
|
||
}
|
||
|
||
async function startRecording(opts) {
|
||
if (recording) return;
|
||
try {
|
||
await djv.recStart();
|
||
recCanvas = document.createElement('canvas');
|
||
recCanvas.width = canvas.width;
|
||
recCanvas.height = canvas.height;
|
||
recCtx = recCanvas.getContext('2d');
|
||
composeFrame();
|
||
const vstream = recCanvas.captureStream(30);
|
||
const astream = audio.recordDest.stream;
|
||
const stream = new MediaStream([...vstream.getVideoTracks(), ...astream.getAudioTracks()]);
|
||
recorder = new MediaRecorder(stream, { mimeType: pickMime(), videoBitsPerSecond: 12e6 });
|
||
// Serialise chunk delivery: ondataavailable is async, so without a chain the
|
||
// header chunk could be sent after a later one and corrupt the WebM.
|
||
let chain = Promise.resolve();
|
||
recorder.ondataavailable = (e) => {
|
||
if (!e.data || !e.data.size) return;
|
||
chain = chain.then(async () => djv.recChunk(new Uint8Array(await e.data.arrayBuffer())));
|
||
};
|
||
recorder.onstop = async () => {
|
||
stopCompose();
|
||
await chain; // ensure every chunk is flushed, in order, before muxing
|
||
const res = await djv.recStop(pendingRecOpts || {});
|
||
recording = false;
|
||
djv.report({ type: 'recState', recording: false });
|
||
if (res && res.ok) djv.report({ type: 'recSaved', path: res.path, name: res.name, url: res.url });
|
||
else djv.report({ type: 'recError', message: (res && res.error) || 'errore sconosciuto' });
|
||
};
|
||
recorder.start(2000); // emit a chunk every 2s
|
||
recording = true;
|
||
// Reel mode: auto-stop after maxMs using the format chosen at start.
|
||
if (opts && opts.maxMs > 0) {
|
||
clearTimeout(recAutoStop);
|
||
recAutoStop = setTimeout(() => stopRecording({ w: opts.w, h: opts.h }), opts.maxMs);
|
||
}
|
||
djv.report({ type: 'recState', recording: true });
|
||
} catch (e) {
|
||
stopCompose();
|
||
djv.report({ type: 'recError', message: e.message });
|
||
}
|
||
}
|
||
|
||
function stopRecording(opts) {
|
||
clearTimeout(recAutoStop); recAutoStop = null;
|
||
if (!recording || !recorder) return;
|
||
pendingRecOpts = { w: opts && opts.w, h: opts && opts.h };
|
||
recorder.stop();
|
||
}
|
||
|
||
// Custom SVG/image source for the "SVG/Immagine" effect family. The image
|
||
// arrives as a data: URL (same-origin) so the canvas isn't tainted and the
|
||
// pixels can be uploaded to a WebGL texture.
|
||
function loadCustomTexture(dataUrl) {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const size = 1024;
|
||
const cv = document.createElement('canvas');
|
||
cv.width = cv.height = size;
|
||
const ctx = cv.getContext('2d');
|
||
const iw = img.width || 512, ih = img.height || 512;
|
||
const s = Math.min(size / iw, size / ih);
|
||
const w = iw * s, h = ih * s;
|
||
ctx.clearRect(0, 0, size, size);
|
||
ctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
|
||
try { viz.setTexture(cv); hideHint(); }
|
||
catch (e) { djv.report({ type: 'error', message: 'Texture SVG: ' + e.message }); }
|
||
};
|
||
img.onerror = () => djv.report({ type: 'error', message: 'SVG/immagine non caricata' });
|
||
img.src = dataUrl;
|
||
}
|
||
|
||
const bgVideo = $('#bg-video');
|
||
const trackVideo = $('#track-video');
|
||
const sceneImage = $('#scene-image');
|
||
|
||
// Two independent logos.
|
||
const logos = [$('#logo-0'), $('#logo-1')];
|
||
function setLogo(i, url) {
|
||
if (!logos[i]) return;
|
||
if (url) { logos[i].src = url; logos[i].classList.add('show'); hideHint(); }
|
||
else { logos[i].classList.remove('show'); logos[i].removeAttribute('src'); }
|
||
}
|
||
|
||
const ticker = $('#ticker');
|
||
const tickerTrack = $('#ticker-track');
|
||
const tickCopies = ticker.querySelectorAll('.tick-copy');
|
||
const sideText = $('#side-text');
|
||
const sideCopies = sideText.querySelectorAll('.side-copy');
|
||
let tickerVisible = false, tickerDir = 'h';
|
||
function buildLetters(txt) {
|
||
const full = (txt || '') + ' • ';
|
||
let html = '';
|
||
for (let i = 0; i < full.length; i++) {
|
||
const c = full[i];
|
||
const ch = c === ' ' ? ' ' : c.replace(/&/g, '&').replace(/</g, '<');
|
||
html += '<span class="tl" style="--i:' + i + '">' + ch + '</span>';
|
||
}
|
||
return html;
|
||
}
|
||
function setTickerText(t) {
|
||
const h = buildLetters(t);
|
||
tickCopies.forEach(c => c.innerHTML = h);
|
||
sideCopies.forEach(c => c.innerHTML = h);
|
||
}
|
||
function setTickerSpeed(m) { document.documentElement.style.setProperty('--ticker-dur', (18 / m) + 's'); }
|
||
function tickerLetters() {
|
||
let out = [];
|
||
if (ticker.classList.contains('show')) out = out.concat([...tickerTrack.querySelectorAll('.tl')]);
|
||
if (sideText.classList.contains('show')) out = out.concat([...sideText.querySelectorAll('.tl')]);
|
||
return out;
|
||
}
|
||
function styleBoth(prop, value) { tickerTrack.style[prop] = value; sideText.style[prop] = value; }
|
||
function updateTickerVis() {
|
||
const sides = tickerDir === 'sides';
|
||
ticker.classList.toggle('show', tickerVisible && !sides);
|
||
sideText.classList.toggle('show', tickerVisible && sides);
|
||
}
|
||
ticker.classList.add('pos-bottom');
|
||
ticker.classList.add('dir-h');
|
||
setTickerSpeed(1);
|
||
|
||
function hideHint() { $('#drop-hint').classList.add('hidden'); }
|
||
|
||
// ---------------------------------------------------------------- audio devices
|
||
// Probe duration (metadata only) for a list of file paths.
|
||
function probeDurations(paths) {
|
||
if (!paths.length) return;
|
||
const results = [];
|
||
let pending = paths.length;
|
||
const finish = () => { if (--pending === 0) djv.report({ type: 'durations', list: results }); };
|
||
paths.forEach(p => {
|
||
// A <video> element reads duration for both audio and video files.
|
||
const a = document.createElement('video');
|
||
a.preload = 'metadata';
|
||
a.onloadedmetadata = () => { results.push({ path: p, duration: isFinite(a.duration) ? a.duration : 0 }); finish(); };
|
||
a.onerror = () => { results.push({ path: p, duration: 0 }); finish(); };
|
||
a.src = toFileURL(p);
|
||
});
|
||
}
|
||
|
||
async function reportDevices() {
|
||
try {
|
||
const inputs = await audio.listInputDevices();
|
||
djv.report({ type: 'devices', list: inputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
|
||
const outputs = await audio.listOutputDevices();
|
||
djv.report({ type: 'outputs', list: outputs.map(d => ({ deviceId: d.deviceId, label: d.label })) });
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
// ---------------------------------------------------------------- command bus
|
||
djv.onControl(async (m) => {
|
||
switch (m.type) {
|
||
case 'effect':
|
||
// A manual pick takes over from the Auto VJ.
|
||
if (autoVj) { autoVj = false; djv.report({ type: 'autoVj', on: false }); }
|
||
viz.setEffect(m.effect);
|
||
break;
|
||
case 'autoVj':
|
||
autoVj = !!m.on;
|
||
if (m.mode) avMode = m.mode;
|
||
avFamilies = Array.isArray(m.families) ? m.families : avFamilies;
|
||
if (autoVj) { avLastSwitch = 0; avBeatMark = audio.beatCount || 0; }
|
||
djv.report({ type: 'autoVj', on: autoVj });
|
||
break;
|
||
case 'svg': loadCustomTexture(m.dataUrl); break;
|
||
case 'modelBg': viz.modelBg = m.mode || 'gradient'; break;
|
||
case 'mapZones':
|
||
try {
|
||
ensureMapping().setZones((m.zones || []).map(z => Object.assign({}, z,
|
||
{ src: z.src && z.src.type === 'image'
|
||
? { type: 'image', url: toFileURL(z.src.path), path: z.src.path }
|
||
: (z.src && z.src.type === 'effect'
|
||
? { type: 'effect', effectIndex: z.src.effectIndex }
|
||
: { type: 'visual' }) })));
|
||
} catch (e) { djv.report({ type: 'error', message: 'Mappatura: ' + e.message }); }
|
||
break;
|
||
case 'mapOn':
|
||
mapOn = !!m.on;
|
||
$('#map-canvas').classList.toggle('show', mapOn);
|
||
break;
|
||
case 'mapEdit':
|
||
if (mapping) mapping.editOn = !!m.on;
|
||
$('#map-canvas').classList.toggle('edit', !!m.on);
|
||
break;
|
||
case 'glbAnims': viz.setClipFilter(m.names || []); break;
|
||
case 'modelBpm': viz.setManualBpm(m.bpm || 0); break;
|
||
case 'modelSpread': viz.setArmSpread(m.deg || 0); break;
|
||
case 'animAdd':
|
||
// extra animation GLB for the dance library
|
||
try {
|
||
const b = await djv.readFile(m.path);
|
||
if (!b) throw new Error('file non leggibile');
|
||
const names = viz.addAnimLibrary(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
||
reportClips();
|
||
if (names.length) djv.report({ type: 'animAdded', names });
|
||
} catch (e) { djv.report({ type: 'error', message: 'Animazione: ' + e.message }); }
|
||
break;
|
||
case 'glb':
|
||
try {
|
||
const bytes = await djv.readFile(m.path);
|
||
if (!bytes) throw new Error('file non leggibile');
|
||
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||
const ok = viz.setModelData(buf);
|
||
if (ok === false) throw new Error((viz.model3d && viz.model3d.loadError) || 'GLB non valido');
|
||
djv.report({ type: 'glbLoaded', name: m.path.split(/[\\/]/).pop() });
|
||
reportClips();
|
||
hideHint();
|
||
} catch (e) { djv.report({ type: 'error', message: 'GLB: ' + e.message }); }
|
||
break;
|
||
case 'ledArea': {
|
||
// Confine the whole show to a W×H rectangle at X,Y (LED pixel mapping).
|
||
const st = $('#stage').style;
|
||
if (m.on && m.w > 0 && m.h > 0) {
|
||
st.left = (m.x || 0) + 'px'; st.top = (m.y || 0) + 'px';
|
||
st.width = m.w + 'px'; st.height = m.h + 'px';
|
||
} else {
|
||
st.left = st.top = '0px';
|
||
st.width = st.height = '';
|
||
}
|
||
viz.resize();
|
||
break;
|
||
}
|
||
case 'recStart': startRecording(m); break;
|
||
case 'recStop': stopRecording(m); break;
|
||
case 'gain': audio.gain = m.value; break;
|
||
case 'speed': speed = m.value; audio.scrollRate = m.value; break;
|
||
case 'bandGain': audio[m.band + 'Gain'] = m.value; break;
|
||
case 'outputDevice':
|
||
try { await audio.setOutputDevice(m.deviceId || ''); }
|
||
catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||
break;
|
||
|
||
case 'loadFile':
|
||
try {
|
||
await audio.loadFile(toFileURL(m.path));
|
||
hideHint();
|
||
djv.report({ type: 'fileLoaded' });
|
||
djv.report({ type: 'playState', playing: true });
|
||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||
break;
|
||
case 'playTrack':
|
||
try {
|
||
// Switching to an audio track: stop/hide any playing video track.
|
||
trackVideo.pause(); trackVideo.classList.remove('show');
|
||
audio.setTrim(m.start || 0, m.end || 0);
|
||
audio.onEnded = () => djv.report({ type: 'trackEnded' });
|
||
await audio.loadFile(toFileURL(m.path), { loop: false, crossfade: m.crossfade || 0 });
|
||
hideHint();
|
||
djv.report({ type: 'playState', playing: true });
|
||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||
break;
|
||
case 'playVideoTrack':
|
||
try {
|
||
audio.setTrim(m.start || 0, m.end || 0);
|
||
audio.onEnded = () => djv.report({ type: 'trackEnded' });
|
||
audio.attachVideo(trackVideo);
|
||
trackVideo.loop = false; trackVideo.muted = false;
|
||
// Force a fresh start even when re-selecting the same file:
|
||
// reassigning the same src may not reload, so pause + load() resets it.
|
||
trackVideo.pause();
|
||
trackVideo.src = toFileURL(m.path);
|
||
trackVideo.load();
|
||
trackVideo.classList.add('show');
|
||
await trackVideo.play();
|
||
audio.seekToTrimStart(trackVideo);
|
||
hideHint();
|
||
djv.report({ type: 'playState', playing: true });
|
||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||
break;
|
||
case 'playSilence':
|
||
// Visual-only interlude: stop audio + hide the video, keep visuals going.
|
||
try {
|
||
trackVideo.pause(); trackVideo.classList.remove('show');
|
||
audio.onEnded = null;
|
||
audio.silence();
|
||
hideHint();
|
||
djv.report({ type: 'playState', playing: true });
|
||
} catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||
break;
|
||
case 'setTrim': audio.setTrim(m.start || 0, m.end || 0); break;
|
||
case 'seek': {
|
||
const el = audio.mediaEl;
|
||
if (el && isFinite(el.duration) && el.duration > 0) {
|
||
el.currentTime = Math.max(0, Math.min(el.duration - 0.05, m.time || 0));
|
||
audio._trimFired = false; // re-arm the trim-end check after seeking
|
||
}
|
||
break;
|
||
}
|
||
case 'probeDurations': probeDurations(m.paths || []); break;
|
||
case 'togglePlay': {
|
||
const playing = audio.togglePlay();
|
||
if (playing !== null) djv.report({ type: 'playState', playing });
|
||
break;
|
||
}
|
||
case 'useInput':
|
||
try { await audio.useInput(m.deviceId || null); hideHint(); await reportDevices(); }
|
||
catch (e) { djv.report({ type: 'error', message: e.message }); }
|
||
break;
|
||
case 'refreshDevices': reportDevices(); break;
|
||
|
||
case 'addImages': addImages(m.paths); break;
|
||
case 'clearImages': clearImages(); break;
|
||
case 'imgBlend': imgBlend = m.value; images.forEach(im => im.style.mixBlendMode = m.value); break;
|
||
case 'slideshow': slideshow = m.on; restartSlideshow(); break;
|
||
case 'slideshowInterval': slideshowMs = m.ms; restartSlideshow(); break;
|
||
case 'imgSize': imgLayer.style.setProperty('--img-size', m.value); break;
|
||
case 'imgBeat': beatPulse = m.on; break;
|
||
case 'imgNext': showImage(imgIndex + 1); break;
|
||
case 'imgPrev': showImage(imgIndex - 1); break;
|
||
|
||
case 'videoLoad':
|
||
bgVideo.src = toFileURL(m.path);
|
||
bgVideo.loop = true; bgVideo.muted = true;
|
||
bgVideo.classList.add('show');
|
||
bgVideo.play().catch(() => {});
|
||
hideHint();
|
||
break;
|
||
case 'videoToggle':
|
||
if (bgVideo.paused) bgVideo.play().catch(() => {}); else bgVideo.pause();
|
||
break;
|
||
case 'videoClear':
|
||
bgVideo.pause(); bgVideo.removeAttribute('src'); bgVideo.load();
|
||
bgVideo.classList.remove('show');
|
||
break;
|
||
case 'videoOpacity': bgVideo.style.opacity = m.value; break;
|
||
case 'videoFit': bgVideo.style.objectFit = m.value; break;
|
||
case 'videoBlend': bgVideo.style.mixBlendMode = m.value; break;
|
||
case 'trackVideoBlend': trackVideo.style.mixBlendMode = m.value; break;
|
||
case 'trackVideoOpacity': trackVideo.style.opacity = m.value; break;
|
||
case 'trackVideoFit': trackVideo.style.objectFit = m.value; break;
|
||
|
||
case 'sceneImage':
|
||
if (m.path) {
|
||
sceneImage.src = toFileURL(m.path);
|
||
const s = m.size || 60;
|
||
sceneImage.style.maxWidth = s + 'vw';
|
||
sceneImage.style.maxHeight = s + 'vh';
|
||
sceneImage.style.left = (m.x != null ? m.x : 50) + '%';
|
||
sceneImage.style.top = (m.y != null ? m.y : 50) + '%';
|
||
sceneImage.classList.add('show'); hideHint();
|
||
} else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); }
|
||
break;
|
||
case 'logo': setLogo(m.index, m.path ? toFileURL(m.path) : ''); break;
|
||
case 'logoX': logos[m.index].style.left = m.value + '%'; break;
|
||
case 'logoY': logos[m.index].style.top = m.value + '%'; break;
|
||
case 'logoSize': logos[m.index].style.width = m.value + 'vw'; break;
|
||
case 'logoOpacity': logos[m.index].style.opacity = m.value; break;
|
||
|
||
case 'tickerText': setTickerText(m.text); break;
|
||
case 'tickerOn': tickerVisible = m.on; updateTickerVis(); break;
|
||
case 'tickerPos':
|
||
ticker.classList.remove('pos-bottom', 'pos-top', 'pos-middle');
|
||
ticker.classList.add('pos-' + m.pos);
|
||
break;
|
||
case 'tickerSpeed': setTickerSpeed(m.mult); break;
|
||
case 'tickerDir':
|
||
tickerDir = m.value;
|
||
if (m.value !== 'sides') {
|
||
ticker.classList.remove('dir-h', 'dir-vup', 'dir-vdown');
|
||
ticker.classList.add('dir-' + m.value);
|
||
}
|
||
updateTickerVis();
|
||
break;
|
||
case 'tickerFont': styleBoth('fontFamily', m.value); break;
|
||
case 'tickerSize': styleBoth('fontSize', m.value + 'vh'); break;
|
||
case 'tickerWeight': styleBoth('fontWeight', m.on ? '800' : '400'); break;
|
||
case 'tickerColor': styleBoth('color', m.value); break;
|
||
case 'tickerFx':
|
||
[tickerTrack, sideText].forEach(el => {
|
||
el.classList.remove('fx-updown', 'fx-wave', 'fx-zoom', 'fx-flash', 'fx-rotate');
|
||
if (m.value && m.value !== 'none') el.classList.add('fx-' + m.value);
|
||
});
|
||
break;
|
||
}
|
||
});
|
||
|
||
// ---------------------------------------------------------------- render loop
|
||
const startTime = performance.now();
|
||
let frames = 0, fpsT = performance.now(), fps = 0, lastReport = 0, prevBeat = 0, lastProgress = 0;
|
||
|
||
// ---- Auto VJ: a director that adapts the visuals to the playing track ------
|
||
// Uses the engine's musical features (mood/bpm/drop/beatCount): switches preset
|
||
// every 16 beats (or ~14s without beats), immediately on a detected drop, and
|
||
// draws from pools of families that fit the current mood.
|
||
let autoVj = false, avLastSwitch = 0, avBeatMark = 0, avRecent = [];
|
||
const AV_POOLS = {
|
||
quiet: ['Cielo', 'Aurora', 'Mare', 'Montagne', 'Galassia', 'Nuvole', 'Fluido', 'Onde',
|
||
'Onda Luminosa', 'Onda 3D'],
|
||
groove: ['Plasma', 'Vortice', 'Truchet', 'Esagoni', 'Spirale', 'Cellule', 'Gyroide',
|
||
'Fluido', 'Fluido Onda', 'Fluido Vortici', 'Griglia Neon', 'Solidi 3D', 'Tunnel Neon',
|
||
'Onda Luminosa', 'Spettro Neon', 'Onda 3D'],
|
||
peak: ['Julia', 'Iperspazio', 'Tunnel', 'Moiré', 'Fluido Anello', 'Fluido Fuoco',
|
||
'Vortice', 'Griglia Neon', 'Cristalli', 'Tunnel Neon', 'Tempesta Plasma', 'Spettro Neon',
|
||
'Metallo Liquido']
|
||
};
|
||
let avMode = 'smart', avFamilies = []; // 'smart' | 'all' | 'custom'
|
||
function avPick(mood) {
|
||
// Family pool: smart = curated per-mood lists; custom = the user's checklist
|
||
// (mood still shapes the speed variant); all = no family restriction.
|
||
let fams = null;
|
||
if (avMode === 'smart') fams = AV_POOLS[mood] || AV_POOLS.groove;
|
||
else if (avMode === 'custom' && avFamilies.length) fams = avFamilies;
|
||
const speedOk = (e) => (mood === 'peak' ? e.speed >= 1.0 : e.speed <= 1.4);
|
||
// Interactive presets are excluded: the Auto VJ must never switch the
|
||
// camera on by itself.
|
||
let cands = window.EFFECTS.list.filter(e => !e.isInteractive &&
|
||
(!fams || fams.indexOf(e.familyName) >= 0) &&
|
||
speedOk(e) && avRecent.indexOf(e.name) < 0);
|
||
// A tight custom list can exhaust the speed filter: retry without it.
|
||
if (!cands.length) cands = window.EFFECTS.list.filter(e => !e.isInteractive &&
|
||
(!fams || fams.indexOf(e.familyName) >= 0) && avRecent.indexOf(e.name) < 0);
|
||
const e = cands.length ? cands[Math.floor(Math.random() * cands.length)] : window.EFFECTS.list[0];
|
||
avRecent = [e.name].concat(avRecent).slice(0, 6); // don't repeat the last few
|
||
return e;
|
||
}
|
||
function avTick(now) {
|
||
if (!autoVj) return;
|
||
const dropNow = audio.drop === 1;
|
||
const due = (audio.beatCount - avBeatMark) >= 16 || (now - avLastSwitch) > 14000;
|
||
if (!due && !dropNow) return;
|
||
avBeatMark = audio.beatCount; avLastSwitch = now;
|
||
const mood = dropNow ? 'peak' : audio.mood;
|
||
const e = avPick(mood);
|
||
viz.setEffect(e);
|
||
djv.report({ type: 'autoVj', on: true, name: e.name, mood, bpm: audio.bpm || 0 });
|
||
}
|
||
|
||
let camErrReported = false, poseErrReported = false, avatarHintAt = 0, avatarHinted = false;
|
||
function frame() {
|
||
const t = (performance.now() - startTime) / 1000;
|
||
audio.update();
|
||
const a = audio.values;
|
||
avTick(performance.now());
|
||
viz.render(t * speed, a);
|
||
if (mapOn && mapping) mapping.render(t * speed, a);
|
||
|
||
// Interactive family: tell the control panel if the camera didn't start
|
||
// (permission denied / missing device). Mouse interaction still works.
|
||
if (viz.inter && viz.inter.camState === 'error' && !camErrReported) {
|
||
camErrReported = true;
|
||
djv.report({ type: 'error', message: 'Camera non disponibile (' + viz.inter.camErr +
|
||
') — l’effetto Interattivo funziona comunque con il mouse sull’output' });
|
||
}
|
||
// Body-tracking diagnostics: tracker failed, or avatar active with no body.
|
||
const now2 = performance.now();
|
||
if (viz.inter && viz.inter.poseState === 'error' && !poseErrReported) {
|
||
poseErrReported = true;
|
||
djv.report({ type: 'error', message: 'Tracking corpo non disponibile: le famiglie ' +
|
||
'Interattivo usano solo il movimento (Avatar non può funzionare)' });
|
||
}
|
||
if (viz.effect && viz.effect.interactiveMode === 'avatar' && viz.inter) {
|
||
if (viz.inter.lmsRaw) { avatarHintAt = 0; avatarHinted = true; }
|
||
else if (!avatarHinted) {
|
||
if (!avatarHintAt) avatarHintAt = now2;
|
||
else if (now2 - avatarHintAt > 5000) {
|
||
avatarHinted = true;
|
||
djv.report({ type: 'error', message: 'Avatar: nessun corpo rilevato — mettiti davanti ' +
|
||
'alla camera (almeno il busto ben inquadrato e illuminato)' });
|
||
}
|
||
}
|
||
} else { avatarHintAt = 0; }
|
||
|
||
// Report beat rising-edges so the control window can auto-cycle effects.
|
||
if (a.beat > 0.6 && prevBeat <= 0.6) djv.report({ type: 'beat' });
|
||
prevBeat = a.beat;
|
||
|
||
// Overlays react to audio.
|
||
imgLayer.style.setProperty('--beat-scale',
|
||
(beatPulse && images.length ? 1 + a.beat * 0.12 + a.bass * 0.05 : 1).toFixed(3));
|
||
tickerTrack.style.setProperty('--ticker-glow', (a.level * 24 + a.beat * 20).toFixed(0) + 'px');
|
||
|
||
// FPS + meter report to the control window (throttled to ~15 Hz).
|
||
frames++;
|
||
const now = performance.now();
|
||
if (now - fpsT > 500) { fps = frames * 1000 / (now - fpsT); frames = 0; fpsT = now; }
|
||
if (now - lastReport > 66) {
|
||
lastReport = now;
|
||
djv.report({ type: 'meters', bass: a.bass, mid: a.mid, treble: a.treble, fps });
|
||
}
|
||
// Stop at the trim end point if one is set.
|
||
audio.checkTrim();
|
||
// Playback progress for the currently playing file.
|
||
if (now - lastProgress > 400) {
|
||
lastProgress = now;
|
||
const el = audio.mediaEl;
|
||
if (el && !el.paused && isFinite(el.duration) && el.duration > 0) {
|
||
djv.report({ type: 'progress', currentTime: el.currentTime, duration: el.duration });
|
||
}
|
||
}
|
||
requestAnimationFrame(frame);
|
||
}
|
||
requestAnimationFrame(frame);
|
||
|
||
// ------------------------------------------------------------- mapping
|
||
let mapping = null, mapOn = false;
|
||
function ensureMapping() {
|
||
if (!mapping) {
|
||
mapping = new window.MappingSim($('#map-canvas'), canvas);
|
||
// corner drags on the output flow back to the panel, which persists them
|
||
mapping.onChange = (zones) => djv.report({ type: 'mapZones',
|
||
zones: zones.map(z => ({ id: z.id, name: z.name,
|
||
src: z.src.type === 'image' ? { type: 'image', path: z.src.path }
|
||
: (z.src.type === 'effect' ? { type: 'effect', effectIndex: z.src.effectIndex }
|
||
: { type: 'visual' }),
|
||
corners: z.corners, srcRect: z.srcRect, opacity: z.opacity })) });
|
||
}
|
||
return mapping;
|
||
}
|
||
|
||
// Tell the panel which dance clips the current model can play.
|
||
function reportClips() {
|
||
const names = viz.model3d && viz.model3d.anims ? viz.model3d.anims.map(a => a.name) : [];
|
||
djv.report({ type: 'glbClips', names });
|
||
}
|
||
|
||
// Bundled dance library (skeleton-only GLB with the Mixamo clips): binds to
|
||
// any rigged model the user loads.
|
||
fetch('djvres://models/dance_library.glb')
|
||
.then(r => r.arrayBuffer())
|
||
.then(b => { viz.setAnimLibrary(b); reportClips(); })
|
||
.catch(() => { /* library optional: models with own clips still dance */ });
|
||
|
||
// Populate the device list once at startup so the control dropdown fills in.
|
||
reportDevices();
|