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:
1 parent
5c60a38ff1
commit
1ae8fa81a7
5 files changed
+99
No files matched your search
@@ -130,6 +130,32 @@
|
||||
|
||||
<!-- ============ 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>
|
||||
<label>Immagini overlay</label>
|
||||
<div class="row">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<body class="output">
|
||||
<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 -->
|
||||
<div id="overlay-images"></div>
|
||||
|
||||
|
||||
@@ -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'); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user