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) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.8 committed 2026-06-19 16:37:59 +02:00
1 parent 5c60a38ff1
commit 1ae8fa81a7
5 files changed
+99

No files matched your search

+26
View File
@@ -130,6 +130,32 @@
<!-- ============ IMMAGINI ============ --> <!-- ============ IMMAGINI ============ -->
<div class="tab-panel" data-tab="immagini"> <div class="tab-panel" data-tab="immagini">
<section class="span2">
<label>Video (loop VJ)</label>
<div class="row">
<button id="btn-video">🎞 Carica video…</button>
<button id="btn-video-toggle">⏯</button>
<button id="btn-video-clear">Rimuovi</button>
</div>
<input type="file" id="video-input" accept="video/*" hidden />
<label>Opacità <span id="video-op-val">100%</span></label>
<input type="range" id="video-op" min="0" max="100" step="1" value="100" />
<div class="row">
<select id="video-fit">
<option value="cover">Riempie schermo</option>
<option value="contain">Adatta (bordi)</option>
</select>
<select id="video-blend">
<option value="normal">normale</option>
<option value="screen">screen</option>
<option value="overlay">overlay</option>
<option value="multiply">multiply</option>
<option value="lighten">lighten</option>
</select>
</div>
<div class="hint-mini">Il video (muto, in loop) si fonde con i visual generati sotto. Per mostrarlo da solo: opacità 100% + fusione "normale".</div>
</section>
<section> <section>
<label>Immagini overlay</label> <label>Immagini overlay</label>
<div class="row"> <div class="row">
+21
View File
@@ -475,6 +475,24 @@ $('#img-interval').addEventListener('input', (e) => {
send({ type: 'slideshowInterval', ms: s * 1000 }); 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
$('#ticker-text').addEventListener('input', (e) => send({ type: 'tickerText', text: e.target.value.replace(/\s*\n\s*/g, ' · ') })); $('#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 })); $('#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/')) const audioItems = files.filter(f => f.type.startsWith('audio/'))
.map(f => ({ path: filePath(f), name: f.name })); .map(f => ({ path: filePath(f), name: f.name }));
const imgPaths = files.filter(f => f.type.startsWith('image/')).map(filePath).filter(Boolean); 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 (audioItems.length) addTracks(audioItems);
if (imgPaths.length) send({ type: 'addImages', paths: imgPaths }); if (imgPaths.length) send({ type: 'addImages', paths: imgPaths });
if (videoPath) send({ type: 'videoLoad', path: videoPath });
}); });
// ---------------------------------------------------------------- keyboard // ---------------------------------------------------------------- keyboard
+3
View File
@@ -10,6 +10,9 @@
<body class="output"> <body class="output">
<canvas id="gl"></canvas> <canvas id="gl"></canvas>
<!-- Video layer (VJ loop) that blends with the generated visuals -->
<video id="bg-video" class="bg-video" loop muted playsinline></video>
<!-- Image overlay layer --> <!-- Image overlay layer -->
<div id="overlay-images"></div> <div id="overlay-images"></div>
+36
View File
@@ -69,6 +69,14 @@ function pickMime() {
// Composite the WebGL visual + DOM overlays (images, logos, ticker) into a 2D // 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. // 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() { function composeFrame() {
const W = recCanvas.width, H = recCanvas.height; const W = recCanvas.width, H = recCanvas.height;
const sx = W / window.innerWidth, sy = H / window.innerHeight; const sx = W / window.innerWidth, sy = H / window.innerHeight;
@@ -77,6 +85,15 @@ function composeFrame() {
recCtx.clearRect(0, 0, W, H); recCtx.clearRect(0, 0, W, H);
recCtx.drawImage(canvas, 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) { for (const im of images) {
if (!im.classList.contains('show')) continue; if (!im.classList.contains('show')) continue;
const r = im.getBoundingClientRect(); const r = im.getBoundingClientRect();
@@ -192,6 +209,7 @@ function loadCustomTexture(dataUrl) {
img.src = dataUrl; img.src = dataUrl;
} }
const bgVideo = $('#bg-video');
const sceneImage = $('#scene-image'); const sceneImage = $('#scene-image');
// Two independent logos. // Two independent logos.
@@ -301,6 +319,24 @@ djv.onControl(async (m) => {
case 'imgNext': showImage(imgIndex + 1); break; case 'imgNext': showImage(imgIndex + 1); break;
case 'imgPrev': 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': case 'sceneImage':
if (m.path) { sceneImage.src = toFileURL(m.path); sceneImage.classList.add('show'); hideHint(); } if (m.path) { sceneImage.src = toFileURL(m.path); sceneImage.classList.add('show'); hideHint(); }
else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); } else { sceneImage.classList.remove('show'); sceneImage.removeAttribute('src'); }
+13
View File
@@ -404,6 +404,19 @@ body.output { cursor: none; background: #000; }
} }
#overlay-images img.show { opacity: 1; } #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 ---- */ /* ---- Per-track scene image ---- */
.scene-image { .scene-image {
position: fixed; position: fixed;