Files
facefusion/test_stream.html
T
2026-03-23 14:20:37 +01:00

1236 lines
42 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Stream</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { overflow: hidden; height: 100vh; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0f; color: #c8c8d0; }
.layout { display: grid; grid-template-columns: 340px 1fr; height: 100vh; }
.sidebar { background: #12121a; border-right: 1px solid #1e1e2e; padding: 1.2rem; overflow-y: auto; scrollbar-width: none; -ms-overflow-style: none; }
.sidebar::-webkit-scrollbar { display: none; }
.sidebar h2 { font-size: 1rem; color: #fff; margin-bottom: 0.8rem; }
.sidebar h2 .badge { font-size: 0.65rem; padding: 0.15rem 0.4rem; border-radius: 4px; margin-left: 0.4rem; vertical-align: middle; background: linear-gradient(135deg, #6c5ce7, #a855f7); color: #fff; }
.section { margin-bottom: 1rem; }
.section-title { font-size: 0.7rem; color: #666; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
.section-title .step-dot { width: 18px; height: 18px; border-radius: 50%; background: #1e1e2e; border: 1px solid #2a2a3a; display: flex; align-items: center; justify-content: center; font-size: 0.55rem; color: #555; flex-shrink: 0; }
.section-title .step-dot.done { background: #00b894; border-color: #00b894; color: #fff; }
.form-row { display: flex; gap: 0.5rem; margin-bottom: 0.6rem; }
.form-row label { font-size: 0.7rem; color: #888; flex: 1; }
.form-row input, .form-row select { width: 100%; padding: 0.35rem; border-radius: 6px; border: 1px solid #2a2a3a; background: #0a0a0f; color: #fff; font-size: 0.8rem; }
.upload-box { border: 2px dashed #2a2a3a; border-radius: 10px; padding: 1rem; text-align: center; cursor: pointer; transition: border-color 0.2s; margin-bottom: 0.6rem; min-height: 80px; display: flex; flex-direction: column; align-items: center; justify-content: center; overflow: hidden; }
.upload-box:hover { border-color: #6c5ce7; }
.upload-box.has-file { border-color: #00b894; border-style: solid; }
.upload-box.disabled { opacity: 0.35; pointer-events: none; }
.upload-box input[type="file"] { display: none; }
.upload-box .label { font-weight: 600; font-size: 0.85rem; }
.upload-box .hint { font-size: 0.7rem; color: #555; margin-top: 0.2rem; }
.upload-box .preview { width: 100%; height: 100%; border-radius: 6px; margin-top: 0.4rem; object-fit: cover; }
.upload-box .filename { font-size: 0.7rem; color: #888; margin-top: 0.2rem; word-break: break-all; }
.switch-row { display: flex; justify-content: space-between; align-items: center; padding: 0.3rem 0; font-size: 0.75rem; color: #888; }
.switch { position: relative; width: 36px; height: 20px; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch .slider { position: absolute; inset: 0; background: #2a2a3a; border-radius: 10px; cursor: pointer; transition: 0.2s; }
.switch .slider:before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; bottom: 3px; background: #666; border-radius: 50%; transition: 0.2s; }
.switch input:checked + .slider { background: #6c5ce7; }
.switch input:checked + .slider:before { transform: translateX(16px); background: #fff; }
.switch input:disabled + .slider { opacity: 0.35; cursor: default; }
.btn { display: block; width: 100%; padding: 0.7rem; border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 700; cursor: pointer; transition: all 0.2s; margin-bottom: 0.5rem; }
.btn-primary { background: linear-gradient(135deg, #6c5ce7, #a855f7); color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-danger { background: transparent; border: 1px solid #e74c3c; color: #e74c3c; }
.btn-danger:hover { background: #e74c3c22; }
.btn-danger:disabled { opacity: 0.3; cursor: not-allowed; }
.btn-secondary { background: #1e1e2e; color: #c8c8d0; border: 1px solid #2a2a3a; }
.btn-secondary:hover { border-color: #6c5ce7; }
.btn-secondary:disabled { opacity: 0.3; cursor: not-allowed; }
.btn-row { display: flex; gap: 0.5rem; margin-bottom: 0.6rem; }
.btn-row .btn { flex: 1; padding: 0.5rem; font-size: 0.8rem; }
.main { display: flex; flex-direction: column; overflow: hidden; }
.topbar { display: flex; gap: 1px; background: #1e1e2e; border-bottom: 1px solid #1e1e2e; flex-shrink: 0; }
.stat-card { flex: 1; background: #12121a; padding: 0.6rem 0.4rem; text-align: center; }
.stat-card .stat-value { font-size: 1.1rem; font-weight: 800; color: #fff; font-family: monospace; }
.stat-card .stat-label { font-size: 0.55rem; color: #666; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 0.1rem; }
.stat-card .stat-value.ok { color: #00b894; }
.stat-card .stat-value.warn { color: #f6ad55; }
.stat-card .stat-value.bad { color: #e74c3c; }
.video-area { flex: 1; display: flex; align-items: center; justify-content: center; padding: 1rem; position: relative; overflow: hidden; background: #08080c; }
#outputVideo { width: 100%; height: 100%; object-fit: contain; background: #000; border-radius: 8px; }
.timeline { display: none; align-items: stretch; padding: 0; background: #0e0e14; border-top: 1px solid #1e1e2e; border-bottom: 1px solid #1e1e2e; flex-shrink: 0; }
.timeline.visible { display: flex; }
.timeline .transport { display: flex; align-items: center; gap: 2px; padding: 0 0.4rem; background: #12121a; border-right: 1px solid #1e1e2e; }
.timeline .transport-btn { width: 36px; height: 36px; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; background: transparent; color: #888; transition: all 0.15s; }
.timeline .transport-btn:hover { background: #1e1e2e; color: #fff; }
.timeline .transport-btn:disabled { opacity: 0.25; cursor: not-allowed; }
.timeline .transport-btn.active { color: #888; }
.timeline .transport-btn svg { width: 18px; height: 18px; fill: currentColor; }
.timeline .time { font-size: 0.75rem; color: #888; font-family: monospace; min-width: 60px; display: flex; align-items: center; justify-content: center; padding: 0 0.6rem; background: #12121a; border-right: 1px solid #1e1e2e; }
.timeline .time:last-child { border-right: none; border-left: 1px solid #1e1e2e; }
.timeline .track { flex: 1; position: relative; height: 2em; cursor: pointer; background: repeating-linear-gradient(90deg, transparent, transparent 59px, #1a1a25 59px, #1a1a25 60px); }
.timeline .track-fill { position: absolute; top: 0; left: 0; height: 100%; background: linear-gradient(135deg, #6c5ce722, #a855f722); border-right: 2px solid #6c5ce7; pointer-events: none; transition: width 0.15s; }
.timeline .track-playhead { position: absolute; top: 0; width: 2px; height: 100%; background: #a855f7; pointer-events: none; box-shadow: 0 0 6px #a855f7aa; transition: left 0.15s; }
.timeline .track-playhead:before { content: ''; position: absolute; top: -4px; left: -5px; width: 0; height: 0; border-left: 6px solid transparent; border-right: 6px solid transparent; border-top: 6px solid #a855f7; }
.timeline input[type=range] { position: absolute; top: 0; left: 0; width: 100%; height: 100%; -webkit-appearance: none; appearance: none; background: transparent; outline: none; cursor: pointer; margin: 0; opacity: 0; z-index: 2; }
.timeline input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 1px; height: 100%; cursor: col-resize; }
.timeline input[type=range]:disabled { cursor: default; }
.timeline .track:has(input:disabled) { opacity: 0.4; cursor: default; }
.log-panel { background: #08080c; border-top: 1px solid #1e1e2e; flex-shrink: 0; max-height: 300px; overflow-y: auto; padding: 0.4rem 1rem; font-family: monospace; font-size: 0.8rem; color: #444; scrollbar-width: none; -ms-overflow-style: none; }
.log-panel::-webkit-scrollbar { display: none; }
.log-panel div { padding: 1px 0; white-space: nowrap; }
.log-panel .log-info { color: #555; }
.log-panel .log-ok { color: #00b894; }
.log-panel .log-error { color: #e74c3c; }
.log-panel .log-warn { color: #f6ad55; }
.log-panel .log-debug { color: #5b7fa6; }
@media (max-width: 900px) {
.layout { grid-template-columns: 1fr; }
.sidebar { border-right: none; border-bottom: 1px solid #1e1e2e; }
.video-area { min-height: 300px; }
}
</style>
</head>
<body>
<div class="layout">
<div class="sidebar">
<h2>Video Stream <span class="badge">LIVE</span></h2>
<div class="section">
<div class="section-title"><span class="step-dot" id="dotSession">1</span> Session</div>
<div class="form-row">
<label>Server<input type="text" id="serverUrl" value="http://localhost:8000" placeholder="server URL"></label>
</div>
<div class="form-row">
<label>API Key<input type="password" id="apiKey" placeholder="optional"></label>
</div>
<button class="btn btn-secondary" onclick="createSession()">Connect</button>
</div>
<div class="section">
<div class="section-title"><span class="step-dot" id="dotSource">2</span> Source Face</div>
<div class="upload-box disabled" id="sourceBox" onclick="document.getElementById('sourceFile').click()">
<input type="file" id="sourceFile" accept="image/*">
<div class="label">Source Face</div>
<div class="hint">JPG, PNG, WEBP</div>
</div>
</div>
<div class="section">
<div class="section-title"><span class="step-dot" id="dotVideo">3</span> Video Source</div>
<div class="btn-row">
<button class="btn btn-secondary" id="btnCamera" onclick="startCamera()" disabled>Camera</button>
<button class="btn btn-secondary" id="btnFile" onclick="document.getElementById('videoFile').click()" disabled>Video File</button>
<input type="file" id="videoFile" accept="video/*" style="display:none">
</div>
</div>
<div class="section">
<div class="section-title"><span class="step-dot" id="dotMode">4</span> Streaming Mode</div>
<div class="form-row">
<label>Approach
<select id="streamMode">
<option value="whip-mediamtx">FFmpeg WHIP + MediaMTX</option>
<option value="whip-python">aiortc WebRTC (no ext. deps)</option>
<option value="whip-datachannel">FFmpeg WHIP + libdatachannel relay</option>
<option value="ws-fmp4">FFmpeg fMP4 + WebSocket (MSE)</option>
<option value="datachannel-direct">libdatachannel direct</option>
<option value="ws-mjpeg">MJPEG over WebSocket (no deps)</option>
</select>
</label>
</div>
</div>
<div class="section">
<div class="section-title"><span class="step-dot" id="dotOptions">5</span> Options</div>
<div class="form-row">
<label>Capture Resolution
<select id="captureRes">
<option value="0">Original</option>
<option value="640">480p</option>
<option value="1280" selected>720p</option>
<option value="1920">1080p</option>
<option value="2560">2K</option>
<option value="3840">4K</option>
</select>
</label>
</div>
<div class="switch-row">
<span>Face Debugger</span>
<label class="switch"><input type="checkbox" id="faceDebugger" onchange="toggleFaceDebugger()" disabled><span class="slider"></span></label>
</div>
<div class="switch-row">
<span>Picture in Picture</span>
<label class="switch"><input type="checkbox" id="pipToggle" onchange="togglePip()" disabled><span class="slider"></span></label>
</div>
</div>
<div class="section">
<div class="section-title"><span class="step-dot" id="dotStream">6</span> Stream</div>
<div class="switch-row">
<span>Ready</span>
<span id="streamReadyHint" style="font-size:0.7rem;color:#555;">set source + video first</span>
</div>
</div>
</div>
<div class="main">
<div class="topbar">
<div class="stat-card">
<div class="stat-value" id="statWs">--</div>
<div class="stat-label">WebSocket</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statRtc">--</div>
<div class="stat-label">RTC</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statIce">--</div>
<div class="stat-label">ICE</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statResolution">--</div>
<div class="stat-label">Resolution</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statFps">--</div>
<div class="stat-label">FPS In</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statFpsSend">--</div>
<div class="stat-label">FPS Out</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statBitrate">--</div>
<div class="stat-label">Kbps In</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statUptime">--</div>
<div class="stat-label">Uptime</div>
</div>
</div>
<div class="topbar" style="border-top: 1px solid #1e1e2e;">
<div class="stat-card">
<div class="stat-value" id="statCodec">--</div>
<div class="stat-label">Codec</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statFrames">--</div>
<div class="stat-label">Frames</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statSent">--</div>
<div class="stat-label">Sent</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statGpu">--</div>
<div class="stat-label">GPU</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statVram">--</div>
<div class="stat-label">VRAM</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statGpuTemp">--</div>
<div class="stat-label">GPU Temp</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statCpu">--</div>
<div class="stat-label">CPU</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statRam">--</div>
<div class="stat-label">RAM</div>
</div>
</div>
<div class="video-area">
<video id="outputVideo" autoplay playsinline></video>
</div>
<video id="inputVideo" autoplay muted playsinline style="display:none;"></video>
<div class="timeline" id="timeline">
<div class="transport">
<button class="transport-btn" id="btnPlay" onclick="connect()" disabled title="Play">
<svg viewBox="0 0 24 24"><polygon points="6,4 20,12 6,20"/></svg>
</button>
<button class="transport-btn" id="btnStop" onclick="disconnect()" disabled title="Stop">
<svg viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>
</button>
</div>
<span class="time" id="timePosition">0:00</span>
<div class="track">
<div class="track-fill" id="trackFill"></div>
<div class="track-playhead" id="trackPlayhead"></div>
<input type="range" id="timeSlider" min="0" max="100" step="0.1" value="0" oninput="onSeekInput()" onchange="onSeekCommit()" disabled>
</div>
<span class="time" id="timeDuration">0:00</span>
</div>
<div class="log-panel" id="log"></div>
</div>
</div>
<script>
var accessToken = null;
var whepUrlFromServer = null;
var ws = null;
var pc = null;
var localStream = null;
var sourceReady = false;
var statsTimer = null;
var captureTimer = null;
var connectTime = null;
var streaming = false;
var inputVideoSource = null;
var framesSent = 0;
var prevBytes = 0;
var prevFrames = 0;
var prevStatsTime = 0;
var prevFramesSent = 0;
var metricsWs = null;
var mediaSource = null;
var sourceBuffer = null;
var mseQueue = [];
var mseReady = false;
var captureCanvas = document.createElement('canvas');
var captureCtx = captureCanvas.getContext('2d');
var audioCtx = null;
var audioWorklet = null;
var audioEchoWs = null;
var audioPlayCtx = null;
var audioPlayNextTime = 0;
var MODE_CONFIG = {
'whip-mediamtx': { wsPath: '/stream/whip', playback: 'whep' },
'whip-python': { wsPath: '/stream/whip-py', playback: 'whep' },
'whip-datachannel': { wsPath: '/stream/whip-dc', playback: 'whep' },
'ws-fmp4': { wsPath: '/stream/live', playback: 'mse' },
'datachannel-direct': { wsPath: '/stream/rtc', playback: 'whep' },
'ws-mjpeg': { wsPath: '/stream/mjpeg', playback: 'mjpeg' }
};
function getMode() {
return document.getElementById('streamMode').value;
}
function getModeConfig() {
return MODE_CONFIG[getMode()];
}
function log(msg, type) {
type = type || 'info';
var el = document.getElementById('log');
var div = document.createElement('div');
div.className = 'log-' + type;
div.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
el.appendChild(div);
el.scrollTop = el.scrollHeight;
}
function markDone(dotId) {
document.getElementById(dotId).classList.add('done');
}
function base() {
return document.getElementById('serverUrl').value.replace(/\/$/, '');
}
function wsBase() {
return base().replace(/^http/, 'ws');
}
function whepUrl() {
return whepUrlFromServer;
}
function authHeaders() {
return { 'Authorization': 'Bearer ' + accessToken };
}
function checkConnectReady() {
if (sourceReady && localStream) {
document.getElementById('btnPlay').disabled = false;
document.getElementById('streamReadyHint').textContent = 'ready';
document.getElementById('streamReadyHint').style.color = '#00b894';
}
}
function setStat(id, value) {
document.getElementById(id).textContent = value;
}
function startStats() {
connectTime = performance.now();
prevBytes = 0;
prevFrames = 0;
prevStatsTime = 0;
prevFramesSent = 0;
connectMetrics();
statsTimer = setInterval(async function() {
if (ws) {
var wsState = ws.readyState === WebSocket.OPEN ? 'open' : 'closed';
setStat('statWs', wsState);
document.getElementById('statWs').className = 'stat-value ' + (wsState === 'open' ? 'ok' : 'bad');
}
if (pc) {
var rtcState = pc.connectionState;
setStat('statRtc', rtcState);
document.getElementById('statRtc').className = 'stat-value ' + (rtcState === 'connected' ? 'ok' : rtcState === 'failed' ? 'bad' : 'warn');
setStat('statIce', pc.iceConnectionState);
var stats = await pc.getStats();
stats.forEach(function(report) {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
var now = report.timestamp;
var bytes = report.bytesReceived || 0;
var frames = report.framesReceived || 0;
if (prevStatsTime > 0) {
var dt = (now - prevStatsTime) / 1000;
setStat('statBitrate', Math.round((bytes - prevBytes) * 8 / dt / 1000));
setStat('statFps', Math.round((frames - prevFrames) / dt));
}
prevBytes = bytes;
prevFrames = frames;
prevStatsTime = now;
setStat('statFrames', report.framesReceived || 0);
if (report.frameWidth && report.frameHeight) {
setStat('statResolution', report.frameWidth + 'x' + report.frameHeight);
}
}
if (report.type === 'codec' && report.mimeType) {
setStat('statCodec', report.mimeType.split('/')[1]);
}
});
}
var fpsSend = framesSent - prevFramesSent;
prevFramesSent = framesSent;
setStat('statFpsSend', fpsSend);
setStat('statSent', framesSent);
var elapsed = Math.floor((performance.now() - connectTime) / 1000);
var min = Math.floor(elapsed / 60);
var sec = elapsed % 60;
setStat('statUptime', min + ':' + String(sec).padStart(2, '0'));
}, 1000);
}
function connectMetrics() {
var metricsUrl = wsBase() + '/metrics';
var protocols = ['access_token.' + accessToken];
metricsWs = new WebSocket(metricsUrl, protocols);
metricsWs.onmessage = function(event) {
var m = JSON.parse(event.data);
var gpu = (m.graphic_devices || [])[0];
if (gpu) {
setStat('statGpu', gpu.utilization.gpu.value + gpu.utilization.gpu.unit);
setStat('statVram', gpu.video_memory.free.value + '/' + gpu.video_memory.total.value + gpu.video_memory.total.unit);
setStat('statGpuTemp', gpu.temperature.gpu.value + gpu.temperature.gpu.unit);
}
if (m.processor) {
setStat('statCpu', m.processor.utilization.value + m.processor.utilization.unit);
}
if (m.memory) {
setStat('statRam', m.memory.utilization.value + m.memory.utilization.unit);
}
};
metricsWs.onerror = function() {
log('metrics websocket error', 'warn');
};
}
function disconnectMetrics() {
if (metricsWs) {
metricsWs.close();
metricsWs = null;
}
}
function stopStats() {
disconnectMetrics();
if (statsTimer) {
clearInterval(statsTimer);
statsTimer = null;
}
}
async function createSession() {
var apiKey = document.getElementById('apiKey').value;
try {
var sessionHeaders = { 'Content-Type': 'application/json' };
var sessionBody = JSON.stringify(apiKey ? { api_key: apiKey } : {});
var res = await fetch(base() + '/session', {
method: 'POST',
headers: sessionHeaders,
body: sessionBody
});
var data = await res.json();
if (!res.ok) { log('session failed: ' + JSON.stringify(data), 'error'); return; }
accessToken = data.access_token;
log('session ok — token: ' + accessToken.slice(0, 10) + '...', 'ok');
await fetch(base() + '/state', {
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ face_selector_mode: 'many' })
});
log('face_selector_mode → many', 'ok');
markDone('dotSession');
document.getElementById('sourceBox').classList.remove('disabled');
document.getElementById('btnCamera').disabled = false;
document.getElementById('btnFile').disabled = false;
document.getElementById('faceDebugger').disabled = false;
document.getElementById('pipToggle').disabled = false;
var storedData = localStorage.getItem('ff_source_data');
var storedName = localStorage.getItem('ff_source_name');
if (storedData && storedName && !sourceReady) {
log('auto-uploading saved source: ' + storedName, 'info');
await uploadSourceFile(dataURLtoFile(storedData, storedName));
}
} catch (e) {
log('fetch error: ' + e.message, 'error');
}
}
function saveSourceToStorage(file) {
var reader = new FileReader();
reader.onload = function() {
localStorage.setItem('ff_source_data', reader.result);
localStorage.setItem('ff_source_name', file.name);
};
reader.readAsDataURL(file);
}
function dataURLtoFile(dataURL, name) {
var parts = dataURL.split(',');
var mime = parts[0].match(/:(.*?);/)[1];
var bytes = atob(parts[1]);
var array = new Uint8Array(bytes.length);
for (var i = 0; i < bytes.length; i++) array[i] = bytes.charCodeAt(i);
return new File([array], name, { type: mime });
}
function loadSourceFromStorage() {
var data = localStorage.getItem('ff_source_data');
var name = localStorage.getItem('ff_source_name');
if (data && name) {
var box = document.getElementById('sourceBox');
box.classList.add('has-file');
box.querySelectorAll('.preview, .filename').forEach(function(el) { el.remove(); });
var img = document.createElement('img');
img.className = 'preview';
img.src = data;
box.appendChild(img);
var fn = document.createElement('div');
fn.className = 'filename';
fn.textContent = name;
box.appendChild(fn);
log('restored source: ' + name, 'info');
}
}
async function uploadSourceFile(file) {
log('uploading source: ' + file.name, 'info');
var form = new FormData();
form.append('file', file);
try {
var uploadRes = await fetch(base() + '/assets?type=source', {
method: 'POST',
headers: authHeaders(),
body: form
});
if (!uploadRes.ok) { log('upload failed: ' + uploadRes.status + ' ' + await uploadRes.text(), 'error'); return; }
var uploadData = await uploadRes.json();
var assetId = (uploadData.asset_ids || [])[0];
if (!assetId) { log('upload failed: no asset_id in response', 'error'); return; }
log('asset uploaded: ' + assetId, 'ok');
var selectRes = await fetch(base() + '/state?action=select&type=source', {
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ asset_ids: [assetId] })
});
if (!selectRes.ok) { log('select source failed', 'error'); return; }
log('source face set', 'ok');
markDone('dotSource');
var box = document.getElementById('sourceBox');
box.classList.add('has-file');
box.querySelectorAll('.preview, .filename').forEach(function(el) { el.remove(); });
var img = document.createElement('img');
img.className = 'preview';
img.src = URL.createObjectURL(file);
box.appendChild(img);
var fn = document.createElement('div');
fn.className = 'filename';
fn.textContent = file.name;
box.appendChild(fn);
saveSourceToStorage(file);
sourceReady = true;
checkConnectReady();
} catch (e) {
log('source error: ' + e.message, 'error');
}
}
document.getElementById('sourceFile').addEventListener('change', function(event) {
var file = event.target.files[0];
if (!file) return;
uploadSourceFile(file);
});
async function startCamera() {
try {
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
inputVideoSource = 'camera';
document.getElementById('inputVideo').srcObject = localStream;
log('camera: ' + localStream.getVideoTracks()[0].label, 'ok');
if (localStream.getAudioTracks().length > 0) {
log('mic: ' + localStream.getAudioTracks()[0].label, 'ok');
}
markDone('dotVideo');
checkConnectReady();
} catch (e) {
log('camera error: ' + e.message, 'error');
}
}
document.getElementById('videoFile').addEventListener('change', function(event) {
var file = event.target.files[0];
if (!file) return;
loadVideoFromFile(file);
saveVideoToStorage(file);
});
function loadVideoFromFile(file) {
var vid = document.getElementById('inputVideo');
var blobUrl = URL.createObjectURL(file);
vid.src = blobUrl;
vid.loop = true;
vid.muted = true;
vid.volume = 0;
vid.play().then(function() {
localStream = (vid.captureStream || vid.mozCaptureStream).call(vid);
inputVideoSource = 'file';
log('video file: ' + file.name, 'ok');
markDone('dotVideo');
checkConnectReady();
initTimeline(vid);
}).catch(function(e) { log('file error: ' + e.message, 'error'); });
}
function openVideoDb() {
return new Promise(function(resolve, reject) {
var req = indexedDB.open('ff_video_store', 1);
req.onupgradeneeded = function() { req.result.createObjectStore('videos'); };
req.onsuccess = function() { resolve(req.result); };
req.onerror = function() { reject(req.error); };
});
}
function saveVideoToStorage(file) {
openVideoDb().then(function(db) {
var tx = db.transaction('videos', 'readwrite');
tx.objectStore('videos').put(file, 'target');
tx.objectStore('videos').put(file.name, 'target_name');
});
}
function loadVideoFromStorage() {
openVideoDb().then(function(db) {
var tx = db.transaction('videos', 'readonly');
var store = tx.objectStore('videos');
var fileReq = store.get('target');
var nameReq = store.get('target_name');
tx.oncomplete = function() {
if (!fileReq.result || !nameReq.result) return;
var file = new File([fileReq.result], nameReq.result, { type: fileReq.result.type });
loadVideoFromFile(file);
log('restored video: ' + file.name, 'info');
};
});
}
var timelineVideo = null;
var timelineSeeking = false;
var timelineTimer = null;
function initTimeline(vid) {
timelineVideo = vid;
var slider = document.getElementById('timeSlider');
vid.addEventListener('loadedmetadata', function() {
slider.max = vid.duration;
document.getElementById('timeDuration').textContent = formatTime(vid.duration);
});
if (vid.duration) {
slider.max = vid.duration;
document.getElementById('timeDuration').textContent = formatTime(vid.duration);
}
document.getElementById('timeline').classList.add('visible');
}
function updateTrackVisual(position, duration) {
var pct = duration > 0 ? (position / duration) * 100 : 0;
document.getElementById('trackFill').style.width = pct + '%';
document.getElementById('trackPlayhead').style.left = pct + '%';
}
function startTimelineSync() {
timelineTimer = setInterval(function() {
if (timelineVideo && !timelineSeeking) {
document.getElementById('timeSlider').value = timelineVideo.currentTime;
document.getElementById('timePosition').textContent = formatTime(timelineVideo.currentTime);
updateTrackVisual(timelineVideo.currentTime, timelineVideo.duration);
}
}, 250);
}
function stopTimelineSync() {
if (timelineTimer) {
clearInterval(timelineTimer);
timelineTimer = null;
}
}
function onSeekInput() {
timelineSeeking = true;
var t = parseFloat(document.getElementById('timeSlider').value);
document.getElementById('timePosition').textContent = formatTime(t);
if (timelineVideo) {
updateTrackVisual(t, timelineVideo.duration);
}
}
function onSeekCommit() {
var t = parseFloat(document.getElementById('timeSlider').value);
timelineSeeking = false;
if (timelineVideo) {
timelineVideo.currentTime = t;
log('seek → ' + formatTime(t), 'info');
}
if (audioPlayCtx) {
audioPlayCtx.close();
audioPlayCtx = new AudioContext({ sampleRate: 48000 });
audioPlayNextTime = 0;
}
}
function formatTime(s) {
var m = Math.floor(s / 60);
var sec = Math.floor(s % 60);
return m + ':' + String(sec).padStart(2, '0');
}
function captureAndSend() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
var video = document.getElementById('inputVideo');
if (!video.videoWidth || !video.videoHeight) return;
var maxW = parseInt(document.getElementById('captureRes').value);
var w = video.videoWidth;
var h = video.videoHeight;
if (maxW > 0 && w > maxW) {
h = Math.round(maxW * h / w);
h = h - (h % 2);
w = maxW;
}
captureCanvas.width = w;
captureCanvas.height = h;
captureCtx.drawImage(video, 0, 0, w, h);
captureCanvas.toBlob(function(blob) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
blob.arrayBuffer().then(function(buf) {
ws.send(buf);
framesSent++;
});
}, 'image/jpeg', 0.7);
}
async function connectWhep() {
var url = whepUrl();
var t0 = performance.now();
log('WHEP → ' + url, 'info');
var PeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
if (!PeerConnection) {
log('WebRTC not supported in this browser', 'error');
return;
}
pc = new PeerConnection({ iceServers: [] });
pc.onconnectionstatechange = function() {
var state = pc.connectionState;
log('rtc: ' + state + ' (' + Math.round(performance.now() - t0) + 'ms)', state === 'connected' ? 'ok' : state === 'failed' ? 'error' : 'info');
};
pc.oniceconnectionstatechange = function() {
log('ice: ' + pc.iceConnectionState + ' (' + Math.round(performance.now() - t0) + 'ms)', 'debug');
};
pc.ontrack = function(event) {
log('remote track: ' + event.track.kind + ' (' + Math.round(performance.now() - t0) + 'ms)', 'ok');
document.getElementById('outputVideo').srcObject = event.streams[0];
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
log('creating offer...', 'debug');
var offer = await pc.createOffer();
await pc.setLocalDescription(offer);
log('offer created (' + Math.round(performance.now() - t0) + 'ms)', 'debug');
var res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp
});
log('WHEP response: ' + res.status + ' (' + Math.round(performance.now() - t0) + 'ms)', res.ok ? 'debug' : 'error');
if (!res.ok) {
log('WHEP body: ' + await res.text(), 'error');
return;
}
var answerSdp = await res.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
log('WHEP answer applied (' + Math.round(performance.now() - t0) + 'ms)', 'ok');
}
function startAudioCapture() {
var stream = localStream;
if (!stream || stream.getAudioTracks().length === 0) {
log('no audio track available', 'warn');
return;
}
audioCtx = new AudioContext({ sampleRate: 48000 });
var source = audioCtx.createMediaStreamSource(stream);
var processor = audioCtx.createScriptProcessor(4096, 2, 2);
processor.onaudioprocess = function(e) {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
var left = e.inputBuffer.getChannelData(0);
var right = e.inputBuffer.getChannelData(1);
var pcm = new Int16Array(left.length * 2);
for (var i = 0; i < left.length; i++) {
pcm[i * 2] = Math.max(-32768, Math.min(32767, left[i] * 32768));
pcm[i * 2 + 1] = Math.max(-32768, Math.min(32767, right[i] * 32768));
}
ws.send(pcm.buffer);
};
source.connect(processor);
processor.connect(audioCtx.destination);
audioWorklet = processor;
log('audio capture started (48kHz stereo s16le)', 'ok');
}
function stopAudioCapture() {
if (audioWorklet) {
audioWorklet.disconnect();
audioWorklet = null;
}
if (audioCtx) {
audioCtx.close();
audioCtx = null;
}
}
function startAudioEcho() {
var stream = localStream;
if (!stream || stream.getAudioTracks().length === 0) {
log('no audio track for echo', 'warn');
return;
}
audioPlayCtx = new AudioContext({ sampleRate: 48000 });
audioPlayNextTime = 0;
var captureCtxAudio = new AudioContext({ sampleRate: 48000 });
var source = captureCtxAudio.createMediaStreamSource(stream);
var processor = captureCtxAudio.createScriptProcessor(4096, 2, 2);
var echoUrl = wsBase() + '/stream/audio';
var protocols = ['access_token.' + accessToken];
audioEchoWs = new WebSocket(echoUrl, protocols);
audioEchoWs.binaryType = 'arraybuffer';
audioEchoWs.onmessage = function(event) {
if (!audioPlayCtx) return;
var pcm = new Int16Array(event.data);
var samples = pcm.length / 2;
var buffer = audioPlayCtx.createBuffer(2, samples, 48000);
var left = buffer.getChannelData(0);
var right = buffer.getChannelData(1);
for (var i = 0; i < samples; i++) {
left[i] = pcm[i * 2] / 32768;
right[i] = pcm[i * 2 + 1] / 32768;
}
var bufferSource = audioPlayCtx.createBufferSource();
bufferSource.buffer = buffer;
bufferSource.connect(audioPlayCtx.destination);
var now = audioPlayCtx.currentTime;
if (audioPlayNextTime < now) audioPlayNextTime = now + 0.05;
bufferSource.start(audioPlayNextTime);
audioPlayNextTime += buffer.duration;
};
processor.onaudioprocess = function(e) {
if (!audioEchoWs || audioEchoWs.readyState !== WebSocket.OPEN) return;
var left = e.inputBuffer.getChannelData(0);
var right = e.inputBuffer.getChannelData(1);
var pcm = new Int16Array(left.length * 2);
for (var i = 0; i < left.length; i++) {
pcm[i * 2] = Math.max(-32768, Math.min(32767, left[i] * 32768));
pcm[i * 2 + 1] = Math.max(-32768, Math.min(32767, right[i] * 32768));
}
audioEchoWs.send(pcm.buffer);
};
source.connect(processor);
processor.connect(captureCtxAudio.destination);
log('audio echo started (48kHz stereo s16le)', 'ok');
}
function stopAudioEcho() {
if (audioEchoWs) {
audioEchoWs.close();
audioEchoWs = null;
}
if (audioPlayCtx) {
audioPlayCtx.close();
audioPlayCtx = null;
}
audioPlayNextTime = 0;
}
function initMse() {
var video = document.getElementById('outputVideo');
mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', function() {
sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.42E01E,mp4a.40.2"');
sourceBuffer.mode = 'sequence';
mseReady = true;
sourceBuffer.addEventListener('updateend', function() {
if (mseQueue.length > 0 && !sourceBuffer.updating) {
sourceBuffer.appendBuffer(mseQueue.shift());
}
});
log('MSE source buffer ready', 'ok');
});
}
function feedMse(data) {
if (!mseReady || !sourceBuffer) return;
if (sourceBuffer.updating || mseQueue.length > 0) {
mseQueue.push(data);
} else {
sourceBuffer.appendBuffer(data);
}
}
function cleanupMse() {
mseQueue = [];
mseReady = false;
sourceBuffer = null;
if (mediaSource && mediaSource.readyState === 'open') {
mediaSource.endOfStream();
}
mediaSource = null;
}
async function connect() {
var config = getModeConfig();
var mode = getMode();
framesSent = 0;
var outputVideo = document.getElementById('outputVideo');
outputVideo.srcObject = null;
outputVideo.removeAttribute('src');
outputVideo.load();
outputVideo.style.display = '';
var mjpegImg = outputVideo._mjpegImg;
if (mjpegImg) {
if (mjpegImg._prevUrl) URL.revokeObjectURL(mjpegImg._prevUrl);
mjpegImg.remove();
outputVideo._mjpegImg = null;
}
cleanupMse();
whepUrlFromServer = null;
var wsUrl = wsBase() + config.wsPath;
var protocols = ['access_token.' + accessToken];
var t0 = performance.now();
log('[' + mode + '] ws → ' + wsUrl, 'info');
if (config.playback === 'mse') {
initMse();
}
ws = new WebSocket(wsUrl, protocols);
ws.binaryType = 'arraybuffer';
ws.onopen = function() {
log('websocket open (' + Math.round(performance.now() - t0) + 'ms) — sending frames', 'ok');
markDone('dotStream');
markDone('dotMode');
document.getElementById('btnPlay').disabled = true;
document.getElementById('btnPlay').classList.add('active');
document.getElementById('btnStop').disabled = false;
document.getElementById('timeSlider').disabled = false;
streaming = true;
updatePipVisibility();
if (timelineVideo) {
timelineVideo.currentTime = 0;
}
document.getElementById('timePosition').textContent = '0:00';
document.getElementById('timeSlider').value = 0;
updateTrackVisual(0, timelineVideo ? timelineVideo.duration : 0);
captureTimer = setInterval(captureAndSend, 1000 / 30);
if (config.playback === 'mjpeg') {
startAudioEcho();
} else {
startAudioCapture();
}
startStats();
};
var streamStarted = false;
function onFirstOutput() {
if (streamStarted) return;
streamStarted = true;
if (timelineVideo) timelineVideo.play();
startTimelineSync();
log('stream output started', 'ok');
}
ws.onmessage = function(event) {
if (config.playback === 'mse' && event.data instanceof ArrayBuffer) {
onFirstOutput();
feedMse(event.data);
return;
}
if (config.playback === 'mjpeg' && event.data instanceof ArrayBuffer) {
onFirstOutput();
var blob = new Blob([event.data], { type: 'image/jpeg' });
var url = URL.createObjectURL(blob);
var video = document.getElementById('outputVideo');
if (!video._mjpegImg) {
video.style.display = 'none';
var img = document.createElement('img');
img.id = 'mjpegOutput';
img.style.cssText = 'width:100%;height:100%;object-fit:contain;border-radius:8px;';
video.parentNode.appendChild(img);
video._mjpegImg = img;
}
if (video._mjpegImg._prevUrl) URL.revokeObjectURL(video._mjpegImg._prevUrl);
video._mjpegImg.src = url;
video._mjpegImg._prevUrl = url;
return;
}
if (typeof event.data === 'string' && !whepUrlFromServer) {
whepUrlFromServer = event.data;
onFirstOutput();
log('stream ready (' + Math.round(performance.now() - t0) + 'ms) — WHEP url: ' + whepUrlFromServer, 'ok');
if (!window.RTCPeerConnection && !window.webkitRTCPeerConnection) {
log('WebRTC not supported — try Chrome or Edge', 'error');
return;
}
var tWhep = performance.now();
connectWhep().then(function() {
log('WHEP connected (' + Math.round(performance.now() - tWhep) + 'ms)', 'ok');
}).catch(function(e) {
log('WHEP failed (' + Math.round(performance.now() - tWhep) + 'ms): ' + e.message, 'error');
});
return;
}
};
ws.onclose = function(event) {
log('websocket closed: ' + event.code, 'warn');
stopStreaming();
};
ws.onerror = function() {
log('websocket error', 'error');
};
}
function stopStreaming() {
streaming = false;
updatePipVisibility();
stopStats();
cleanupMse();
if (captureTimer) {
clearInterval(captureTimer);
captureTimer = null;
}
document.getElementById('btnPlay').disabled = false;
document.getElementById('btnPlay').classList.remove('active');
document.getElementById('btnStop').disabled = true;
document.getElementById('timeSlider').disabled = true;
document.getElementById('dotStream').classList.remove('done');
document.getElementById('dotMode').classList.remove('done');
}
function disconnect() {
stopAudioCapture();
stopAudioEcho();
stopTimelineSync();
if (pc) {
pc.close();
pc = null;
}
if (ws) {
ws.close();
ws = null;
}
whepUrlFromServer = null;
var video = document.getElementById('outputVideo');
video.srcObject = null;
video.src = '';
stopStreaming();
log('disconnected', 'warn');
}
async function toggleFaceDebugger() {
var enabled = document.getElementById('faceDebugger').checked;
var processors = enabled ? ['face_swapper', 'face_debugger'] : ['face_swapper'];
var face_mask_types = enabled ? ['box', 'occlusion', 'region'] : ['box', 'region'];
var res = await fetch(base() + '/state', {
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ processors: processors, face_mask_types: face_mask_types })
});
if (res.ok) {
log('processors → ' + processors.join(', ') + ', masks → ' + face_mask_types.join(', '), 'ok');
}
if (!res.ok) {
log('failed to set processors', 'error');
}
}
function togglePip() {
updatePipVisibility();
}
function updatePipVisibility() {
var checked = document.getElementById('pipToggle').checked;
var input = document.getElementById('inputVideo');
if (checked && streaming) {
input.classList.add('visible');
}
if (!checked || !streaming) {
input.classList.remove('visible');
}
}
loadSourceFromStorage();
loadVideoFromStorage();
var autoConnectTimer = setInterval(function() {
if (accessToken) {
clearInterval(autoConnectTimer);
return;
}
log('auto-connect attempt...', 'debug');
createSession().catch(function() {});
}, 2000);
createSession().catch(function() {});
</script>
</body>
</html>