mirror of
https://github.com/facefusion/facefusion.git
synced 2026-04-22 17:36:16 +02:00
1236 lines
42 KiB
HTML
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>
|