add individual stream paths

This commit is contained in:
henryruhs
2026-03-20 18:18:13 +01:00
parent 796805dd13
commit fe6273b5a0
6 changed files with 912 additions and 30 deletions
+13 -1
View File
@@ -1,8 +1,12 @@
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
from starlette.routing import Route, WebSocketRoute
from facefusion import mediamtx
from facefusion.apis.endpoints.assets import delete_assets, get_asset, get_assets, upload_asset
from facefusion.apis.endpoints.capabilities import get_capabilities
from facefusion.apis.endpoints.metrics import get_metrics, websocket_metrics
@@ -13,6 +17,14 @@ from facefusion.apis.endpoints.stream import websocket_stream, websocket_stream_
from facefusion.apis.middlewares.session import create_session_guard
@asynccontextmanager
async def lifespan(app : Starlette) -> AsyncGenerator[None, None]:
mediamtx.start()
mediamtx.wait_for_ready()
yield
mediamtx.stop()
def create_api() -> Starlette:
session_guard = Middleware(create_session_guard)
routes =\
@@ -35,7 +47,7 @@ def create_api() -> Starlette:
WebSocketRoute('/stream/whip', websocket_stream_whip, middleware = [ session_guard ])
]
api = Starlette(routes = routes)
api = Starlette(routes = routes, lifespan = lifespan)
api.add_middleware(CORSMiddleware, allow_origins = [ '*' ], allow_methods = [ '*' ], allow_headers = [ '*' ])
return api
+25 -17
View File
@@ -49,7 +49,7 @@ async def websocket_stream(websocket : WebSocket) -> None:
await websocket.close()
def run_whip_pipeline(latest_frame_holder : list, lock : threading.Lock, stop_event : threading.Event, audio_write_fd_holder : list) -> None:
def run_whip_pipeline(latest_frame_holder : list, lock : threading.Lock, stop_event : threading.Event, ready_event : threading.Event, audio_write_fd_holder : list, stream_path : str) -> None:
encoder = None
audio_write_fd = -1
output_deque : Deque[VisionFrame] = deque()
@@ -70,17 +70,25 @@ def run_whip_pipeline(latest_frame_holder : list, lock : threading.Lock, stop_ev
output_deque.append(future_done.result())
futures.remove(future_done)
if encoder and encoder.poll() is not None:
stderr_output = encoder.stderr.read() if encoder.stderr else b''
logger.error('encoder died with code ' + str(encoder.returncode) + ': ' + stderr_output.decode(), __name__)
break
while output_deque:
temp_vision_frame = output_deque.popleft()
if not encoder:
height, width = temp_vision_frame.shape[:2]
encoder, audio_write_fd = create_whip_encoder(width, height, STREAM_FPS, STREAM_QUALITY)
encoder, audio_write_fd = create_whip_encoder(width, height, STREAM_FPS, STREAM_QUALITY, stream_path)
audio_write_fd_holder[0] = audio_write_fd
logger.info('whip encoder started ' + str(width) + 'x' + str(height), __name__)
feed_whip_frame(encoder, temp_vision_frame)
if encoder and not ready_event.is_set() and mediamtx.is_path_ready(stream_path):
ready_event.set()
if capture_frame is None and not output_deque:
time.sleep(0.005)
@@ -104,28 +112,29 @@ async def websocket_stream_whip(websocket : WebSocket) -> None:
await websocket.accept(subprotocol = subprotocol)
if source_paths:
mediamtx_process = mediamtx.start()
is_ready = await asyncio.get_running_loop().run_in_executor(None, mediamtx.wait_for_ready)
if not is_ready:
logger.error('mediamtx failed to start', __name__)
mediamtx.stop(mediamtx_process)
await websocket.close()
return
logger.info('mediamtx ready', __name__)
stream_path = 'stream/' + session_id
mediamtx.remove_path(stream_path)
mediamtx.add_path(stream_path)
logger.info('mediamtx path added ' + stream_path, __name__)
latest_frame_holder : list = [None]
audio_write_fd_holder : list = [-1]
whep_sent = False
lock = threading.Lock()
stop_event = threading.Event()
worker = threading.Thread(target = run_whip_pipeline, args = (latest_frame_holder, lock, stop_event, audio_write_fd_holder), daemon = True)
ready_event = threading.Event()
worker = threading.Thread(target = run_whip_pipeline, args = (latest_frame_holder, lock, stop_event, ready_event, audio_write_fd_holder, stream_path), daemon = True)
worker.start()
try:
while True:
message = await websocket.receive()
if not whep_sent and ready_event.is_set():
whep_url = mediamtx.get_whep_url(stream_path)
await websocket.send_text(whep_url)
whep_sent = True
if message.get('bytes'):
data = message.get('bytes')
@@ -143,10 +152,9 @@ async def websocket_stream_whip(websocket : WebSocket) -> None:
logger.error(str(exception), __name__)
stop_event.set()
worker.join(timeout = 10)
if mediamtx_process:
mediamtx.stop(mediamtx_process)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, worker.join, 10)
mediamtx.remove_path(stream_path)
return
await websocket.close()
+2 -2
View File
@@ -27,10 +27,10 @@ def create_dtls_certificate() -> None:
], stdout = subprocess.DEVNULL, stderr = subprocess.DEVNULL)
def create_whip_encoder(width : int, height : int, stream_fps : int, stream_quality : int) -> Tuple[subprocess.Popen[bytes], int]:
def create_whip_encoder(width : int, height : int, stream_fps : int, stream_quality : int, stream_path : str) -> Tuple[subprocess.Popen[bytes], int]:
create_dtls_certificate()
audio_read_fd, audio_write_fd = os.pipe()
whip_url = mediamtx.get_whip_url()
whip_url = mediamtx.get_whip_url(stream_path)
commands = ffmpeg_builder.chain(
[ '-use_wallclock_as_timestamps', '1' ],
ffmpeg_builder.capture_video(),
+45 -9
View File
@@ -9,13 +9,17 @@ import httpx
MEDIAMTX_WHIP_PORT : int = 8889
MEDIAMTX_API_PORT : int = 9997
MEDIAMTX_PATH : str = 'stream'
MEDIAMTX_CONFIG : str = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'mediamtx.yml')
MEDIAMTX_FALLBACK_BINARY : str = '/home/henry/local/bin/mediamtx'
MEDIAMTX_PROCESS : Optional[subprocess.Popen[bytes]] = None
def get_whip_url() -> str:
return 'http://localhost:' + str(MEDIAMTX_WHIP_PORT) + '/' + MEDIAMTX_PATH + '/whip'
def get_whip_url(stream_path : str) -> str:
return 'http://localhost:' + str(MEDIAMTX_WHIP_PORT) + '/' + stream_path + '/whip'
def get_whep_url(stream_path : str) -> str:
return 'http://localhost:' + str(MEDIAMTX_WHIP_PORT) + '/' + stream_path + '/whep'
def get_api_url() -> str:
@@ -30,20 +34,25 @@ def resolve_binary() -> str:
return MEDIAMTX_FALLBACK_BINARY
def start() -> Optional[subprocess.Popen[bytes]]:
def start() -> None:
global MEDIAMTX_PROCESS
stop_stale()
mediamtx_binary = resolve_binary()
return subprocess.Popen(
MEDIAMTX_PROCESS = subprocess.Popen(
[ mediamtx_binary, MEDIAMTX_CONFIG ],
stdout = subprocess.DEVNULL,
stderr = subprocess.DEVNULL
)
def stop(process : subprocess.Popen[bytes]) -> None:
process.terminate()
process.wait()
def stop() -> None:
global MEDIAMTX_PROCESS
if MEDIAMTX_PROCESS:
MEDIAMTX_PROCESS.terminate()
MEDIAMTX_PROCESS.wait()
MEDIAMTX_PROCESS = None
def stop_stale() -> None:
@@ -66,3 +75,30 @@ def wait_for_ready() -> bool:
pass
time.sleep(0.5)
return False
def is_path_ready(stream_path : str) -> bool:
api_url = get_api_url() + '/v3/paths/get/' + stream_path
try:
response = httpx.get(api_url, timeout = 1)
if response.status_code == 200:
return response.json().get('ready', False)
except Exception:
pass
return False
def add_path(stream_path : str) -> bool:
api_url = get_api_url() + '/v3/config/paths/add/' + stream_path
response = httpx.post(api_url, json = {}, timeout = 5)
return response.status_code == 200
def remove_path(stream_path : str) -> bool:
api_url = get_api_url() + '/v3/config/paths/delete/' + stream_path
response = httpx.delete(api_url, timeout = 5)
return response.status_code == 200
-1
View File
@@ -7,4 +7,3 @@ webrtcAddress: :8889
api: yes
apiAddress: :9997
paths:
stream:
+827
View File
@@ -0,0 +1,827 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>whip_stream test</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: monospace; background: #111; color: #eee; padding: 20px; }
h1 { font-size: 15px; margin-bottom: 16px; color: #888; }
.steps { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
.step {
border: 1px solid #333; padding: 12px 14px;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}
.step-num { font-size: 11px; color: #555; min-width: 14px; }
.step-label { font-size: 12px; color: #777; min-width: 130px; }
.step-body { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; flex: 1; }
.step.done { border-color: #2a5a3a; }
.step.done .step-num { color: #4a9; }
select, input[type=text], input[type=password] {
background: #1a1a1a; border: 1px solid #444; color: #eee;
padding: 5px 8px; font-family: monospace; font-size: 13px; width: 220px;
}
input[type=file] { font-size: 12px; color: #888; }
button {
background: #222; border: 1px solid #555; color: #ccc;
padding: 6px 12px; font-family: monospace; font-size: 12px; cursor: pointer;
white-space: nowrap;
}
button:hover:not(:disabled) { background: #333; border-color: #777; }
button:disabled { opacity: 0.35; cursor: default; }
button.danger { border-color: #a44; color: #a44; }
#sourceThumb {
width: 48px; height: 48px; object-fit: cover;
border: 1px solid #444; display: none;
}
.video-row { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 16px; }
.video-container { position: relative; display: inline-block; flex-shrink: 0; }
.video-container span { font-size: 11px; color: #555; display: block; margin-bottom: 5px; }
#outputVideo { background: #000; width: 960px; height: 540px; display: block; }
#inputVideo { position: absolute; bottom: 10px; right: 10px; width: 240px; height: 135px; border: 1px solid #444; background: #000; z-index: 1; display: none; }
#inputVideo.visible { display: block; }
#stats {
border: 1px solid #333; border-radius: 4px;
padding: 10px 12px; font-size: 13px; line-height: 1.7; color: #aaa;
width: 210px; display: none; flex-shrink: 0;
}
#stats.visible { display: block; }
#stats .label { color: #666; }
#stats .value { color: #4a9; }
#stats div { white-space: nowrap; }
#timeline {
display: none; margin-bottom: 8px;
}
#timeline.visible { display: flex; align-items: center; gap: 10px; width: 960px; }
#timeline input[type=range] {
flex: 1; height: 4px; -webkit-appearance: none; appearance: none;
background: #333; outline: none; cursor: pointer;
}
#timeline input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px;
background: #4a9; border-radius: 50%; cursor: pointer;
}
#timeline .time { font-size: 12px; color: #888; min-width: 80px; }
#log {
background: #161616; border: 1px solid #2a2a2a;
padding: 10px; height: 200px; overflow-y: auto;
font-size: 12px; line-height: 1.7;
}
.info { color: #888; }
.ok { color: #4a9; }
.error { color: #e55; }
.warn { color: #ca5; }
.debug { color: #68a; }
</style>
</head>
<body>
<h1>whip_stream — face swap via ffmpeg WHIP + mediamtx</h1>
<div class="steps">
<div class="step" id="stepSession">
<span class="step-num">1</span>
<span class="step-label">session</span>
<div class="step-body">
<input type="text" id="serverUrl" value="http://localhost:8000" placeholder="server URL">
<input type="password" id="apiKey" placeholder="api key (optional)">
<button onclick="createSession()">Connect</button>
</div>
</div>
<div class="step" id="stepSource">
<span class="step-num">2</span>
<span class="step-label">source face</span>
<div class="step-body">
<input type="file" id="sourceFile" accept="image/*" onchange="uploadSource(event)" disabled>
<img id="sourceThumb" alt="source">
</div>
</div>
<div class="step" id="stepVideo">
<span class="step-num">3</span>
<span class="step-label">video source</span>
<div class="step-body">
<button id="btnCamera" onclick="startCamera()" disabled>Use Camera</button>
<button id="btnFile" onclick="document.getElementById('videoFile').click()" disabled>Use Video File</button>
<input type="file" id="videoFile" accept="video/*" style="display:none" onchange="loadVideoFile(event)">
<video id="videoThumb" muted playsinline style="width: 80px; height: 48px; object-fit: cover; border: 1px solid #444; display: none;"></video>
</div>
</div>
<div class="step" id="stepOptions">
<span class="step-num">4</span>
<span class="step-label">options</span>
<div class="step-body">
<select id="captureRes" style="width: auto;">
<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 style="font-size: 12px; color: #888; display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="faceDebugger" onchange="toggleFaceDebugger()" disabled> face debugger
</label>
<label style="font-size: 12px; color: #888; display: flex; align-items: center; gap: 6px; cursor: pointer;">
<input type="checkbox" id="pipToggle" onchange="togglePip()" disabled> picture in picture
</label>
</div>
</div>
<div class="step" id="stepConnect">
<span class="step-num">5</span>
<span class="step-label">whip stream</span>
<div class="step-body">
<button id="btnConnect" onclick="connect()" disabled>Stream</button>
<button id="btnDisconnect" class="danger" onclick="disconnect()" disabled>Disconnect</button>
</div>
</div>
</div>
<div class="video-row">
<div class="video-container">
<span>processed output (webrtc via WHEP)</span>
<video id="outputVideo" autoplay playsinline></video>
<video id="inputVideo" autoplay muted playsinline></video>
</div>
<div id="stats">
<div><span class="label">ws </span><span class="value" id="statWs"></span></div>
<div><span class="label">rtc </span><span class="value" id="statRtc"></span></div>
<div><span class="label">ice </span><span class="value" id="statIce"></span></div>
<div><span class="label">codec </span><span class="value" id="statCodec"></span></div>
<div><span class="label">res </span><span class="value" id="statResolution"></span></div>
<div><span class="label">fps in </span><span class="value" id="statFps"></span></div>
<div><span class="label">kbps in </span><span class="value" id="statBitrate"></span></div>
<div><span class="label">frames </span><span class="value" id="statFrames"></span></div>
<div><span class="label">fps out </span><span class="value" id="statFpsSend"></span></div>
<div><span class="label">sent </span><span class="value" id="statSent"></span></div>
<div><span class="label">up </span><span class="value" id="statUptime"></span></div>
</div>
</div>
<div id="timeline">
<span class="time" id="timePosition">0:00</span>
<input type="range" id="timeSlider" min="0" max="100" step="0.1" value="0" oninput="onSeekInput()" onchange="onSeekCommit()">
<span class="time" id="timeDuration">0:00</span>
</div>
<div id="log"></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 captureCanvas = document.createElement('canvas');
var captureCtx = captureCanvas.getContext('2d');
var audioCtx = null;
var audioWorklet = null;
function log(msg, type) {
type = type || 'info';
var el = document.getElementById('log');
var div = document.createElement('div');
div.className = type;
div.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg;
el.appendChild(div);
el.scrollTop = el.scrollHeight;
}
function markDone(stepId) {
document.getElementById(stepId).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('btnConnect').disabled = false;
}
}
function setStat(id, value) {
document.getElementById(id).textContent = value;
}
function startStats() {
connectTime = performance.now();
prevBytes = 0;
prevFrames = 0;
prevStatsTime = 0;
prevFramesSent = 0;
document.getElementById('stats').className = 'visible';
statsTimer = setInterval(async function() {
if (ws) {
setStat('statWs', ws.readyState === WebSocket.OPEN ? 'open' : 'closed');
}
if (pc) {
setStat('statRtc', pc.connectionState);
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', frames);
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 stopStats() {
if (statsTimer) {
clearInterval(statsTimer);
statsTimer = null;
}
document.getElementById('stats').className = '';
}
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('stepSession');
document.getElementById('sourceFile').disabled = false;
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 thumb = document.getElementById('sourceThumb');
thumb.src = data;
thumb.style.display = 'block';
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('stepSource');
var thumb = document.getElementById('sourceThumb');
thumb.src = URL.createObjectURL(file);
thumb.style.display = 'block';
saveSourceToStorage(file);
sourceReady = true;
checkConnectReady();
} catch (e) {
log('source error: ' + e.message, 'error');
}
}
async function uploadSource(event) {
var file = event.target.files[0];
if (!file) return;
await 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('stepVideo');
checkConnectReady();
} catch (e) {
log('camera error: ' + e.message, 'error');
}
}
function loadVideoFile(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 = false;
vid.volume = 0;
var thumb = document.getElementById('videoThumb');
thumb.src = blobUrl;
thumb.style.display = 'block';
thumb.play().catch(function() {});
vid.play().then(function() {
localStream = (vid.captureStream || vid.mozCaptureStream).call(vid);
inputVideoSource = 'file';
log('video file: ' + file.name, 'ok');
markDone('stepVideo');
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').className = 'visible';
}
function startTimelineSync() {
timelineTimer = setInterval(function() {
if (timelineVideo && !timelineSeeking) {
document.getElementById('timeSlider').value = timelineVideo.currentTime;
document.getElementById('timePosition').textContent = formatTime(timelineVideo.currentTime);
}
}, 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);
}
function onSeekCommit() {
var t = parseFloat(document.getElementById('timeSlider').value);
timelineSeeking = false;
if (timelineVideo) {
timelineVideo.currentTime = t;
log('seek → ' + formatTime(t), 'info');
}
}
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;
}
}
async function connect() {
framesSent = 0;
var wsUrl = wsBase() + '/stream/whip';
var protocols = ['access_token.' + accessToken];
var t0 = performance.now();
log('ws → ' + wsUrl, 'info');
ws = new WebSocket(wsUrl, protocols);
ws.binaryType = 'arraybuffer';
ws.onopen = function() {
log('websocket open (' + Math.round(performance.now() - t0) + 'ms) — sending frames', 'ok');
markDone('stepConnect');
document.getElementById('btnConnect').disabled = true;
document.getElementById('btnDisconnect').disabled = false;
streaming = true;
updatePipVisibility();
captureTimer = setInterval(captureAndSend, 1000 / 30);
startAudioCapture();
startTimelineSync();
startStats();
};
ws.onmessage = function(event) {
if (typeof event.data === 'string' && !whepUrlFromServer) {
whepUrlFromServer = event.data;
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();
if (captureTimer) {
clearInterval(captureTimer);
captureTimer = null;
}
document.getElementById('btnConnect').disabled = false;
document.getElementById('btnDisconnect').disabled = true;
document.getElementById('stepConnect').classList.remove('done');
}
function disconnect() {
stopAudioCapture();
stopTimelineSync();
if (pc) {
pc.close();
pc = null;
}
if (ws) {
ws.close();
ws = null;
}
whepUrlFromServer = null;
document.getElementById('outputVideo').srcObject = null;
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();
createSession().catch(function() {});
</script>
</body>
</html>