From 1ae8fa81a7bfb6adcae987a2a454e0abb5e4984a Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 19 Jun 2026 16:37:59 +0200 Subject: [PATCH] Add video layer: load VJ video loops that blend with the visuals Loads a muted looping video as a layer above the WebGL canvas with opacity, blend mode and fit controls; included in MP4 recordings and droppable. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/control.html | 26 ++++++++++++++++++++++++++ src/control.js | 21 +++++++++++++++++++++ src/output.html | 3 +++ src/output.js | 36 ++++++++++++++++++++++++++++++++++++ src/style.css | 13 +++++++++++++ 5 files changed, 99 insertions(+) diff --git a/src/control.html b/src/control.html index 0b559cc..78e35b4 100644 --- a/src/control.html +++ b/src/control.html @@ -130,6 +130,32 @@
+
+ +
+ + + +
+ + + +
+ + +
+
Il video (muto, in loop) si fonde con i visual generati sotto. Per mostrarlo da solo: opacità 100% + fusione "normale".
+
+
diff --git a/src/control.js b/src/control.js index be62506..a4c32e0 100644 --- a/src/control.js +++ b/src/control.js @@ -475,6 +475,24 @@ $('#img-interval').addEventListener('input', (e) => { send({ type: 'slideshowInterval', ms: s * 1000 }); }); +// ---------------------------------------------------------------- video +$('#btn-video').addEventListener('click', () => $('#video-input').click()); +$('#video-input').addEventListener('change', (e) => { + const f = e.target.files[0]; + const p = f && filePath(f); + if (p) send({ type: 'videoLoad', path: p }); + e.target.value = ''; +}); +$('#btn-video-toggle').addEventListener('click', () => send({ type: 'videoToggle' })); +$('#btn-video-clear').addEventListener('click', () => send({ type: 'videoClear' })); +$('#video-op').addEventListener('input', (e) => { + const v = parseInt(e.target.value, 10); + $('#video-op-val').textContent = v + '%'; + send({ type: 'videoOpacity', value: v / 100 }); +}); +$('#video-fit').addEventListener('change', (e) => send({ type: 'videoFit', value: e.target.value })); +$('#video-blend').addEventListener('change', (e) => send({ type: 'videoBlend', value: e.target.value })); + // ---------------------------------------------------------------- ticker $('#ticker-text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value.replace(/\s*\n\s*/g, ' · ') })); $('#ticker-on').addEventListener('change', (e) => send({ type: 'tickerOn', on: e.target.checked })); @@ -708,8 +726,11 @@ window.addEventListener('drop', (e) => { const audioItems = files.filter(f => f.type.startsWith('audio/')) .map(f => ({ path: filePath(f), name: f.name })); const imgPaths = files.filter(f => f.type.startsWith('image/')).map(filePath).filter(Boolean); + const videoFile = files.find(f => f.type.startsWith('video/')); + const videoPath = videoFile && filePath(videoFile); if (audioItems.length) addTracks(audioItems); if (imgPaths.length) send({ type: 'addImages', paths: imgPaths }); + if (videoPath) send({ type: 'videoLoad', path: videoPath }); }); // ---------------------------------------------------------------- keyboard diff --git a/src/output.html b/src/output.html index 10b7371..c8cbf99 100644 --- a/src/output.html +++ b/src/output.html @@ -10,6 +10,9 @@ + + +
diff --git a/src/output.js b/src/output.js index afc60af..d468e1c 100644 --- a/src/output.js +++ b/src/output.js @@ -69,6 +69,14 @@ function pickMime() { // 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; @@ -77,6 +85,15 @@ function composeFrame() { 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'; + } + for (const im of images) { if (!im.classList.contains('show')) continue; const r = im.getBoundingClientRect(); @@ -192,6 +209,7 @@ function loadCustomTexture(dataUrl) { img.src = dataUrl; } +const bgVideo = $('#bg-video'); const sceneImage = $('#scene-image'); // Two independent logos. @@ -301,6 +319,24 @@ djv.onControl(async (m) => { 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 'sceneImage': if (m.path) { sceneImage.src = toFileURL(m.path); sceneImage.classList.add('show'); hideHint(); } else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); } diff --git a/src/style.css b/src/style.css index 1bbc18c..beef52f 100644 --- a/src/style.css +++ b/src/style.css @@ -404,6 +404,19 @@ body.output { cursor: none; background: #000; } } #overlay-images img.show { opacity: 1; } +/* ---- Video layer (VJ loop) ---- */ +.bg-video { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + object-fit: cover; + z-index: 1; /* above the WebGL canvas, below overlays */ + pointer-events: none; + display: none; +} +.bg-video.show { display: block; } + /* ---- Per-track scene image ---- */ .scene-image { position: fixed;