// 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); try { const gl = canvas.getContext('webgl2'); const d = gl && gl.getExtension('WEBGL_debug_renderer_info'); djv.report({ type: 'gpuInfo', webgl2: !!gl, renderer: d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : (gl && gl.getParameter(gl.RENDERER)) }); } catch (e2) {} } catch (e) { djv.report({ type: 'gpuInfo', webgl2: false, message: e.message }); document.body.innerHTML = '
' + 'Errore grafica:
' + e.message + '
'; throw e; } const audio = new AudioEngine(); let speed = 1.0; // In the Pi/web build, paths are already server URLs (e.g. /media/song.mp3), // so they can be used directly as the resource URL. function toFileURL(p) { return p; } // ---------------------------------------------------------------- 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; 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); recCtx.drawImage(canvas, 0, 0, W, H); 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() { 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 }); else djv.report({ type: 'recError', message: (res && res.error) || 'errore sconosciuto' }); }; recorder.start(2000); // emit a chunk every 2s recording = true; djv.report({ type: 'recState', recording: true }); } catch (e) { stopCompose(); djv.report({ type: 'recError', message: e.message }); } } function stopRecording(opts) { 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(/' + ch + ''; } 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