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;