mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-05-02 04:52:04 -04:00
clipping improvements
This commit is contained in:
committed by
GitHub
parent
e398ad69c3
commit
4f3624dd33
@@ -918,6 +918,86 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Contestant row */
|
||||||
|
.contestant-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar {
|
||||||
|
position: relative;
|
||||||
|
cursor: default;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar img {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
display: block;
|
||||||
|
transition: filter 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar.eliminated img {
|
||||||
|
filter: grayscale(100%) brightness(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar img:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
z-index: 100;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar:hover .contestant-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip .ct-name {
|
||||||
|
font-family: 'Bebas Neue', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip .ct-job {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip .ct-endorsements {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip .ct-eliminated {
|
||||||
|
color: var(--accent2);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Viewer count badge */
|
/* Viewer count badge */
|
||||||
.cam-viewers {
|
.cam-viewers {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1112,6 +1192,86 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Contestant row */
|
||||||
|
.contestant-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar {
|
||||||
|
position: relative;
|
||||||
|
cursor: default;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar img {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
display: block;
|
||||||
|
transition: filter 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar.eliminated img {
|
||||||
|
filter: grayscale(100%) brightness(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar img:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
z-index: 100;
|
||||||
|
font-family: 'Share Tech Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-avatar:hover .contestant-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip .ct-name {
|
||||||
|
font-family: 'Bebas Neue', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip .ct-job {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip .ct-endorsements {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contestant-tooltip .ct-eliminated {
|
||||||
|
color: var(--accent2);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Viewer count badge */
|
/* Viewer count badge */
|
||||||
.cam-viewers {
|
.cam-viewers {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1316,13 +1476,13 @@
|
|||||||
// Apply saved thumbnail interval from dropdown
|
// Apply saved thumbnail interval from dropdown
|
||||||
const iv = document.getElementById('intervalSelect');
|
const iv = document.getElementById('intervalSelect');
|
||||||
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
||||||
// Recalc on resize
|
// Recalc on resize — use lambda so reference resolves at call time not definition time
|
||||||
window.addEventListener('resize', recalcGridRows);
|
window.addEventListener('resize', () => window.recalcGridRows && window.recalcGridRows());
|
||||||
window.addEventListener('load', recalcGridRows);
|
window.addEventListener('load', () => window.recalcGridRows && window.recalcGridRows());
|
||||||
// Poll recalc for 3s after load to catch any layout settling
|
// Poll recalc for 3s after load to catch any layout settling
|
||||||
let _recalcCount = 0;
|
let _recalcCount = 0;
|
||||||
const _recalcInit = setInterval(() => {
|
const _recalcInit = setInterval(() => {
|
||||||
recalcGridRows();
|
if (window.recalcGridRows) window.recalcGridRows();
|
||||||
if (++_recalcCount >= 6) clearInterval(_recalcInit);
|
if (++_recalcCount >= 6) clearInterval(_recalcInit);
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
@@ -1405,13 +1565,13 @@
|
|||||||
// Apply saved thumbnail interval from dropdown
|
// Apply saved thumbnail interval from dropdown
|
||||||
const iv = document.getElementById('intervalSelect');
|
const iv = document.getElementById('intervalSelect');
|
||||||
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
||||||
// Recalc on resize
|
// Recalc on resize — use lambda so reference resolves at call time not definition time
|
||||||
window.addEventListener('resize', recalcGridRows);
|
window.addEventListener('resize', () => window.recalcGridRows && window.recalcGridRows());
|
||||||
window.addEventListener('load', recalcGridRows);
|
window.addEventListener('load', () => window.recalcGridRows && window.recalcGridRows());
|
||||||
// Poll recalc for 3s after load to catch any layout settling
|
// Poll recalc for 3s after load to catch any layout settling
|
||||||
let _recalcCount = 0;
|
let _recalcCount = 0;
|
||||||
const _recalcInit = setInterval(() => {
|
const _recalcInit = setInterval(() => {
|
||||||
recalcGridRows();
|
if (window.recalcGridRows) window.recalcGridRows();
|
||||||
if (++_recalcCount >= 6) clearInterval(_recalcInit);
|
if (++_recalcCount >= 6) clearInterval(_recalcInit);
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
@@ -1585,11 +1745,8 @@
|
|||||||
<div class="panel-title" style="color:var(--accent)">CAMERAS</div>
|
<div class="panel-title" style="color:var(--accent)">CAMERAS</div>
|
||||||
<div style="display:flex;align-items:center;gap:8px;margin-left:auto;">
|
<div style="display:flex;align-items:center;gap:8px;margin-left:auto;">
|
||||||
<button class="range-btn" id="thumbToggle" onclick="toggleThumbMode()" style="font-size:10px;">THUMBS</button>
|
<button class="range-btn" id="thumbToggle" onclick="toggleThumbMode()" style="font-size:10px;">THUMBS</button>
|
||||||
<button class="range-btn" id="sizeLimitToggle" onclick="toggleSizeLimit()" style="font-size:10px;color:var(--muted);border-color:var(--muted);">3.9MB LIMIT</button>
|
|
||||||
<span id="recSize" style="font-family:'Share Tech Mono',monospace;font-size:10px;color:#e2e8f0;display:none;min-width:52px;"></span>
|
|
||||||
<button class="range-btn" onclick="takeScreenshot()" style="font-size:10px;">📷 SNAP</button>
|
<button class="range-btn" onclick="takeScreenshot()" style="font-size:10px;">📷 SNAP</button>
|
||||||
<button class="range-btn" id="clipBtn" onclick="saveClip()" style="font-size:10px;">✂ CLIP</button>
|
<button class="range-btn" id="clipBtn" onclick="saveClip()" style="font-size:10px;">✂ CLIP</button>
|
||||||
<button class="range-btn" id="recordBtn" onclick="startRecord()" style="font-size:10px;">⏺ REC</button>
|
|
||||||
|
|
||||||
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);">VOL</span>
|
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);">VOL</span>
|
||||||
<input type="range" id="featuredVolume" min="0" max="1" step="0.01" value="1" style="width:120px;accent-color:var(--accent);cursor:pointer;" oninput="setFeaturedVolume(this.value)">
|
<input type="range" id="featuredVolume" min="0" max="1" step="0.01" value="1" style="width:120px;accent-color:var(--accent);cursor:pointer;" oninput="setFeaturedVolume(this.value)">
|
||||||
@@ -1994,7 +2151,7 @@
|
|||||||
const datasets = tickers.map(ticker => {
|
const datasets = tickers.map(ticker => {
|
||||||
const priceMap = prices[ticker];
|
const priceMap = prices[ticker];
|
||||||
const data = allTimestamps.map(ts => priceMap[ts] ?? null);
|
const data = allTimestamps.map(ts => priceMap[ts] ?? null);
|
||||||
const color = TICKER_COLORS[ticker] || '#ffffff';
|
const color = contestantTickerColors[ticker] || TICKER_COLORS[ticker] || '#ffffff';
|
||||||
const lastPrice = data.filter(v => v !== null).at(-1);
|
const lastPrice = data.filter(v => v !== null).at(-1);
|
||||||
return { ticker, data, color, lastPrice };
|
return { ticker, data, color, lastPrice };
|
||||||
});
|
});
|
||||||
@@ -2002,9 +2159,19 @@
|
|||||||
// Sort legend by last price desc
|
// Sort legend by last price desc
|
||||||
const sorted = [...datasets].sort((a, b) => (b.lastPrice || 0) - (a.lastPrice || 0));
|
const sorted = [...datasets].sort((a, b) => (b.lastPrice || 0) - (a.lastPrice || 0));
|
||||||
|
|
||||||
// Build legend with % change from earliest to latest
|
// Sort legend by last price desc
|
||||||
|
const sortedLegend = [...datasets].sort((a, b) => (b.lastPrice || 0) - (a.lastPrice || 0));
|
||||||
|
|
||||||
|
// Auto-hide eliminated contestants on first render
|
||||||
|
sortedLegend.forEach(d => {
|
||||||
|
const isEliminated = window._contestantEliminated && window._contestantEliminated[d.ticker];
|
||||||
|
if (isEliminated && !hiddenTickers.has(d.ticker)) {
|
||||||
|
hiddenTickers.add(d.ticker);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const legend = document.getElementById('stocksLegend');
|
const legend = document.getElementById('stocksLegend');
|
||||||
legend.innerHTML = sorted.map(d => {
|
legend.innerHTML = sortedLegend.map(d => {
|
||||||
const validPrices = d.data.filter(v => v !== null);
|
const validPrices = d.data.filter(v => v !== null);
|
||||||
const earliest = validPrices[0];
|
const earliest = validPrices[0];
|
||||||
const latest = validPrices[validPrices.length - 1];
|
const latest = validPrices[validPrices.length - 1];
|
||||||
@@ -2015,9 +2182,31 @@
|
|||||||
changeClass = pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat';
|
changeClass = pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat';
|
||||||
arrow = pct > 0 ? '▲' : pct < 0 ? '▼' : '—';
|
arrow = pct > 0 ? '▲' : pct < 0 ? '▼' : '—';
|
||||||
}
|
}
|
||||||
|
const photo = window._contestantPhotos && window._contestantPhotos[d.ticker];
|
||||||
|
const job = window._contestantJobs && window._contestantJobs[d.ticker];
|
||||||
|
const endorsements= window._contestantEndorsements && window._contestantEndorsements[d.ticker];
|
||||||
|
const eliminated = window._contestantEliminated && window._contestantEliminated[d.ticker];
|
||||||
|
const isHidden = hiddenTickers.has(d.ticker);
|
||||||
|
|
||||||
|
const tooltipLines = [
|
||||||
|
job ? `<div style="color:var(--muted);font-size:9px;">${job}</div>` : '',
|
||||||
|
endorsements!== undefined ? `<div style="color:var(--accent);font-size:9px;">⬡ ${endorsements.toLocaleString()} endorsements</div>` : '',
|
||||||
|
eliminated ? `<div style="color:var(--accent2);font-size:9px;">ELIMINATED</div>` : '',
|
||||||
|
].filter(Boolean).join('');
|
||||||
|
|
||||||
|
const avatarStyle = `width:22px;height:22px;border-radius:50%;object-fit:cover;border:1.5px solid ${d.color};flex-shrink:0;${eliminated ? 'filter:grayscale(100%) brightness(0.5);' : ''}`;
|
||||||
|
const avatarHtml = photo
|
||||||
|
? `<div style="position:relative;flex-shrink:0;">
|
||||||
|
<img src="${photo}" style="${avatarStyle}" onerror="this.style.display='none'">
|
||||||
|
${tooltipLines ? `<div style="position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:5px 8px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity 0.15s;z-index:100;font-family:'Share Tech Mono',monospace;" class="ct-tip">${tooltipLines}</div>` : ''}
|
||||||
|
</div>`
|
||||||
|
: `<div class="legend-dot" style="background:${d.color}"></div>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="legend-item ${hiddenTickers.has(d.ticker) ? 'hidden' : ''}" onclick="toggleTicker('${d.ticker}')" id="leg-${d.ticker}">
|
<div class="legend-item ${isHidden ? 'hidden' : ''}" onclick="toggleTicker('${d.ticker}')" id="leg-${d.ticker}" style="position:relative;"
|
||||||
<div class="legend-dot" style="background:${d.color}"></div>
|
onmouseenter="this.querySelector('.ct-tip')&&(this.querySelector('.ct-tip').style.opacity='1')"
|
||||||
|
onmouseleave="this.querySelector('.ct-tip')&&(this.querySelector('.ct-tip').style.opacity='0')">
|
||||||
|
${avatarHtml}
|
||||||
<span>${d.ticker}</span>
|
<span>${d.ticker}</span>
|
||||||
<span class="legend-price">${d.lastPrice ?? '—'}</span>
|
<span class="legend-price">${d.lastPrice ?? '—'}</span>
|
||||||
${changePct !== null ? `<span class="legend-change ${changeClass}">${arrow} ${changePct}</span>` : ''}
|
${changePct !== null ? `<span class="legend-change ${changeClass}">${arrow} ${changePct}</span>` : ''}
|
||||||
@@ -2260,15 +2449,16 @@
|
|||||||
label.textContent = name.toUpperCase();
|
label.textContent = name.toUpperCase();
|
||||||
if (hlsInstances['featured']) hlsInstances['featured'].destroy();
|
if (hlsInstances['featured']) hlsInstances['featured'].destroy();
|
||||||
hlsInstances['featured'] = makeHls(slug, video, false);
|
hlsInstances['featured'] = makeHls(slug, video, false);
|
||||||
video.addEventListener('canplay', () => { const s = document.getElementById('featuredVolume'); if (s) video.volume = parseFloat(s.value); }, { once: true });
|
video.addEventListener('canplay', () => { const s = document.getElementById('featuredVolume'); if (s) { video.volume = bufferAudioCtx && bufferAudioCtx._gain ? 1.0 : parseFloat(s.value); if (bufferAudioCtx && bufferAudioCtx._gain) bufferAudioCtx._gain.gain.value = parseFloat(s.value); } }, { once: true });
|
||||||
// Hide play overlay once user has interacted — browser will allow autoplay after first click
|
// Hide play overlay once user has interacted — browser will allow autoplay after first click
|
||||||
const overlay = document.getElementById('featPlayOverlay');
|
const overlay = document.getElementById('featPlayOverlay');
|
||||||
if (overlay) overlay.classList.add('hidden');
|
if (overlay) overlay.classList.add('hidden');
|
||||||
featuredIdx = idx;
|
featuredIdx = idx;
|
||||||
// Restart buffer so clips target the new camera
|
// Restart buffer for new camera — reset audio context too (new video element)
|
||||||
stopBuffer();
|
stopBuffer();
|
||||||
bufferChunks = [];
|
bufferAudioCtx = null;
|
||||||
bufferInitChunk = null;
|
if (bufferCanvasInterval) { clearInterval(bufferCanvasInterval); bufferCanvasInterval = null; }
|
||||||
|
if (bufferCanvas) { bufferCanvas.remove(); bufferCanvas = null; }
|
||||||
setTimeout(() => ensureBuffer(), 500);
|
setTimeout(() => ensureBuffer(), 500);
|
||||||
// Refresh overlays and viewer counts for new featured cam
|
// Refresh overlays and viewer counts for new featured cam
|
||||||
if (typeof updateContestantOverlays === 'function' && window._lastContestantData && Object.keys(window._lastContestantData).length) {
|
if (typeof updateContestantOverlays === 'function' && window._lastContestantData && Object.keys(window._lastContestantData).length) {
|
||||||
@@ -2330,94 +2520,178 @@
|
|||||||
|
|
||||||
// ── Rolling 60s buffer (for CLIP) ──────────────────────────
|
// ── Rolling 60s buffer (for CLIP) ──────────────────────────
|
||||||
let bufferRecorder = null;
|
let bufferRecorder = null;
|
||||||
let bufferChunks = []; // { data, ts }
|
let bufferChunks = [];
|
||||||
let bufferInitChunk = null; // WebM init segment, kept permanently
|
let bufferInitChunk = null;
|
||||||
const BUFFER_SECS = 65; // keep a bit extra
|
let bufferAudioCtx = null; // persists across restarts — can't re-create on same element
|
||||||
|
let bufferCanvasInterval = null;
|
||||||
let mirrorVideo = null;
|
let bufferCanvas = null;
|
||||||
let mirrorCanvas = null;
|
const BUFFER_SECS = 65;
|
||||||
let mirrorCanvasInterval = null;
|
|
||||||
|
|
||||||
function stopBuffer() {
|
function stopBuffer() {
|
||||||
if (bufferRecorder && bufferRecorder.state !== 'inactive') bufferRecorder.stop();
|
if (bufferRecorder && bufferRecorder.state !== 'inactive') {
|
||||||
|
try { bufferRecorder.stop(); } catch(e) {}
|
||||||
|
}
|
||||||
bufferRecorder = null;
|
bufferRecorder = null;
|
||||||
bufferChunks = [];
|
bufferChunks = [];
|
||||||
if (mirrorCanvasInterval) { clearInterval(mirrorCanvasInterval); mirrorCanvasInterval = null; }
|
bufferInitChunk = null;
|
||||||
if (mirrorCanvas) { mirrorCanvas.remove(); mirrorCanvas = null; }
|
// canvas and audioCtx are reused — only cleared on camera switch
|
||||||
if (mirrorVideo) { if (mirrorVideo._hls) mirrorVideo._hls.destroy(); mirrorVideo.remove(); mirrorVideo = null; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecordStream() {
|
function getOrCreateCanvasStream() {
|
||||||
const wrap = document.getElementById('camFeaturedWrap');
|
const wrap = document.getElementById('camFeaturedWrap');
|
||||||
if (!wrap) return null;
|
if (!wrap) return null;
|
||||||
const featVideo = wrap.querySelector('video');
|
const featVideo = wrap.querySelector('video');
|
||||||
if (!featVideo) return null;
|
if (!featVideo) return null;
|
||||||
|
|
||||||
if (mirrorCanvasInterval) { clearInterval(mirrorCanvasInterval); mirrorCanvasInterval = null; }
|
// Always use a 960x540 canvas for the buffer — fixes background tab throttling
|
||||||
if (mirrorCanvas) { mirrorCanvas.remove(); mirrorCanvas = null; }
|
// and ensures consistent 540p output regardless of source resolution
|
||||||
|
if (!bufferCanvas || !bufferCanvas.isConnected) {
|
||||||
|
if (bufferCanvas) bufferCanvas.remove();
|
||||||
|
bufferCanvas = document.createElement('canvas');
|
||||||
|
bufferCanvas.width = 960;
|
||||||
|
bufferCanvas.height = 540;
|
||||||
|
bufferCanvas.style.cssText = 'position:fixed;top:-9999px;';
|
||||||
|
document.body.appendChild(bufferCanvas);
|
||||||
|
}
|
||||||
|
const ctx = bufferCanvas.getContext('2d');
|
||||||
|
if (bufferCanvasInterval) clearInterval(bufferCanvasInterval);
|
||||||
|
|
||||||
mirrorCanvas = document.createElement('canvas');
|
function drawFrame() {
|
||||||
mirrorCanvas.width = 960;
|
if (!bufferCanvas || !bufferCanvas.isConnected) return;
|
||||||
mirrorCanvas.height = 540;
|
if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 960, 540);
|
||||||
mirrorCanvas.style.cssText = 'position:fixed;top:-9999px;';
|
if (typeof featVideo.requestVideoFrameCallback === 'function') {
|
||||||
document.body.appendChild(mirrorCanvas);
|
featVideo.requestVideoFrameCallback(drawFrame);
|
||||||
const ctx = mirrorCanvas.getContext('2d');
|
|
||||||
|
|
||||||
mirrorCanvasInterval = setInterval(() => {
|
|
||||||
if (featVideo.readyState >= 2) {
|
|
||||||
ctx.drawImage(featVideo, 0, 0, 960, 540);
|
|
||||||
}
|
}
|
||||||
}, 1000 / 30);
|
}
|
||||||
|
if (typeof featVideo.requestVideoFrameCallback === 'function') {
|
||||||
const canvasStream = mirrorCanvas.captureStream(30);
|
featVideo.requestVideoFrameCallback(drawFrame);
|
||||||
|
} else {
|
||||||
// Always capture audio into the buffer — stripping happens at export time
|
bufferCanvasInterval = setInterval(() => {
|
||||||
try {
|
if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 960, 540);
|
||||||
const audioCtx = new AudioContext();
|
}, 1000 / 30);
|
||||||
const source = audioCtx.createMediaElementSource(featVideo);
|
|
||||||
const dest = audioCtx.createMediaStreamDestination();
|
|
||||||
source.connect(dest);
|
|
||||||
source.connect(audioCtx.destination);
|
|
||||||
dest.stream.getAudioTracks().forEach(t => canvasStream.addTrack(t));
|
|
||||||
} catch(e) {
|
|
||||||
console.warn('Audio capture failed (stream may not have audio):', e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return canvasStream;
|
const stream = bufferCanvas.captureStream(30);
|
||||||
|
|
||||||
|
// Audio: create AudioContext once per video element
|
||||||
|
if (!bufferAudioCtx) {
|
||||||
|
try {
|
||||||
|
bufferAudioCtx = new AudioContext();
|
||||||
|
const src = bufferAudioCtx.createMediaElementSource(featVideo);
|
||||||
|
const dest = bufferAudioCtx.createMediaStreamDestination();
|
||||||
|
// recordGain: always 1.0 — buffer is always recorded at full volume
|
||||||
|
const recordGain = bufferAudioCtx.createGain();
|
||||||
|
recordGain.gain.value = 1.0;
|
||||||
|
// speakerGain: controlled by volume slider
|
||||||
|
const speakerGain = bufferAudioCtx.createGain();
|
||||||
|
const slider = document.getElementById('featuredVolume');
|
||||||
|
speakerGain.gain.value = slider ? parseFloat(slider.value) : 1.0;
|
||||||
|
src.connect(recordGain);
|
||||||
|
recordGain.connect(dest); // to buffer — always full
|
||||||
|
src.connect(speakerGain);
|
||||||
|
speakerGain.connect(bufferAudioCtx.destination); // to speakers — follows slider
|
||||||
|
bufferAudioCtx._stream = dest.stream;
|
||||||
|
bufferAudioCtx._gain = speakerGain; // slider updates this
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('[Buffer] Audio setup failed:', e);
|
||||||
|
bufferAudioCtx = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add audio tracks to stream if not already present
|
||||||
|
if (bufferAudioCtx && bufferAudioCtx._stream) {
|
||||||
|
const existingAudio = stream.getAudioTracks();
|
||||||
|
if (existingAudio.length === 0) {
|
||||||
|
bufferAudioCtx._stream.getAudioTracks().forEach(t => stream.addTrack(t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
function startBuffer() {
|
function startBuffer() {
|
||||||
stopBuffer();
|
stopBuffer(); // clears recorder + chunks, keeps canvas + audioCtx
|
||||||
const stream = getRecordStream();
|
|
||||||
|
const stream = getOrCreateCanvasStream();
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm';
|
|
||||||
try { bufferRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); }
|
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
|
||||||
catch(e) { return; }
|
? 'video/webm;codecs=vp9,opus'
|
||||||
bufferChunks = [];
|
: MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||||
bufferInitChunk = null; // reset so first chunk of new recorder is captured as init
|
? 'video/webm;codecs=vp9'
|
||||||
|
: 'video/webm';
|
||||||
|
|
||||||
|
try { bufferRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 1800000 }); }
|
||||||
|
catch(e) { console.warn('[Buffer] MediaRecorder creation failed:', e); return; }
|
||||||
|
|
||||||
|
let isFirstChunk = true;
|
||||||
bufferRecorder.ondataavailable = e => {
|
bufferRecorder.ondataavailable = e => {
|
||||||
if (!e.data || e.data.size === 0) return;
|
if (!e.data || e.data.size === 0) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!bufferInitChunk) {
|
if (isFirstChunk) {
|
||||||
// First chunk is always the WebM init segment — store separately
|
isFirstChunk = false;
|
||||||
bufferInitChunk = e.data;
|
bufferInitChunk = e.data; // init segment
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bufferChunks.push({ data: e.data, ts: now });
|
bufferChunks.push({ data: e.data, ts: now });
|
||||||
const cutoff = now - BUFFER_SECS * 1000;
|
const cutoff = now - BUFFER_SECS * 1000;
|
||||||
bufferChunks = bufferChunks.filter(c => c.ts >= cutoff);
|
bufferChunks = bufferChunks.filter(c => c.ts >= cutoff);
|
||||||
};
|
};
|
||||||
bufferRecorder._getBlob = (mimeType) => {
|
|
||||||
|
bufferRecorder._getBlob = () => {
|
||||||
const chunks = bufferInitChunk
|
const chunks = bufferInitChunk
|
||||||
? [bufferInitChunk, ...bufferChunks.map(c => c.data)]
|
? [bufferInitChunk, ...bufferChunks.map(c => c.data)]
|
||||||
: bufferChunks.map(c => c.data);
|
: bufferChunks.map(c => c.data);
|
||||||
return new Blob(chunks, { type: mimeType });
|
return new Blob(chunks, { type: mimeType });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
bufferRecorder.onerror = () => {
|
||||||
|
console.warn('[Buffer] Recorder error, restarting...');
|
||||||
|
setTimeout(startBuffer, 500);
|
||||||
|
};
|
||||||
|
|
||||||
bufferRecorder.start(250);
|
bufferRecorder.start(250);
|
||||||
|
console.log('[Buffer] Started. mimeType:', mimeType);
|
||||||
|
|
||||||
|
// Keep AudioContext running in background — prevents browser from throttling
|
||||||
|
if (bufferAudioCtx && bufferAudioCtx.state === 'suspended') {
|
||||||
|
bufferAudioCtx.resume();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sizeLimitEnabled = false;
|
// Acquire a Web Lock to prevent browser from throttling background tab
|
||||||
const SIZE_LIMIT_MB = 3.9;
|
// This keeps timers, video playback and canvas captures running at full speed
|
||||||
|
if (navigator.locks) {
|
||||||
|
navigator.locks.request('ft_buffer_keepalive', { mode: 'shared' }, () => {
|
||||||
|
return new Promise(() => {}); // never resolve — holds lock for page lifetime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// When tab becomes visible again, resume video and audio
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
const clipBtn = document.getElementById('clipBtn');
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
// Warn user buffer quality will degrade
|
||||||
|
if (clipBtn) { clipBtn.style.borderColor = 'var(--accent3)'; clipBtn.style.color = 'var(--accent3)'; clipBtn.title = 'Buffer quality reduced — tab is not active'; }
|
||||||
|
} else {
|
||||||
|
const wrap = document.getElementById('camFeaturedWrap');
|
||||||
|
const featVideo = wrap && wrap.querySelector('video');
|
||||||
|
if (featVideo && featVideo.paused) featVideo.play().catch(() => {});
|
||||||
|
if (bufferAudioCtx && bufferAudioCtx.state === 'suspended') bufferAudioCtx.resume();
|
||||||
|
// Reset clip button
|
||||||
|
if (clipBtn) { clipBtn.style.borderColor = ''; clipBtn.style.color = ''; clipBtn.title = ''; }
|
||||||
|
// Restart buffer fresh when returning — old frames are degraded
|
||||||
|
setTimeout(() => { stopBuffer(); bufferAudioCtx = null; setTimeout(startBuffer, 300); }, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check — restart only if truly dead
|
||||||
|
setInterval(() => {
|
||||||
|
if (bufferRecorder && bufferRecorder.state === 'inactive') {
|
||||||
|
console.warn('[Buffer] Detected inactive recorder, restarting...');
|
||||||
|
setTimeout(startBuffer, 500);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
function takeScreenshot() {
|
function takeScreenshot() {
|
||||||
const wrap = document.getElementById('camFeaturedWrap');
|
const wrap = document.getElementById('camFeaturedWrap');
|
||||||
@@ -2441,92 +2715,7 @@
|
|||||||
}, 'image/png');
|
}, 'image/png');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSizeLimit() {
|
|
||||||
sizeLimitEnabled = !sizeLimitEnabled;
|
|
||||||
const btn = document.getElementById('sizeLimitToggle');
|
|
||||||
if (sizeLimitEnabled) {
|
|
||||||
btn.style.color = 'var(--accent3)';
|
|
||||||
btn.style.borderColor = 'var(--accent3)';
|
|
||||||
} else {
|
|
||||||
btn.style.color = 'var(--muted)';
|
|
||||||
btn.style.borderColor = 'var(--muted)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Record (forward, manual stop) ──────────────────────────
|
|
||||||
let mediaRecorder = null;
|
|
||||||
let clipChunks = [];
|
|
||||||
let clipTimeout = null;
|
|
||||||
|
|
||||||
function startRecord() {
|
|
||||||
ensureBuffer();
|
|
||||||
const btn = document.getElementById('recordBtn');
|
|
||||||
const wrap = document.getElementById('camFeaturedWrap');
|
|
||||||
if (!wrap) return;
|
|
||||||
const video = wrap.querySelector('video');
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
||||||
stopRecord();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stream = getRecordStream();
|
|
||||||
if (!stream) { alert('Could not capture stream'); return; }
|
|
||||||
|
|
||||||
clipChunks = [];
|
|
||||||
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm';
|
|
||||||
try { mediaRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); }
|
|
||||||
catch(e) { alert('MediaRecorder error: ' + e.message); return; }
|
|
||||||
|
|
||||||
mediaRecorder.ondataavailable = e => {
|
|
||||||
if (e.data && e.data.size > 0) {
|
|
||||||
clipChunks.push(e.data);
|
|
||||||
const totalBytes = clipChunks.reduce((s, c) => s + c.size, 0);
|
|
||||||
const mb = (totalBytes / 1048576).toFixed(1);
|
|
||||||
const sizeEl = document.getElementById('recSize');
|
|
||||||
if (sizeEl) {
|
|
||||||
sizeEl.textContent = mb + ' MB';
|
|
||||||
sizeEl.style.color = parseFloat(mb) > 3.5 ? 'var(--accent2)' : '#e2e8f0';
|
|
||||||
}
|
|
||||||
if (sizeLimitEnabled && totalBytes >= (SIZE_LIMIT_MB - 0.3) * 1048576) {
|
|
||||||
stopRecord();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
|
||||||
const blob = new Blob(clipChunks, { type: mimeType });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const wrap = document.getElementById('camFeaturedWrap');
|
|
||||||
const label = wrap ? wrap.querySelector('.cam-label') : null;
|
|
||||||
const camName = label ? label.textContent.replace(/[^a-z0-9]/gi, '_').toLowerCase() : 'rec';
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = camName + '_rec_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
btn.textContent = '⏺ REC';
|
|
||||||
btn.style.borderColor = '';
|
|
||||||
btn.style.color = '';
|
|
||||||
mediaRecorder = null;
|
|
||||||
if (clipTimeout) { clearTimeout(clipTimeout); clipTimeout = null; }
|
|
||||||
const sizeEl = document.getElementById('recSize');
|
|
||||||
if (sizeEl) sizeEl.style.display = 'none';
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaRecorder.start(250);
|
|
||||||
btn.textContent = '⏹ STOP';
|
|
||||||
btn.style.borderColor = 'var(--accent2)';
|
|
||||||
btn.style.color = 'var(--accent2)';
|
|
||||||
const sizeEl = document.getElementById('recSize');
|
|
||||||
if (sizeEl) { sizeEl.textContent = '0.0 MB'; sizeEl.style.display = ''; }
|
|
||||||
clipTimeout = setTimeout(() => stopRecord(), 3600000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecord() {
|
|
||||||
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureBuffer(force) {
|
function ensureBuffer(force) {
|
||||||
if (force || !bufferRecorder || bufferRecorder.state === 'inactive') {
|
if (force || !bufferRecorder || bufferRecorder.state === 'inactive') {
|
||||||
@@ -2575,7 +2764,6 @@
|
|||||||
<span id="clipExportLabel">Exporting...</span>
|
<span id="clipExportLabel">Exporting...</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn stop" onclick="closeClipEditor()">CANCEL</button>
|
<button class="btn stop" onclick="closeClipEditor()">CANCEL</button>
|
||||||
<button class="btn" id="clipAudioBtn" onclick="toggleClipAudio()" style="border-color:var(--green);color:var(--green);">🔊 AUDIO</button>
|
|
||||||
<button class="btn" id="clipSaveFullBtn" onclick="downloadClipFull()" title="Saves full buffer — audio always included">SAVE FULL</button>
|
<button class="btn" id="clipSaveFullBtn" onclick="downloadClipFull()" title="Saves full buffer — audio always included">SAVE FULL</button>
|
||||||
<button class="btn" id="clipSaveTrimBtn" onclick="exportTrimmedClip()">SAVE TRIMMED</button>
|
<button class="btn" id="clipSaveTrimBtn" onclick="exportTrimmedClip()">SAVE TRIMMED</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2608,7 +2796,7 @@
|
|||||||
document.getElementById('clipInLabel').textContent = inTime.toFixed(1) + 's';
|
document.getElementById('clipInLabel').textContent = inTime.toFixed(1) + 's';
|
||||||
document.getElementById('clipOutLabel').textContent = outTime.toFixed(1) + 's';
|
document.getElementById('clipOutLabel').textContent = outTime.toFixed(1) + 's';
|
||||||
const trimDur = Math.max(0, outTime - inTime);
|
const trimDur = Math.max(0, outTime - inTime);
|
||||||
const estMB = (trimDur * 4000000 / 8 / 1048576).toFixed(1);
|
const estMB = (trimDur * 1800000 / 8 / 1048576).toFixed(1);
|
||||||
document.getElementById('clipDurLabel').textContent = trimDur.toFixed(1) + 's (~' + estMB + ' MB)';
|
document.getElementById('clipDurLabel').textContent = trimDur.toFixed(1) + 's (~' + estMB + ' MB)';
|
||||||
fill.style.left = (inTime / d * 100) + '%';
|
fill.style.left = (inTime / d * 100) + '%';
|
||||||
fill.style.width = (trimDur / d * 100) + '%';
|
fill.style.width = (trimDur / d * 100) + '%';
|
||||||
@@ -2659,7 +2847,6 @@
|
|||||||
playbackWatcher = setInterval(() => {
|
playbackWatcher = setInterval(() => {
|
||||||
if (video.currentTime >= outTime - 0.05) {
|
if (video.currentTime >= outTime - 0.05) {
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
video.currentTime = parseFloat(inSlider.value);
|
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
@@ -2667,45 +2854,21 @@
|
|||||||
|
|
||||||
video.addEventListener('click', () => video.paused ? playFromIn() : stopPlayback());
|
video.addEventListener('click', () => video.paused ? playFromIn() : stopPlayback());
|
||||||
|
|
||||||
// Audio toggle state for this clip session
|
// Set up audio routing for preview — createMediaElementSource can only be called once
|
||||||
let clipIncludeAudio = true;
|
|
||||||
video._includeAudio = true;
|
|
||||||
|
|
||||||
// Set up audio routing once — createMediaElementSource can only be called once per element
|
|
||||||
let clipAudioDest = null;
|
|
||||||
try {
|
try {
|
||||||
const clipAudioCtx = new AudioContext();
|
const clipAudioCtx = new AudioContext();
|
||||||
const clipSrc = clipAudioCtx.createMediaElementSource(video);
|
const clipSrc = clipAudioCtx.createMediaElementSource(video);
|
||||||
clipAudioDest = clipAudioCtx.createMediaStreamDestination();
|
const clipAudioDest = clipAudioCtx.createMediaStreamDestination();
|
||||||
clipSrc.connect(clipAudioDest);
|
clipSrc.connect(clipAudioDest);
|
||||||
clipSrc.connect(clipAudioCtx.destination); // so preview plays audio
|
clipSrc.connect(clipAudioCtx.destination); // preview plays audio at full volume
|
||||||
video._audioStream = clipAudioDest.stream;
|
video._audioStream = clipAudioDest.stream;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.warn('Clip editor audio setup failed:', e);
|
console.warn('Clip editor audio setup failed:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
window._toggleClipAudio = function() {
|
|
||||||
clipIncludeAudio = !clipIncludeAudio;
|
|
||||||
video._includeAudio = clipIncludeAudio;
|
|
||||||
const btn = document.getElementById('clipAudioBtn');
|
|
||||||
if (clipIncludeAudio) {
|
|
||||||
btn.textContent = '🔊 AUDIO';
|
|
||||||
btn.style.borderColor = 'var(--green)';
|
|
||||||
btn.style.color = 'var(--green)';
|
|
||||||
} else {
|
|
||||||
btn.textContent = '🔇 NO AUDIO';
|
|
||||||
btn.style.borderColor = 'var(--muted)';
|
|
||||||
btn.style.color = 'var(--muted)';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
video.load();
|
video.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleClipAudio() {
|
|
||||||
if (window._toggleClipAudio) window._toggleClipAudio();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeClipEditor() {
|
function closeClipEditor() {
|
||||||
const backdrop = document.querySelector('.clip-modal-backdrop');
|
const backdrop = document.querySelector('.clip-modal-backdrop');
|
||||||
if (!backdrop) return;
|
if (!backdrop) return;
|
||||||
@@ -2719,14 +2882,35 @@
|
|||||||
setTimeout(() => ensureBuffer(), 500);
|
setTimeout(() => ensureBuffer(), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadClipFull() {
|
async function downloadClipFull() {
|
||||||
const video = document.getElementById('clipEditorVideo');
|
const video = document.getElementById('clipEditorVideo');
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
// Note: SAVE FULL always includes audio if buffered — use SAVE TRIMMED to strip audio
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const baseName = video._camName + '_clip_' + ts;
|
||||||
|
|
||||||
|
// Download with audio
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = video._blobUrl;
|
a.href = video._blobUrl; a.download = baseName + '.webm'; a.click();
|
||||||
a.download = video._camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
|
|
||||||
a.click();
|
// Auto-strip audio copy
|
||||||
|
try {
|
||||||
|
const blob = await fetch(video._blobUrl).then(r => r.blob());
|
||||||
|
const res = await fetch('http://localhost:3000/strip-audio', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'video/webm' },
|
||||||
|
body: blob,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const noAudio = await res.blob();
|
||||||
|
const url2 = URL.createObjectURL(noAudio);
|
||||||
|
const a2 = document.createElement('a');
|
||||||
|
a2.href = url2; a2.download = baseName + '_noaudio.webm'; a2.click();
|
||||||
|
URL.revokeObjectURL(url2);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('Auto strip-audio failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
closeClipEditor();
|
closeClipEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2754,7 +2938,7 @@
|
|||||||
|
|
||||||
const camName = video._camName;
|
const camName = video._camName;
|
||||||
const blobUrl = video._blobUrl;
|
const blobUrl = video._blobUrl;
|
||||||
const includeAudio = video._includeAudio;
|
const includeAudio = true; // always export with audio; no-audio copy handled by server
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
let drawInterval = null;
|
let drawInterval = null;
|
||||||
@@ -2762,7 +2946,7 @@
|
|||||||
// Use a hidden video element for export — fresh audio context, independent of preview
|
// Use a hidden video element for export — fresh audio context, independent of preview
|
||||||
const ev = document.createElement('video');
|
const ev = document.createElement('video');
|
||||||
ev.src = blobUrl;
|
ev.src = blobUrl;
|
||||||
ev.muted = false;
|
ev.muted = false; // independent of main volume — export always at full volume
|
||||||
ev.style.cssText = 'position:fixed;top:-9999px;width:1px;height:1px;';
|
ev.style.cssText = 'position:fixed;top:-9999px;width:1px;height:1px;';
|
||||||
document.body.appendChild(ev);
|
document.body.appendChild(ev);
|
||||||
|
|
||||||
@@ -2771,8 +2955,9 @@
|
|||||||
: (MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm');
|
: (MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm');
|
||||||
|
|
||||||
function startRecording() {
|
function startRecording() {
|
||||||
canvas.width = ev.videoWidth || 960;
|
// Export at 540p regardless of source resolution — maximises duration at given bitrate
|
||||||
canvas.height = ev.videoHeight || 540;
|
canvas.width = 960;
|
||||||
|
canvas.height = 540;
|
||||||
|
|
||||||
const stream = canvas.captureStream(30);
|
const stream = canvas.captureStream(30);
|
||||||
|
|
||||||
@@ -2786,19 +2971,40 @@
|
|||||||
} catch(e) { console.warn('Export audio setup failed:', e); }
|
} catch(e) { console.warn('Export audio setup failed:', e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const recorder = new MediaRecorder(stream, { mimeType: exportMimeType, videoBitsPerSecond: 4000000 });
|
const recorder = new MediaRecorder(stream, { mimeType: exportMimeType, videoBitsPerSecond: 1800000 });
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
|
|
||||||
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
|
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
|
||||||
recorder.onstop = () => {
|
recorder.onstop = async () => {
|
||||||
ev.remove();
|
ev.remove();
|
||||||
const trimmed = new Blob(chunks, { type: exportMimeType });
|
const trimmed = new Blob(chunks, { type: exportMimeType });
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const baseName = camName + '_trimmed_' + ts;
|
||||||
|
|
||||||
|
// Download with audio
|
||||||
const url = URL.createObjectURL(trimmed);
|
const url = URL.createObjectURL(trimmed);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url; a.download = baseName + '.webm'; a.click();
|
||||||
a.download = camName + '_trimmed_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Auto-strip audio and download second copy via server FFmpeg
|
||||||
|
try {
|
||||||
|
const res = await fetch('http://localhost:3000/strip-audio', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'video/webm' },
|
||||||
|
body: trimmed,
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const noAudio = await res.blob();
|
||||||
|
const url2 = URL.createObjectURL(noAudio);
|
||||||
|
const a2 = document.createElement('a');
|
||||||
|
a2.href = url2; a2.download = baseName + '_noaudio.webm'; a2.click();
|
||||||
|
URL.revokeObjectURL(url2);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.warn('Auto strip-audio failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
closeClipEditor();
|
closeClipEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2833,12 +3039,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Record (forward, manual stop) ──────────────────────────
|
// ── Record (forward, manual stop) ──────────────────────────
|
||||||
let recordAudio = false;
|
|
||||||
|
|
||||||
function toggleRecordAudio() {
|
|
||||||
// Kept for REC button compatibility — buffer always records audio now
|
|
||||||
recordAudio = !recordAudio;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2992,7 +3193,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
hlsInstances['featured'] = makeHls(CAMERAS[DEFAULT_IDX][1], featVideo, false);
|
hlsInstances['featured'] = makeHls(CAMERAS[DEFAULT_IDX][1], featVideo, false);
|
||||||
featVideo.addEventListener('canplay', () => { const s = document.getElementById('featuredVolume'); if (s) featVideo.volume = parseFloat(s.value); }, { once: true });
|
featVideo.addEventListener('canplay', () => { const s = document.getElementById('featuredVolume'); if (s) { featVideo.volume = bufferAudioCtx && bufferAudioCtx._gain ? 1.0 : parseFloat(s.value); if (bufferAudioCtx && bufferAudioCtx._gain) bufferAudioCtx._gain.gain.value = parseFloat(s.value); } }, { once: true });
|
||||||
|
|
||||||
// Build thumb grid — skip Director Mode and Cameraman
|
// Build thumb grid — skip Director Mode and Cameraman
|
||||||
const cammanIdx = CAMERAS.findIndex(([,s]) => s === "cameraman2-5");
|
const cammanIdx = CAMERAS.findIndex(([,s]) => s === "cameraman2-5");
|
||||||
@@ -3046,7 +3247,17 @@
|
|||||||
const wrap = document.getElementById('camFeaturedWrap');
|
const wrap = document.getElementById('camFeaturedWrap');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
const video = wrap.querySelector('video');
|
const video = wrap.querySelector('video');
|
||||||
if (video) video.volume = parseFloat(val);
|
if (!video) return;
|
||||||
|
const v = parseFloat(val);
|
||||||
|
if (bufferAudioCtx && bufferAudioCtx._gain) {
|
||||||
|
// Route volume through GainNode — video element stays at volume 1.0
|
||||||
|
// so the buffer always captures full volume regardless of slider position
|
||||||
|
video.volume = 1.0;
|
||||||
|
bufferAudioCtx._gain.gain.value = v;
|
||||||
|
} else {
|
||||||
|
// AudioContext not set up yet — fall back to video volume
|
||||||
|
video.volume = v;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateClock() {
|
function updateClock() {
|
||||||
@@ -3058,7 +3269,40 @@
|
|||||||
|
|
||||||
async function slowTick() {
|
async function slowTick() {
|
||||||
if (!getToken()) return;
|
if (!getToken()) return;
|
||||||
await fetchStocks();
|
await Promise.all([fetchStocks(), fetchContestants()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchContestants() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(BASE + '/v1/contestants', { headers: headers() });
|
||||||
|
if (!r.ok) throw new Error(r.status);
|
||||||
|
const data = await r.json();
|
||||||
|
renderContestants(data.contestants || []);
|
||||||
|
// Refresh chart so it picks up the new colors
|
||||||
|
if (stocksChart) { stocksChart.destroy(); stocksChart = null; fetchStocks(); }
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Contestants error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map ticker -> color/photo/endorsements from API
|
||||||
|
const contestantTickerColors = {};
|
||||||
|
|
||||||
|
function renderContestants(contestants) {
|
||||||
|
// Update ticker maps
|
||||||
|
window._contestantPhotos = {};
|
||||||
|
window._contestantEndorsements = {};
|
||||||
|
window._contestantJobs = {};
|
||||||
|
window._contestantEliminated = {};
|
||||||
|
contestants.forEach(c => {
|
||||||
|
if (c.tickerSymbol) {
|
||||||
|
if (c.color) contestantTickerColors[c.tickerSymbol] = c.color;
|
||||||
|
if (c.photo) window._contestantPhotos[c.tickerSymbol] = c.photo;
|
||||||
|
if (c.endorsements !== undefined) window._contestantEndorsements[c.tickerSymbol] = c.endorsements;
|
||||||
|
if (c.job) window._contestantJobs[c.tickerSymbol] = c.job;
|
||||||
|
if (c.eliminatedAt) window._contestantEliminated[c.tickerSymbol] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
@@ -3066,6 +3310,7 @@
|
|||||||
fetchPoll();
|
fetchPoll();
|
||||||
fetchTTSHistory();
|
fetchTTSHistory();
|
||||||
fetchFeatureToggles();
|
fetchFeatureToggles();
|
||||||
|
fetchContestants();
|
||||||
slowTick();
|
slowTick();
|
||||||
if (!slowIntervalId) {
|
if (!slowIntervalId) {
|
||||||
slowIntervalId = setInterval(slowTick, 60000);
|
slowIntervalId = setInterval(slowTick, 60000);
|
||||||
@@ -3108,13 +3353,13 @@
|
|||||||
// Apply saved thumbnail interval from dropdown
|
// Apply saved thumbnail interval from dropdown
|
||||||
const iv = document.getElementById('intervalSelect');
|
const iv = document.getElementById('intervalSelect');
|
||||||
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
||||||
// Recalc on resize
|
// Recalc on resize — use lambda so reference resolves at call time not definition time
|
||||||
window.addEventListener('resize', recalcGridRows);
|
window.addEventListener('resize', () => window.recalcGridRows && window.recalcGridRows());
|
||||||
window.addEventListener('load', recalcGridRows);
|
window.addEventListener('load', () => window.recalcGridRows && window.recalcGridRows());
|
||||||
// Poll recalc for 3s after load to catch any layout settling
|
// Poll recalc for 3s after load to catch any layout settling
|
||||||
let _recalcCount = 0;
|
let _recalcCount = 0;
|
||||||
const _recalcInit = setInterval(() => {
|
const _recalcInit = setInterval(() => {
|
||||||
recalcGridRows();
|
if (window.recalcGridRows) window.recalcGridRows();
|
||||||
if (++_recalcCount >= 6) clearInterval(_recalcInit);
|
if (++_recalcCount >= 6) clearInterval(_recalcInit);
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
@@ -3218,7 +3463,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg._ft === 'event') {
|
if (msg._ft === 'event') {
|
||||||
console.log(`[FT-WS EVENT] "${msg.event}"`, msg.data);
|
if (msg.event !== 'chat:message') console.log(`[FT-WS EVENT] "${msg.event}"`, msg.data);
|
||||||
if (msg.event === 'notification:global') {
|
if (msg.event === 'notification:global') {
|
||||||
const [message, subtitle] = Array.isArray(msg.data) ? msg.data : [msg.data, ''];
|
const [message, subtitle] = Array.isArray(msg.data) ? msg.data : [msg.data, ''];
|
||||||
if (message) showNotif(message, subtitle || '');
|
if (message) showNotif(message, subtitle || '');
|
||||||
@@ -3324,7 +3569,7 @@
|
|||||||
let notifTimer = null;
|
let notifTimer = null;
|
||||||
|
|
||||||
// ── Grid row recalculation ───────────────────────────────────
|
// ── Grid row recalculation ───────────────────────────────────
|
||||||
function recalcGridRows() {
|
window.recalcGridRows = function recalcGridRows() {
|
||||||
const grid = document.getElementById('cameraGrid');
|
const grid = document.getElementById('cameraGrid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
const gridW = grid.clientWidth;
|
const gridW = grid.clientWidth;
|
||||||
@@ -3350,7 +3595,7 @@
|
|||||||
const capH = visibleRows * thumbH + (visibleRows - 1) * gap + pad * 2;
|
const capH = visibleRows * thumbH + (visibleRows - 1) * gap + pad * 2;
|
||||||
grid.style.height = capH + 'px';
|
grid.style.height = capH + 'px';
|
||||||
grid.style.maxHeight = capH + 'px';
|
grid.style.maxHeight = capH + 'px';
|
||||||
}
|
};
|
||||||
// ── End grid row recalculation ────────────────────────────────
|
// ── End grid row recalculation ────────────────────────────────
|
||||||
|
|
||||||
// ── Stocks panel collapse ────────────────────────────────────
|
// ── Stocks panel collapse ────────────────────────────────────
|
||||||
|
|||||||
58
server.js
58
server.js
@@ -4,6 +4,8 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const url = require('url');
|
const url = require('url');
|
||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
|
const os = require('os');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
@@ -424,6 +426,61 @@ const server = http.createServer((req, res) => {
|
|||||||
}); return;
|
}); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.pathname === '/strip-audio') {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
// Accept webm upload, strip audio with ffmpeg, return result
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', c => chunks.push(c));
|
||||||
|
req.on('end', () => {
|
||||||
|
const inputBuf = Buffer.concat(chunks);
|
||||||
|
const tmpIn = path.join(os.tmpdir(), 'ft_strip_in_' + Date.now() + '.webm');
|
||||||
|
const tmpOut = path.join(os.tmpdir(), 'ft_strip_out_' + Date.now() + '.webm');
|
||||||
|
fs.writeFile(tmpIn, inputBuf, err => {
|
||||||
|
if (err) { res.writeHead(500); res.end('Write error'); return; }
|
||||||
|
// -an = no audio, -c:v copy = copy video stream without re-encoding
|
||||||
|
// Find ffmpeg — try PATH first, then common Windows locations
|
||||||
|
const ffmpegCandidates = [
|
||||||
|
'ffmpeg',
|
||||||
|
'ffmpeg.exe',
|
||||||
|
'C:\\ffmpeg\\bin\\ffmpeg.exe',
|
||||||
|
'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',
|
||||||
|
process.env.APPDATA + '\\ffmpeg\\bin\\ffmpeg.exe',
|
||||||
|
];
|
||||||
|
const ffmpegBin = ffmpegCandidates[0]; // will try with shell:true to use PATH
|
||||||
|
console.log('[STRIP] running ffmpeg:', tmpIn, '->', tmpOut);
|
||||||
|
execFile(ffmpegBin, ['-y', '-i', tmpIn, '-an', '-c:v', 'copy', tmpOut],
|
||||||
|
{ shell: true }, (err, stdout, stderr) => {
|
||||||
|
console.log('[STRIP] ffmpeg done. err:', err && err.code, 'stderr:', stderr && stderr.slice(0,300));
|
||||||
|
fs.unlink(tmpIn, () => {});
|
||||||
|
if (err) {
|
||||||
|
fs.unlink(tmpOut, () => {});
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('FFmpeg error: ' + (stderr || err.message || 'unknown'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.readFile(tmpOut, (err, data) => {
|
||||||
|
fs.unlink(tmpOut, () => {});
|
||||||
|
if (err) { res.writeHead(500); res.end('Read error'); return; }
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'video/webm',
|
||||||
|
'Content-Disposition': 'attachment; filename="noaudio.webm"',
|
||||||
|
'Content-Length': data.length,
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
});
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}); return;
|
||||||
|
}
|
||||||
|
const file = path.join(__dirname, 'strip-audio.html');
|
||||||
|
fs.readFile(file, (err, data) => {
|
||||||
|
if (err) { res.writeHead(404); res.end('Tool not found'); return; }
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(data);
|
||||||
|
}); return;
|
||||||
|
}
|
||||||
|
|
||||||
// Token registration from dashboard
|
// Token registration from dashboard
|
||||||
if (parsed.pathname === '/ws-token' && req.method === 'POST') {
|
if (parsed.pathname === '/ws-token' && req.method === 'POST') {
|
||||||
let body = '';
|
let body = '';
|
||||||
@@ -480,7 +537,6 @@ const server = http.createServer((req, res) => {
|
|||||||
const rawText = body.toString('utf8');
|
const rawText = body.toString('utf8');
|
||||||
// Log first segment line to debug path format
|
// Log first segment line to debug path format
|
||||||
const firstSeg = rawText.split('\n').find(l => l.trim() && !l.startsWith('#'));
|
const firstSeg = rawText.split('\n').find(l => l.trim() && !l.startsWith('#'));
|
||||||
console.log('[CAM] sub-playlist first line:', JSON.stringify(firstSeg));
|
|
||||||
const basePath = slug + '/' + parts.slice(1, -1).join('/') + '/';
|
const basePath = slug + '/' + parts.slice(1, -1).join('/') + '/';
|
||||||
const rewritten = rewriteM3u8(rawText, basePath);
|
const rewritten = rewriteM3u8(rawText, basePath);
|
||||||
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' });
|
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' });
|
||||||
|
|||||||
Reference in New Issue
Block a user