Commit ·
e62aafd
1
Parent(s): 64b0a86
Add WebRTC streaming interface with vast.ai deployment
Browse files- Update WebRTC interface with race condition fix (isConnecting flag)
- Add automated test scripts (aiortc and Playwright)
- Add deployment scripts for vast.ai and SkyPilot
- Improve idle video sync and transitions
- Fix audio-video buffering for better sync
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- interface/deploy_to_server.sh +30 -0
- interface/index.html +354 -401
- interface/index_optimized.html +534 -136
- interface/index_streaming.html +30 -20
- interface/server.py +496 -329
- interface/server_optimized.py +24 -10
- interface/server_streaming.py +473 -9
- interface/test_webrtc_client.py +281 -0
- interface/test_webrtc_playwright.py +218 -0
- interface/webrtc_skypilot.yaml +19 -0
interface/deploy_to_server.sh
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
echo "==================================================="
|
| 3 |
+
echo "Deploy WebRTC para VPS"
|
| 4 |
+
echo "==================================================="
|
| 5 |
+
|
| 6 |
+
SERVER="root@62.107.25.198"
|
| 7 |
+
PORT="47824"
|
| 8 |
+
REMOTE_DIR="/workspace/interface"
|
| 9 |
+
|
| 10 |
+
echo ""
|
| 11 |
+
echo "Copiando arquivos para o servidor..."
|
| 12 |
+
echo ""
|
| 13 |
+
|
| 14 |
+
# Copiar arquivos principais
|
| 15 |
+
scp -P $PORT \
|
| 16 |
+
server.py \
|
| 17 |
+
index.html \
|
| 18 |
+
$SERVER:$REMOTE_DIR/
|
| 19 |
+
|
| 20 |
+
echo ""
|
| 21 |
+
echo "==================================================="
|
| 22 |
+
echo "Deploy concluído!"
|
| 23 |
+
echo "==================================================="
|
| 24 |
+
echo ""
|
| 25 |
+
echo "Para reiniciar o servidor:"
|
| 26 |
+
echo "ssh -p $PORT $SERVER"
|
| 27 |
+
echo "cd $REMOTE_DIR"
|
| 28 |
+
echo "pkill -f server.py"
|
| 29 |
+
echo "python3 server.py"
|
| 30 |
+
echo ""
|
interface/index.html
CHANGED
|
@@ -3,458 +3,411 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>Avatar
|
| 7 |
<style>
|
| 8 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 9 |
body {
|
| 10 |
-
font-family:
|
| 11 |
background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3a 100%);
|
| 12 |
color: #fff;
|
| 13 |
min-height: 100vh;
|
| 14 |
padding: 20px;
|
| 15 |
}
|
| 16 |
-
.container { max-width:
|
| 17 |
-
h1 { text-align: center; margin-bottom: 20px; color: #00d4ff; }
|
| 18 |
|
| 19 |
-
.status
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
padding: 10px 20px; border-radius: 20px;
|
| 25 |
-
background: rgba(255,255,255,0.1);
|
| 26 |
font-size: 14px;
|
|
|
|
| 27 |
}
|
| 28 |
-
.status
|
| 29 |
-
.status
|
| 30 |
-
.status
|
| 31 |
-
|
| 32 |
-
.main-content { display: flex; gap: 20px; flex-wrap: wrap; }
|
| 33 |
|
| 34 |
-
.video-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
| 41 |
position: relative;
|
| 42 |
}
|
| 43 |
-
|
| 44 |
-
width: 100%;
|
|
|
|
| 45 |
object-fit: contain;
|
| 46 |
-
background: #000;
|
| 47 |
}
|
| 48 |
-
.
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
font-size: 18px; color: #aaa;
|
| 53 |
-
pointer-events: none;
|
| 54 |
}
|
| 55 |
-
.video-overlay.hidden { display: none; }
|
| 56 |
|
| 57 |
-
.
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
-
.
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
border: 1px solid #333; border-radius: 8px;
|
| 67 |
-
background: rgba(255,255,255,0.05); color: #fff;
|
| 68 |
-
resize: vertical; font-size: 14px;
|
| 69 |
}
|
| 70 |
-
select, button {
|
| 71 |
-
width: 100%; padding: 12px; margin-top: 10px;
|
| 72 |
-
border: none; border-radius: 8px; cursor: pointer;
|
| 73 |
-
}
|
| 74 |
-
select { background: rgba(255,255,255,0.1); color: #fff; }
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
}
|
| 84 |
button:hover { opacity: 0.9; }
|
| 85 |
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
.metrics {
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
.log {
|
| 96 |
-
margin-top: 20px; padding: 10px;
|
| 97 |
-
background: #000; border-radius: 8px;
|
| 98 |
-
font-family: monospace; font-size: 12px;
|
| 99 |
-
max-height: 150px; overflow-y: auto;
|
| 100 |
}
|
| 101 |
-
.
|
| 102 |
-
.
|
| 103 |
-
.log-msg { color: #0f0; }
|
| 104 |
-
.log-error { color: #f00; }
|
| 105 |
-
.log-status { color: #fc0; }
|
| 106 |
</style>
|
| 107 |
</head>
|
| 108 |
<body>
|
| 109 |
<div class="container">
|
| 110 |
-
<
|
| 111 |
|
| 112 |
-
<div class="
|
| 113 |
-
<
|
| 114 |
-
<div class="
|
| 115 |
-
<div class="status-item" id="tts-status">TTS: --</div>
|
| 116 |
</div>
|
| 117 |
|
| 118 |
-
<div class="
|
| 119 |
-
<
|
| 120 |
-
<h3>Video Stream</h3>
|
| 121 |
-
<div class="video-container">
|
| 122 |
-
<video id="video-player" autoplay></video>
|
| 123 |
-
<div class="video-overlay" id="video-overlay">
|
| 124 |
-
Aguardando video...
|
| 125 |
-
</div>
|
| 126 |
-
</div>
|
| 127 |
-
</div>
|
| 128 |
-
|
| 129 |
-
<div class="control-section">
|
| 130 |
-
<h3>Controles</h3>
|
| 131 |
-
|
| 132 |
-
<div class="input-group">
|
| 133 |
-
<label>Texto para falar:</label>
|
| 134 |
-
<textarea id="text-input" placeholder="Digite o texto aqui...">Hello! I am a real-time streaming avatar powered by AI.</textarea>
|
| 135 |
-
</div>
|
| 136 |
-
|
| 137 |
-
<div class="input-group">
|
| 138 |
-
<label>Voz:</label>
|
| 139 |
-
<select id="voice-select">
|
| 140 |
-
<option value="tara">Tara (Female)</option>
|
| 141 |
-
<option value="leah">Leah (Female)</option>
|
| 142 |
-
<option value="jess">Jess (Female)</option>
|
| 143 |
-
<option value="leo">Leo (Male)</option>
|
| 144 |
-
<option value="dan">Dan (Male)</option>
|
| 145 |
-
</select>
|
| 146 |
-
</div>
|
| 147 |
-
|
| 148 |
-
<button id="generate-btn" class="btn-primary" onclick="generate()">
|
| 149 |
-
Gerar Avatar
|
| 150 |
-
</button>
|
| 151 |
-
<button id="stop-btn" class="btn-danger" onclick="stop()" disabled>
|
| 152 |
-
Parar
|
| 153 |
-
</button>
|
| 154 |
-
|
| 155 |
-
<div class="metrics">
|
| 156 |
-
<h4>Metricas</h4>
|
| 157 |
-
<div class="metric-row">
|
| 158 |
-
<span>Latencia:</span>
|
| 159 |
-
<span class="metric-value" id="latency">--</span>
|
| 160 |
-
</div>
|
| 161 |
-
<div class="metric-row">
|
| 162 |
-
<span>Frames:</span>
|
| 163 |
-
<span class="metric-value" id="frames">0</span>
|
| 164 |
-
</div>
|
| 165 |
-
<div class="metric-row">
|
| 166 |
-
<span>Chunks WebM:</span>
|
| 167 |
-
<span class="metric-value" id="chunks">0</span>
|
| 168 |
-
</div>
|
| 169 |
-
<div class="metric-row">
|
| 170 |
-
<span>Bytes recebidos:</span>
|
| 171 |
-
<span class="metric-value" id="bytes">0 KB</span>
|
| 172 |
-
</div>
|
| 173 |
-
<div class="metric-row">
|
| 174 |
-
<span>Duracao:</span>
|
| 175 |
-
<span class="metric-value" id="duration">--</span>
|
| 176 |
-
</div>
|
| 177 |
-
</div>
|
| 178 |
-
|
| 179 |
-
<div class="log" id="log"></div>
|
| 180 |
-
</div>
|
| 181 |
</div>
|
| 182 |
-
</div>
|
| 183 |
-
|
| 184 |
-
<script>
|
| 185 |
-
// Configuracao
|
| 186 |
-
const WS_URL = "ws://" + window.location.host + "/ws";
|
| 187 |
-
|
| 188 |
-
// Estado
|
| 189 |
-
let ws = null;
|
| 190 |
-
let isGenerating = false;
|
| 191 |
-
let startTime = null;
|
| 192 |
-
let totalFrames = 0;
|
| 193 |
-
let totalChunks = 0;
|
| 194 |
-
let totalBytes = 0;
|
| 195 |
-
|
| 196 |
-
// Elementos
|
| 197 |
-
const video = document.getElementById("video-player");
|
| 198 |
-
const overlay = document.getElementById("video-overlay");
|
| 199 |
-
|
| 200 |
-
// Buffer de WebM para reproducao sequencial
|
| 201 |
-
let webmQueue = [];
|
| 202 |
-
let isPlaying = false;
|
| 203 |
-
|
| 204 |
-
function log(msg, type = "msg") {
|
| 205 |
-
const logDiv = document.getElementById("log");
|
| 206 |
-
const time = new Date().toLocaleTimeString();
|
| 207 |
-
logDiv.innerHTML = `<div class="log-entry"><span class="log-time">${time}</span> <span class="log-${type}">${msg}</span></div>` + logDiv.innerHTML;
|
| 208 |
-
|
| 209 |
-
while (logDiv.children.length > 50) {
|
| 210 |
-
logDiv.removeChild(logDiv.lastChild);
|
| 211 |
-
}
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
function updateStatus(element, status, text) {
|
| 215 |
-
const el = document.getElementById(element);
|
| 216 |
-
el.textContent = text;
|
| 217 |
-
el.className = "status-item " + status;
|
| 218 |
-
}
|
| 219 |
-
|
| 220 |
-
async function checkHealth() {
|
| 221 |
-
try {
|
| 222 |
-
const resp = await fetch("/health");
|
| 223 |
-
const status = await resp.json();
|
| 224 |
-
|
| 225 |
-
updateStatus("wav2lip-status",
|
| 226 |
-
status.wav2lip ? "online" : "offline",
|
| 227 |
-
"Wav2Lip: " + (status.wav2lip ? "Online" : "Offline")
|
| 228 |
-
);
|
| 229 |
-
updateStatus("tts-status",
|
| 230 |
-
status.tts ? "online" : "offline",
|
| 231 |
-
"TTS: " + (status.tts ? "Online" : "Offline")
|
| 232 |
-
);
|
| 233 |
-
} catch (e) {
|
| 234 |
-
updateStatus("wav2lip-status", "offline", "Wav2Lip: Erro");
|
| 235 |
-
updateStatus("tts-status", "offline", "TTS: Erro");
|
| 236 |
-
}
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
function connectWebSocket() {
|
| 240 |
-
if (ws && ws.readyState === WebSocket.OPEN) return;
|
| 241 |
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
| 252 |
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
}
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
function handleMessage(data) {
|
| 274 |
-
const type = data.type;
|
| 275 |
-
|
| 276 |
-
switch (type) {
|
| 277 |
-
case "status":
|
| 278 |
-
log(data.message, "status");
|
| 279 |
-
break;
|
| 280 |
-
|
| 281 |
-
case "webm_chunk":
|
| 282 |
-
handleWebMChunk(data);
|
| 283 |
-
break;
|
| 284 |
-
|
| 285 |
-
case "done":
|
| 286 |
-
handleDone(data);
|
| 287 |
-
break;
|
| 288 |
-
|
| 289 |
-
case "error":
|
| 290 |
-
log("Erro: " + data.message, "error");
|
| 291 |
-
stopGeneration();
|
| 292 |
-
break;
|
| 293 |
-
|
| 294 |
-
case "pong":
|
| 295 |
-
break;
|
| 296 |
-
|
| 297 |
-
default:
|
| 298 |
-
console.log("Mensagem desconhecida:", data);
|
| 299 |
-
}
|
| 300 |
-
}
|
| 301 |
-
|
| 302 |
-
function handleWebMChunk(data) {
|
| 303 |
-
totalChunks++;
|
| 304 |
-
document.getElementById("chunks").textContent = totalChunks;
|
| 305 |
-
|
| 306 |
-
// Decodificar WebM
|
| 307 |
-
const webmData = base64ToArrayBuffer(data.data);
|
| 308 |
-
totalBytes += webmData.byteLength;
|
| 309 |
-
document.getElementById("bytes").textContent = (totalBytes / 1024).toFixed(1) + " KB";
|
| 310 |
-
|
| 311 |
-
// Atualizar latencia (primeiro chunk)
|
| 312 |
-
if (startTime && totalChunks === 1) {
|
| 313 |
-
const latency = Date.now() - startTime;
|
| 314 |
-
document.getElementById("latency").textContent = latency + "ms";
|
| 315 |
-
log(`Primeiro chunk em ${latency}ms`, "status");
|
| 316 |
}
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
overlay.classList.add("hidden");
|
| 320 |
-
|
| 321 |
-
// Adicionar a fila e reproduzir
|
| 322 |
-
webmQueue.push(webmData);
|
| 323 |
-
|
| 324 |
-
if (!isPlaying) {
|
| 325 |
-
playNextWebM();
|
| 326 |
}
|
| 327 |
-
}
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
}
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
video.src = url;
|
| 350 |
-
video.play().catch(e => {
|
| 351 |
-
log("Erro ao reproduzir: " + e, "error");
|
| 352 |
-
playNextWebM();
|
| 353 |
-
});
|
| 354 |
-
}
|
| 355 |
-
|
| 356 |
-
function handleDone(data) {
|
| 357 |
-
totalFrames = data.total_frames;
|
| 358 |
-
document.getElementById("frames").textContent = totalFrames;
|
| 359 |
-
document.getElementById("duration").textContent = data.total_duration_ms + "ms";
|
| 360 |
-
|
| 361 |
-
log(`Geracao concluida: ${data.total_frames} frames, ${data.total_duration_ms}ms`, "msg");
|
| 362 |
-
|
| 363 |
-
isGenerating = false;
|
| 364 |
-
document.getElementById("generate-btn").disabled = false;
|
| 365 |
-
document.getElementById("stop-btn").disabled = true;
|
| 366 |
-
}
|
| 367 |
-
|
| 368 |
-
function base64ToArrayBuffer(base64) {
|
| 369 |
-
const binaryString = atob(base64);
|
| 370 |
-
const bytes = new Uint8Array(binaryString.length);
|
| 371 |
-
for (let i = 0; i < binaryString.length; i++) {
|
| 372 |
-
bytes[i] = binaryString.charCodeAt(i);
|
| 373 |
}
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
}
|
| 376 |
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
text: text,
|
| 424 |
-
voice: voice
|
| 425 |
-
})
|
| 426 |
-
}
|
| 427 |
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
}
|
| 432 |
-
stopGeneration();
|
| 433 |
}
|
| 434 |
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
}
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
|
|
|
|
|
|
|
|
|
| 459 |
</body>
|
| 460 |
</html>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Avatar - WebRTC</title>
|
| 7 |
<style>
|
| 8 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 9 |
body {
|
| 10 |
+
font-family: system-ui, sans-serif;
|
| 11 |
background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3a 100%);
|
| 12 |
color: #fff;
|
| 13 |
min-height: 100vh;
|
| 14 |
padding: 20px;
|
| 15 |
}
|
| 16 |
+
.container { max-width: 900px; margin: 0 auto; }
|
|
|
|
| 17 |
|
| 18 |
+
.status {
|
| 19 |
+
text-align: center;
|
| 20 |
+
padding: 10px;
|
| 21 |
+
margin-bottom: 15px;
|
| 22 |
+
border-radius: 8px;
|
|
|
|
|
|
|
| 23 |
font-size: 14px;
|
| 24 |
+
background: rgba(255,255,255,0.1);
|
| 25 |
}
|
| 26 |
+
.status.connected { background: rgba(0,255,100,0.2); color: #0f0; }
|
| 27 |
+
.status.busy { background: rgba(255,200,0,0.2); color: #fc0; }
|
| 28 |
+
.status.error { background: rgba(255,0,0,0.2); color: #f55; }
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
.video-box {
|
| 31 |
+
background: #000;
|
| 32 |
+
border-radius: 10px;
|
| 33 |
+
overflow: hidden;
|
| 34 |
+
margin-bottom: 20px;
|
| 35 |
+
aspect-ratio: 16/9;
|
| 36 |
+
display: flex;
|
| 37 |
+
align-items: center;
|
| 38 |
+
justify-content: center;
|
| 39 |
position: relative;
|
| 40 |
}
|
| 41 |
+
video {
|
| 42 |
+
max-width: 100%;
|
| 43 |
+
max-height: 100%;
|
| 44 |
object-fit: contain;
|
|
|
|
| 45 |
}
|
| 46 |
+
.placeholder {
|
| 47 |
+
color: #666;
|
| 48 |
+
font-size: 14px;
|
| 49 |
+
position: absolute;
|
|
|
|
|
|
|
| 50 |
}
|
|
|
|
| 51 |
|
| 52 |
+
.controls {
|
| 53 |
+
display: flex;
|
| 54 |
+
gap: 10px;
|
| 55 |
+
margin-bottom: 15px;
|
| 56 |
+
flex-wrap: wrap;
|
| 57 |
}
|
| 58 |
|
| 59 |
+
.call-controls {
|
| 60 |
+
display: flex;
|
| 61 |
+
gap: 10px;
|
| 62 |
+
margin-bottom: 20px;
|
|
|
|
|
|
|
|
|
|
| 63 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
textarea {
|
| 66 |
+
flex: 1;
|
| 67 |
+
min-width: 200px;
|
| 68 |
+
padding: 12px;
|
| 69 |
+
border: 1px solid #333;
|
| 70 |
+
border-radius: 8px;
|
| 71 |
+
background: #1a1a2e;
|
| 72 |
+
color: #fff;
|
| 73 |
+
font-size: 14px;
|
| 74 |
+
resize: none;
|
| 75 |
+
height: 60px;
|
| 76 |
+
}
|
| 77 |
+
select {
|
| 78 |
+
padding: 12px;
|
| 79 |
+
border: 1px solid #333;
|
| 80 |
+
border-radius: 8px;
|
| 81 |
+
background: #1a1a2e;
|
| 82 |
+
color: #fff;
|
| 83 |
+
font-size: 14px;
|
| 84 |
}
|
| 85 |
+
button {
|
| 86 |
+
padding: 12px 24px;
|
| 87 |
+
border: none;
|
| 88 |
+
border-radius: 8px;
|
| 89 |
+
font-size: 14px;
|
| 90 |
+
font-weight: bold;
|
| 91 |
+
cursor: pointer;
|
| 92 |
+
transition: opacity 0.2s;
|
| 93 |
}
|
| 94 |
button:hover { opacity: 0.9; }
|
| 95 |
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
| 96 |
|
| 97 |
+
.btn-call { background: #00aaff; color: #fff; }
|
| 98 |
+
.btn-call.active { background: #ff4444; }
|
| 99 |
+
.btn-generate { background: #00ff88; color: #000; flex: 0 0 auto; }
|
| 100 |
+
|
| 101 |
.metrics {
|
| 102 |
+
display: grid;
|
| 103 |
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
| 104 |
+
gap: 8px;
|
| 105 |
+
padding: 12px;
|
| 106 |
+
background: #1a1a2e;
|
| 107 |
+
border-radius: 8px;
|
| 108 |
+
font-size: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
}
|
| 110 |
+
.metric { display: flex; justify-content: space-between; }
|
| 111 |
+
.val { color: #00ff88; font-family: monospace; }
|
|
|
|
|
|
|
|
|
|
| 112 |
</style>
|
| 113 |
</head>
|
| 114 |
<body>
|
| 115 |
<div class="container">
|
| 116 |
+
<div class="status" id="status">Desconectado</div>
|
| 117 |
|
| 118 |
+
<div class="video-box">
|
| 119 |
+
<video id="video" autoplay playsinline></video>
|
| 120 |
+
<div class="placeholder" id="placeholder">Clique em "Conectar" para iniciar</div>
|
|
|
|
| 121 |
</div>
|
| 122 |
|
| 123 |
+
<div class="call-controls">
|
| 124 |
+
<button class="btn-call" id="btnConnect">Conectar</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
<div class="controls">
|
| 128 |
+
<textarea id="text" placeholder="Digite o texto para o avatar falar...">Hello! I am testing the WebRTC streaming avatar with VP9 codec.</textarea>
|
| 129 |
+
<select id="voice">
|
| 130 |
+
<option value="tara">Tara</option>
|
| 131 |
+
<option value="leah">Leah</option>
|
| 132 |
+
<option value="jess">Jess</option>
|
| 133 |
+
<option value="leo">Leo</option>
|
| 134 |
+
<option value="dan">Dan</option>
|
| 135 |
+
</select>
|
| 136 |
+
<button class="btn-generate" id="btnGenerate" disabled>Gerar</button>
|
| 137 |
+
</div>
|
| 138 |
|
| 139 |
+
<div class="metrics">
|
| 140 |
+
<div class="metric"><span>WebRTC:</span><span class="val" id="mWebrtc">--</span></div>
|
| 141 |
+
<div class="metric"><span>Video:</span><span class="val" id="mVideo">--</span></div>
|
| 142 |
+
<div class="metric"><span>Audio:</span><span class="val" id="mAudio">--</span></div>
|
| 143 |
+
<div class="metric"><span>Latencia:</span><span class="val" id="mLatency">--</span></div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
|
| 147 |
+
<script>
|
| 148 |
+
const video = document.getElementById('video');
|
| 149 |
+
const status = document.getElementById('status');
|
| 150 |
+
const placeholder = document.getElementById('placeholder');
|
| 151 |
+
const btnConnect = document.getElementById('btnConnect');
|
| 152 |
+
const btnGenerate = document.getElementById('btnGenerate');
|
| 153 |
+
|
| 154 |
+
// Global variables (para debug)
|
| 155 |
+
window.pc = null;
|
| 156 |
+
let pc = null;
|
| 157 |
+
let sessionId = null;
|
| 158 |
+
let isConnected = false;
|
| 159 |
+
let isConnecting = false; // Flag para prevenir múltiplas chamadas simultâneas
|
| 160 |
+
|
| 161 |
+
function setStatus(txt, cls) {
|
| 162 |
+
status.textContent = txt;
|
| 163 |
+
status.className = 'status ' + (cls || '');
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function setMetric(id, val) {
|
| 167 |
+
document.getElementById(id).textContent = val;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
async function connect() {
|
| 171 |
+
if (isConnected) {
|
| 172 |
+
disconnect();
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// Prevenir múltiplas chamadas simultâneas
|
| 177 |
+
if (isConnecting) {
|
| 178 |
+
console.log('Conexão já em andamento, ignorando...');
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
isConnecting = true;
|
| 183 |
+
setStatus('Conectando...', 'busy');
|
| 184 |
+
|
| 185 |
+
try {
|
| 186 |
+
// Criar RTCPeerConnection com STUN + TURN múltiplos
|
| 187 |
+
pc = window.pc = new RTCPeerConnection({
|
| 188 |
+
iceServers: [
|
| 189 |
+
{ urls: 'stun:stun.l.google.com:19302' },
|
| 190 |
+
// Servidores TURN públicos - múltiplas opções
|
| 191 |
+
{
|
| 192 |
+
urls: [
|
| 193 |
+
'turn:openrelay.metered.ca:80',
|
| 194 |
+
'turn:openrelay.metered.ca:443',
|
| 195 |
+
'turn:openrelay.metered.ca:443?transport=tcp'
|
| 196 |
+
],
|
| 197 |
+
username: 'openrelayproject',
|
| 198 |
+
credential: 'openrelayproject'
|
| 199 |
+
},
|
| 200 |
+
// Servidor TURN alternativo (Twilio)
|
| 201 |
+
{
|
| 202 |
+
urls: 'turn:global.turn.twilio.com:3478?transport=udp',
|
| 203 |
+
username: 'f4b4035eaa76f4a55de5f4351567653ee4ff6fa97b50b6b334fcc1be9c27212d',
|
| 204 |
+
credential: 'w1uxM55V9yVoqyVFjt+mxDBV0F87AUCemaYVQGxsPLw='
|
| 205 |
}
|
| 206 |
+
],
|
| 207 |
+
iceCandidatePoolSize: 10,
|
| 208 |
+
bundlePolicy: 'max-bundle',
|
| 209 |
+
rtcpMuxPolicy: 'require'
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
// Handler para tracks recebidos
|
| 213 |
+
pc.ontrack = (event) => {
|
| 214 |
+
console.log('Track recebido:', event.track.kind);
|
| 215 |
+
if (event.track.kind === 'video') {
|
| 216 |
+
video.srcObject = event.streams[0];
|
| 217 |
+
placeholder.style.display = 'none';
|
| 218 |
+
setMetric('mVideo', 'Ativo');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
}
|
| 220 |
+
if (event.track.kind === 'audio') {
|
| 221 |
+
setMetric('mAudio', 'Ativo');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
}
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
// Handler para mudancas de estado
|
| 226 |
+
pc.onconnectionstatechange = () => {
|
| 227 |
+
console.log('Estado WebRTC:', pc.connectionState);
|
| 228 |
+
setMetric('mWebrtc', pc.connectionState);
|
| 229 |
+
|
| 230 |
+
if (pc.connectionState === 'connected') {
|
| 231 |
+
isConnected = true;
|
| 232 |
+
isConnecting = false; // Reset flag quando conectado
|
| 233 |
+
updateConnectButton();
|
| 234 |
+
setStatus('Conectado - Streaming ativo', 'connected');
|
| 235 |
+
btnGenerate.disabled = false;
|
| 236 |
+
startStatsMonitor();
|
| 237 |
+
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
| 238 |
+
isConnecting = false; // Reset flag em caso de falha
|
| 239 |
+
disconnect();
|
| 240 |
}
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
pc.oniceconnectionstatechange = () => {
|
| 244 |
+
console.log('ICE State:', pc.iceConnectionState);
|
| 245 |
+
};
|
| 246 |
+
|
| 247 |
+
// Debug: Log ICE candidates
|
| 248 |
+
pc.onicecandidate = (event) => {
|
| 249 |
+
if (event.candidate) {
|
| 250 |
+
console.log('ICE Candidate:', {
|
| 251 |
+
type: event.candidate.type,
|
| 252 |
+
protocol: event.candidate.protocol,
|
| 253 |
+
address: event.candidate.address,
|
| 254 |
+
port: event.candidate.port
|
| 255 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
}
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
// Criar transceiver para receber video e audio
|
| 260 |
+
pc.addTransceiver('video', { direction: 'recvonly' });
|
| 261 |
+
pc.addTransceiver('audio', { direction: 'recvonly' });
|
| 262 |
+
|
| 263 |
+
// Criar offer
|
| 264 |
+
const offer = await pc.createOffer();
|
| 265 |
+
await pc.setLocalDescription(offer);
|
| 266 |
+
|
| 267 |
+
// Aguardar alguns candidatos ICE (mas não esperar complete)
|
| 268 |
+
// Trickle ICE: enviamos o offer logo e candidatos vão depois
|
| 269 |
+
await new Promise(resolve => setTimeout(resolve, 500));
|
| 270 |
+
|
| 271 |
+
console.log('Enviando offer para servidor...');
|
| 272 |
+
|
| 273 |
+
// Enviar offer para o servidor
|
| 274 |
+
const response = await fetch('/offer', {
|
| 275 |
+
method: 'POST',
|
| 276 |
+
headers: { 'Content-Type': 'application/json' },
|
| 277 |
+
body: JSON.stringify({
|
| 278 |
+
sdp: pc.localDescription.sdp,
|
| 279 |
+
type: pc.localDescription.type
|
| 280 |
+
})
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
if (!response.ok) {
|
| 284 |
+
throw new Error('Erro ao conectar: ' + response.status);
|
| 285 |
}
|
| 286 |
|
| 287 |
+
const answer = await response.json();
|
| 288 |
+
sessionId = answer.session_id;
|
| 289 |
+
|
| 290 |
+
// Aplicar answer
|
| 291 |
+
await pc.setRemoteDescription(new RTCSessionDescription({
|
| 292 |
+
sdp: answer.sdp,
|
| 293 |
+
type: answer.type
|
| 294 |
+
}));
|
| 295 |
+
|
| 296 |
+
console.log('Session ID:', sessionId);
|
| 297 |
+
|
| 298 |
+
} catch (err) {
|
| 299 |
+
console.error('Erro ao conectar:', err);
|
| 300 |
+
isConnecting = false; // Reset flag em caso de erro
|
| 301 |
+
setStatus('Erro: ' + err.message, 'error');
|
| 302 |
+
disconnect();
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
function disconnect() {
|
| 307 |
+
if (pc) {
|
| 308 |
+
pc.close();
|
| 309 |
+
pc = null;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
isConnected = false;
|
| 313 |
+
sessionId = null;
|
| 314 |
+
video.srcObject = null;
|
| 315 |
+
placeholder.style.display = 'block';
|
| 316 |
+
|
| 317 |
+
updateConnectButton();
|
| 318 |
+
btnGenerate.disabled = true;
|
| 319 |
+
|
| 320 |
+
setStatus('Desconectado');
|
| 321 |
+
setMetric('mWebrtc', '--');
|
| 322 |
+
setMetric('mVideo', '--');
|
| 323 |
+
setMetric('mAudio', '--');
|
| 324 |
+
setMetric('mLatency', '--');
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function updateConnectButton() {
|
| 328 |
+
if (isConnected) {
|
| 329 |
+
btnConnect.textContent = 'Desconectar';
|
| 330 |
+
btnConnect.classList.add('active');
|
| 331 |
+
} else {
|
| 332 |
+
btnConnect.textContent = 'Conectar';
|
| 333 |
+
btnConnect.classList.remove('active');
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
async function generate() {
|
| 338 |
+
const text = document.getElementById('text').value.trim();
|
| 339 |
+
if (!text || !sessionId) return;
|
| 340 |
+
|
| 341 |
+
setStatus('Gerando fala...', 'busy');
|
| 342 |
+
btnGenerate.disabled = true;
|
| 343 |
+
|
| 344 |
+
try {
|
| 345 |
+
const response = await fetch('/generate', {
|
| 346 |
+
method: 'POST',
|
| 347 |
+
headers: { 'Content-Type': 'application/json' },
|
| 348 |
+
body: JSON.stringify({
|
| 349 |
+
session_id: sessionId,
|
| 350 |
text: text,
|
| 351 |
+
voice: document.getElementById('voice').value
|
| 352 |
+
})
|
| 353 |
+
});
|
| 354 |
|
| 355 |
+
if (!response.ok) {
|
| 356 |
+
const err = await response.json();
|
| 357 |
+
throw new Error(err.error || 'Erro ao gerar');
|
|
|
|
|
|
|
| 358 |
}
|
| 359 |
|
| 360 |
+
setStatus('Reproduzindo...', 'connected');
|
| 361 |
+
|
| 362 |
+
} catch (err) {
|
| 363 |
+
console.error('Erro ao gerar:', err);
|
| 364 |
+
setStatus('Erro: ' + err.message, 'error');
|
| 365 |
+
} finally {
|
| 366 |
+
btnGenerate.disabled = false;
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
function startStatsMonitor() {
|
| 371 |
+
setInterval(async () => {
|
| 372 |
+
if (!pc || !isConnected) return;
|
| 373 |
+
|
| 374 |
+
try {
|
| 375 |
+
const stats = await pc.getStats();
|
| 376 |
+
stats.forEach(report => {
|
| 377 |
+
if (report.type === 'inbound-rtp' && report.kind === 'video') {
|
| 378 |
+
const fps = report.framesPerSecond || 0;
|
| 379 |
+
const width = report.frameWidth || 0;
|
| 380 |
+
const height = report.frameHeight || 0;
|
| 381 |
+
if (width && height) {
|
| 382 |
+
setMetric('mVideo', `${width}x${height} @${fps.toFixed(0)}fps`);
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
| 386 |
+
const rtt = report.currentRoundTripTime;
|
| 387 |
+
if (rtt) {
|
| 388 |
+
setMetric('mLatency', `${(rtt * 1000).toFixed(0)}ms`);
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
});
|
| 392 |
+
} catch (e) {
|
| 393 |
+
// Ignorar erros de stats
|
| 394 |
}
|
| 395 |
+
}, 1000);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
// Event listeners
|
| 399 |
+
btnConnect.onclick = connect;
|
| 400 |
+
btnGenerate.onclick = generate;
|
| 401 |
+
|
| 402 |
+
// Atalho Enter no textarea
|
| 403 |
+
document.getElementById('text').onkeydown = (e) => {
|
| 404 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 405 |
+
e.preventDefault();
|
| 406 |
+
if (!btnGenerate.disabled) {
|
| 407 |
+
generate();
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
};
|
| 411 |
+
</script>
|
| 412 |
</body>
|
| 413 |
</html>
|
interface/index_optimized.html
CHANGED
|
@@ -138,7 +138,7 @@
|
|
| 138 |
<div class="video-section">
|
| 139 |
<h3 style="margin-bottom:10px">Avatar Stream (Binary Mode)</h3>
|
| 140 |
<div class="canvas-container">
|
| 141 |
-
<video id="idle-video"
|
| 142 |
<canvas id="avatar-canvas"></canvas>
|
| 143 |
</div>
|
| 144 |
</div>
|
|
@@ -162,13 +162,47 @@
|
|
| 162 |
</select>
|
| 163 |
</div>
|
| 164 |
|
| 165 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
Gerar
|
| 167 |
</button>
|
| 168 |
<button id="stop-btn" class="btn-danger" onclick="stop()" disabled>
|
| 169 |
Parar
|
| 170 |
</button>
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
<div class="metrics">
|
| 173 |
<h4>Metricas (Binary)</h4>
|
| 174 |
<div class="metric-row">
|
|
@@ -243,17 +277,32 @@
|
|
| 243 |
let syncedFrameInterval = FRAME_INTERVAL;
|
| 244 |
let playbackStarted = false;
|
| 245 |
|
| 246 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
let nextAudioTime = 0; // Próximo tempo para agendar audio
|
| 248 |
let audioScheduledChunks = 0; // Quantos chunks já agendamos
|
| 249 |
let firstChunkTime = null; // Tempo do primeiro chunk (para latência)
|
| 250 |
let totalAudioSamples = 0; // Total de samples de audio recebidos
|
| 251 |
-
let currentAudioSource = null; // Referência ao audio source atual
|
| 252 |
-
let audioPlaybackStartTime = 0; // Tempo em que o audio começou a tocar
|
| 253 |
-
let audioExpectedEndTime = 0; // Tempo esperado para o audio terminar
|
|
|
|
|
|
|
| 254 |
|
| 255 |
-
// Sincronização de transição
|
| 256 |
let endVideoTimeMs = null; // Tempo para continuar o vídeo idle após fala
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
// Renderização unificada no canvas
|
| 259 |
let renderSource = 'idle'; // 'idle' = video, 'speaking' = frames do servidor
|
|
@@ -280,63 +329,85 @@
|
|
| 280 |
let speakingFrameIndex = 0;
|
| 281 |
let lastSpeakingRenderTime = 0;
|
| 282 |
|
| 283 |
-
//
|
| 284 |
-
let isTransitioning = false;
|
| 285 |
-
let transitionStartTime = 0;
|
| 286 |
-
let lastSpeakingFrame = null; // Guarda o último frame do speaking para crossfade
|
| 287 |
-
const TRANSITION_DURATION_MS = 300; // Duração do crossfade em ms
|
| 288 |
|
| 289 |
function startUnifiedRenderLoop() {
|
| 290 |
if (unifiedRenderLoop) return; // Já está rodando
|
| 291 |
|
| 292 |
function renderFrame() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
if (renderSource === 'idle') {
|
| 294 |
-
//
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
const progress = Math.min(elapsed / TRANSITION_DURATION_MS, 1);
|
| 299 |
-
|
| 300 |
-
// Desenha o último frame do speaking
|
| 301 |
-
ctx.globalAlpha = 1 - progress;
|
| 302 |
-
ctx.drawImage(lastSpeakingFrame, 0, 0, canvas.width, canvas.height);
|
| 303 |
-
|
| 304 |
-
// Desenha o vídeo idle por cima com alpha crescente
|
| 305 |
-
ctx.globalAlpha = progress;
|
| 306 |
-
if (idleVideo.readyState >= 2) {
|
| 307 |
-
ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
// Restaurar alpha
|
| 311 |
-
ctx.globalAlpha = 1;
|
| 312 |
-
|
| 313 |
-
// Fim da transição
|
| 314 |
-
if (progress >= 1) {
|
| 315 |
-
isTransitioning = false;
|
| 316 |
-
lastSpeakingFrame = null;
|
| 317 |
-
console.log("Crossfade concluído");
|
| 318 |
-
}
|
| 319 |
} else {
|
| 320 |
-
//
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
}
|
| 324 |
}
|
| 325 |
} else if (renderSource === 'speaking') {
|
| 326 |
// Desenha frames do servidor com timing controlado
|
| 327 |
const now = performance.now();
|
| 328 |
const elapsed = now - lastSpeakingRenderTime;
|
| 329 |
|
| 330 |
-
//
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
finishPlayback();
|
| 337 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
}
|
| 339 |
|
|
|
|
| 340 |
if (elapsed >= syncedFrameInterval && frameQueue.length > 0) {
|
| 341 |
const frameData = frameQueue.shift();
|
| 342 |
|
|
@@ -367,12 +438,6 @@
|
|
| 367 |
const bw = totalElapsed > 0 ? (totalBytes / 1024 / totalElapsed) : 0;
|
| 368 |
updateStatus("bandwidth-status", "", `BW: ${bw.toFixed(0)} KB/s`);
|
| 369 |
|
| 370 |
-
// Verificar se terminou
|
| 371 |
-
if (streamDone && frameQueue.length === 0) {
|
| 372 |
-
// Salvar último frame para crossfade
|
| 373 |
-
saveLastFrameForTransition();
|
| 374 |
-
finishPlayback();
|
| 375 |
-
}
|
| 376 |
}
|
| 377 |
}
|
| 378 |
|
|
@@ -391,31 +456,68 @@
|
|
| 391 |
}
|
| 392 |
}
|
| 393 |
|
| 394 |
-
// Salvar o último frame do speaking para crossfade
|
| 395 |
-
function saveLastFrameForTransition() {
|
| 396 |
-
// Criar uma cópia do canvas atual como imagem
|
| 397 |
-
const tempCanvas = document.createElement('canvas');
|
| 398 |
-
tempCanvas.width = canvas.width;
|
| 399 |
-
tempCanvas.height = canvas.height;
|
| 400 |
-
const tempCtx = tempCanvas.getContext('2d');
|
| 401 |
-
tempCtx.drawImage(canvas, 0, 0);
|
| 402 |
-
|
| 403 |
-
// Criar imagem a partir do canvas
|
| 404 |
-
const img = new Image();
|
| 405 |
-
img.src = tempCanvas.toDataURL('image/jpeg', 0.95);
|
| 406 |
-
lastSpeakingFrame = img;
|
| 407 |
-
|
| 408 |
-
console.log("Último frame salvo para crossfade");
|
| 409 |
-
}
|
| 410 |
-
|
| 411 |
// Iniciar o render loop quando a página carrega
|
| 412 |
idleVideo.addEventListener('loadeddata', () => {
|
| 413 |
// Ajustar tamanho do canvas para match do vídeo
|
| 414 |
canvas.width = idleVideo.videoWidth || 512;
|
| 415 |
canvas.height = idleVideo.videoHeight || 512;
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
});
|
| 418 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
function log(msg, type = "msg") {
|
| 420 |
const logDiv = document.getElementById("log");
|
| 421 |
const time = new Date().toLocaleTimeString();
|
|
@@ -559,16 +661,26 @@
|
|
| 559 |
break;
|
| 560 |
|
| 561 |
case "first_frame":
|
| 562 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
log(`Primeiro frame (server): ${data.latency_ms}ms`, "status");
|
| 564 |
break;
|
| 565 |
|
| 566 |
case "done":
|
| 567 |
-
// Salvar
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
if (data.end_video_time_ms !== undefined) {
|
| 569 |
endVideoTimeMs = data.end_video_time_ms;
|
| 570 |
-
console.log(`Transição: vídeo idle continuará em ${endVideoTimeMs}ms`);
|
| 571 |
}
|
|
|
|
| 572 |
|
| 573 |
const framesCount = data.total_frames || data.frames || allFrames.length;
|
| 574 |
const bytesInfo = data.bytes_sent ? `${(data.bytes_sent/1024).toFixed(1)}KB` : '';
|
|
@@ -598,8 +710,8 @@
|
|
| 598 |
// Acumular frames
|
| 599 |
allFrames.push({ url, index: frameIndex });
|
| 600 |
|
| 601 |
-
// Se já está reproduzindo, adicionar ao buffer
|
| 602 |
-
if (playbackStarted) {
|
| 603 |
frameQueue.push({ url, index: frameIndex });
|
| 604 |
document.getElementById("buffer").textContent = frameQueue.length;
|
| 605 |
}
|
|
@@ -612,13 +724,11 @@
|
|
| 612 |
document.getElementById("video-duration").textContent = videoDuration.toFixed(2) + "s";
|
| 613 |
updateSyncDiff();
|
| 614 |
|
| 615 |
-
if (!playbackStarted) {
|
| 616 |
document.getElementById("buffer").textContent = received + " (buffering)";
|
| 617 |
}
|
| 618 |
|
| 619 |
if (received === 1) {
|
| 620 |
-
// Primeiro frame - apenas log, NÃO mudar para speaking ainda
|
| 621 |
-
// (vai mudar quando playback sincronizado começar)
|
| 622 |
console.log("Primeiro frame recebido - buffering...");
|
| 623 |
updateStatus("stream-status", "streaming", "Stream: Buffering...");
|
| 624 |
}
|
|
@@ -656,8 +766,8 @@
|
|
| 656 |
initAudioContext();
|
| 657 |
}
|
| 658 |
|
| 659 |
-
// Acumular chunk (NÃO tocar ainda - esperar sync)
|
| 660 |
if (chunkData.length > 0) {
|
|
|
|
| 661 |
audioChunks.push(chunkData);
|
| 662 |
|
| 663 |
// Acumular samples para calcular duração
|
|
@@ -671,7 +781,12 @@
|
|
| 671 |
document.getElementById("audio-duration").textContent = totalAudioDuration.toFixed(2) + "s";
|
| 672 |
updateSyncDiff();
|
| 673 |
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
}
|
| 676 |
|
| 677 |
if (isLast) {
|
|
@@ -680,10 +795,57 @@
|
|
| 680 |
console.log(`Todos chunks recebidos: ${audioChunks.length}`);
|
| 681 |
}
|
| 682 |
|
| 683 |
-
// Tentar iniciar
|
| 684 |
tryStartStreamingPlayback();
|
| 685 |
}
|
| 686 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 687 |
function initAudioContext() {
|
| 688 |
if (!audioContext) {
|
| 689 |
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
@@ -803,60 +965,128 @@
|
|
| 803 |
}
|
| 804 |
|
| 805 |
function tryStartStreamingPlayback() {
|
| 806 |
-
//
|
| 807 |
-
// 1. Já iniciou? Sair
|
| 808 |
if (playbackStarted) return;
|
| 809 |
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
if (!streamDone) {
|
| 813 |
-
console.log(`Aguardando stream completo...`);
|
| 814 |
-
return;
|
| 815 |
-
}
|
| 816 |
|
| 817 |
-
//
|
| 818 |
-
if (
|
| 819 |
-
|
| 820 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 821 |
}
|
| 822 |
|
| 823 |
-
//
|
| 824 |
-
if (
|
| 825 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 826 |
return;
|
| 827 |
}
|
| 828 |
|
| 829 |
-
//
|
| 830 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
|
| 832 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
const playbackLatency = Date.now() - startTime;
|
| 834 |
document.getElementById("latency").textContent = playbackLatency + "ms";
|
| 835 |
log(`Latencia: ${playbackLatency}ms`, "status");
|
| 836 |
|
| 837 |
-
//
|
| 838 |
-
|
| 839 |
-
const totalFrames = allFrames.length;
|
| 840 |
-
const calculatedFps = totalFrames / audioDurationSec;
|
| 841 |
-
syncedFrameInterval = 1000 / calculatedFps;
|
| 842 |
|
| 843 |
-
console.log(`
|
| 844 |
-
console.log(`Latencia: ${playbackLatency}ms`);
|
| 845 |
-
console.log(`Frames: ${totalFrames}`);
|
| 846 |
-
console.log(`Audio: ${audioDurationSec.toFixed(2)}s (${totalAudioSamples} samples)`);
|
| 847 |
-
console.log(`FPS calculado: ${calculatedFps.toFixed(2)} (interval: ${syncedFrameInterval.toFixed(1)}ms)`);
|
| 848 |
|
| 849 |
-
//
|
| 850 |
frameQueue = [...allFrames];
|
| 851 |
document.getElementById("buffer").textContent = frameQueue.length;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
renderSource = 'speaking';
|
| 853 |
lastSpeakingRenderTime = performance.now();
|
| 854 |
|
| 855 |
-
//
|
| 856 |
-
|
| 857 |
-
restartAudioPlayback();
|
| 858 |
|
| 859 |
-
updateStatus("stream-status", "streaming", "Stream: Reproduzindo");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
}
|
| 861 |
|
| 862 |
function restartAudioPlayback() {
|
|
@@ -1006,10 +1236,14 @@
|
|
| 1006 |
currentAudioSource = null;
|
| 1007 |
audioPlaybackStartTime = 0;
|
| 1008 |
audioExpectedEndTime = 0;
|
|
|
|
|
|
|
| 1009 |
|
| 1010 |
-
// Reset
|
| 1011 |
-
|
| 1012 |
-
|
|
|
|
|
|
|
| 1013 |
|
| 1014 |
document.getElementById("frames").textContent = "0";
|
| 1015 |
document.getElementById("rendered").textContent = "0";
|
|
@@ -1026,6 +1260,15 @@
|
|
| 1026 |
document.getElementById("stop-btn").disabled = false;
|
| 1027 |
updateStatus("stream-status", "", "Stream: Iniciando...");
|
| 1028 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1029 |
log("Enviando: " + text.substring(0, 30) + "...", "status");
|
| 1030 |
|
| 1031 |
// Enviar com o timestamp do vídeo idle para sincronização
|
|
@@ -1033,7 +1276,8 @@
|
|
| 1033 |
action: "generate",
|
| 1034 |
text: text,
|
| 1035 |
voice: voice,
|
| 1036 |
-
idle_video_time_ms: idleVideoTimeMs // Timestamp exato do vídeo idle
|
|
|
|
| 1037 |
}));
|
| 1038 |
}
|
| 1039 |
|
|
@@ -1062,13 +1306,27 @@
|
|
| 1062 |
log(`Finalizado: ${renderedFrames} frames em ${elapsed.toFixed(1)}s`, "msg");
|
| 1063 |
console.log(`Playback finalizado: ${renderedFrames} frames renderizados`);
|
| 1064 |
|
| 1065 |
-
// Transição
|
| 1066 |
-
//
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
const videoDuration = idleVideo.duration || 60;
|
| 1070 |
-
const
|
| 1071 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
idleVideo.currentTime = seekTime;
|
| 1073 |
}
|
| 1074 |
|
|
@@ -1076,16 +1334,9 @@
|
|
| 1076 |
idleVideo.loop = true;
|
| 1077 |
idleVideo.play().catch(e => console.log("Erro ao retomar idle video:", e));
|
| 1078 |
|
| 1079 |
-
//
|
| 1080 |
-
if (lastSpeakingFrame) {
|
| 1081 |
-
isTransitioning = true;
|
| 1082 |
-
transitionStartTime = performance.now();
|
| 1083 |
-
console.log("Iniciando crossfade de 300ms");
|
| 1084 |
-
}
|
| 1085 |
-
|
| 1086 |
-
// Transição: speaking → idle (só muda a fonte, canvas continua renderizando)
|
| 1087 |
renderSource = 'idle';
|
| 1088 |
-
console.log("Transição para idle
|
| 1089 |
|
| 1090 |
// Reset estado de audio
|
| 1091 |
audioExpectedEndTime = 0;
|
|
@@ -1093,8 +1344,14 @@
|
|
| 1093 |
|
| 1094 |
// Reset estado geral
|
| 1095 |
isStreaming = false;
|
| 1096 |
-
endVideoTimeMs = null;
|
| 1097 |
playbackStarted = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1098 |
document.getElementById("generate-btn").disabled = false;
|
| 1099 |
document.getElementById("stop-btn").disabled = true;
|
| 1100 |
updateStatus("stream-status", "online", "Stream: Concluido");
|
|
@@ -1123,12 +1380,14 @@
|
|
| 1123 |
audioDuration = 0;
|
| 1124 |
audioBuffer = null;
|
| 1125 |
playbackStarted = false;
|
|
|
|
| 1126 |
nextAudioTime = 0;
|
| 1127 |
audioScheduledChunks = 0;
|
| 1128 |
firstChunkTime = null;
|
| 1129 |
totalAudioSamples = 0;
|
| 1130 |
audioPlaybackStartTime = 0;
|
| 1131 |
audioExpectedEndTime = 0;
|
|
|
|
| 1132 |
|
| 1133 |
if (currentAudioSource) {
|
| 1134 |
try { currentAudioSource.stop(); } catch (e) {}
|
|
@@ -1140,6 +1399,12 @@
|
|
| 1140 |
audioSource = null;
|
| 1141 |
}
|
| 1142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1143 |
// Voltar ao video idle (apenas muda a fonte do render loop)
|
| 1144 |
renderSource = 'idle';
|
| 1145 |
idleVideo.play().catch(e => {});
|
|
@@ -1157,6 +1422,139 @@
|
|
| 1157 |
}
|
| 1158 |
}, 30000);
|
| 1159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1160 |
connectWebSocket();
|
| 1161 |
</script>
|
| 1162 |
</body>
|
|
|
|
| 138 |
<div class="video-section">
|
| 139 |
<h3 style="margin-bottom:10px">Avatar Stream (Binary Mode)</h3>
|
| 140 |
<div class="canvas-container">
|
| 141 |
+
<video id="idle-video" loop muted playsinline autoplay preload="auto" src="idle.mp4"></video>
|
| 142 |
<canvas id="avatar-canvas"></canvas>
|
| 143 |
</div>
|
| 144 |
</div>
|
|
|
|
| 162 |
</select>
|
| 163 |
</div>
|
| 164 |
|
| 165 |
+
<div class="input-group" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid rgba(255,255,255,0.1);">
|
| 166 |
+
<label>Qualidade Video Fala: <span id="quality-value">95</span>%</label>
|
| 167 |
+
<input type="range" id="quality-slider" min="50" max="100" value="95"
|
| 168 |
+
style="width: 100%; margin-top: 5px;"
|
| 169 |
+
oninput="document.getElementById('quality-value').textContent = this.value">
|
| 170 |
+
<small style="color: #666; font-size: 11px;">Aumentar para igualar com vídeo idle</small>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div class="input-group">
|
| 174 |
+
<label>Offset Transição: <span id="offset-value">0</span>ms</label>
|
| 175 |
+
<input type="range" id="offset-slider" min="-500" max="500" value="0"
|
| 176 |
+
style="width: 100%; margin-top: 5px;"
|
| 177 |
+
oninput="document.getElementById('offset-value').textContent = this.value">
|
| 178 |
+
<small style="color: #666; font-size: 11px;">Ajuste fino do momento de voltar ao idle</small>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<button id="generate-btn" class="btn-primary" onclick="generate()" style="margin-top: 15px;">
|
| 182 |
Gerar
|
| 183 |
</button>
|
| 184 |
<button id="stop-btn" class="btn-danger" onclick="stop()" disabled>
|
| 185 |
Parar
|
| 186 |
</button>
|
| 187 |
|
| 188 |
+
<div class="input-group" style="margin-top: 15px; padding: 12px; background: rgba(255,200,0,0.1); border-radius: 8px; border: 1px solid rgba(255,200,0,0.3);">
|
| 189 |
+
<label style="color: #fc0; font-weight: bold;">Modo Demo</label>
|
| 190 |
+
<div style="display: flex; gap: 10px; margin-top: 8px; align-items: center;">
|
| 191 |
+
<button id="demo-btn" class="btn-primary" onclick="toggleDemo()" style="flex: 1; background: linear-gradient(135deg, #fc0, #f90); margin: 0;">
|
| 192 |
+
Iniciar Demo
|
| 193 |
+
</button>
|
| 194 |
+
</div>
|
| 195 |
+
<div style="display: flex; gap: 10px; margin-top: 10px;">
|
| 196 |
+
<div style="flex: 1;">
|
| 197 |
+
<label style="font-size: 11px;">Idle: <span id="demo-idle-value">5</span>s</label>
|
| 198 |
+
<input type="range" id="demo-idle-slider" min="1" max="30" value="5"
|
| 199 |
+
style="width: 100%;"
|
| 200 |
+
oninput="document.getElementById('demo-idle-value').textContent = this.value">
|
| 201 |
+
</div>
|
| 202 |
+
</div>
|
| 203 |
+
<small style="color: #666; font-size: 11px;">Alterna entre falar e idle automaticamente</small>
|
| 204 |
+
</div>
|
| 205 |
+
|
| 206 |
<div class="metrics">
|
| 207 |
<h4>Metricas (Binary)</h4>
|
| 208 |
<div class="metric-row">
|
|
|
|
| 277 |
let syncedFrameInterval = FRAME_INTERVAL;
|
| 278 |
let playbackStarted = false;
|
| 279 |
|
| 280 |
+
// === REPRODUÇÃO PROGRESSIVA ===
|
| 281 |
+
// Configurações de buffer mínimo para começar a reproduzir
|
| 282 |
+
const MIN_FRAMES_TO_START = 5; // Mínimo de frames antes de iniciar (200ms de vídeo)
|
| 283 |
+
const MIN_AUDIO_CHUNKS_TO_START = 2; // Mínimo de chunks de áudio antes de iniciar
|
| 284 |
+
const PROGRESSIVE_MODE = true; // Ativar reprodução progressiva
|
| 285 |
+
|
| 286 |
+
// Streaming de audio real - agendamento sequencial
|
| 287 |
let nextAudioTime = 0; // Próximo tempo para agendar audio
|
| 288 |
let audioScheduledChunks = 0; // Quantos chunks já agendamos
|
| 289 |
let firstChunkTime = null; // Tempo do primeiro chunk (para latência)
|
| 290 |
let totalAudioSamples = 0; // Total de samples de audio recebidos
|
| 291 |
+
let currentAudioSource = null; // Referência ao audio source atual
|
| 292 |
+
let audioPlaybackStartTime = 0; // Tempo em que o audio começou a tocar (performance.now)
|
| 293 |
+
let audioExpectedEndTime = 0; // Tempo esperado para o audio terminar (performance.now)
|
| 294 |
+
let progressivePlaybackStarted = false; // Flag para reprodução progressiva
|
| 295 |
+
let audioContextStartTime = 0; // audioContext.currentTime quando começou
|
| 296 |
|
| 297 |
+
// Sincronização de transição no nível de frame
|
| 298 |
let endVideoTimeMs = null; // Tempo para continuar o vídeo idle após fala
|
| 299 |
+
let startFrameIdx = null; // Frame inicial que o Wav2Lip usou
|
| 300 |
+
let endFrameIdx = null; // Frame final que o Wav2Lip usou
|
| 301 |
+
let waitingForFrameSync = false; // Esperando o frame certo para começar
|
| 302 |
+
let frameSyncStartTime = 0; // Quando começou a esperar pelo frame
|
| 303 |
+
const FRAME_SYNC_TIMEOUT = 500; // Timeout máximo em ms para sincronização
|
| 304 |
+
let idleVideoDurationMs = 0; // Duração total do vídeo idle em ms
|
| 305 |
+
let idleVideoTotalFrames = 0; // Total de frames do vídeo idle
|
| 306 |
|
| 307 |
// Renderização unificada no canvas
|
| 308 |
let renderSource = 'idle'; // 'idle' = video, 'speaking' = frames do servidor
|
|
|
|
| 329 |
let speakingFrameIndex = 0;
|
| 330 |
let lastSpeakingRenderTime = 0;
|
| 331 |
|
| 332 |
+
// Transição direta (sem crossfade para parecer mais natural)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
|
| 334 |
function startUnifiedRenderLoop() {
|
| 335 |
if (unifiedRenderLoop) return; // Já está rodando
|
| 336 |
|
| 337 |
function renderFrame() {
|
| 338 |
+
// === ESTADO: AGUARDANDO SINCRONIZAÇÃO DE FRAME ===
|
| 339 |
+
if (waitingForFrameSync && startFrameIdx !== null) {
|
| 340 |
+
// Continuar mostrando idle video enquanto aguarda
|
| 341 |
+
if (idleVideo.readyState >= 2) {
|
| 342 |
+
ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// Verificar timeout - se demorou muito, iniciar mesmo assim
|
| 346 |
+
const waitingTime = performance.now() - frameSyncStartTime;
|
| 347 |
+
if (waitingTime > FRAME_SYNC_TIMEOUT) {
|
| 348 |
+
console.log(`Frame sync: TIMEOUT após ${waitingTime.toFixed(0)}ms - iniciando sem sync`);
|
| 349 |
+
waitingForFrameSync = false;
|
| 350 |
+
doStartSpeaking();
|
| 351 |
+
unifiedRenderLoop = requestAnimationFrame(renderFrame);
|
| 352 |
+
return;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
// Calcular frame atual do vídeo idle
|
| 356 |
+
const currentIdleFrame = Math.floor(idleVideo.currentTime * IDLE_VIDEO_FPS) % idleVideoTotalFrames;
|
| 357 |
+
|
| 358 |
+
// Verificar se chegou no frame alvo (com tolerância de ±2 frames)
|
| 359 |
+
const frameDiff = Math.abs(currentIdleFrame - startFrameIdx);
|
| 360 |
+
const isCloseEnough = frameDiff <= 2 || frameDiff >= (idleVideoTotalFrames - 2);
|
| 361 |
+
|
| 362 |
+
if (isCloseEnough) {
|
| 363 |
+
console.log(`Frame sync: frame atual=${currentIdleFrame}, alvo=${startFrameIdx} - INICIANDO! (${waitingTime.toFixed(0)}ms)`);
|
| 364 |
+
waitingForFrameSync = false;
|
| 365 |
+
doStartSpeaking();
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
unifiedRenderLoop = requestAnimationFrame(renderFrame);
|
| 369 |
+
return;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
if (renderSource === 'idle') {
|
| 373 |
+
// Transição direta - sem fade, apenas troca para o vídeo idle
|
| 374 |
+
// O vídeo idle já está sincronizado no ponto correto (endVideoTimeMs)
|
| 375 |
+
if (idleVideo.readyState >= 2) {
|
| 376 |
+
ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
} else {
|
| 378 |
+
// Video não está pronto - tentar recarregar
|
| 379 |
+
console.log(`[IDLE] Video não pronto: readyState=${idleVideo.readyState}, tentando play...`);
|
| 380 |
+
idleVideo.play().catch(e => {});
|
|
|
|
| 381 |
}
|
| 382 |
} else if (renderSource === 'speaking') {
|
| 383 |
// Desenha frames do servidor com timing controlado
|
| 384 |
const now = performance.now();
|
| 385 |
const elapsed = now - lastSpeakingRenderTime;
|
| 386 |
|
| 387 |
+
// === VERIFICAR SE DEVE FINALIZAR ===
|
| 388 |
+
|
| 389 |
+
// Debug: mostrar estado a cada segundo
|
| 390 |
+
if (Math.floor(now / 1000) !== Math.floor(lastSpeakingRenderTime / 1000)) {
|
| 391 |
+
console.log(`[DEBUG] streamDone=${streamDone}, queue=${frameQueue.length}, rendered=${renderedFrames}, audioEnd=${audioExpectedEndTime.toFixed(0)}, now=${now.toFixed(0)}`);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
// 1. Áudio terminou (tempo esperado passou + offset do usuário)
|
| 395 |
+
const transitionOffset = parseInt(document.getElementById("offset-slider").value) || 0;
|
| 396 |
+
const adjustedEndTime = audioExpectedEndTime + transitionOffset;
|
| 397 |
+
if (audioExpectedEndTime > 0 && now >= adjustedEndTime) {
|
| 398 |
+
console.log(`[FIM] Audio terminou: now=${now.toFixed(0)} >= end=${adjustedEndTime.toFixed(0)} (offset=${transitionOffset})`);
|
| 399 |
finishPlayback();
|
| 400 |
+
// NÃO fazer return aqui - precisa continuar o render loop para o idle
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
// 2. Stream completo e fila de frames vazia
|
| 404 |
+
else if (streamDone && frameQueue.length === 0) {
|
| 405 |
+
console.log(`[FIM] Stream done + fila vazia: rendered=${renderedFrames}, total=${allFrames.length}`);
|
| 406 |
+
finishPlayback();
|
| 407 |
+
// NÃO fazer return aqui - precisa continuar o render loop para o idle
|
| 408 |
}
|
| 409 |
|
| 410 |
+
// === RENDERIZAR PRÓXIMO FRAME ===
|
| 411 |
if (elapsed >= syncedFrameInterval && frameQueue.length > 0) {
|
| 412 |
const frameData = frameQueue.shift();
|
| 413 |
|
|
|
|
| 438 |
const bw = totalElapsed > 0 ? (totalBytes / 1024 / totalElapsed) : 0;
|
| 439 |
updateStatus("bandwidth-status", "", `BW: ${bw.toFixed(0)} KB/s`);
|
| 440 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
}
|
| 442 |
}
|
| 443 |
|
|
|
|
| 456 |
}
|
| 457 |
}
|
| 458 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
// Iniciar o render loop quando a página carrega
|
| 460 |
idleVideo.addEventListener('loadeddata', () => {
|
| 461 |
// Ajustar tamanho do canvas para match do vídeo
|
| 462 |
canvas.width = idleVideo.videoWidth || 512;
|
| 463 |
canvas.height = idleVideo.videoHeight || 512;
|
| 464 |
+
|
| 465 |
+
// Capturar duração e calcular total de frames
|
| 466 |
+
idleVideoDurationMs = (idleVideo.duration || 60) * 1000;
|
| 467 |
+
idleVideoTotalFrames = Math.round((idleVideo.duration || 60) * IDLE_VIDEO_FPS);
|
| 468 |
+
console.log(`Video idle carregado: ${canvas.width}x${canvas.height}, ${idleVideo.duration?.toFixed(1)}s, ~${idleVideoTotalFrames} frames`);
|
| 469 |
+
|
| 470 |
+
// Garantir que o vídeo idle está tocando em loop
|
| 471 |
+
idleVideo.loop = true;
|
| 472 |
+
idleVideo.muted = true;
|
| 473 |
+
idleVideo.play().then(() => {
|
| 474 |
+
console.log("Video idle iniciado com sucesso");
|
| 475 |
+
startUnifiedRenderLoop();
|
| 476 |
+
}).catch(e => {
|
| 477 |
+
console.log("Autoplay bloqueado, iniciando render loop mesmo assim:", e);
|
| 478 |
+
startUnifiedRenderLoop();
|
| 479 |
+
});
|
| 480 |
+
});
|
| 481 |
+
|
| 482 |
+
// Fallback: se o vídeo não carregar em 3 segundos, iniciar mesmo assim
|
| 483 |
+
setTimeout(() => {
|
| 484 |
+
if (!unifiedRenderLoop) {
|
| 485 |
+
console.log("Fallback: iniciando render loop após timeout");
|
| 486 |
+
canvas.width = 512;
|
| 487 |
+
canvas.height = 512;
|
| 488 |
+
startUnifiedRenderLoop();
|
| 489 |
+
}
|
| 490 |
+
}, 3000);
|
| 491 |
+
|
| 492 |
+
// Tratamento de erro do vídeo idle
|
| 493 |
+
idleVideo.addEventListener('error', (e) => {
|
| 494 |
+
console.error("Erro carregando vídeo idle:", e);
|
| 495 |
+
log("Erro carregando vídeo idle", "error");
|
| 496 |
+
// Tentar carregar novamente após 2 segundos
|
| 497 |
+
setTimeout(() => {
|
| 498 |
+
console.log("Tentando recarregar vídeo idle...");
|
| 499 |
+
idleVideo.load();
|
| 500 |
+
}, 2000);
|
| 501 |
});
|
| 502 |
|
| 503 |
+
// Quando o vídeo idle termina (não deveria acontecer com loop=true, mas por segurança)
|
| 504 |
+
idleVideo.addEventListener('ended', () => {
|
| 505 |
+
console.log("Video idle ended - reiniciando");
|
| 506 |
+
idleVideo.currentTime = 0;
|
| 507 |
+
idleVideo.play().catch(e => console.log("Erro reiniciando idle:", e));
|
| 508 |
+
});
|
| 509 |
+
|
| 510 |
+
// Quando o vídeo idle para por algum motivo
|
| 511 |
+
idleVideo.addEventListener('pause', () => {
|
| 512 |
+
if (renderSource === 'idle') {
|
| 513 |
+
console.log("Video idle pausou inesperadamente - retomando");
|
| 514 |
+
idleVideo.play().catch(e => console.log("Erro retomando idle:", e));
|
| 515 |
+
}
|
| 516 |
+
});
|
| 517 |
+
|
| 518 |
+
// Forçar carregamento do vídeo idle com cache-busting
|
| 519 |
+
idleVideo.src = `idle.mp4?t=${Date.now()}`;
|
| 520 |
+
|
| 521 |
function log(msg, type = "msg") {
|
| 522 |
const logDiv = document.getElementById("log");
|
| 523 |
const time = new Date().toLocaleTimeString();
|
|
|
|
| 661 |
break;
|
| 662 |
|
| 663 |
case "first_frame":
|
| 664 |
+
// Capturar start_frame_idx para sincronização no nível de frame
|
| 665 |
+
if (data.start_frame_idx !== undefined && data.start_frame_idx !== null) {
|
| 666 |
+
startFrameIdx = data.start_frame_idx;
|
| 667 |
+
console.log(`Sync: start_frame_idx=${startFrameIdx}`);
|
| 668 |
+
}
|
| 669 |
log(`Primeiro frame (server): ${data.latency_ms}ms`, "status");
|
| 670 |
break;
|
| 671 |
|
| 672 |
case "done":
|
| 673 |
+
// Salvar índices de frame para sincronização
|
| 674 |
+
if (data.start_frame_idx !== undefined && data.start_frame_idx !== null) {
|
| 675 |
+
startFrameIdx = data.start_frame_idx;
|
| 676 |
+
}
|
| 677 |
+
if (data.end_frame_idx !== undefined && data.end_frame_idx !== null) {
|
| 678 |
+
endFrameIdx = data.end_frame_idx;
|
| 679 |
+
}
|
| 680 |
if (data.end_video_time_ms !== undefined) {
|
| 681 |
endVideoTimeMs = data.end_video_time_ms;
|
|
|
|
| 682 |
}
|
| 683 |
+
console.log(`Sync: start_frame=${startFrameIdx}, end_frame=${endFrameIdx}, end_time_ms=${endVideoTimeMs}`);
|
| 684 |
|
| 685 |
const framesCount = data.total_frames || data.frames || allFrames.length;
|
| 686 |
const bytesInfo = data.bytes_sent ? `${(data.bytes_sent/1024).toFixed(1)}KB` : '';
|
|
|
|
| 710 |
// Acumular frames
|
| 711 |
allFrames.push({ url, index: frameIndex });
|
| 712 |
|
| 713 |
+
// Se já está reproduzindo (progressivo ou normal), adicionar ao buffer
|
| 714 |
+
if (progressivePlaybackStarted || playbackStarted) {
|
| 715 |
frameQueue.push({ url, index: frameIndex });
|
| 716 |
document.getElementById("buffer").textContent = frameQueue.length;
|
| 717 |
}
|
|
|
|
| 724 |
document.getElementById("video-duration").textContent = videoDuration.toFixed(2) + "s";
|
| 725 |
updateSyncDiff();
|
| 726 |
|
| 727 |
+
if (!progressivePlaybackStarted && !playbackStarted) {
|
| 728 |
document.getElementById("buffer").textContent = received + " (buffering)";
|
| 729 |
}
|
| 730 |
|
| 731 |
if (received === 1) {
|
|
|
|
|
|
|
| 732 |
console.log("Primeiro frame recebido - buffering...");
|
| 733 |
updateStatus("stream-status", "streaming", "Stream: Buffering...");
|
| 734 |
}
|
|
|
|
| 766 |
initAudioContext();
|
| 767 |
}
|
| 768 |
|
|
|
|
| 769 |
if (chunkData.length > 0) {
|
| 770 |
+
// Acumular chunk
|
| 771 |
audioChunks.push(chunkData);
|
| 772 |
|
| 773 |
// Acumular samples para calcular duração
|
|
|
|
| 781 |
document.getElementById("audio-duration").textContent = totalAudioDuration.toFixed(2) + "s";
|
| 782 |
updateSyncDiff();
|
| 783 |
|
| 784 |
+
// === MODO PROGRESSIVO: Agendar chunk imediatamente ===
|
| 785 |
+
if (PROGRESSIVE_MODE && progressivePlaybackStarted) {
|
| 786 |
+
scheduleAudioChunkProgressive(chunkData);
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
console.log(`Audio chunk ${chunkIndex}: ${chunkData.length} bytes, scheduled=${audioScheduledChunks}`);
|
| 790 |
}
|
| 791 |
|
| 792 |
if (isLast) {
|
|
|
|
| 795 |
console.log(`Todos chunks recebidos: ${audioChunks.length}`);
|
| 796 |
}
|
| 797 |
|
| 798 |
+
// Tentar iniciar playback
|
| 799 |
tryStartStreamingPlayback();
|
| 800 |
}
|
| 801 |
|
| 802 |
+
function scheduleAudioChunkProgressive(chunkData) {
|
| 803 |
+
// Agendar este chunk de áudio para tocar em sequência
|
| 804 |
+
if (!audioContext) return;
|
| 805 |
+
|
| 806 |
+
try {
|
| 807 |
+
// Converter PCM int16 para float32
|
| 808 |
+
const alignedBuffer = new ArrayBuffer(chunkData.length);
|
| 809 |
+
new Uint8Array(alignedBuffer).set(chunkData);
|
| 810 |
+
const samples = new Int16Array(alignedBuffer);
|
| 811 |
+
const floatSamples = new Float32Array(samples.length);
|
| 812 |
+
for (let i = 0; i < samples.length; i++) {
|
| 813 |
+
floatSamples[i] = samples[i] / 32768;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
// Criar buffer de audio
|
| 817 |
+
const buffer = audioContext.createBuffer(1, floatSamples.length, 24000);
|
| 818 |
+
buffer.getChannelData(0).set(floatSamples);
|
| 819 |
+
|
| 820 |
+
// Criar source
|
| 821 |
+
const source = audioContext.createBufferSource();
|
| 822 |
+
source.buffer = buffer;
|
| 823 |
+
source.connect(audioContext.destination);
|
| 824 |
+
|
| 825 |
+
// Garantir que não agendamos no passado
|
| 826 |
+
const now = audioContext.currentTime;
|
| 827 |
+
if (nextAudioTime < now) {
|
| 828 |
+
nextAudioTime = now + 0.01;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
// Agendar para tocar
|
| 832 |
+
source.start(nextAudioTime);
|
| 833 |
+
|
| 834 |
+
// Calcular duração e atualizar próximo tempo
|
| 835 |
+
const chunkDuration = floatSamples.length / 24000;
|
| 836 |
+
nextAudioTime += chunkDuration;
|
| 837 |
+
audioScheduledChunks++;
|
| 838 |
+
|
| 839 |
+
// Atualizar tempo esperado de fim (em performance.now)
|
| 840 |
+
const audioEndContextTime = nextAudioTime;
|
| 841 |
+
const elapsedSinceStart = audioEndContextTime - audioContextStartTime;
|
| 842 |
+
audioExpectedEndTime = audioPlaybackStartTime + (elapsedSinceStart * 1000);
|
| 843 |
+
|
| 844 |
+
} catch (e) {
|
| 845 |
+
console.error("Erro agendando audio chunk:", e);
|
| 846 |
+
}
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
function initAudioContext() {
|
| 850 |
if (!audioContext) {
|
| 851 |
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
|
|
| 965 |
}
|
| 966 |
|
| 967 |
function tryStartStreamingPlayback() {
|
| 968 |
+
// Já iniciou playback final? Sair
|
|
|
|
| 969 |
if (playbackStarted) return;
|
| 970 |
|
| 971 |
+
const framesCount = allFrames.length;
|
| 972 |
+
const audioChunksCount = audioChunks.length;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 973 |
|
| 974 |
+
// === MODO PROGRESSIVO ===
|
| 975 |
+
if (PROGRESSIVE_MODE && !progressivePlaybackStarted) {
|
| 976 |
+
// Verificar se temos buffer mínimo para começar
|
| 977 |
+
const hasMinFrames = framesCount >= MIN_FRAMES_TO_START;
|
| 978 |
+
const hasMinAudio = audioChunksCount >= MIN_AUDIO_CHUNKS_TO_START;
|
| 979 |
+
|
| 980 |
+
if (hasMinFrames && hasMinAudio) {
|
| 981 |
+
console.log(`=== INICIANDO REPRODUÇÃO PROGRESSIVA ===`);
|
| 982 |
+
console.log(`Buffer: ${framesCount} frames, ${audioChunksCount} chunks de áudio`);
|
| 983 |
+
|
| 984 |
+
progressivePlaybackStarted = true;
|
| 985 |
+
startProgressivePlayback();
|
| 986 |
+
return;
|
| 987 |
+
} else {
|
| 988 |
+
// Ainda buffering
|
| 989 |
+
const status = `Buffering: ${framesCount}/${MIN_FRAMES_TO_START} frames, ${audioChunksCount}/${MIN_AUDIO_CHUNKS_TO_START} chunks`;
|
| 990 |
+
document.getElementById("buffer").textContent = status;
|
| 991 |
+
return;
|
| 992 |
+
}
|
| 993 |
}
|
| 994 |
|
| 995 |
+
// === STREAM COMPLETO - Ajustar FPS final ===
|
| 996 |
+
if (streamDone && progressivePlaybackStarted && !playbackStarted) {
|
| 997 |
+
playbackStarted = true;
|
| 998 |
+
|
| 999 |
+
// Calcular FPS final baseado na duração real
|
| 1000 |
+
const audioDurationSec = totalAudioSamples / 24000;
|
| 1001 |
+
const calculatedFps = framesCount / audioDurationSec;
|
| 1002 |
+
syncedFrameInterval = 1000 / calculatedFps;
|
| 1003 |
+
|
| 1004 |
+
console.log(`=== AJUSTE FINAL DE SYNC ===`);
|
| 1005 |
+
console.log(`Frames: ${framesCount}, Audio: ${audioDurationSec.toFixed(2)}s`);
|
| 1006 |
+
console.log(`FPS ajustado: ${calculatedFps.toFixed(2)} (interval: ${syncedFrameInterval.toFixed(1)}ms)`);
|
| 1007 |
return;
|
| 1008 |
}
|
| 1009 |
|
| 1010 |
+
// === MODO TRADICIONAL (sem progressivo) ===
|
| 1011 |
+
if (!PROGRESSIVE_MODE && streamDone) {
|
| 1012 |
+
if (framesCount === 0 || totalAudioSamples === 0) return;
|
| 1013 |
+
|
| 1014 |
+
playbackStarted = true;
|
| 1015 |
+
|
| 1016 |
+
const playbackLatency = Date.now() - startTime;
|
| 1017 |
+
document.getElementById("latency").textContent = playbackLatency + "ms";
|
| 1018 |
+
log(`Latencia: ${playbackLatency}ms`, "status");
|
| 1019 |
+
|
| 1020 |
+
const audioDurationSec = totalAudioSamples / 24000;
|
| 1021 |
+
const calculatedFps = framesCount / audioDurationSec;
|
| 1022 |
+
syncedFrameInterval = 1000 / calculatedFps;
|
| 1023 |
+
|
| 1024 |
+
frameQueue = [...allFrames];
|
| 1025 |
+
renderSource = 'speaking';
|
| 1026 |
+
lastSpeakingRenderTime = performance.now();
|
| 1027 |
+
restartAudioPlayback();
|
| 1028 |
|
| 1029 |
+
updateStatus("stream-status", "streaming", "Stream: Reproduzindo");
|
| 1030 |
+
}
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
function startProgressivePlayback() {
|
| 1034 |
+
// Registrar latência
|
| 1035 |
const playbackLatency = Date.now() - startTime;
|
| 1036 |
document.getElementById("latency").textContent = playbackLatency + "ms";
|
| 1037 |
log(`Latencia: ${playbackLatency}ms`, "status");
|
| 1038 |
|
| 1039 |
+
// Usar FPS padrão (25fps)
|
| 1040 |
+
syncedFrameInterval = FRAME_INTERVAL;
|
|
|
|
|
|
|
|
|
|
| 1041 |
|
| 1042 |
+
console.log(`Iniciando progressivo: ${allFrames.length} frames, ${audioChunks.length} chunks`);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1043 |
|
| 1044 |
+
// Copiar frames disponíveis para a fila
|
| 1045 |
frameQueue = [...allFrames];
|
| 1046 |
document.getElementById("buffer").textContent = frameQueue.length;
|
| 1047 |
+
|
| 1048 |
+
// === SINCRONIZAÇÃO NO NÍVEL DE FRAME ===
|
| 1049 |
+
// Se temos startFrameIdx, esperar o vídeo idle chegar no frame certo
|
| 1050 |
+
if (startFrameIdx !== null && idleVideoTotalFrames > 0) {
|
| 1051 |
+
waitingForFrameSync = true;
|
| 1052 |
+
frameSyncStartTime = performance.now();
|
| 1053 |
+
console.log(`Frame sync: aguardando frame ${startFrameIdx} de ${idleVideoTotalFrames}`);
|
| 1054 |
+
updateStatus("stream-status", "streaming", `Sync: aguardando frame ${startFrameIdx}...`);
|
| 1055 |
+
// O render loop vai detectar waitingForFrameSync e fazer a transição quando chegar o frame certo
|
| 1056 |
+
return;
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
// Sem frame sync - iniciar imediatamente
|
| 1060 |
+
doStartSpeaking();
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
function doStartSpeaking() {
|
| 1064 |
+
// Mudar para modo speaking
|
| 1065 |
renderSource = 'speaking';
|
| 1066 |
lastSpeakingRenderTime = performance.now();
|
| 1067 |
|
| 1068 |
+
// Iniciar áudio - agendar todos os chunks que já temos
|
| 1069 |
+
startProgressiveAudio();
|
|
|
|
| 1070 |
|
| 1071 |
+
updateStatus("stream-status", "streaming", "Stream: Reproduzindo...");
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
function startProgressiveAudio() {
|
| 1075 |
+
if (!audioContext) {
|
| 1076 |
+
initAudioContext();
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
// Registrar tempo de início
|
| 1080 |
+
audioPlaybackStartTime = performance.now();
|
| 1081 |
+
audioContextStartTime = audioContext.currentTime;
|
| 1082 |
+
nextAudioTime = audioContext.currentTime + 0.02; // 20ms de buffer inicial
|
| 1083 |
+
|
| 1084 |
+
// Agendar todos os chunks que já temos
|
| 1085 |
+
for (const chunk of audioChunks) {
|
| 1086 |
+
scheduleAudioChunkProgressive(chunk);
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
console.log(`Audio progressivo: ${audioScheduledChunks} chunks agendados`);
|
| 1090 |
}
|
| 1091 |
|
| 1092 |
function restartAudioPlayback() {
|
|
|
|
| 1236 |
currentAudioSource = null;
|
| 1237 |
audioPlaybackStartTime = 0;
|
| 1238 |
audioExpectedEndTime = 0;
|
| 1239 |
+
audioContextStartTime = 0;
|
| 1240 |
+
progressivePlaybackStarted = false;
|
| 1241 |
|
| 1242 |
+
// Reset frame sync state
|
| 1243 |
+
startFrameIdx = null;
|
| 1244 |
+
endFrameIdx = null;
|
| 1245 |
+
endVideoTimeMs = null;
|
| 1246 |
+
waitingForFrameSync = false;
|
| 1247 |
|
| 1248 |
document.getElementById("frames").textContent = "0";
|
| 1249 |
document.getElementById("rendered").textContent = "0";
|
|
|
|
| 1260 |
document.getElementById("stop-btn").disabled = false;
|
| 1261 |
updateStatus("stream-status", "", "Stream: Iniciando...");
|
| 1262 |
|
| 1263 |
+
// Pegar valores dos controles
|
| 1264 |
+
const quality = parseInt(document.getElementById("quality-slider").value);
|
| 1265 |
+
|
| 1266 |
+
// Calcular start_frame_idx localmente (mesmo cálculo que o Wav2Lip faz)
|
| 1267 |
+
// Isso permite sincronização imediata sem esperar resposta do servidor
|
| 1268 |
+
const localStartFrameIdx = Math.floor((idleVideoTimeMs / 1000) * IDLE_VIDEO_FPS) % idleVideoTotalFrames;
|
| 1269 |
+
startFrameIdx = localStartFrameIdx;
|
| 1270 |
+
console.log(`Frame sync local: start_frame_idx=${startFrameIdx} (de ${idleVideoTotalFrames} frames)`);
|
| 1271 |
+
|
| 1272 |
log("Enviando: " + text.substring(0, 30) + "...", "status");
|
| 1273 |
|
| 1274 |
// Enviar com o timestamp do vídeo idle para sincronização
|
|
|
|
| 1276 |
action: "generate",
|
| 1277 |
text: text,
|
| 1278 |
voice: voice,
|
| 1279 |
+
idle_video_time_ms: idleVideoTimeMs, // Timestamp exato do vídeo idle
|
| 1280 |
+
jpeg_quality: quality // Qualidade do JPEG (50-100)
|
| 1281 |
}));
|
| 1282 |
}
|
| 1283 |
|
|
|
|
| 1306 |
log(`Finalizado: ${renderedFrames} frames em ${elapsed.toFixed(1)}s`, "msg");
|
| 1307 |
console.log(`Playback finalizado: ${renderedFrames} frames renderizados`);
|
| 1308 |
|
| 1309 |
+
// Transição para idle - calcular posição exata considerando tempo de rede
|
| 1310 |
+
// O servidor envia end_video_time_ms = posição do último frame usado
|
| 1311 |
+
// Precisamos compensar pelo tempo que passou desde que o servidor terminou até agora
|
| 1312 |
+
if (endVideoTimeMs !== null && endVideoTimeMs > 0) {
|
| 1313 |
const videoDuration = idleVideo.duration || 60;
|
| 1314 |
+
const videoDurationMs = videoDuration * 1000;
|
| 1315 |
+
|
| 1316 |
+
// Calcular quanto tempo passou desde o início (tempo de processamento + rede)
|
| 1317 |
+
const totalElapsedMs = Date.now() - startTime;
|
| 1318 |
+
|
| 1319 |
+
// O áudio tem a duração real - usar isso como referência
|
| 1320 |
+
const audioDurationMs = totalAudioSamples / 24000 * 1000;
|
| 1321 |
+
|
| 1322 |
+
// Tempo extra que passou além da duração do áudio (overhead de rede/processamento)
|
| 1323 |
+
const networkOverheadMs = Math.max(0, totalElapsedMs - audioDurationMs);
|
| 1324 |
+
|
| 1325 |
+
// Posição compensada: end_video_time_ms + overhead de rede
|
| 1326 |
+
const compensatedTimeMs = endVideoTimeMs + networkOverheadMs;
|
| 1327 |
+
const seekTime = (compensatedTimeMs % videoDurationMs) / 1000;
|
| 1328 |
+
|
| 1329 |
+
console.log(`Transição: end=${endVideoTimeMs}ms, elapsed=${totalElapsedMs}ms, audio=${audioDurationMs.toFixed(0)}ms, overhead=${networkOverheadMs.toFixed(0)}ms -> seek=${seekTime.toFixed(2)}s`);
|
| 1330 |
idleVideo.currentTime = seekTime;
|
| 1331 |
}
|
| 1332 |
|
|
|
|
| 1334 |
idleVideo.loop = true;
|
| 1335 |
idleVideo.play().catch(e => console.log("Erro ao retomar idle video:", e));
|
| 1336 |
|
| 1337 |
+
// Transição direta: speaking → idle (sem fade, mais natural)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1338 |
renderSource = 'idle';
|
| 1339 |
+
console.log("Transição direta para idle");
|
| 1340 |
|
| 1341 |
// Reset estado de audio
|
| 1342 |
audioExpectedEndTime = 0;
|
|
|
|
| 1344 |
|
| 1345 |
// Reset estado geral
|
| 1346 |
isStreaming = false;
|
|
|
|
| 1347 |
playbackStarted = false;
|
| 1348 |
+
progressivePlaybackStarted = false;
|
| 1349 |
+
|
| 1350 |
+
// Reset frame sync state (manter endVideoTimeMs até aqui pois é usado acima)
|
| 1351 |
+
startFrameIdx = null;
|
| 1352 |
+
endFrameIdx = null;
|
| 1353 |
+
endVideoTimeMs = null;
|
| 1354 |
+
waitingForFrameSync = false;
|
| 1355 |
document.getElementById("generate-btn").disabled = false;
|
| 1356 |
document.getElementById("stop-btn").disabled = true;
|
| 1357 |
updateStatus("stream-status", "online", "Stream: Concluido");
|
|
|
|
| 1380 |
audioDuration = 0;
|
| 1381 |
audioBuffer = null;
|
| 1382 |
playbackStarted = false;
|
| 1383 |
+
progressivePlaybackStarted = false;
|
| 1384 |
nextAudioTime = 0;
|
| 1385 |
audioScheduledChunks = 0;
|
| 1386 |
firstChunkTime = null;
|
| 1387 |
totalAudioSamples = 0;
|
| 1388 |
audioPlaybackStartTime = 0;
|
| 1389 |
audioExpectedEndTime = 0;
|
| 1390 |
+
audioContextStartTime = 0;
|
| 1391 |
|
| 1392 |
if (currentAudioSource) {
|
| 1393 |
try { currentAudioSource.stop(); } catch (e) {}
|
|
|
|
| 1399 |
audioSource = null;
|
| 1400 |
}
|
| 1401 |
|
| 1402 |
+
// Reset frame sync state
|
| 1403 |
+
startFrameIdx = null;
|
| 1404 |
+
endFrameIdx = null;
|
| 1405 |
+
endVideoTimeMs = null;
|
| 1406 |
+
waitingForFrameSync = false;
|
| 1407 |
+
|
| 1408 |
// Voltar ao video idle (apenas muda a fonte do render loop)
|
| 1409 |
renderSource = 'idle';
|
| 1410 |
idleVideo.play().catch(e => {});
|
|
|
|
| 1422 |
}
|
| 1423 |
}, 30000);
|
| 1424 |
|
| 1425 |
+
// === MODO DEMO ===
|
| 1426 |
+
let demoMode = false;
|
| 1427 |
+
let demoTimeout = null;
|
| 1428 |
+
let lastDemoText = "";
|
| 1429 |
+
|
| 1430 |
+
function toggleDemo() {
|
| 1431 |
+
if (demoMode) {
|
| 1432 |
+
stopDemo();
|
| 1433 |
+
} else {
|
| 1434 |
+
startDemo();
|
| 1435 |
+
}
|
| 1436 |
+
}
|
| 1437 |
+
|
| 1438 |
+
function startDemo() {
|
| 1439 |
+
demoMode = true;
|
| 1440 |
+
lastDemoText = document.getElementById("text-input").value.trim();
|
| 1441 |
+
|
| 1442 |
+
if (!lastDemoText) {
|
| 1443 |
+
lastDemoText = "Hello! I am a real-time streaming avatar optimized for low latency.";
|
| 1444 |
+
document.getElementById("text-input").value = lastDemoText;
|
| 1445 |
+
}
|
| 1446 |
+
|
| 1447 |
+
document.getElementById("demo-btn").textContent = "Parar Demo";
|
| 1448 |
+
document.getElementById("demo-btn").style.background = "linear-gradient(135deg, #f44, #c00)";
|
| 1449 |
+
document.getElementById("generate-btn").disabled = true;
|
| 1450 |
+
document.getElementById("text-input").disabled = true;
|
| 1451 |
+
|
| 1452 |
+
log("Modo demo iniciado", "status");
|
| 1453 |
+
console.log("Demo: iniciando ciclo");
|
| 1454 |
+
|
| 1455 |
+
// Iniciar primeiro ciclo
|
| 1456 |
+
demoSpeak();
|
| 1457 |
+
}
|
| 1458 |
+
|
| 1459 |
+
function stopDemo() {
|
| 1460 |
+
demoMode = false;
|
| 1461 |
+
if (demoTimeout) {
|
| 1462 |
+
clearTimeout(demoTimeout);
|
| 1463 |
+
demoTimeout = null;
|
| 1464 |
+
}
|
| 1465 |
+
|
| 1466 |
+
document.getElementById("demo-btn").textContent = "Iniciar Demo";
|
| 1467 |
+
document.getElementById("demo-btn").style.background = "linear-gradient(135deg, #fc0, #f90)";
|
| 1468 |
+
document.getElementById("generate-btn").disabled = false;
|
| 1469 |
+
document.getElementById("text-input").disabled = false;
|
| 1470 |
+
|
| 1471 |
+
stop();
|
| 1472 |
+
log("Modo demo parado", "status");
|
| 1473 |
+
console.log("Demo: parado");
|
| 1474 |
+
}
|
| 1475 |
+
|
| 1476 |
+
function demoSpeak() {
|
| 1477 |
+
if (!demoMode) return;
|
| 1478 |
+
|
| 1479 |
+
console.log("Demo: falando...");
|
| 1480 |
+
|
| 1481 |
+
// Usar o texto configurado
|
| 1482 |
+
const text = document.getElementById("text-input").value.trim() || lastDemoText;
|
| 1483 |
+
const voice = document.getElementById("voice-select").value;
|
| 1484 |
+
const quality = parseInt(document.getElementById("quality-slider").value);
|
| 1485 |
+
const idleVideoTimeMs = getCompensatedIdleVideoTime();
|
| 1486 |
+
|
| 1487 |
+
// Reset estado
|
| 1488 |
+
stopStream();
|
| 1489 |
+
frameQueue = [];
|
| 1490 |
+
allFrames = [];
|
| 1491 |
+
renderedFrames = 0;
|
| 1492 |
+
totalBytes = 0;
|
| 1493 |
+
startTime = Date.now();
|
| 1494 |
+
audioChunks = [];
|
| 1495 |
+
audioChunksComplete = false;
|
| 1496 |
+
streamDone = false;
|
| 1497 |
+
audioDuration = 0;
|
| 1498 |
+
audioBuffer = null;
|
| 1499 |
+
syncedFrameInterval = FRAME_INTERVAL;
|
| 1500 |
+
playbackStarted = false;
|
| 1501 |
+
nextAudioTime = 0;
|
| 1502 |
+
audioScheduledChunks = 0;
|
| 1503 |
+
firstChunkTime = null;
|
| 1504 |
+
totalAudioSamples = 0;
|
| 1505 |
+
currentAudioSource = null;
|
| 1506 |
+
audioPlaybackStartTime = 0;
|
| 1507 |
+
audioExpectedEndTime = 0;
|
| 1508 |
+
audioContextStartTime = 0;
|
| 1509 |
+
progressivePlaybackStarted = false;
|
| 1510 |
+
|
| 1511 |
+
// Reset frame sync state
|
| 1512 |
+
endFrameIdx = null;
|
| 1513 |
+
endVideoTimeMs = null;
|
| 1514 |
+
waitingForFrameSync = false;
|
| 1515 |
+
|
| 1516 |
+
// Calcular start_frame_idx localmente (mesmo cálculo que o Wav2Lip faz)
|
| 1517 |
+
const localStartFrameIdx = Math.floor((idleVideoTimeMs / 1000) * IDLE_VIDEO_FPS) % idleVideoTotalFrames;
|
| 1518 |
+
startFrameIdx = localStartFrameIdx;
|
| 1519 |
+
console.log(`Demo frame sync: start_frame_idx=${startFrameIdx}`);
|
| 1520 |
+
|
| 1521 |
+
isStreaming = true;
|
| 1522 |
+
updateStatus("stream-status", "streaming", "Demo: Falando...");
|
| 1523 |
+
|
| 1524 |
+
ws.send(JSON.stringify({
|
| 1525 |
+
action: "generate",
|
| 1526 |
+
text: text,
|
| 1527 |
+
voice: voice,
|
| 1528 |
+
idle_video_time_ms: idleVideoTimeMs,
|
| 1529 |
+
jpeg_quality: quality
|
| 1530 |
+
}));
|
| 1531 |
+
}
|
| 1532 |
+
|
| 1533 |
+
function demoScheduleNextCycle() {
|
| 1534 |
+
if (!demoMode) return;
|
| 1535 |
+
|
| 1536 |
+
const idleTime = parseInt(document.getElementById("demo-idle-slider").value) * 1000;
|
| 1537 |
+
console.log(`Demo: aguardando ${idleTime}ms em idle...`);
|
| 1538 |
+
updateStatus("stream-status", "online", `Demo: Idle (${idleTime/1000}s)`);
|
| 1539 |
+
|
| 1540 |
+
demoTimeout = setTimeout(() => {
|
| 1541 |
+
if (demoMode) {
|
| 1542 |
+
demoSpeak();
|
| 1543 |
+
}
|
| 1544 |
+
}, idleTime);
|
| 1545 |
+
}
|
| 1546 |
+
|
| 1547 |
+
// Modificar finishPlayback para suportar demo mode
|
| 1548 |
+
const originalFinishPlayback = finishPlayback;
|
| 1549 |
+
finishPlayback = function() {
|
| 1550 |
+
originalFinishPlayback();
|
| 1551 |
+
|
| 1552 |
+
// Se está em modo demo, agendar próximo ciclo
|
| 1553 |
+
if (demoMode) {
|
| 1554 |
+
demoScheduleNextCycle();
|
| 1555 |
+
}
|
| 1556 |
+
};
|
| 1557 |
+
|
| 1558 |
connectWebSocket();
|
| 1559 |
</script>
|
| 1560 |
</body>
|
interface/index_streaming.html
CHANGED
|
@@ -275,9 +275,8 @@ async function startSyncedPlayback(base64Audio, durationMs) {
|
|
| 275 |
audioSource.connect(audioContext.destination);
|
| 276 |
|
| 277 |
audioSource.onended = () => {
|
|
|
|
| 278 |
audioSource = null;
|
| 279 |
-
// Transicao imediata quando audio termina
|
| 280 |
-
stopPlayback();
|
| 281 |
};
|
| 282 |
|
| 283 |
// Calcular quantos frames usar baseado na duracao do audio
|
|
@@ -304,7 +303,7 @@ async function startSyncedPlayback(base64Audio, durationMs) {
|
|
| 304 |
lastRenderedFrame = 0;
|
| 305 |
}
|
| 306 |
|
| 307 |
-
//
|
| 308 |
talkCanvas.style.display = 'block';
|
| 309 |
|
| 310 |
// INICIAR TUDO SINCRONIZADO: audio + video ao mesmo tempo!
|
|
@@ -329,12 +328,18 @@ async function startSyncedPlayback(base64Audio, durationMs) {
|
|
| 329 |
function renderLoop() {
|
| 330 |
if (!isPlaying) return;
|
| 331 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
const elapsed = performance.now() - playbackStartTime;
|
| 333 |
// Usar duracao dinamica para sincronizar com audio
|
| 334 |
const targetFrame = Math.floor(elapsed / dynamicFrameDuration);
|
| 335 |
const total = totalFrames || frameCount;
|
| 336 |
|
| 337 |
-
// So renderizar se for um frame diferente do anterior
|
| 338 |
if (targetFrame !== lastRenderedFrame && targetFrame < total) {
|
| 339 |
// Acesso O(1) ao frame pelo indice
|
| 340 |
let frameToRender = frames[targetFrame];
|
|
@@ -362,12 +367,8 @@ function renderLoop() {
|
|
| 362 |
progress.style.width = (displayedFrame / total * 100) + '%';
|
| 363 |
}
|
| 364 |
|
| 365 |
-
// Continuar enquanto
|
| 366 |
-
|
| 367 |
-
animationId = requestAnimationFrame(renderLoop);
|
| 368 |
-
} else {
|
| 369 |
-
stopPlayback();
|
| 370 |
-
}
|
| 371 |
}
|
| 372 |
|
| 373 |
function stopPlayback() {
|
|
@@ -386,21 +387,30 @@ function stopPlayback() {
|
|
| 386 |
audioSource = null;
|
| 387 |
}
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
// Sincronizar idle video para o tempo correto (onde a fala terminou)
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
endVideoTimeMs = 0; // Reset para proxima vez
|
| 398 |
}
|
| 399 |
|
| 400 |
-
// Esconder canvas, mostrar idle
|
| 401 |
-
talkCanvas.style.display = 'none';
|
| 402 |
-
ctx.clearRect(0, 0, talkCanvas.width, talkCanvas.height);
|
| 403 |
-
|
| 404 |
frames = [];
|
| 405 |
lastRenderedFrame = -1;
|
| 406 |
setStatus('Pronto', 'ok');
|
|
|
|
| 275 |
audioSource.connect(audioContext.destination);
|
| 276 |
|
| 277 |
audioSource.onended = () => {
|
| 278 |
+
// Apenas marcar como null - o renderLoop vai detectar e parar
|
| 279 |
audioSource = null;
|
|
|
|
|
|
|
| 280 |
};
|
| 281 |
|
| 282 |
// Calcular quantos frames usar baseado na duracao do audio
|
|
|
|
| 303 |
lastRenderedFrame = 0;
|
| 304 |
}
|
| 305 |
|
| 306 |
+
// Mostrar canvas
|
| 307 |
talkCanvas.style.display = 'block';
|
| 308 |
|
| 309 |
// INICIAR TUDO SINCRONIZADO: audio + video ao mesmo tempo!
|
|
|
|
| 328 |
function renderLoop() {
|
| 329 |
if (!isPlaying) return;
|
| 330 |
|
| 331 |
+
// Se audio terminou, parar imediatamente (transicao instantanea)
|
| 332 |
+
if (!audioSource) {
|
| 333 |
+
stopPlayback();
|
| 334 |
+
return;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
const elapsed = performance.now() - playbackStartTime;
|
| 338 |
// Usar duracao dinamica para sincronizar com audio
|
| 339 |
const targetFrame = Math.floor(elapsed / dynamicFrameDuration);
|
| 340 |
const total = totalFrames || frameCount;
|
| 341 |
|
| 342 |
+
// So renderizar se for um frame diferente do anterior e dentro do limite
|
| 343 |
if (targetFrame !== lastRenderedFrame && targetFrame < total) {
|
| 344 |
// Acesso O(1) ao frame pelo indice
|
| 345 |
let frameToRender = frames[targetFrame];
|
|
|
|
| 367 |
progress.style.width = (displayedFrame / total * 100) + '%';
|
| 368 |
}
|
| 369 |
|
| 370 |
+
// Continuar apenas enquanto audio estiver tocando
|
| 371 |
+
animationId = requestAnimationFrame(renderLoop);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
}
|
| 373 |
|
| 374 |
function stopPlayback() {
|
|
|
|
| 387 |
audioSource = null;
|
| 388 |
}
|
| 389 |
|
| 390 |
+
// Esconder canvas IMEDIATAMENTE para evitar "travadinha"
|
| 391 |
+
// O video idle ja esta tocando por baixo, entao a transicao sera suave
|
| 392 |
+
talkCanvas.style.display = 'none';
|
| 393 |
+
ctx.clearRect(0, 0, talkCanvas.width, talkCanvas.height);
|
| 394 |
+
|
| 395 |
// Sincronizar idle video para o tempo correto (onde a fala terminou)
|
| 396 |
+
// Isso acontece em background, o usuario ja ve o video idle
|
| 397 |
+
if (endVideoTimeMs > 0 && idleVideo.duration > 0) {
|
| 398 |
+
const targetTime = (endVideoTimeMs / 1000) % idleVideo.duration;
|
| 399 |
+
console.log(`Idle video sync: seeking to ${targetTime.toFixed(2)}s (endVideoTimeMs=${endVideoTimeMs})`);
|
| 400 |
+
|
| 401 |
+
// Fazer o seek em background - video ja esta visivel
|
| 402 |
+
if (idleVideo.fastSeek) {
|
| 403 |
+
idleVideo.fastSeek(targetTime);
|
| 404 |
+
} else {
|
| 405 |
+
idleVideo.currentTime = targetTime;
|
| 406 |
}
|
| 407 |
+
|
| 408 |
+
// Garantir que esta tocando
|
| 409 |
+
idleVideo.play().catch(() => {});
|
| 410 |
+
|
| 411 |
endVideoTimeMs = 0; // Reset para proxima vez
|
| 412 |
}
|
| 413 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
frames = [];
|
| 415 |
lastRenderedFrame = -1;
|
| 416 |
setStatus('Pronto', 'ok');
|
interface/server.py
CHANGED
|
@@ -1,17 +1,14 @@
|
|
| 1 |
"""
|
| 2 |
-
Interface Server - Streaming
|
| 3 |
Porta: 8080
|
| 4 |
|
| 5 |
-
Arquitetura
|
| 6 |
-
1.
|
| 7 |
-
2.
|
| 8 |
-
3.
|
| 9 |
-
4.
|
| 10 |
-
5. Envia chunks IMEDIATAMENTE para o frontend
|
| 11 |
|
| 12 |
-
|
| 13 |
-
- NAO modificar Wav2Lip - ele gera lip sync com eSpeak interno
|
| 14 |
-
- Audio final = Orpheus (descartar audio do Wav2Lip)
|
| 15 |
"""
|
| 16 |
from aiohttp import web
|
| 17 |
import aiohttp
|
|
@@ -20,368 +17,523 @@ import json
|
|
| 20 |
import base64
|
| 21 |
import os
|
| 22 |
import time
|
| 23 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
# Configuracao
|
| 26 |
-
ORPHEUS_WS = os.getenv("ORPHEUS_WS", "ws://localhost:8081/ws")
|
| 27 |
WAV2LIP_WS = os.getenv("WAV2LIP_WS", "ws://localhost:8082/ws")
|
| 28 |
PORT = int(os.getenv("PORT", "8080"))
|
|
|
|
| 29 |
|
| 30 |
# Constantes
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
AUDIO_BYTES_PER_FRAME = int(MS_PER_FRAME * AUDIO_SAMPLE_RATE * BYTES_PER_SAMPLE / 1000) # 1920 bytes
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
"""Monta chunk binario: [audio_size][audio][num_frames][frame_sizes][frames]"""
|
| 42 |
-
data = bytearray()
|
| 43 |
-
data.extend(struct.pack('>I', len(audio_bytes)))
|
| 44 |
-
data.extend(audio_bytes)
|
| 45 |
-
data.extend(struct.pack('>I', len(frames)))
|
| 46 |
-
for frame in frames:
|
| 47 |
-
data.extend(struct.pack('>I', len(frame)))
|
| 48 |
-
data.extend(frame)
|
| 49 |
-
return bytes(data)
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
class ParallelStreamingSession:
|
| 53 |
-
"""Streaming paralelo: Orpheus (audio) + Wav2Lip (frames)"""
|
| 54 |
-
|
| 55 |
-
def __init__(self, client_ws):
|
| 56 |
-
self.client_ws = client_ws
|
| 57 |
-
self.is_running = False
|
| 58 |
-
self.start_time = None
|
| 59 |
-
|
| 60 |
-
# Buffers compartilhados
|
| 61 |
-
self.audio_buffer = bytearray()
|
| 62 |
-
self.frame_buffer = []
|
| 63 |
-
|
| 64 |
-
# Locks para acesso thread-safe
|
| 65 |
-
self.buffer_lock = asyncio.Lock()
|
| 66 |
-
|
| 67 |
-
# Estados
|
| 68 |
-
self.orpheus_done = False
|
| 69 |
-
self.wav2lip_done = False
|
| 70 |
-
self.first_chunk_sent = False
|
| 71 |
-
self.chunk_index = 0
|
| 72 |
-
|
| 73 |
-
# Estatisticas
|
| 74 |
-
self.total_audio_bytes = 0
|
| 75 |
-
self.total_frames = 0
|
| 76 |
-
self.chunks_sent = 0
|
| 77 |
-
|
| 78 |
-
async def send_status(self, message: str):
|
| 79 |
-
"""Envia status para o cliente."""
|
| 80 |
-
try:
|
| 81 |
-
if not self.client_ws.closed:
|
| 82 |
-
await self.client_ws.send_json({"type": "status", "message": message})
|
| 83 |
-
except:
|
| 84 |
-
pass
|
| 85 |
-
|
| 86 |
-
async def send_chunk(self, audio: bytes, frames: list):
|
| 87 |
-
"""Monta e envia chunk para o cliente."""
|
| 88 |
-
if self.client_ws.closed or not audio or not frames:
|
| 89 |
-
return
|
| 90 |
-
|
| 91 |
-
try:
|
| 92 |
-
chunk_data = build_chunk(audio, frames)
|
| 93 |
-
chunk_b64 = base64.b64encode(chunk_data).decode()
|
| 94 |
-
audio_ms = len(audio) / BYTES_PER_SAMPLE / AUDIO_SAMPLE_RATE * 1000
|
| 95 |
-
|
| 96 |
-
await self.client_ws.send_json({
|
| 97 |
-
"type": "chunk",
|
| 98 |
-
"chunk_index": self.chunk_index,
|
| 99 |
-
"audio_size": len(audio),
|
| 100 |
-
"audio_duration_ms": int(audio_ms),
|
| 101 |
-
"num_frames": len(frames),
|
| 102 |
-
"data": chunk_b64
|
| 103 |
-
})
|
| 104 |
-
|
| 105 |
-
self.chunk_index += 1
|
| 106 |
-
self.chunks_sent += 1
|
| 107 |
-
|
| 108 |
-
if not self.first_chunk_sent:
|
| 109 |
-
self.first_chunk_sent = True
|
| 110 |
-
ttfb = int((time.time() - self.start_time) * 1000)
|
| 111 |
-
print(f"[Stream] Primeiro chunk: TTFB={ttfb}ms")
|
| 112 |
-
await self.client_ws.send_json({"type": "stream_start", "ttfb_ms": ttfb})
|
| 113 |
-
|
| 114 |
-
except Exception as e:
|
| 115 |
-
print(f"[Stream] Erro enviando chunk: {e}")
|
| 116 |
-
|
| 117 |
-
async def try_send_chunks(self):
|
| 118 |
-
"""Tenta montar e enviar chunks com dados disponiveis."""
|
| 119 |
-
async with self.buffer_lock:
|
| 120 |
-
# Enquanto tiver 1 frame + audio correspondente
|
| 121 |
-
while len(self.frame_buffer) > 0 and len(self.audio_buffer) >= AUDIO_BYTES_PER_FRAME:
|
| 122 |
-
# Pega 1 frame
|
| 123 |
-
frame = self.frame_buffer.pop(0)
|
| 124 |
-
|
| 125 |
-
# Pega audio correspondente (~40ms = 1920 bytes)
|
| 126 |
-
audio = bytes(self.audio_buffer[:AUDIO_BYTES_PER_FRAME])
|
| 127 |
-
del self.audio_buffer[:AUDIO_BYTES_PER_FRAME]
|
| 128 |
-
|
| 129 |
-
await self.send_chunk(audio, [frame])
|
| 130 |
-
|
| 131 |
-
# Se ambos terminaram, enviar o que sobrou
|
| 132 |
-
if self.orpheus_done and self.wav2lip_done:
|
| 133 |
-
if self.frame_buffer and self.audio_buffer:
|
| 134 |
-
# Dividir audio restante pelos frames restantes
|
| 135 |
-
audio_per_frame = len(self.audio_buffer) // len(self.frame_buffer) if self.frame_buffer else 0
|
| 136 |
-
audio_per_frame = max(audio_per_frame, 2) # Minimo 2 bytes
|
| 137 |
-
audio_per_frame = audio_per_frame - (audio_per_frame % 2) # Alinhamento 16-bit
|
| 138 |
-
|
| 139 |
-
while self.frame_buffer and self.audio_buffer:
|
| 140 |
-
frame = self.frame_buffer.pop(0)
|
| 141 |
-
audio_size = min(audio_per_frame, len(self.audio_buffer))
|
| 142 |
-
if not self.frame_buffer: # Ultimo frame pega todo o resto
|
| 143 |
-
audio_size = len(self.audio_buffer)
|
| 144 |
-
audio = bytes(self.audio_buffer[:audio_size])
|
| 145 |
-
del self.audio_buffer[:audio_size]
|
| 146 |
-
await self.send_chunk(audio, [frame])
|
| 147 |
-
|
| 148 |
-
elif self.frame_buffer:
|
| 149 |
-
# Frames sem audio - enviar com audio vazio
|
| 150 |
-
for frame in self.frame_buffer:
|
| 151 |
-
await self.send_chunk(b'', [frame])
|
| 152 |
-
self.frame_buffer.clear()
|
| 153 |
-
|
| 154 |
-
async def stream_orpheus(self, text: str, voice: str):
|
| 155 |
-
"""Conecta ao Orpheus e recebe chunks de audio."""
|
| 156 |
-
try:
|
| 157 |
-
print(f"[Orpheus] Conectando a {ORPHEUS_WS}...")
|
| 158 |
-
async with aiohttp.ClientSession() as session:
|
| 159 |
-
ws = await session.ws_connect(ORPHEUS_WS, timeout=aiohttp.ClientWSTimeout(ws_close=120))
|
| 160 |
-
|
| 161 |
-
# Enviar requisicao
|
| 162 |
-
await ws.send_json({
|
| 163 |
-
"action": "synthesize",
|
| 164 |
-
"text": text,
|
| 165 |
-
"voice": voice,
|
| 166 |
-
"stream": True
|
| 167 |
-
})
|
| 168 |
-
print(f"[Orpheus] Requisicao enviada")
|
| 169 |
-
|
| 170 |
-
async for msg in ws:
|
| 171 |
-
if not self.is_running:
|
| 172 |
-
break
|
| 173 |
|
| 174 |
-
|
| 175 |
-
data = json.loads(msg.data)
|
| 176 |
-
msg_type = data.get("type", "")
|
| 177 |
|
| 178 |
-
if msg_type == "audio_chunk":
|
| 179 |
-
# Decodificar e adicionar ao buffer
|
| 180 |
-
audio_b64 = data.get("audio", "")
|
| 181 |
-
audio_bytes = base64.b64decode(audio_b64)
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
-
|
| 188 |
-
await self.try_send_chunks()
|
| 189 |
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
| 193 |
|
| 194 |
-
|
| 195 |
-
total = data.get("total_bytes", 0)
|
| 196 |
-
print(f"[Orpheus] Concluido: {total} bytes")
|
| 197 |
-
break
|
| 198 |
|
| 199 |
-
elif msg_type == "error":
|
| 200 |
-
print(f"[Orpheus] Erro: {data.get('message')}")
|
| 201 |
-
break
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
|
|
|
| 205 |
|
| 206 |
-
|
|
|
|
| 207 |
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
traceback.print_exc()
|
| 212 |
|
| 213 |
-
|
| 214 |
-
self.orpheus_done = True
|
| 215 |
-
await self.try_send_chunks()
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
"action": "generate",
|
| 227 |
-
"text": text,
|
| 228 |
-
"voice": voice
|
| 229 |
-
})
|
| 230 |
-
print(f"[Wav2Lip] Requisicao enviada")
|
| 231 |
-
|
| 232 |
-
async for msg in ws:
|
| 233 |
-
if not self.is_running:
|
| 234 |
-
break
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
msg_type = data.get("type", "")
|
| 239 |
|
| 240 |
-
if msg_type == "frame":
|
| 241 |
-
# Decodificar e adicionar ao buffer
|
| 242 |
-
frame_b64 = data.get("frame", "")
|
| 243 |
-
frame_bytes = base64.b64decode(frame_b64)
|
| 244 |
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
|
|
|
| 258 |
|
| 259 |
-
|
| 260 |
-
print(f"[Wav2Lip] eSpeak latency: {data.get('latency_ms')}ms")
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
print(f"[Wav2Lip] Concluido: {frames} frames")
|
| 269 |
-
break
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
-
|
|
|
|
| 280 |
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
| 285 |
|
| 286 |
-
|
| 287 |
-
self.wav2lip_done = True
|
| 288 |
-
await self.try_send_chunks()
|
| 289 |
|
| 290 |
-
|
| 291 |
-
"""
|
| 292 |
-
|
| 293 |
-
|
| 294 |
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
orpheus_task = asyncio.create_task(self.stream_orpheus(text, voice))
|
| 300 |
-
wav2lip_task = asyncio.create_task(self.stream_wav2lip(text, voice))
|
| 301 |
|
| 302 |
-
# Aguardar ambos terminarem
|
| 303 |
-
await asyncio.gather(orpheus_task, wav2lip_task)
|
| 304 |
|
| 305 |
-
|
| 306 |
-
|
| 307 |
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
|
|
|
| 315 |
|
| 316 |
try:
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
})
|
| 324 |
-
except:
|
| 325 |
-
pass
|
| 326 |
-
|
| 327 |
-
def stop(self):
|
| 328 |
-
self.is_running = False
|
| 329 |
-
|
| 330 |
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
ws = web.WebSocketResponse()
|
| 334 |
-
await ws.prepare(request)
|
| 335 |
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
try:
|
| 340 |
-
async for msg in ws:
|
| 341 |
-
if msg.type == aiohttp.WSMsgType.TEXT:
|
| 342 |
-
try:
|
| 343 |
data = json.loads(msg.data)
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
if
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
|
| 359 |
-
|
| 360 |
-
|
|
|
|
| 361 |
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
session.stop()
|
| 365 |
-
await ws.send_json({"type": "stopped"})
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
| 369 |
|
| 370 |
-
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
| 372 |
|
| 373 |
-
|
| 374 |
-
print(f"WebSocket error: {ws.exception()}")
|
| 375 |
-
break
|
| 376 |
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
session.stop()
|
| 382 |
-
print("Cliente desconectado")
|
| 383 |
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
|
| 386 |
|
| 387 |
@routes.get("/")
|
|
@@ -389,11 +541,6 @@ async def index(request):
|
|
| 389 |
return web.FileResponse(os.path.join(os.path.dirname(__file__), "index.html"))
|
| 390 |
|
| 391 |
|
| 392 |
-
@routes.get("/idle.mp4")
|
| 393 |
-
async def idle_video(request):
|
| 394 |
-
return web.FileResponse(os.path.join(os.path.dirname(__file__), "idle.mp4"))
|
| 395 |
-
|
| 396 |
-
|
| 397 |
@routes.get("/{filename}")
|
| 398 |
async def static_file(request):
|
| 399 |
filename = request.match_info["filename"]
|
|
@@ -405,21 +552,41 @@ async def static_file(request):
|
|
| 405 |
|
| 406 |
@routes.get("/health")
|
| 407 |
async def health(request):
|
| 408 |
-
return web.json_response({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
|
| 411 |
app = web.Application()
|
| 412 |
app.add_routes(routes)
|
|
|
|
| 413 |
|
| 414 |
|
| 415 |
if __name__ == "__main__":
|
| 416 |
print("=" * 50)
|
| 417 |
-
print("Interface Server -
|
| 418 |
print("=" * 50)
|
| 419 |
print(f"Porta: {PORT}")
|
| 420 |
-
print(f"
|
| 421 |
print(f"Wav2Lip: {WAV2LIP_WS}")
|
| 422 |
-
print(f"Audio: {AUDIO_SAMPLE_RATE}Hz, {AUDIO_BYTES_PER_FRAME} bytes/frame")
|
| 423 |
-
print(f"Video: {VIDEO_FPS}fps, {MS_PER_FRAME}ms/frame")
|
| 424 |
print("=" * 50)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
web.run_app(app, host="0.0.0.0", port=PORT)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Interface Server - WebRTC Streaming com VP9
|
| 3 |
Porta: 8080
|
| 4 |
|
| 5 |
+
Arquitetura:
|
| 6 |
+
1. Cliente conecta via WebRTC (signaling por WebSocket)
|
| 7 |
+
2. Servidor envia stream de video VP9 + audio Opus
|
| 8 |
+
3. Fusao idle/lip-sync feita no backend
|
| 9 |
+
4. Frontend apenas renderiza o <video>
|
|
|
|
| 10 |
|
| 11 |
+
Framework: aiortc (https://github.com/aiortc/aiortc)
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
from aiohttp import web
|
| 14 |
import aiohttp
|
|
|
|
| 17 |
import base64
|
| 18 |
import os
|
| 19 |
import time
|
| 20 |
+
import uuid
|
| 21 |
+
import fractions
|
| 22 |
+
import numpy as np
|
| 23 |
+
from av import VideoFrame, AudioFrame
|
| 24 |
+
from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack, RTCConfiguration, RTCIceServer
|
| 25 |
+
from aiortc.contrib.media import MediaRelay
|
| 26 |
+
import cv2
|
| 27 |
+
import subprocess
|
| 28 |
+
import tempfile
|
| 29 |
|
| 30 |
# Configuracao
|
|
|
|
| 31 |
WAV2LIP_WS = os.getenv("WAV2LIP_WS", "ws://localhost:8082/ws")
|
| 32 |
PORT = int(os.getenv("PORT", "8080"))
|
| 33 |
+
IDLE_VIDEO = os.path.join(os.path.dirname(__file__), "idle.mp4")
|
| 34 |
|
| 35 |
# Constantes
|
| 36 |
+
VIDEO_FPS = 25
|
| 37 |
+
AUDIO_SAMPLE_RATE = 24000
|
| 38 |
+
VIDEO_TIME_BASE = fractions.Fraction(1, VIDEO_FPS)
|
| 39 |
+
AUDIO_TIME_BASE = fractions.Fraction(1, AUDIO_SAMPLE_RATE)
|
|
|
|
| 40 |
|
| 41 |
+
# Cache global
|
| 42 |
+
idle_frames_cache = []
|
| 43 |
+
pcs = set() # Track peer connections
|
| 44 |
+
relay = MediaRelay()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
+
routes = web.RouteTableDef()
|
|
|
|
|
|
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
+
def calculate_frame_difference(frame1, frame2):
|
| 50 |
+
"""Calcula diferenca entre dois frames (0 = identicos, 1 = muito diferentes)."""
|
| 51 |
+
if frame1 is None or frame2 is None:
|
| 52 |
+
return 1.0
|
| 53 |
+
|
| 54 |
+
# Converter para grayscale se necessario
|
| 55 |
+
if len(frame1.shape) == 3:
|
| 56 |
+
gray1 = cv2.cvtColor(frame1, cv2.COLOR_RGB2GRAY)
|
| 57 |
+
else:
|
| 58 |
+
gray1 = frame1
|
| 59 |
+
|
| 60 |
+
if len(frame2.shape) == 3:
|
| 61 |
+
gray2 = cv2.cvtColor(frame2, cv2.COLOR_RGB2GRAY)
|
| 62 |
+
else:
|
| 63 |
+
gray2 = frame2
|
| 64 |
+
|
| 65 |
+
# Redimensionar para mesma resolucao se necessario
|
| 66 |
+
if gray1.shape != gray2.shape:
|
| 67 |
+
gray2 = cv2.resize(gray2, (gray1.shape[1], gray1.shape[0]))
|
| 68 |
+
|
| 69 |
+
# Calcular diferenca
|
| 70 |
+
diff = cv2.absdiff(gray1, gray2)
|
| 71 |
+
return np.mean(diff) / 255.0
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def find_best_matching_idle_frame(last_speak_frame, idle_frames, sample_step=10):
|
| 75 |
+
"""
|
| 76 |
+
Encontra o frame idle mais similar ao ultimo frame de fala.
|
| 77 |
+
Usa amostragem para ser mais rapido.
|
| 78 |
+
"""
|
| 79 |
+
if not idle_frames or last_speak_frame is None:
|
| 80 |
+
return 0, float('inf')
|
| 81 |
+
|
| 82 |
+
best_idx = 0
|
| 83 |
+
best_diff = float('inf')
|
| 84 |
+
|
| 85 |
+
# Primeira passada: amostragem grosseira
|
| 86 |
+
for i in range(0, len(idle_frames), sample_step):
|
| 87 |
+
diff = calculate_frame_difference(last_speak_frame, idle_frames[i])
|
| 88 |
+
if diff < best_diff:
|
| 89 |
+
best_diff = diff
|
| 90 |
+
best_idx = i
|
| 91 |
+
|
| 92 |
+
# Segunda passada: refinamento na regiao
|
| 93 |
+
start = max(0, best_idx - sample_step)
|
| 94 |
+
end = min(len(idle_frames), best_idx + sample_step)
|
| 95 |
+
|
| 96 |
+
for i in range(start, end):
|
| 97 |
+
diff = calculate_frame_difference(last_speak_frame, idle_frames[i])
|
| 98 |
+
if diff < best_diff:
|
| 99 |
+
best_diff = diff
|
| 100 |
+
best_idx = i
|
| 101 |
+
|
| 102 |
+
return best_idx, best_diff
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def trim_high_motion_frames(frames, threshold_multiplier=1.0, max_trim=20):
|
| 106 |
+
"""
|
| 107 |
+
Remove frames do final que tem movimento muito alto (saltos).
|
| 108 |
+
"""
|
| 109 |
+
if len(frames) < 20:
|
| 110 |
+
return frames, None
|
| 111 |
+
|
| 112 |
+
# Calcular diferencas entre frames consecutivos (ultimos 20)
|
| 113 |
+
last_n = min(20, len(frames) - 1)
|
| 114 |
+
differences = []
|
| 115 |
+
for i in range(len(frames) - last_n, len(frames)):
|
| 116 |
+
if i > 0:
|
| 117 |
+
diff = calculate_frame_difference(frames[i-1], frames[i])
|
| 118 |
+
differences.append((i, diff))
|
| 119 |
+
|
| 120 |
+
if not differences:
|
| 121 |
+
return frames, frames[-1] if frames else None
|
| 122 |
+
|
| 123 |
+
# Calcular media e desvio padrao
|
| 124 |
+
diffs = [d[1] for d in differences]
|
| 125 |
+
mean_diff = np.mean(diffs)
|
| 126 |
+
std_diff = np.std(diffs)
|
| 127 |
+
|
| 128 |
+
# Threshold agressivo
|
| 129 |
+
threshold = mean_diff + threshold_multiplier * std_diff
|
| 130 |
+
min_threshold = 0.7
|
| 131 |
+
if threshold > min_threshold:
|
| 132 |
+
threshold = min_threshold
|
| 133 |
+
|
| 134 |
+
# Encontrar onde comecam os frames problematicos
|
| 135 |
+
trim_from = len(frames)
|
| 136 |
+
frames_removed = 0
|
| 137 |
+
|
| 138 |
+
for i in range(len(differences) - 1, -1, -1):
|
| 139 |
+
idx, diff = differences[i]
|
| 140 |
+
if diff > threshold:
|
| 141 |
+
trim_from = idx
|
| 142 |
+
frames_removed += 1
|
| 143 |
+
if frames_removed >= max_trim:
|
| 144 |
+
break
|
| 145 |
+
else:
|
| 146 |
+
break
|
| 147 |
|
| 148 |
+
frames_to_trim = len(frames) - trim_from
|
|
|
|
| 149 |
|
| 150 |
+
if frames_to_trim > 0 and frames_to_trim <= max_trim:
|
| 151 |
+
print(f"[Trim] Removendo {frames_to_trim} frames problematicos")
|
| 152 |
+
trimmed_frames = frames[:trim_from]
|
| 153 |
+
return trimmed_frames, trimmed_frames[-1] if trimmed_frames else None
|
| 154 |
|
| 155 |
+
return frames, frames[-1] if frames else None
|
|
|
|
|
|
|
|
|
|
| 156 |
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
+
def load_idle_frames():
|
| 159 |
+
"""Carrega frames do idle.mp4 como arrays numpy."""
|
| 160 |
+
global idle_frames_cache
|
| 161 |
|
| 162 |
+
if idle_frames_cache:
|
| 163 |
+
return idle_frames_cache
|
| 164 |
|
| 165 |
+
if not os.path.exists(IDLE_VIDEO):
|
| 166 |
+
print(f"[Idle] Arquivo nao encontrado: {IDLE_VIDEO}")
|
| 167 |
+
return []
|
|
|
|
| 168 |
|
| 169 |
+
print(f"[Idle] Carregando frames de {IDLE_VIDEO}...")
|
|
|
|
|
|
|
| 170 |
|
| 171 |
+
cap = cv2.VideoCapture(IDLE_VIDEO)
|
| 172 |
+
while True:
|
| 173 |
+
ret, frame = cap.read()
|
| 174 |
+
if not ret:
|
| 175 |
+
break
|
| 176 |
+
# Converter BGR para RGB
|
| 177 |
+
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 178 |
+
idle_frames_cache.append(frame_rgb)
|
| 179 |
+
cap.release()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
+
print(f"[Idle] Carregados {len(idle_frames_cache)} frames")
|
| 182 |
+
return idle_frames_cache
|
|
|
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
+
class AvatarVideoTrack(MediaStreamTrack):
|
| 186 |
+
"""
|
| 187 |
+
Track de video que envia frames do avatar.
|
| 188 |
+
Alterna entre idle e lip-sync conforme necessario.
|
| 189 |
+
"""
|
| 190 |
+
kind = "video"
|
| 191 |
|
| 192 |
+
def __init__(self):
|
| 193 |
+
super().__init__()
|
| 194 |
+
self.idle_frames = load_idle_frames()
|
| 195 |
+
self.current_idx = 0
|
| 196 |
+
self.frame_count = 0
|
| 197 |
+
self.start_time = None
|
| 198 |
|
| 199 |
+
# Estado
|
| 200 |
+
self.is_speaking = False
|
| 201 |
+
self.speaking_frames = []
|
| 202 |
+
self.speaking_idx = 0
|
| 203 |
+
self.best_idle_idx = None # Frame idle para transicao suave
|
| 204 |
+
|
| 205 |
+
# Dimensoes do video
|
| 206 |
+
if self.idle_frames:
|
| 207 |
+
self.height, self.width = self.idle_frames[0].shape[:2]
|
| 208 |
+
else:
|
| 209 |
+
self.width, self.height = 640, 480
|
| 210 |
+
|
| 211 |
+
print(f"[VideoTrack] Inicializado: {self.width}x{self.height} @ {VIDEO_FPS}fps")
|
| 212 |
+
|
| 213 |
+
async def recv(self):
|
| 214 |
+
"""Retorna o proximo frame de video."""
|
| 215 |
+
if self.start_time is None:
|
| 216 |
+
self.start_time = time.time()
|
| 217 |
+
|
| 218 |
+
# Calcular pts baseado no tempo
|
| 219 |
+
pts = int(self.frame_count * VIDEO_TIME_BASE.denominator / VIDEO_FPS)
|
| 220 |
+
self.frame_count += 1
|
| 221 |
+
|
| 222 |
+
# Escolher frame: speaking ou idle
|
| 223 |
+
if self.is_speaking and self.speaking_frames:
|
| 224 |
+
if self.speaking_idx < len(self.speaking_frames):
|
| 225 |
+
frame_data = self.speaking_frames[self.speaking_idx]
|
| 226 |
+
self.speaking_idx += 1
|
| 227 |
+
else:
|
| 228 |
+
# Acabou a fala, voltar ao idle no best_idle_idx
|
| 229 |
+
self.is_speaking = False
|
| 230 |
+
self.speaking_frames = []
|
| 231 |
+
self.speaking_idx = 0
|
| 232 |
+
|
| 233 |
+
# Usar o frame idle pre-calculado para transicao suave
|
| 234 |
+
if self.best_idle_idx is not None and self.idle_frames:
|
| 235 |
+
self.current_idx = self.best_idle_idx
|
| 236 |
+
self.best_idle_idx = None
|
| 237 |
+
print(f"[VideoTrack] Transicao suave -> idle frame {self.current_idx}")
|
| 238 |
+
|
| 239 |
+
frame_data = self.idle_frames[self.current_idx % len(self.idle_frames)]
|
| 240 |
+
self.current_idx += 1
|
| 241 |
+
elif self.idle_frames:
|
| 242 |
+
frame_data = self.idle_frames[self.current_idx % len(self.idle_frames)]
|
| 243 |
+
self.current_idx += 1
|
| 244 |
+
else:
|
| 245 |
+
# Fallback: frame preto
|
| 246 |
+
frame_data = np.zeros((self.height, self.width, 3), dtype=np.uint8)
|
| 247 |
+
|
| 248 |
+
# Criar VideoFrame
|
| 249 |
+
frame = VideoFrame.from_ndarray(frame_data, format="rgb24")
|
| 250 |
+
frame.pts = pts
|
| 251 |
+
frame.time_base = VIDEO_TIME_BASE
|
| 252 |
+
|
| 253 |
+
# Manter timing de 25fps
|
| 254 |
+
elapsed = time.time() - self.start_time
|
| 255 |
+
expected = self.frame_count / VIDEO_FPS
|
| 256 |
+
if expected > elapsed:
|
| 257 |
+
await asyncio.sleep(expected - elapsed)
|
| 258 |
+
|
| 259 |
+
return frame
|
| 260 |
+
|
| 261 |
+
def set_speaking_frames(self, frames):
|
| 262 |
+
"""Define frames de lip-sync para reproduzir com transicao suave."""
|
| 263 |
+
# Aplicar trim de frames problematicos
|
| 264 |
+
trimmed_frames, last_frame = trim_high_motion_frames(frames)
|
| 265 |
+
|
| 266 |
+
# Encontrar o melhor frame idle para transicao suave
|
| 267 |
+
if last_frame is not None and self.idle_frames:
|
| 268 |
+
best_idx, best_diff = find_best_matching_idle_frame(
|
| 269 |
+
last_frame, self.idle_frames, sample_step=10
|
| 270 |
+
)
|
| 271 |
+
self.best_idle_idx = best_idx
|
| 272 |
+
print(f"[VideoTrack] Best match: idle frame {best_idx} (diff: {best_diff:.2f})")
|
| 273 |
+
else:
|
| 274 |
+
self.best_idle_idx = None
|
| 275 |
+
|
| 276 |
+
self.speaking_frames = trimmed_frames
|
| 277 |
+
self.speaking_idx = 0
|
| 278 |
+
self.is_speaking = True
|
| 279 |
+
print(f"[VideoTrack] Speaking: {len(trimmed_frames)} frames (original: {len(frames)})")
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
class AvatarAudioTrack(MediaStreamTrack):
|
| 283 |
+
"""
|
| 284 |
+
Track de audio que envia silencio ou audio do Orpheus.
|
| 285 |
+
"""
|
| 286 |
+
kind = "audio"
|
| 287 |
+
|
| 288 |
+
def __init__(self):
|
| 289 |
+
super().__init__()
|
| 290 |
+
self.sample_rate = AUDIO_SAMPLE_RATE
|
| 291 |
+
self.samples_per_frame = 960 # 40ms @ 24kHz
|
| 292 |
+
self.frame_count = 0
|
| 293 |
+
self.start_time = None
|
| 294 |
|
| 295 |
+
# Buffer de audio
|
| 296 |
+
self.audio_buffer = []
|
| 297 |
+
self.buffer_idx = 0
|
| 298 |
|
| 299 |
+
print(f"[AudioTrack] Inicializado: {self.sample_rate}Hz")
|
|
|
|
| 300 |
|
| 301 |
+
async def recv(self):
|
| 302 |
+
"""Retorna o proximo frame de audio."""
|
| 303 |
+
if self.start_time is None:
|
| 304 |
+
self.start_time = time.time()
|
| 305 |
|
| 306 |
+
pts = self.frame_count * self.samples_per_frame
|
| 307 |
+
self.frame_count += 1
|
|
|
|
|
|
|
| 308 |
|
| 309 |
+
# Pegar audio do buffer ou silencio
|
| 310 |
+
if self.audio_buffer and self.buffer_idx < len(self.audio_buffer):
|
| 311 |
+
samples = self.audio_buffer[self.buffer_idx]
|
| 312 |
+
self.buffer_idx += 1
|
| 313 |
+
else:
|
| 314 |
+
# Silencio
|
| 315 |
+
samples = np.zeros(self.samples_per_frame, dtype=np.int16)
|
| 316 |
|
| 317 |
+
# Criar AudioFrame
|
| 318 |
+
frame = AudioFrame(format="s16", layout="mono", samples=len(samples))
|
| 319 |
+
frame.sample_rate = self.sample_rate
|
| 320 |
+
frame.pts = pts
|
| 321 |
+
frame.time_base = AUDIO_TIME_BASE
|
| 322 |
|
| 323 |
+
# Copiar samples
|
| 324 |
+
frame.planes[0].update(samples.tobytes())
|
| 325 |
|
| 326 |
+
# Manter timing
|
| 327 |
+
elapsed = time.time() - self.start_time
|
| 328 |
+
expected = self.frame_count * self.samples_per_frame / self.sample_rate
|
| 329 |
+
if expected > elapsed:
|
| 330 |
+
await asyncio.sleep(expected - elapsed)
|
| 331 |
|
| 332 |
+
return frame
|
|
|
|
|
|
|
| 333 |
|
| 334 |
+
def set_audio(self, pcm_data):
|
| 335 |
+
"""Define audio PCM para reproduzir."""
|
| 336 |
+
# Converter bytes para numpy array
|
| 337 |
+
samples = np.frombuffer(pcm_data, dtype=np.int16)
|
| 338 |
|
| 339 |
+
# Dividir em frames de 40ms
|
| 340 |
+
self.audio_buffer = []
|
| 341 |
+
for i in range(0, len(samples), self.samples_per_frame):
|
| 342 |
+
chunk = samples[i:i + self.samples_per_frame]
|
| 343 |
+
if len(chunk) < self.samples_per_frame:
|
| 344 |
+
# Padding com zeros
|
| 345 |
+
chunk = np.pad(chunk, (0, self.samples_per_frame - len(chunk)))
|
| 346 |
+
self.audio_buffer.append(chunk)
|
| 347 |
|
| 348 |
+
self.buffer_idx = 0
|
| 349 |
+
print(f"[AudioTrack] Audio: {len(self.audio_buffer)} frames ({len(pcm_data)} bytes)")
|
|
|
|
|
|
|
| 350 |
|
|
|
|
|
|
|
| 351 |
|
| 352 |
+
class AvatarSession:
|
| 353 |
+
"""Gerencia uma sessao WebRTC com o cliente."""
|
| 354 |
|
| 355 |
+
def __init__(self, pc, video_track, audio_track):
|
| 356 |
+
self.pc = pc
|
| 357 |
+
self.video_track = video_track
|
| 358 |
+
self.audio_track = audio_track
|
| 359 |
+
self.wav2lip_ws = None
|
| 360 |
+
self.wav2lip_session = None
|
| 361 |
|
| 362 |
+
async def generate(self, text: str, voice: str):
|
| 363 |
+
"""Gera fala com lip-sync via Wav2Lip."""
|
| 364 |
+
print(f"[Session] Gerando: '{text[:50]}...'")
|
| 365 |
|
| 366 |
try:
|
| 367 |
+
# Conectar ao Wav2Lip
|
| 368 |
+
self.wav2lip_session = aiohttp.ClientSession()
|
| 369 |
+
self.wav2lip_ws = await self.wav2lip_session.ws_connect(
|
| 370 |
+
WAV2LIP_WS,
|
| 371 |
+
timeout=aiohttp.ClientWSTimeout(ws_close=120)
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
# Enviar requisicao
|
| 375 |
+
await self.wav2lip_ws.send_json({
|
| 376 |
+
"action": "generate",
|
| 377 |
+
"text": text,
|
| 378 |
+
"voice": voice
|
| 379 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
|
| 381 |
+
speaking_frames = []
|
| 382 |
+
audio_data = b''
|
|
|
|
|
|
|
| 383 |
|
| 384 |
+
# Receber frames e audio
|
| 385 |
+
async for msg in self.wav2lip_ws:
|
| 386 |
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
data = json.loads(msg.data)
|
| 388 |
+
msg_type = data.get("type", "")
|
| 389 |
+
|
| 390 |
+
if msg_type == "frame":
|
| 391 |
+
frame_b64 = data.get("frame", "")
|
| 392 |
+
if frame_b64:
|
| 393 |
+
# Decodificar JPEG para numpy
|
| 394 |
+
jpeg_data = base64.b64decode(frame_b64)
|
| 395 |
+
nparr = np.frombuffer(jpeg_data, np.uint8)
|
| 396 |
+
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 397 |
+
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 398 |
+
speaking_frames.append(frame_rgb)
|
| 399 |
+
|
| 400 |
+
elif msg_type == "full_audio":
|
| 401 |
+
audio_b64 = data.get("audio", "")
|
| 402 |
+
if audio_b64:
|
| 403 |
+
audio_data = base64.b64decode(audio_b64)
|
| 404 |
+
|
| 405 |
+
elif msg_type == "done":
|
| 406 |
+
break
|
| 407 |
|
| 408 |
+
elif msg_type == "error":
|
| 409 |
+
print(f"[Session] Erro Wav2Lip: {data.get('message')}")
|
| 410 |
+
break
|
| 411 |
|
| 412 |
+
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
| 413 |
+
break
|
|
|
|
|
|
|
| 414 |
|
| 415 |
+
await self.wav2lip_ws.close()
|
| 416 |
+
await self.wav2lip_session.close()
|
| 417 |
|
| 418 |
+
# Aplicar frames e audio aos tracks
|
| 419 |
+
if speaking_frames:
|
| 420 |
+
self.video_track.set_speaking_frames(speaking_frames)
|
| 421 |
+
if audio_data:
|
| 422 |
+
self.audio_track.set_audio(audio_data)
|
| 423 |
|
| 424 |
+
print(f"[Session] Gerado: {len(speaking_frames)} frames, {len(audio_data)} bytes audio")
|
|
|
|
|
|
|
| 425 |
|
| 426 |
+
except Exception as e:
|
| 427 |
+
print(f"[Session] Erro: {e}")
|
| 428 |
+
import traceback
|
| 429 |
+
traceback.print_exc()
|
|
|
|
|
|
|
| 430 |
|
| 431 |
+
async def close(self):
|
| 432 |
+
"""Fecha a sessao."""
|
| 433 |
+
if self.wav2lip_ws and not self.wav2lip_ws.closed:
|
| 434 |
+
await self.wav2lip_ws.close()
|
| 435 |
+
if self.wav2lip_session:
|
| 436 |
+
await self.wav2lip_session.close()
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
# Armazenar sessoes ativas
|
| 440 |
+
sessions = {}
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
@routes.post("/offer")
|
| 444 |
+
async def offer(request):
|
| 445 |
+
"""Recebe offer SDP do cliente e retorna answer."""
|
| 446 |
+
params = await request.json()
|
| 447 |
+
offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
|
| 448 |
+
|
| 449 |
+
# Configurar ICE servers (STUN + TURN publicos)
|
| 450 |
+
ice_servers = [
|
| 451 |
+
RTCIceServer(urls=["stun:stun.l.google.com:19302"]),
|
| 452 |
+
# Servidores TURN com múltiplas URLs
|
| 453 |
+
RTCIceServer(
|
| 454 |
+
urls=[
|
| 455 |
+
"turn:openrelay.metered.ca:80",
|
| 456 |
+
"turn:openrelay.metered.ca:443",
|
| 457 |
+
"turn:openrelay.metered.ca:443?transport=tcp"
|
| 458 |
+
],
|
| 459 |
+
username="openrelayproject",
|
| 460 |
+
credential="openrelayproject"
|
| 461 |
+
),
|
| 462 |
+
# TURN alternativo (Twilio)
|
| 463 |
+
RTCIceServer(
|
| 464 |
+
urls=["turn:global.turn.twilio.com:3478?transport=udp"],
|
| 465 |
+
username="f4b4035eaa76f4a55de5f4351567653ee4ff6fa97b50b6b334fcc1be9c27212d",
|
| 466 |
+
credential="w1uxM55V9yVoqyVFjt+mxDBV0F87AUCemaYVQGxsPLw="
|
| 467 |
+
),
|
| 468 |
+
]
|
| 469 |
+
|
| 470 |
+
config = RTCConfiguration(iceServers=ice_servers)
|
| 471 |
+
pc = RTCPeerConnection(configuration=config)
|
| 472 |
+
pc_id = str(uuid.uuid4())
|
| 473 |
+
pcs.add(pc)
|
| 474 |
+
|
| 475 |
+
print(f"[WebRTC] Nova conexao: {pc_id}")
|
| 476 |
+
|
| 477 |
+
# Criar tracks
|
| 478 |
+
video_track = AvatarVideoTrack()
|
| 479 |
+
audio_track = AvatarAudioTrack()
|
| 480 |
+
|
| 481 |
+
# Adicionar tracks ao peer connection
|
| 482 |
+
pc.addTrack(video_track)
|
| 483 |
+
pc.addTrack(audio_track)
|
| 484 |
+
|
| 485 |
+
# Criar sessao
|
| 486 |
+
session = AvatarSession(pc, video_track, audio_track)
|
| 487 |
+
sessions[pc_id] = session
|
| 488 |
+
|
| 489 |
+
@pc.on("iceconnectionstatechange")
|
| 490 |
+
async def on_ice_state():
|
| 491 |
+
print(f"[ICE] Estado: {pc.iceConnectionState}")
|
| 492 |
+
|
| 493 |
+
@pc.on("icegatheringstatechange")
|
| 494 |
+
async def on_ice_gathering():
|
| 495 |
+
print(f"[ICE] Gathering: {pc.iceGatheringState}")
|
| 496 |
+
|
| 497 |
+
@pc.on("connectionstatechange")
|
| 498 |
+
async def on_connectionstatechange():
|
| 499 |
+
print(f"[WebRTC] Estado: {pc.connectionState}")
|
| 500 |
+
if pc.connectionState == "failed" or pc.connectionState == "closed":
|
| 501 |
+
await pc.close()
|
| 502 |
+
pcs.discard(pc)
|
| 503 |
+
if pc_id in sessions:
|
| 504 |
+
await sessions[pc_id].close()
|
| 505 |
+
del sessions[pc_id]
|
| 506 |
+
|
| 507 |
+
# Processar offer e criar answer
|
| 508 |
+
await pc.setRemoteDescription(offer)
|
| 509 |
+
answer = await pc.createAnswer()
|
| 510 |
+
await pc.setLocalDescription(answer)
|
| 511 |
+
|
| 512 |
+
return web.json_response({
|
| 513 |
+
"sdp": pc.localDescription.sdp,
|
| 514 |
+
"type": pc.localDescription.type,
|
| 515 |
+
"session_id": pc_id
|
| 516 |
+
})
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
@routes.post("/generate")
|
| 520 |
+
async def generate(request):
|
| 521 |
+
"""Gera fala com lip-sync."""
|
| 522 |
+
params = await request.json()
|
| 523 |
+
session_id = params.get("session_id")
|
| 524 |
+
text = params.get("text", "").strip()
|
| 525 |
+
voice = params.get("voice", "tara")
|
| 526 |
+
|
| 527 |
+
if not session_id or session_id not in sessions:
|
| 528 |
+
return web.json_response({"error": "Sessao invalida"}, status=400)
|
| 529 |
+
|
| 530 |
+
if not text:
|
| 531 |
+
return web.json_response({"error": "Texto obrigatorio"}, status=400)
|
| 532 |
+
|
| 533 |
+
session = sessions[session_id]
|
| 534 |
+
asyncio.create_task(session.generate(text, voice))
|
| 535 |
+
|
| 536 |
+
return web.json_response({"status": "generating"})
|
| 537 |
|
| 538 |
|
| 539 |
@routes.get("/")
|
|
|
|
| 541 |
return web.FileResponse(os.path.join(os.path.dirname(__file__), "index.html"))
|
| 542 |
|
| 543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
@routes.get("/{filename}")
|
| 545 |
async def static_file(request):
|
| 546 |
filename = request.match_info["filename"]
|
|
|
|
| 552 |
|
| 553 |
@routes.get("/health")
|
| 554 |
async def health(request):
|
| 555 |
+
return web.json_response({
|
| 556 |
+
"status": "ok",
|
| 557 |
+
"mode": "webrtc",
|
| 558 |
+
"connections": len(pcs)
|
| 559 |
+
})
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
async def on_shutdown(app):
|
| 563 |
+
"""Fecha todas as conexoes ao desligar."""
|
| 564 |
+
coros = [pc.close() for pc in pcs]
|
| 565 |
+
await asyncio.gather(*coros)
|
| 566 |
+
pcs.clear()
|
| 567 |
|
| 568 |
|
| 569 |
app = web.Application()
|
| 570 |
app.add_routes(routes)
|
| 571 |
+
app.on_shutdown.append(on_shutdown)
|
| 572 |
|
| 573 |
|
| 574 |
if __name__ == "__main__":
|
| 575 |
print("=" * 50)
|
| 576 |
+
print("Interface Server - WebRTC VP9 Streaming")
|
| 577 |
print("=" * 50)
|
| 578 |
print(f"Porta: {PORT}")
|
| 579 |
+
print(f"Idle Video: {IDLE_VIDEO}")
|
| 580 |
print(f"Wav2Lip: {WAV2LIP_WS}")
|
|
|
|
|
|
|
| 581 |
print("=" * 50)
|
| 582 |
+
print("Endpoints:")
|
| 583 |
+
print(" POST /offer - WebRTC signaling")
|
| 584 |
+
print(" POST /generate - Gerar fala")
|
| 585 |
+
print("=" * 50)
|
| 586 |
+
|
| 587 |
+
# Pre-carregar idle frames
|
| 588 |
+
print("Carregando idle frames...")
|
| 589 |
+
load_idle_frames()
|
| 590 |
+
print("=" * 50)
|
| 591 |
+
|
| 592 |
web.run_app(app, host="0.0.0.0", port=PORT)
|
interface/server_optimized.py
CHANGED
|
@@ -48,6 +48,8 @@ class OptimizedSession:
|
|
| 48 |
self.total_frames = 0
|
| 49 |
self.total_bytes = 0
|
| 50 |
self.end_video_time_ms = None # Tempo do vídeo onde parou (para sync)
|
|
|
|
|
|
|
| 51 |
|
| 52 |
async def send_json(self, msg_type: str, **kwargs):
|
| 53 |
"""Envia mensagem JSON (para status/controle)."""
|
|
@@ -162,7 +164,7 @@ class OptimizedSession:
|
|
| 162 |
|
| 163 |
return total_chunks > 0
|
| 164 |
|
| 165 |
-
async def run(self, text: str, voice: str):
|
| 166 |
"""Streaming otimizado - áudio e frames em paralelo, enviados conforme chegam."""
|
| 167 |
self.is_running = True
|
| 168 |
self.start_time = time.time()
|
|
@@ -186,17 +188,19 @@ class OptimizedSession:
|
|
| 186 |
|
| 187 |
await self.send_json("status", message="Gerando...")
|
| 188 |
|
| 189 |
-
# Enviar texto para Wav2Lip
|
| 190 |
await wav2lip_ws.send_json({
|
| 191 |
-
"action": "
|
| 192 |
"text": text,
|
| 193 |
"voice": voice,
|
| 194 |
-
"
|
|
|
|
| 195 |
})
|
| 196 |
|
| 197 |
print(f"[Optimized] Texto: '{text[:50]}...'")
|
| 198 |
|
| 199 |
first_frame_time = None
|
|
|
|
| 200 |
|
| 201 |
# Receber frames do Wav2Lip
|
| 202 |
async for msg in wav2lip_ws:
|
|
@@ -217,8 +221,10 @@ class OptimizedSession:
|
|
| 217 |
if first_frame_time is None:
|
| 218 |
first_frame_time = time.time() - self.start_time
|
| 219 |
latency_ms = int(first_frame_time * 1000)
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
| 222 |
|
| 223 |
elif msg_type == "full_audio":
|
| 224 |
print(f"[Optimized] Ignorando audio Wav2Lip (usando Orpheus streaming)")
|
|
@@ -234,10 +240,14 @@ class OptimizedSession:
|
|
| 234 |
break
|
| 235 |
|
| 236 |
elif msg_type == "done":
|
| 237 |
-
# Capturar
|
| 238 |
self.end_video_time_ms = data.get("end_video_time_ms")
|
|
|
|
|
|
|
| 239 |
end_frame_idx = data.get("end_frame_idx")
|
| 240 |
-
|
|
|
|
|
|
|
| 241 |
break
|
| 242 |
|
| 243 |
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
@@ -272,6 +282,8 @@ class OptimizedSession:
|
|
| 272 |
total_frames=self.total_frames,
|
| 273 |
elapsed_ms=int(elapsed * 1000),
|
| 274 |
bytes_sent=self.total_bytes,
|
|
|
|
|
|
|
| 275 |
end_video_time_ms=self.end_video_time_ms
|
| 276 |
)
|
| 277 |
|
|
@@ -297,18 +309,20 @@ async def websocket_handler(request):
|
|
| 297 |
if action == "generate":
|
| 298 |
text = data.get("text", "").strip()
|
| 299 |
voice = data.get("voice", "tara")
|
|
|
|
|
|
|
| 300 |
|
| 301 |
if not text:
|
| 302 |
await ws.send_json({"type": "error", "message": "Text required"})
|
| 303 |
continue
|
| 304 |
|
| 305 |
-
print(f"Gerando: '{text[:50]}...'")
|
| 306 |
|
| 307 |
if session:
|
| 308 |
session.stop()
|
| 309 |
|
| 310 |
session = OptimizedSession(ws)
|
| 311 |
-
await session.run(text, voice)
|
| 312 |
|
| 313 |
elif action == "stop":
|
| 314 |
if session:
|
|
|
|
| 48 |
self.total_frames = 0
|
| 49 |
self.total_bytes = 0
|
| 50 |
self.end_video_time_ms = None # Tempo do vídeo onde parou (para sync)
|
| 51 |
+
self.start_frame_idx = None # Frame inicial usado pelo Wav2Lip
|
| 52 |
+
self.end_frame_idx = None # Frame final usado pelo Wav2Lip
|
| 53 |
|
| 54 |
async def send_json(self, msg_type: str, **kwargs):
|
| 55 |
"""Envia mensagem JSON (para status/controle)."""
|
|
|
|
| 164 |
|
| 165 |
return total_chunks > 0
|
| 166 |
|
| 167 |
+
async def run(self, text: str, voice: str, jpeg_quality: int = 95, idle_video_time_ms: int = 0):
|
| 168 |
"""Streaming otimizado - áudio e frames em paralelo, enviados conforme chegam."""
|
| 169 |
self.is_running = True
|
| 170 |
self.start_time = time.time()
|
|
|
|
| 188 |
|
| 189 |
await self.send_json("status", message="Gerando...")
|
| 190 |
|
| 191 |
+
# Enviar texto para Wav2Lip com action "generate" para receber end_video_time_ms
|
| 192 |
await wav2lip_ws.send_json({
|
| 193 |
+
"action": "generate",
|
| 194 |
"text": text,
|
| 195 |
"voice": voice,
|
| 196 |
+
"idle_video_time_ms": idle_video_time_ms, # Tempo inicial do vídeo idle
|
| 197 |
+
"jpeg_quality": jpeg_quality # Qualidade do JPEG (50-100)
|
| 198 |
})
|
| 199 |
|
| 200 |
print(f"[Optimized] Texto: '{text[:50]}...'")
|
| 201 |
|
| 202 |
first_frame_time = None
|
| 203 |
+
start_frame_idx = None # Frame inicial usado pelo Wav2Lip
|
| 204 |
|
| 205 |
# Receber frames do Wav2Lip
|
| 206 |
async for msg in wav2lip_ws:
|
|
|
|
| 221 |
if first_frame_time is None:
|
| 222 |
first_frame_time = time.time() - self.start_time
|
| 223 |
latency_ms = int(first_frame_time * 1000)
|
| 224 |
+
# Capturar start_frame_idx do primeiro frame
|
| 225 |
+
start_frame_idx = data.get("source_frame_idx", None)
|
| 226 |
+
print(f"[Optimized] Primeiro frame: {latency_ms}ms, source_frame={start_frame_idx}")
|
| 227 |
+
await self.send_json("first_frame", latency_ms=latency_ms, start_frame_idx=start_frame_idx)
|
| 228 |
|
| 229 |
elif msg_type == "full_audio":
|
| 230 |
print(f"[Optimized] Ignorando audio Wav2Lip (usando Orpheus streaming)")
|
|
|
|
| 240 |
break
|
| 241 |
|
| 242 |
elif msg_type == "done":
|
| 243 |
+
# Capturar índices de frame para sincronização
|
| 244 |
self.end_video_time_ms = data.get("end_video_time_ms")
|
| 245 |
+
if start_frame_idx is None:
|
| 246 |
+
start_frame_idx = data.get("start_frame_idx")
|
| 247 |
end_frame_idx = data.get("end_frame_idx")
|
| 248 |
+
self.start_frame_idx = start_frame_idx
|
| 249 |
+
self.end_frame_idx = end_frame_idx
|
| 250 |
+
print(f"[Optimized] Wav2Lip done: {self.total_frames} frames, start_frame={start_frame_idx}, end_frame={end_frame_idx}, end_time_ms={self.end_video_time_ms}")
|
| 251 |
break
|
| 252 |
|
| 253 |
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
|
|
| 282 |
total_frames=self.total_frames,
|
| 283 |
elapsed_ms=int(elapsed * 1000),
|
| 284 |
bytes_sent=self.total_bytes,
|
| 285 |
+
start_frame_idx=self.start_frame_idx,
|
| 286 |
+
end_frame_idx=self.end_frame_idx,
|
| 287 |
end_video_time_ms=self.end_video_time_ms
|
| 288 |
)
|
| 289 |
|
|
|
|
| 309 |
if action == "generate":
|
| 310 |
text = data.get("text", "").strip()
|
| 311 |
voice = data.get("voice", "tara")
|
| 312 |
+
jpeg_quality = data.get("jpeg_quality", 95) # Qualidade JPEG (50-100)
|
| 313 |
+
idle_video_time_ms = data.get("idle_video_time_ms", 0) # Tempo do vídeo idle
|
| 314 |
|
| 315 |
if not text:
|
| 316 |
await ws.send_json({"type": "error", "message": "Text required"})
|
| 317 |
continue
|
| 318 |
|
| 319 |
+
print(f"Gerando: '{text[:50]}...' (quality={jpeg_quality}, idle_time={idle_video_time_ms}ms)")
|
| 320 |
|
| 321 |
if session:
|
| 322 |
session.stop()
|
| 323 |
|
| 324 |
session = OptimizedSession(ws)
|
| 325 |
+
await session.run(text, voice, jpeg_quality, idle_video_time_ms)
|
| 326 |
|
| 327 |
elif action == "stop":
|
| 328 |
if session:
|
interface/server_streaming.py
CHANGED
|
@@ -24,11 +24,22 @@ routes = web.RouteTableDef()
|
|
| 24 |
# Cache de frames idle
|
| 25 |
idle_frames = []
|
| 26 |
idle_frame_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def load_idle_frames():
|
| 30 |
-
"""Carrega frames do idle.mp4"""
|
| 31 |
-
global idle_frames, idle_frame_count
|
| 32 |
|
| 33 |
if idle_frames:
|
| 34 |
return
|
|
@@ -40,16 +51,21 @@ def load_idle_frames():
|
|
| 40 |
print(f"Carregando idle frames de {IDLE_VIDEO}...")
|
| 41 |
cap = cv2.VideoCapture(IDLE_VIDEO)
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
while True:
|
| 44 |
ret, frame = cap.read()
|
| 45 |
if not ret:
|
| 46 |
break
|
| 47 |
-
# Manter em BGR para processamento, converter para JPEG depois
|
| 48 |
idle_frames.append(frame)
|
| 49 |
|
| 50 |
cap.release()
|
| 51 |
idle_frame_count = len(idle_frames)
|
| 52 |
-
print(f"Carregados {idle_frame_count} frames idle")
|
| 53 |
|
| 54 |
|
| 55 |
def frame_to_jpeg_base64(frame, quality=85):
|
|
@@ -66,6 +82,403 @@ def jpeg_base64_to_frame(b64_data):
|
|
| 66 |
return cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 67 |
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
def blend_frames(frame1, frame2, alpha):
|
| 70 |
"""Blend entre dois frames. alpha=0 -> frame1, alpha=1 -> frame2"""
|
| 71 |
# Garantir que ambos frames tem o mesmo tamanho
|
|
@@ -132,6 +545,13 @@ async def websocket_handler(request):
|
|
| 132 |
audio_duration = 0
|
| 133 |
end_video_time_ms = 0
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
async for w2l_msg in wav2lip_ws:
|
| 136 |
if w2l_msg.type == aiohttp.WSMsgType.TEXT:
|
| 137 |
w2l_data = json.loads(w2l_msg.data)
|
|
@@ -144,6 +564,20 @@ async def websocket_handler(request):
|
|
| 144 |
frame_b64 = w2l_data.get("frame", "")
|
| 145 |
if frame_b64:
|
| 146 |
frame = jpeg_base64_to_frame(frame_b64)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
speaking_frames.append(frame)
|
| 148 |
|
| 149 |
elif msg_type == "full_audio":
|
|
@@ -166,17 +600,45 @@ async def websocket_handler(request):
|
|
| 166 |
|
| 167 |
# Enviar frames SEM crossfade - transicao e feita no cliente
|
| 168 |
if speaking_frames:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
# Atualizar posicao do idle para continuidade apos fala
|
| 170 |
if idle_frames:
|
| 171 |
-
idle_position =
|
| 172 |
|
| 173 |
# Enviar stream_start
|
| 174 |
ttfb = int((time.time() - start_time) * 1000)
|
| 175 |
await ws.send_json({"type": "stream_start", "ttfb_ms": ttfb})
|
| 176 |
|
| 177 |
# Enviar apenas os frames de fala (sem crossfade)
|
|
|
|
| 178 |
for idx, frame in enumerate(speaking_frames):
|
| 179 |
-
frame_b64 = frame_to_jpeg_base64(frame)
|
| 180 |
await ws.send_json({
|
| 181 |
"type": "frame",
|
| 182 |
"frame": frame_b64,
|
|
@@ -200,7 +662,7 @@ async def websocket_handler(request):
|
|
| 200 |
"end_video_time_ms": end_video_time_ms
|
| 201 |
})
|
| 202 |
|
| 203 |
-
print(f"Enviados {len(speaking_frames)} frames
|
| 204 |
|
| 205 |
except Exception as e:
|
| 206 |
print(f"Erro: {e}")
|
|
@@ -286,14 +748,16 @@ app.add_routes(routes)
|
|
| 286 |
|
| 287 |
if __name__ == "__main__":
|
| 288 |
print("=" * 50)
|
| 289 |
-
print("Streaming Server
|
| 290 |
print("Wav2Lip:", WAV2LIP_WS)
|
| 291 |
print("Idle Video:", IDLE_VIDEO)
|
| 292 |
-
print("Crossfade: DESABILITADO (transicao no cliente)")
|
| 293 |
print("=" * 50)
|
| 294 |
|
| 295 |
# Carregar idle frames
|
| 296 |
load_idle_frames()
|
| 297 |
|
|
|
|
|
|
|
|
|
|
| 298 |
print("=" * 50)
|
| 299 |
web.run_app(app, host="0.0.0.0", port=PORT)
|
|
|
|
| 24 |
# Cache de frames idle
|
| 25 |
idle_frames = []
|
| 26 |
idle_frame_count = 0
|
| 27 |
+
idle_resolution = (1920, 1080) # Resolucao do idle video (width, height)
|
| 28 |
+
|
| 29 |
+
# Regiao da boca/queixo (em ratio do frame)
|
| 30 |
+
# Regiao mais focada para evitar "pulos" na transicao
|
| 31 |
+
# Apenas boca e queixo, sem incluir muito do rosto
|
| 32 |
+
MOUTH_REGION = {
|
| 33 |
+
'top': 0.50, # 50% do topo (comeca abaixo do nariz)
|
| 34 |
+
'bottom': 0.80, # ate 80% (apenas queixo)
|
| 35 |
+
'left': 0.32, # 32% da esquerda
|
| 36 |
+
'right': 0.68 # ate 68% (mais estreito)
|
| 37 |
+
}
|
| 38 |
|
| 39 |
|
| 40 |
def load_idle_frames():
|
| 41 |
+
"""Carrega frames do idle.mp4 e obtem resolucao"""
|
| 42 |
+
global idle_frames, idle_frame_count, idle_resolution
|
| 43 |
|
| 44 |
if idle_frames:
|
| 45 |
return
|
|
|
|
| 51 |
print(f"Carregando idle frames de {IDLE_VIDEO}...")
|
| 52 |
cap = cv2.VideoCapture(IDLE_VIDEO)
|
| 53 |
|
| 54 |
+
# Obter resolucao do video
|
| 55 |
+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 56 |
+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 57 |
+
idle_resolution = (width, height)
|
| 58 |
+
print(f"Resolucao idle: {width}x{height}")
|
| 59 |
+
|
| 60 |
while True:
|
| 61 |
ret, frame = cap.read()
|
| 62 |
if not ret:
|
| 63 |
break
|
|
|
|
| 64 |
idle_frames.append(frame)
|
| 65 |
|
| 66 |
cap.release()
|
| 67 |
idle_frame_count = len(idle_frames)
|
| 68 |
+
print(f"Carregados {idle_frame_count} frames idle em full resolution")
|
| 69 |
|
| 70 |
|
| 71 |
def frame_to_jpeg_base64(frame, quality=85):
|
|
|
|
| 82 |
return cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 83 |
|
| 84 |
|
| 85 |
+
def upscale_frame(frame, target_size):
|
| 86 |
+
"""
|
| 87 |
+
Upscale frame para a resolucao alvo usando LANCZOS4 (alta qualidade).
|
| 88 |
+
target_size: (width, height)
|
| 89 |
+
"""
|
| 90 |
+
if frame is None:
|
| 91 |
+
return frame
|
| 92 |
+
|
| 93 |
+
current_h, current_w = frame.shape[:2]
|
| 94 |
+
target_w, target_h = target_size
|
| 95 |
+
|
| 96 |
+
# Se ja esta na resolucao correta, retornar
|
| 97 |
+
if current_w == target_w and current_h == target_h:
|
| 98 |
+
return frame
|
| 99 |
+
|
| 100 |
+
# Upscale usando LANCZOS4 (melhor qualidade para upscaling)
|
| 101 |
+
upscaled = cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_LANCZOS4)
|
| 102 |
+
return upscaled
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def match_histogram(source, reference):
|
| 106 |
+
"""
|
| 107 |
+
Ajusta o histograma da source para corresponder ao da reference.
|
| 108 |
+
Isso corrige diferencas de brilho/cor entre Wav2Lip e idle frames.
|
| 109 |
+
Usa o espaco de cor LAB para melhor correspondencia perceptual.
|
| 110 |
+
"""
|
| 111 |
+
# Converter para LAB (melhor para correspondencia de cor)
|
| 112 |
+
source_lab = cv2.cvtColor(source, cv2.COLOR_BGR2LAB).astype(np.float32)
|
| 113 |
+
reference_lab = cv2.cvtColor(reference, cv2.COLOR_BGR2LAB).astype(np.float32)
|
| 114 |
+
|
| 115 |
+
# Para cada canal, ajustar media e desvio padrao
|
| 116 |
+
for i in range(3):
|
| 117 |
+
src_mean, src_std = source_lab[:, :, i].mean(), source_lab[:, :, i].std()
|
| 118 |
+
ref_mean, ref_std = reference_lab[:, :, i].mean(), reference_lab[:, :, i].std()
|
| 119 |
+
|
| 120 |
+
# Evitar divisao por zero
|
| 121 |
+
if src_std < 1e-6:
|
| 122 |
+
src_std = 1e-6
|
| 123 |
+
|
| 124 |
+
# Normalizar e reescalar
|
| 125 |
+
source_lab[:, :, i] = (source_lab[:, :, i] - src_mean) * (ref_std / src_std) + ref_mean
|
| 126 |
+
|
| 127 |
+
# Clipar valores validos e converter de volta
|
| 128 |
+
source_lab = np.clip(source_lab, 0, 255).astype(np.uint8)
|
| 129 |
+
result = cv2.cvtColor(source_lab, cv2.COLOR_LAB2BGR)
|
| 130 |
+
|
| 131 |
+
return result
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def extract_mouth_region(frame, region=MOUTH_REGION):
|
| 135 |
+
"""
|
| 136 |
+
Extrai apenas a regiao da boca/queixo do frame.
|
| 137 |
+
Retorna (regiao_cortada, coordenadas) para posterior blending.
|
| 138 |
+
"""
|
| 139 |
+
h, w = frame.shape[:2]
|
| 140 |
+
|
| 141 |
+
y1 = int(h * region['top'])
|
| 142 |
+
y2 = int(h * region['bottom'])
|
| 143 |
+
x1 = int(w * region['left'])
|
| 144 |
+
x2 = int(w * region['right'])
|
| 145 |
+
|
| 146 |
+
mouth_crop = frame[y1:y2, x1:x2].copy()
|
| 147 |
+
return mouth_crop, (x1, y1, x2, y2)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def create_feathered_mask(shape, feather_pixels=15):
|
| 151 |
+
"""
|
| 152 |
+
Cria mascara com bordas suavizadas (feathered) para blending seamless.
|
| 153 |
+
Usa gradiente suave (ease-in-out) para transicao mais natural.
|
| 154 |
+
"""
|
| 155 |
+
h, w = shape[:2]
|
| 156 |
+
mask = np.ones((h, w), dtype=np.float32)
|
| 157 |
+
|
| 158 |
+
# Criar gradiente nas bordas usando curva suave (ease-in-out)
|
| 159 |
+
for i in range(feather_pixels):
|
| 160 |
+
# Curva suave: smoothstep para transicao mais natural
|
| 161 |
+
t = i / feather_pixels
|
| 162 |
+
alpha = t * t * (3 - 2 * t) # smoothstep
|
| 163 |
+
|
| 164 |
+
# Top
|
| 165 |
+
mask[i, :] = np.minimum(mask[i, :], alpha)
|
| 166 |
+
# Bottom
|
| 167 |
+
mask[h - 1 - i, :] = np.minimum(mask[h - 1 - i, :], alpha)
|
| 168 |
+
# Left
|
| 169 |
+
mask[:, i] = np.minimum(mask[:, i], alpha)
|
| 170 |
+
# Right
|
| 171 |
+
mask[:, w - 1 - i] = np.minimum(mask[:, w - 1 - i], alpha)
|
| 172 |
+
|
| 173 |
+
return mask
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def blend_mouth_region_only(wav2lip_frame, idle_frame):
|
| 177 |
+
"""
|
| 178 |
+
Nova estrategia: Manter idle em full resolution, substituir APENAS a boca.
|
| 179 |
+
|
| 180 |
+
1. Extrai regiao da boca do frame Wav2Lip (853x480)
|
| 181 |
+
2. Upscala APENAS essa regiao para a escala do idle (1920x1080)
|
| 182 |
+
3. Aplica Poisson Blending apenas na regiao da boca
|
| 183 |
+
4. Retorna o frame idle com apenas a boca substituida
|
| 184 |
+
|
| 185 |
+
Isso preserva toda a qualidade do idle (cabelo, fundo, roupa) e
|
| 186 |
+
so substitui a pequena regiao da boca.
|
| 187 |
+
"""
|
| 188 |
+
if wav2lip_frame is None or idle_frame is None:
|
| 189 |
+
return wav2lip_frame if wav2lip_frame is not None else idle_frame
|
| 190 |
+
|
| 191 |
+
# Dimensoes
|
| 192 |
+
idle_h, idle_w = idle_frame.shape[:2]
|
| 193 |
+
w2l_h, w2l_w = wav2lip_frame.shape[:2]
|
| 194 |
+
|
| 195 |
+
# Calcular escala entre frames
|
| 196 |
+
scale_x = idle_w / w2l_w
|
| 197 |
+
scale_y = idle_h / w2l_h
|
| 198 |
+
|
| 199 |
+
# 1. Extrair regiao da boca do Wav2Lip
|
| 200 |
+
mouth_crop, (x1_w2l, y1_w2l, x2_w2l, y2_w2l) = extract_mouth_region(wav2lip_frame)
|
| 201 |
+
|
| 202 |
+
# 2. Calcular coordenadas equivalentes no idle (full res)
|
| 203 |
+
x1_idle = int(x1_w2l * scale_x)
|
| 204 |
+
y1_idle = int(y1_w2l * scale_y)
|
| 205 |
+
x2_idle = int(x2_w2l * scale_x)
|
| 206 |
+
y2_idle = int(y2_w2l * scale_y)
|
| 207 |
+
|
| 208 |
+
# Dimensao da regiao no idle
|
| 209 |
+
region_w = x2_idle - x1_idle
|
| 210 |
+
region_h = y2_idle - y1_idle
|
| 211 |
+
|
| 212 |
+
# 3. Upscale apenas a regiao da boca para a resolucao do idle
|
| 213 |
+
mouth_upscaled = cv2.resize(mouth_crop, (region_w, region_h), interpolation=cv2.INTER_LANCZOS4)
|
| 214 |
+
|
| 215 |
+
# 3.5 Histogram matching: ajustar cor/brilho do mouth para corresponder ao idle
|
| 216 |
+
idle_region = idle_frame[y1_idle:y2_idle, x1_idle:x2_idle]
|
| 217 |
+
mouth_upscaled = match_histogram(mouth_upscaled, idle_region)
|
| 218 |
+
|
| 219 |
+
# 4. Criar mascara com bordas suavizadas
|
| 220 |
+
# Usar 25% da menor dimensao para feathering bem suave
|
| 221 |
+
feather = max(30, min(region_w, region_h) // 4) # ~25% da menor dimensao
|
| 222 |
+
mask = create_feathered_mask((region_h, region_w), feather_pixels=feather)
|
| 223 |
+
mask_3ch = np.dstack([mask, mask, mask])
|
| 224 |
+
|
| 225 |
+
# 5. Fazer copia do idle e aplicar blending na regiao
|
| 226 |
+
result = idle_frame.copy()
|
| 227 |
+
|
| 228 |
+
# Regiao do idle onde vai o mouth
|
| 229 |
+
idle_region = result[y1_idle:y2_idle, x1_idle:x2_idle]
|
| 230 |
+
|
| 231 |
+
# Blending com mascara feathered
|
| 232 |
+
blended_region = (mouth_upscaled * mask_3ch + idle_region * (1 - mask_3ch)).astype(np.uint8)
|
| 233 |
+
|
| 234 |
+
# Substituir regiao
|
| 235 |
+
result[y1_idle:y2_idle, x1_idle:x2_idle] = blended_region
|
| 236 |
+
|
| 237 |
+
return result
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def blend_with_poisson(wav2lip_frame, idle_frame):
|
| 241 |
+
"""
|
| 242 |
+
Estrategia alternativa: Poisson Blending apenas na regiao da boca.
|
| 243 |
+
Mais lento mas com transicao mais suave nos bordos.
|
| 244 |
+
"""
|
| 245 |
+
if wav2lip_frame is None or idle_frame is None:
|
| 246 |
+
return wav2lip_frame if wav2lip_frame is not None else idle_frame
|
| 247 |
+
|
| 248 |
+
idle_h, idle_w = idle_frame.shape[:2]
|
| 249 |
+
w2l_h, w2l_w = wav2lip_frame.shape[:2]
|
| 250 |
+
|
| 251 |
+
scale_x = idle_w / w2l_w
|
| 252 |
+
scale_y = idle_h / w2l_h
|
| 253 |
+
|
| 254 |
+
# Extrair e upscalar boca
|
| 255 |
+
mouth_crop, (x1_w2l, y1_w2l, x2_w2l, y2_w2l) = extract_mouth_region(wav2lip_frame)
|
| 256 |
+
|
| 257 |
+
x1_idle = int(x1_w2l * scale_x)
|
| 258 |
+
y1_idle = int(y1_w2l * scale_y)
|
| 259 |
+
x2_idle = int(x2_w2l * scale_x)
|
| 260 |
+
y2_idle = int(y2_w2l * scale_y)
|
| 261 |
+
|
| 262 |
+
region_w = x2_idle - x1_idle
|
| 263 |
+
region_h = y2_idle - y1_idle
|
| 264 |
+
|
| 265 |
+
mouth_upscaled = cv2.resize(mouth_crop, (region_w, region_h), interpolation=cv2.INTER_LANCZOS4)
|
| 266 |
+
|
| 267 |
+
# Criar imagem source do tamanho do idle (preta com boca no lugar certo)
|
| 268 |
+
source = np.zeros_like(idle_frame)
|
| 269 |
+
source[y1_idle:y2_idle, x1_idle:x2_idle] = mouth_upscaled
|
| 270 |
+
|
| 271 |
+
# Criar mascara eliptica para a regiao
|
| 272 |
+
mask = np.zeros((idle_h, idle_w), dtype=np.uint8)
|
| 273 |
+
center_x = (x1_idle + x2_idle) // 2
|
| 274 |
+
center_y = (y1_idle + y2_idle) // 2
|
| 275 |
+
axes_x = region_w // 2 - 10 # Um pouco menor para evitar bordas
|
| 276 |
+
axes_y = region_h // 2 - 10
|
| 277 |
+
cv2.ellipse(mask, (center_x, center_y), (axes_x, axes_y), 0, 0, 360, 255, -1)
|
| 278 |
+
|
| 279 |
+
try:
|
| 280 |
+
result = cv2.seamlessClone(
|
| 281 |
+
source,
|
| 282 |
+
idle_frame,
|
| 283 |
+
mask,
|
| 284 |
+
(center_x, center_y),
|
| 285 |
+
cv2.NORMAL_CLONE
|
| 286 |
+
)
|
| 287 |
+
return result
|
| 288 |
+
except Exception as e:
|
| 289 |
+
print(f"[Poisson] Erro: {e}, usando feathered blend")
|
| 290 |
+
return blend_mouth_region_only(wav2lip_frame, idle_frame)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def calculate_frame_difference(frame1, frame2):
|
| 294 |
+
"""
|
| 295 |
+
Calcula a diferenca entre dois frames.
|
| 296 |
+
Retorna um valor de 0-100 indicando quanta diferenca ha.
|
| 297 |
+
"""
|
| 298 |
+
if frame1 is None or frame2 is None:
|
| 299 |
+
return 0
|
| 300 |
+
|
| 301 |
+
# Converter para grayscale
|
| 302 |
+
gray1 = cv2.cvtColor(frame1, cv2.COLOR_BGR2GRAY)
|
| 303 |
+
gray2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2GRAY)
|
| 304 |
+
|
| 305 |
+
# Calcular diferenca absoluta
|
| 306 |
+
diff = cv2.absdiff(gray1, gray2)
|
| 307 |
+
|
| 308 |
+
# Valor medio da diferenca (0-255)
|
| 309 |
+
mean_diff = np.mean(diff)
|
| 310 |
+
|
| 311 |
+
# Normalizar para 0-100
|
| 312 |
+
return (mean_diff / 255.0) * 100
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def calculate_sharpness(frame):
|
| 316 |
+
"""
|
| 317 |
+
Calcula a nitidez de um frame usando variância do Laplaciano.
|
| 318 |
+
Quanto maior o valor, mais nítido o frame.
|
| 319 |
+
"""
|
| 320 |
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if len(frame.shape) == 3 else frame
|
| 321 |
+
laplacian = cv2.Laplacian(gray, cv2.CV_64F)
|
| 322 |
+
return laplacian.var()
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def find_best_matching_idle_frame(target_frame, idle_frames, sample_step=5, sharpness_weight=0.3):
|
| 326 |
+
"""
|
| 327 |
+
Encontra o frame do idle mais similar ao target_frame.
|
| 328 |
+
Considera tanto similaridade quanto nitidez para evitar frames desfocados.
|
| 329 |
+
|
| 330 |
+
Args:
|
| 331 |
+
target_frame: Frame para comparar (último frame da fala)
|
| 332 |
+
idle_frames: Lista de frames idle
|
| 333 |
+
sample_step: Passo de amostragem (5 = compara 1 a cada 5 frames)
|
| 334 |
+
sharpness_weight: Peso da nitidez no score (0-1)
|
| 335 |
+
|
| 336 |
+
Returns:
|
| 337 |
+
Índice do frame idle mais similar e nítido
|
| 338 |
+
"""
|
| 339 |
+
if not idle_frames or target_frame is None:
|
| 340 |
+
return 0, 0
|
| 341 |
+
|
| 342 |
+
# Converter target para grayscale uma vez
|
| 343 |
+
target_gray = cv2.cvtColor(target_frame, cv2.COLOR_BGR2GRAY)
|
| 344 |
+
|
| 345 |
+
# Primeira fase: encontrar os N melhores candidatos por similaridade
|
| 346 |
+
candidates = []
|
| 347 |
+
|
| 348 |
+
for i in range(0, len(idle_frames), sample_step):
|
| 349 |
+
idle_gray = cv2.cvtColor(idle_frames[i], cv2.COLOR_BGR2GRAY)
|
| 350 |
+
diff = np.mean(cv2.absdiff(target_gray, idle_gray))
|
| 351 |
+
candidates.append((i, diff))
|
| 352 |
+
|
| 353 |
+
# Ordenar por diferença (menor = mais similar)
|
| 354 |
+
candidates.sort(key=lambda x: x[1])
|
| 355 |
+
|
| 356 |
+
# Pegar os top 20 candidatos mais similares
|
| 357 |
+
top_candidates = candidates[:20]
|
| 358 |
+
|
| 359 |
+
# Segunda fase: refinar busca na vizinhança dos top candidatos
|
| 360 |
+
refined_candidates = []
|
| 361 |
+
|
| 362 |
+
for idx, _ in top_candidates:
|
| 363 |
+
start = max(0, idx - sample_step)
|
| 364 |
+
end = min(len(idle_frames), idx + sample_step + 1)
|
| 365 |
+
|
| 366 |
+
for i in range(start, end):
|
| 367 |
+
idle_frame = idle_frames[i]
|
| 368 |
+
idle_gray = cv2.cvtColor(idle_frame, cv2.COLOR_BGR2GRAY)
|
| 369 |
+
|
| 370 |
+
# Calcular diferença
|
| 371 |
+
diff = np.mean(cv2.absdiff(target_gray, idle_gray))
|
| 372 |
+
|
| 373 |
+
# Calcular nitidez
|
| 374 |
+
sharpness = calculate_sharpness(idle_frame)
|
| 375 |
+
|
| 376 |
+
refined_candidates.append((i, diff, sharpness))
|
| 377 |
+
|
| 378 |
+
if not refined_candidates:
|
| 379 |
+
return 0, 0
|
| 380 |
+
|
| 381 |
+
# Normalizar valores para scoring
|
| 382 |
+
diffs = [c[1] for c in refined_candidates]
|
| 383 |
+
sharpnesses = [c[2] for c in refined_candidates]
|
| 384 |
+
|
| 385 |
+
min_diff, max_diff = min(diffs), max(diffs)
|
| 386 |
+
min_sharp, max_sharp = min(sharpnesses), max(sharpnesses)
|
| 387 |
+
|
| 388 |
+
# Evitar divisão por zero
|
| 389 |
+
diff_range = max_diff - min_diff if max_diff > min_diff else 1
|
| 390 |
+
sharp_range = max_sharp - min_sharp if max_sharp > min_sharp else 1
|
| 391 |
+
|
| 392 |
+
# Calcular score combinado (menor = melhor)
|
| 393 |
+
# diff_score: 0 = mais similar, 1 = menos similar
|
| 394 |
+
# sharp_score: 0 = mais nítido, 1 = menos nítido (invertido)
|
| 395 |
+
best_idx = 0
|
| 396 |
+
best_score = float('inf')
|
| 397 |
+
best_diff = 0
|
| 398 |
+
|
| 399 |
+
for i, diff, sharpness in refined_candidates:
|
| 400 |
+
diff_score = (diff - min_diff) / diff_range
|
| 401 |
+
sharp_score = 1 - (sharpness - min_sharp) / sharp_range # Invertido: maior nitidez = menor score
|
| 402 |
+
|
| 403 |
+
# Score combinado
|
| 404 |
+
combined_score = (1 - sharpness_weight) * diff_score + sharpness_weight * sharp_score
|
| 405 |
+
|
| 406 |
+
if combined_score < best_score:
|
| 407 |
+
best_score = combined_score
|
| 408 |
+
best_idx = i
|
| 409 |
+
best_diff = diff
|
| 410 |
+
|
| 411 |
+
return best_idx, best_diff
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def trim_high_motion_frames(frames, threshold_multiplier=1.0, max_trim=20):
|
| 415 |
+
"""
|
| 416 |
+
Remove frames do final que tem movimento muito alto (saltos).
|
| 417 |
+
Isso elimina os frames problemáticos que causam "travamento".
|
| 418 |
+
|
| 419 |
+
Versão mais agressiva: usa threshold menor e remove mais frames.
|
| 420 |
+
|
| 421 |
+
Args:
|
| 422 |
+
frames: Lista de frames
|
| 423 |
+
threshold_multiplier: Multiplicador do threshold (media + multiplier * std)
|
| 424 |
+
max_trim: Maximo de frames a remover
|
| 425 |
+
|
| 426 |
+
Returns:
|
| 427 |
+
Lista de frames com os problematicos removidos
|
| 428 |
+
"""
|
| 429 |
+
if len(frames) < 20:
|
| 430 |
+
return frames
|
| 431 |
+
|
| 432 |
+
# Calcular diferenças entre frames consecutivos (últimos 20)
|
| 433 |
+
last_n = min(20, len(frames) - 1)
|
| 434 |
+
differences = []
|
| 435 |
+
for i in range(len(frames) - last_n, len(frames)):
|
| 436 |
+
if i > 0:
|
| 437 |
+
diff = calculate_frame_difference(frames[i-1], frames[i])
|
| 438 |
+
differences.append((i, diff))
|
| 439 |
+
|
| 440 |
+
if not differences:
|
| 441 |
+
return frames
|
| 442 |
+
|
| 443 |
+
# Calcular média e desvio padrão
|
| 444 |
+
diffs = [d[1] for d in differences]
|
| 445 |
+
mean_diff = np.mean(diffs)
|
| 446 |
+
std_diff = np.std(diffs)
|
| 447 |
+
|
| 448 |
+
# Threshold mais agressivo: média + 1.0*std (antes era 1.5)
|
| 449 |
+
threshold = mean_diff + threshold_multiplier * std_diff
|
| 450 |
+
|
| 451 |
+
# Threshold mínimo absoluto para evitar frames com muito movimento
|
| 452 |
+
min_threshold = 0.7 # Frames com diff > 0.7 são sempre problemáticos
|
| 453 |
+
if threshold > min_threshold:
|
| 454 |
+
threshold = min_threshold
|
| 455 |
+
|
| 456 |
+
# Encontrar onde começam os frames problemáticos (do fim para o início)
|
| 457 |
+
trim_from = len(frames)
|
| 458 |
+
frames_removed = 0
|
| 459 |
+
|
| 460 |
+
# Abordagem mais agressiva: remove todos os frames problemáticos do final
|
| 461 |
+
for i in range(len(differences) - 1, -1, -1):
|
| 462 |
+
idx, diff = differences[i]
|
| 463 |
+
if diff > threshold:
|
| 464 |
+
trim_from = idx
|
| 465 |
+
frames_removed += 1
|
| 466 |
+
if frames_removed >= max_trim:
|
| 467 |
+
break
|
| 468 |
+
else:
|
| 469 |
+
# Para no primeiro frame bom encontrado
|
| 470 |
+
break
|
| 471 |
+
|
| 472 |
+
# Calcular quantos frames remover
|
| 473 |
+
frames_to_trim = len(frames) - trim_from
|
| 474 |
+
|
| 475 |
+
if frames_to_trim > 0 and frames_to_trim <= max_trim:
|
| 476 |
+
print(f"[Trim] Removendo {frames_to_trim} frames problemáticos (threshold: {threshold:.2f}, mean: {mean_diff:.2f})")
|
| 477 |
+
return frames[:trim_from]
|
| 478 |
+
|
| 479 |
+
return frames
|
| 480 |
+
|
| 481 |
+
|
| 482 |
def blend_frames(frame1, frame2, alpha):
|
| 483 |
"""Blend entre dois frames. alpha=0 -> frame1, alpha=1 -> frame2"""
|
| 484 |
# Garantir que ambos frames tem o mesmo tamanho
|
|
|
|
| 545 |
audio_duration = 0
|
| 546 |
end_video_time_ms = 0
|
| 547 |
|
| 548 |
+
# Calcular posicao inicial no idle baseado no tempo
|
| 549 |
+
# idle_video_time_ms em ms, video @ 25fps = 40ms/frame
|
| 550 |
+
fps = 25
|
| 551 |
+
frame_duration_ms = 1000 / fps
|
| 552 |
+
start_idle_idx = int(idle_video_time_ms / frame_duration_ms) % idle_frame_count if idle_frame_count > 0 else 0
|
| 553 |
+
current_idle_idx = start_idle_idx
|
| 554 |
+
|
| 555 |
async for w2l_msg in wav2lip_ws:
|
| 556 |
if w2l_msg.type == aiohttp.WSMsgType.TEXT:
|
| 557 |
w2l_data = json.loads(w2l_msg.data)
|
|
|
|
| 564 |
frame_b64 = w2l_data.get("frame", "")
|
| 565 |
if frame_b64:
|
| 566 |
frame = jpeg_base64_to_frame(frame_b64)
|
| 567 |
+
|
| 568 |
+
# Pegar frame idle full-res correspondente para histogram matching
|
| 569 |
+
idle_ref = None
|
| 570 |
+
if idle_frames and idle_frame_count > 0:
|
| 571 |
+
idle_ref = idle_frames[current_idle_idx]
|
| 572 |
+
current_idle_idx = (current_idle_idx + 1) % idle_frame_count
|
| 573 |
+
|
| 574 |
+
# Upscale frame inteiro do Wav2Lip
|
| 575 |
+
frame = upscale_frame(frame, idle_resolution)
|
| 576 |
+
|
| 577 |
+
# Histogram matching para consistencia de cor
|
| 578 |
+
if idle_ref is not None:
|
| 579 |
+
frame = match_histogram(frame, idle_ref)
|
| 580 |
+
|
| 581 |
speaking_frames.append(frame)
|
| 582 |
|
| 583 |
elif msg_type == "full_audio":
|
|
|
|
| 600 |
|
| 601 |
# Enviar frames SEM crossfade - transicao e feita no cliente
|
| 602 |
if speaking_frames:
|
| 603 |
+
# 1. Primeiro, remover frames problemáticos do final (alto movimento)
|
| 604 |
+
original_count = len(speaking_frames)
|
| 605 |
+
speaking_frames = trim_high_motion_frames(speaking_frames)
|
| 606 |
+
if len(speaking_frames) < original_count:
|
| 607 |
+
print(f"[Motion Trim] {original_count} -> {len(speaking_frames)} frames")
|
| 608 |
+
|
| 609 |
+
# 2. Depois, trim para match audio duration (se ainda houver excesso)
|
| 610 |
+
fps = 25
|
| 611 |
+
if audio_duration > 0:
|
| 612 |
+
expected_frames = int(audio_duration / 1000 * fps)
|
| 613 |
+
if len(speaking_frames) > expected_frames:
|
| 614 |
+
trimmed = len(speaking_frames) - expected_frames
|
| 615 |
+
print(f"[Duration Trim] {trimmed} extra frames ({len(speaking_frames)} -> {expected_frames})")
|
| 616 |
+
speaking_frames = speaking_frames[:expected_frames]
|
| 617 |
+
|
| 618 |
+
# 3. Encontrar o frame idle mais similar ao último frame de fala
|
| 619 |
+
# Isso minimiza o "salto" visual na transição speak->idle
|
| 620 |
+
best_idle_idx = 0
|
| 621 |
+
if idle_frames and speaking_frames:
|
| 622 |
+
last_speak_frame = speaking_frames[-1]
|
| 623 |
+
best_idle_idx, best_diff = find_best_matching_idle_frame(
|
| 624 |
+
last_speak_frame, idle_frames, sample_step=10
|
| 625 |
+
)
|
| 626 |
+
# Converter índice para tempo em ms (25fps = 40ms/frame)
|
| 627 |
+
end_video_time_ms = int(best_idle_idx * 40)
|
| 628 |
+
print(f"[Best Match] Idle frame {best_idle_idx} (diff: {best_diff:.2f}) -> {end_video_time_ms}ms")
|
| 629 |
+
|
| 630 |
# Atualizar posicao do idle para continuidade apos fala
|
| 631 |
if idle_frames:
|
| 632 |
+
idle_position = best_idle_idx
|
| 633 |
|
| 634 |
# Enviar stream_start
|
| 635 |
ttfb = int((time.time() - start_time) * 1000)
|
| 636 |
await ws.send_json({"type": "stream_start", "ttfb_ms": ttfb})
|
| 637 |
|
| 638 |
# Enviar apenas os frames de fala (sem crossfade)
|
| 639 |
+
# Usar qualidade JPEG alta (95) para minimizar artefatos
|
| 640 |
for idx, frame in enumerate(speaking_frames):
|
| 641 |
+
frame_b64 = frame_to_jpeg_base64(frame, quality=95)
|
| 642 |
await ws.send_json({
|
| 643 |
"type": "frame",
|
| 644 |
"frame": frame_b64,
|
|
|
|
| 662 |
"end_video_time_ms": end_video_time_ms
|
| 663 |
})
|
| 664 |
|
| 665 |
+
print(f"Enviados {len(speaking_frames)} frames (Poisson Blending)")
|
| 666 |
|
| 667 |
except Exception as e:
|
| 668 |
print(f"Erro: {e}")
|
|
|
|
| 748 |
|
| 749 |
if __name__ == "__main__":
|
| 750 |
print("=" * 50)
|
| 751 |
+
print("Streaming Server - Porta", PORT)
|
| 752 |
print("Wav2Lip:", WAV2LIP_WS)
|
| 753 |
print("Idle Video:", IDLE_VIDEO)
|
|
|
|
| 754 |
print("=" * 50)
|
| 755 |
|
| 756 |
# Carregar idle frames
|
| 757 |
load_idle_frames()
|
| 758 |
|
| 759 |
+
print(f"Upscaling: ENABLED (target {idle_resolution[0]}x{idle_resolution[1]})")
|
| 760 |
+
print("Interpolacao: LANCZOS4 (alta qualidade)")
|
| 761 |
+
print("Color: HISTOGRAM MATCHING (LAB color space)")
|
| 762 |
print("=" * 50)
|
| 763 |
web.run_app(app, host="0.0.0.0", port=PORT)
|
interface/test_webrtc_client.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Cliente WebRTC automatizado para testar conexão com servidor
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import aiohttp
|
| 8 |
+
import sys
|
| 9 |
+
from aiortc import RTCPeerConnection, RTCConfiguration, RTCIceServer, RTCSessionDescription
|
| 10 |
+
from aiortc.contrib.media import MediaRecorder
|
| 11 |
+
import time
|
| 12 |
+
|
| 13 |
+
# Configuração
|
| 14 |
+
SERVER_URL = "http://62.107.25.198:47898"
|
| 15 |
+
|
| 16 |
+
# Cores para terminal
|
| 17 |
+
GREEN = '\033[92m'
|
| 18 |
+
RED = '\033[91m'
|
| 19 |
+
YELLOW = '\033[93m'
|
| 20 |
+
BLUE = '\033[94m'
|
| 21 |
+
RESET = '\033[0m'
|
| 22 |
+
|
| 23 |
+
class WebRTCTester:
|
| 24 |
+
def __init__(self):
|
| 25 |
+
self.pc = None
|
| 26 |
+
self.session_id = None
|
| 27 |
+
self.video_frames = 0
|
| 28 |
+
self.audio_frames = 0
|
| 29 |
+
self.ice_candidates = []
|
| 30 |
+
self.connected = False
|
| 31 |
+
|
| 32 |
+
async def test_connection(self):
|
| 33 |
+
"""Testa conexão WebRTC completa"""
|
| 34 |
+
|
| 35 |
+
print(f"\n{'='*60}")
|
| 36 |
+
print(f"{BLUE}WebRTC Connection Test{RESET}")
|
| 37 |
+
print(f"{'='*60}\n")
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
# 1. Configurar ICE servers (STUN + TURN)
|
| 41 |
+
print(f"{YELLOW}[1/5]{RESET} Configurando ICE servers...")
|
| 42 |
+
ice_servers = [
|
| 43 |
+
RTCIceServer(urls=["stun:stun.l.google.com:19302"]),
|
| 44 |
+
RTCIceServer(urls=["stun:stun1.l.google.com:19302"]),
|
| 45 |
+
RTCIceServer(
|
| 46 |
+
urls=["turn:openrelay.metered.ca:80"],
|
| 47 |
+
username="openrelayproject",
|
| 48 |
+
credential="openrelayproject"
|
| 49 |
+
),
|
| 50 |
+
RTCIceServer(
|
| 51 |
+
urls=["turn:openrelay.metered.ca:443"],
|
| 52 |
+
username="openrelayproject",
|
| 53 |
+
credential="openrelayproject"
|
| 54 |
+
),
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
config = RTCConfiguration(iceServers=ice_servers)
|
| 58 |
+
self.pc = RTCPeerConnection(configuration=config)
|
| 59 |
+
print(f"{GREEN}✓{RESET} ICE servers configurados\n")
|
| 60 |
+
|
| 61 |
+
# 2. Configurar handlers
|
| 62 |
+
print(f"{YELLOW}[2/5]{RESET} Configurando event handlers...")
|
| 63 |
+
|
| 64 |
+
@self.pc.on("icecandidate")
|
| 65 |
+
async def on_ice_candidate(candidate):
|
| 66 |
+
if candidate:
|
| 67 |
+
self.ice_candidates.append({
|
| 68 |
+
'type': candidate.candidate.type if hasattr(candidate.candidate, 'type') else 'unknown',
|
| 69 |
+
'protocol': candidate.candidate.protocol if hasattr(candidate.candidate, 'protocol') else 'unknown'
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
@self.pc.on("connectionstatechange")
|
| 73 |
+
async def on_connection_state():
|
| 74 |
+
state = self.pc.connectionState
|
| 75 |
+
if state == "connected":
|
| 76 |
+
print(f"{GREEN}✓{RESET} WebRTC Estado: {GREEN}CONNECTED{RESET}")
|
| 77 |
+
self.connected = True
|
| 78 |
+
elif state == "failed":
|
| 79 |
+
print(f"{RED}✗{RESET} WebRTC Estado: {RED}FAILED{RESET}")
|
| 80 |
+
elif state == "connecting":
|
| 81 |
+
print(f"{YELLOW}⟳{RESET} WebRTC Estado: CONNECTING...")
|
| 82 |
+
else:
|
| 83 |
+
print(f" WebRTC Estado: {state}")
|
| 84 |
+
|
| 85 |
+
@self.pc.on("iceconnectionstatechange")
|
| 86 |
+
async def on_ice_state():
|
| 87 |
+
state = self.pc.iceConnectionState
|
| 88 |
+
if state == "connected":
|
| 89 |
+
print(f"{GREEN}✓{RESET} ICE Estado: {GREEN}CONNECTED{RESET}")
|
| 90 |
+
elif state == "failed":
|
| 91 |
+
print(f"{RED}✗{RESET} ICE Estado: {RED}FAILED{RESET}")
|
| 92 |
+
elif state == "checking":
|
| 93 |
+
print(f"{YELLOW}⟳{RESET} ICE Estado: CHECKING...")
|
| 94 |
+
else:
|
| 95 |
+
print(f" ICE Estado: {state}")
|
| 96 |
+
|
| 97 |
+
@self.pc.on("track")
|
| 98 |
+
async def on_track(track):
|
| 99 |
+
print(f"{GREEN}✓{RESET} Track recebido: {BLUE}{track.kind}{RESET}")
|
| 100 |
+
|
| 101 |
+
if track.kind == "video":
|
| 102 |
+
while True:
|
| 103 |
+
try:
|
| 104 |
+
frame = await track.recv()
|
| 105 |
+
self.video_frames += 1
|
| 106 |
+
if self.video_frames % 25 == 0: # A cada segundo (25fps)
|
| 107 |
+
print(f" {BLUE}Video:{RESET} {self.video_frames} frames recebidos")
|
| 108 |
+
except Exception as e:
|
| 109 |
+
break
|
| 110 |
+
|
| 111 |
+
elif track.kind == "audio":
|
| 112 |
+
while True:
|
| 113 |
+
try:
|
| 114 |
+
frame = await track.recv()
|
| 115 |
+
self.audio_frames += 1
|
| 116 |
+
if self.audio_frames % 50 == 0: # A cada ~1 segundo
|
| 117 |
+
print(f" {BLUE}Audio:{RESET} {self.audio_frames} frames recebidos")
|
| 118 |
+
except Exception as e:
|
| 119 |
+
break
|
| 120 |
+
|
| 121 |
+
print(f"{GREEN}✓{RESET} Handlers configurados\n")
|
| 122 |
+
|
| 123 |
+
# 3. Criar transceivers
|
| 124 |
+
print(f"{YELLOW}[3/5]{RESET} Criando transceivers...")
|
| 125 |
+
self.pc.addTransceiver("video", direction="recvonly")
|
| 126 |
+
self.pc.addTransceiver("audio", direction="recvonly")
|
| 127 |
+
print(f"{GREEN}✓{RESET} Transceivers criados\n")
|
| 128 |
+
|
| 129 |
+
# 4. Criar offer e enviar para servidor
|
| 130 |
+
print(f"{YELLOW}[4/5]{RESET} Criando offer SDP...")
|
| 131 |
+
offer = await self.pc.createOffer()
|
| 132 |
+
await self.pc.setLocalDescription(offer)
|
| 133 |
+
|
| 134 |
+
# Aguardar ICE gathering
|
| 135 |
+
print(f"{YELLOW}⟳{RESET} Aguardando ICE gathering...")
|
| 136 |
+
await self.wait_for_ice_gathering()
|
| 137 |
+
|
| 138 |
+
print(f"{GREEN}✓{RESET} Offer criado\n")
|
| 139 |
+
|
| 140 |
+
# 5. Enviar offer para servidor
|
| 141 |
+
print(f"{YELLOW}[5/5]{RESET} Enviando offer para servidor...")
|
| 142 |
+
async with aiohttp.ClientSession() as session:
|
| 143 |
+
async with session.post(
|
| 144 |
+
f"{SERVER_URL}/offer",
|
| 145 |
+
json={
|
| 146 |
+
"sdp": self.pc.localDescription.sdp,
|
| 147 |
+
"type": self.pc.localDescription.type
|
| 148 |
+
}
|
| 149 |
+
) as resp:
|
| 150 |
+
if resp.status != 200:
|
| 151 |
+
raise Exception(f"Erro HTTP {resp.status}")
|
| 152 |
+
|
| 153 |
+
answer = await resp.json()
|
| 154 |
+
self.session_id = answer.get('session_id')
|
| 155 |
+
|
| 156 |
+
# Aplicar answer
|
| 157 |
+
await self.pc.setRemoteDescription(
|
| 158 |
+
RTCSessionDescription(
|
| 159 |
+
sdp=answer["sdp"],
|
| 160 |
+
type=answer["type"]
|
| 161 |
+
)
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
print(f"{GREEN}✓{RESET} Answer recebido")
|
| 165 |
+
print(f"{GREEN}✓{RESET} Session ID: {BLUE}{self.session_id}{RESET}\n")
|
| 166 |
+
|
| 167 |
+
# 6. Aguardar conexão
|
| 168 |
+
print(f"{YELLOW}⟳{RESET} Aguardando conexão WebRTC...\n")
|
| 169 |
+
|
| 170 |
+
# Aguardar até 15 segundos
|
| 171 |
+
for i in range(15):
|
| 172 |
+
await asyncio.sleep(1)
|
| 173 |
+
if self.connected:
|
| 174 |
+
break
|
| 175 |
+
|
| 176 |
+
# 7. Mostrar resultados
|
| 177 |
+
await self.show_results()
|
| 178 |
+
|
| 179 |
+
# 8. Manter vivo por 10 segundos para receber frames
|
| 180 |
+
if self.connected:
|
| 181 |
+
print(f"\n{YELLOW}⟳{RESET} Monitorando recepção de frames por 10 segundos...\n")
|
| 182 |
+
await asyncio.sleep(10)
|
| 183 |
+
|
| 184 |
+
# 9. Resultados finais
|
| 185 |
+
await self.show_final_results()
|
| 186 |
+
|
| 187 |
+
except Exception as e:
|
| 188 |
+
print(f"\n{RED}✗ ERRO:{RESET} {e}\n")
|
| 189 |
+
import traceback
|
| 190 |
+
traceback.print_exc()
|
| 191 |
+
return False
|
| 192 |
+
|
| 193 |
+
finally:
|
| 194 |
+
if self.pc:
|
| 195 |
+
await self.pc.close()
|
| 196 |
+
|
| 197 |
+
return self.connected
|
| 198 |
+
|
| 199 |
+
async def wait_for_ice_gathering(self):
|
| 200 |
+
"""Aguarda ICE gathering completar"""
|
| 201 |
+
max_wait = 5
|
| 202 |
+
for _ in range(max_wait * 10):
|
| 203 |
+
if self.pc.iceGatheringState == "complete":
|
| 204 |
+
return
|
| 205 |
+
await asyncio.sleep(0.1)
|
| 206 |
+
|
| 207 |
+
async def show_results(self):
|
| 208 |
+
"""Mostra resultados da conexão"""
|
| 209 |
+
print(f"\n{'='*60}")
|
| 210 |
+
print(f"{BLUE}Resultados da Conexão{RESET}")
|
| 211 |
+
print(f"{'='*60}\n")
|
| 212 |
+
|
| 213 |
+
# Estado da conexão
|
| 214 |
+
conn_state = self.pc.connectionState
|
| 215 |
+
if conn_state == "connected":
|
| 216 |
+
print(f"WebRTC State: {GREEN}✓ CONNECTED{RESET}")
|
| 217 |
+
elif conn_state == "failed":
|
| 218 |
+
print(f"WebRTC State: {RED}✗ FAILED{RESET}")
|
| 219 |
+
else:
|
| 220 |
+
print(f"WebRTC State: {YELLOW}{conn_state.upper()}{RESET}")
|
| 221 |
+
|
| 222 |
+
# Estado ICE
|
| 223 |
+
ice_state = self.pc.iceConnectionState
|
| 224 |
+
if ice_state == "connected":
|
| 225 |
+
print(f"ICE State: {GREEN}✓ CONNECTED{RESET}")
|
| 226 |
+
elif ice_state == "failed":
|
| 227 |
+
print(f"ICE State: {RED}✗ FAILED{RESET}")
|
| 228 |
+
else:
|
| 229 |
+
print(f"ICE State: {YELLOW}{ice_state.upper()}{RESET}")
|
| 230 |
+
|
| 231 |
+
# Candidatos ICE
|
| 232 |
+
print(f"\nICE Candidates: {len(self.ice_candidates)} gerados")
|
| 233 |
+
|
| 234 |
+
candidate_types = {}
|
| 235 |
+
for c in self.ice_candidates:
|
| 236 |
+
ctype = c['type']
|
| 237 |
+
candidate_types[ctype] = candidate_types.get(ctype, 0) + 1
|
| 238 |
+
|
| 239 |
+
for ctype, count in candidate_types.items():
|
| 240 |
+
icon = "✓" if ctype == "relay" else "•"
|
| 241 |
+
color = GREEN if ctype == "relay" else BLUE
|
| 242 |
+
print(f" {color}{icon}{RESET} {ctype}: {count}")
|
| 243 |
+
|
| 244 |
+
# Session ID
|
| 245 |
+
if self.session_id:
|
| 246 |
+
print(f"\nSession ID: {BLUE}{self.session_id}{RESET}")
|
| 247 |
+
|
| 248 |
+
print()
|
| 249 |
+
|
| 250 |
+
async def show_final_results(self):
|
| 251 |
+
"""Mostra resultados finais"""
|
| 252 |
+
print(f"\n{'='*60}")
|
| 253 |
+
print(f"{BLUE}Resultados Finais{RESET}")
|
| 254 |
+
print(f"{'='*60}\n")
|
| 255 |
+
|
| 256 |
+
if self.connected:
|
| 257 |
+
print(f"Status: {GREEN}✓ SUCESSO{RESET}")
|
| 258 |
+
else:
|
| 259 |
+
print(f"Status: {RED}✗ FALHA{RESET}")
|
| 260 |
+
|
| 261 |
+
print(f"Video Frames: {BLUE}{self.video_frames}{RESET}")
|
| 262 |
+
print(f"Audio Frames: {BLUE}{self.audio_frames}{RESET}")
|
| 263 |
+
|
| 264 |
+
if self.video_frames > 0 and self.audio_frames > 0:
|
| 265 |
+
print(f"\n{GREEN}✓ WebRTC funcionando perfeitamente!{RESET}")
|
| 266 |
+
elif self.connected:
|
| 267 |
+
print(f"\n{YELLOW}⚠ Conectado mas sem receber frames{RESET}")
|
| 268 |
+
else:
|
| 269 |
+
print(f"\n{RED}✗ Falha na conexão WebRTC{RESET}")
|
| 270 |
+
|
| 271 |
+
print()
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
async def main():
|
| 275 |
+
tester = WebRTCTester()
|
| 276 |
+
success = await tester.test_connection()
|
| 277 |
+
sys.exit(0 if success else 1)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
if __name__ == "__main__":
|
| 281 |
+
asyncio.run(main())
|
interface/test_webrtc_playwright.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Teste WebRTC automatizado com Playwright headless
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
from playwright.async_api import async_playwright
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
SERVER_URL = "http://62.107.25.198:47898"
|
| 11 |
+
|
| 12 |
+
async def test_webrtc():
|
| 13 |
+
print("="*60)
|
| 14 |
+
print("Teste WebRTC com Playwright (Headless)")
|
| 15 |
+
print("="*60)
|
| 16 |
+
|
| 17 |
+
async with async_playwright() as p:
|
| 18 |
+
# Iniciar browser headless
|
| 19 |
+
print("\n[1/6] Iniciando browser headless...")
|
| 20 |
+
browser = await p.chromium.launch(
|
| 21 |
+
headless=True,
|
| 22 |
+
args=[
|
| 23 |
+
'--use-fake-ui-for-media-stream',
|
| 24 |
+
'--use-fake-device-for-media-stream',
|
| 25 |
+
'--no-sandbox'
|
| 26 |
+
]
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
context = await browser.new_context(
|
| 30 |
+
permissions=['camera', 'microphone']
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
page = await context.new_page()
|
| 34 |
+
|
| 35 |
+
# Coletar logs do console e erros
|
| 36 |
+
console_logs = []
|
| 37 |
+
page_errors = []
|
| 38 |
+
|
| 39 |
+
page.on("console", lambda msg: console_logs.append(f"[{msg.type}] {msg.text}"))
|
| 40 |
+
page.on("pageerror", lambda exc: page_errors.append(f"ERROR: {exc}"))
|
| 41 |
+
|
| 42 |
+
# Capturar requests/responses
|
| 43 |
+
network_logs = []
|
| 44 |
+
|
| 45 |
+
def handle_response(response):
|
| 46 |
+
if '/offer' in response.url:
|
| 47 |
+
network_logs.append(f"POST /offer → Status: {response.status}")
|
| 48 |
+
if response.status != 200:
|
| 49 |
+
network_logs.append(f" Error: {response.status_text}")
|
| 50 |
+
|
| 51 |
+
page.on("response", handle_response)
|
| 52 |
+
|
| 53 |
+
# Navegar para a página
|
| 54 |
+
print(f"[2/6] Navegando para {SERVER_URL}...")
|
| 55 |
+
try:
|
| 56 |
+
await page.goto(SERVER_URL, timeout=10000)
|
| 57 |
+
print("✓ Página carregada")
|
| 58 |
+
except Exception as e:
|
| 59 |
+
print(f"✗ Erro ao carregar página: {e}")
|
| 60 |
+
await browser.close()
|
| 61 |
+
return False
|
| 62 |
+
|
| 63 |
+
# Verificar se a página carregou
|
| 64 |
+
print("[3/6] Verificando elementos...")
|
| 65 |
+
try:
|
| 66 |
+
await page.wait_for_selector('#btnConnect', timeout=5000)
|
| 67 |
+
print("✓ Botão 'Conectar' encontrado")
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"✗ Erro: {e}")
|
| 70 |
+
await browser.close()
|
| 71 |
+
return False
|
| 72 |
+
|
| 73 |
+
# Injetar código para capturar eventos ICE
|
| 74 |
+
print("[4/6] Configurando captura de eventos ICE...")
|
| 75 |
+
await page.evaluate("""
|
| 76 |
+
window.iceInfo = {
|
| 77 |
+
candidates: [],
|
| 78 |
+
states: [],
|
| 79 |
+
connectionStates: []
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
// Interceptar console.log de ICE candidates
|
| 83 |
+
const originalLog = console.log;
|
| 84 |
+
console.log = function(...args) {
|
| 85 |
+
if (args[0] === 'ICE Candidate:') {
|
| 86 |
+
window.iceInfo.candidates.push(args[1]);
|
| 87 |
+
} else if (args[0] === 'ICE State:') {
|
| 88 |
+
window.iceInfo.states.push(args[1]);
|
| 89 |
+
} else if (args[0] === 'Estado WebRTC:') {
|
| 90 |
+
window.iceInfo.connectionStates.push(args[1]);
|
| 91 |
+
}
|
| 92 |
+
originalLog.apply(console, args);
|
| 93 |
+
};
|
| 94 |
+
""")
|
| 95 |
+
|
| 96 |
+
# Clicar em conectar
|
| 97 |
+
print("[5/6] Clicando em 'Conectar'...")
|
| 98 |
+
await page.click('#btnConnect')
|
| 99 |
+
|
| 100 |
+
# Aguardar tentativa de conexão
|
| 101 |
+
print("[6/6] Aguardando conexão (15s)...")
|
| 102 |
+
await asyncio.sleep(15)
|
| 103 |
+
|
| 104 |
+
# Capturar informações ICE
|
| 105 |
+
ice_info = await page.evaluate("window.iceInfo")
|
| 106 |
+
|
| 107 |
+
# Capturar estado da conexão
|
| 108 |
+
webrtc_state = await page.evaluate("""
|
| 109 |
+
(() => {
|
| 110 |
+
const pc = window.pc;
|
| 111 |
+
if (!pc) return null;
|
| 112 |
+
return {
|
| 113 |
+
connectionState: pc.connectionState,
|
| 114 |
+
iceConnectionState: pc.iceConnectionState,
|
| 115 |
+
iceGatheringState: pc.iceGatheringState,
|
| 116 |
+
signalingState: pc.signalingState
|
| 117 |
+
};
|
| 118 |
+
})()
|
| 119 |
+
""")
|
| 120 |
+
|
| 121 |
+
# Resultados
|
| 122 |
+
print("\n" + "="*60)
|
| 123 |
+
print("RESULTADOS")
|
| 124 |
+
print("="*60)
|
| 125 |
+
|
| 126 |
+
if webrtc_state:
|
| 127 |
+
print(f"\nEstados WebRTC:")
|
| 128 |
+
print(f" Connection: {webrtc_state['connectionState']}")
|
| 129 |
+
print(f" ICE Connection: {webrtc_state['iceConnectionState']}")
|
| 130 |
+
print(f" ICE Gathering: {webrtc_state['iceGatheringState']}")
|
| 131 |
+
print(f" Signaling: {webrtc_state['signalingState']}")
|
| 132 |
+
else:
|
| 133 |
+
print("\n✗ Peer connection não foi criada!")
|
| 134 |
+
|
| 135 |
+
# Análise de candidatos ICE
|
| 136 |
+
candidates = ice_info.get('candidates', [])
|
| 137 |
+
print(f"\nCandidatos ICE: {len(candidates)}")
|
| 138 |
+
|
| 139 |
+
candidate_types = {}
|
| 140 |
+
for c in candidates:
|
| 141 |
+
ctype = c.get('type', 'unknown')
|
| 142 |
+
candidate_types[ctype] = candidate_types.get(ctype, 0) + 1
|
| 143 |
+
|
| 144 |
+
for ctype, count in candidate_types.items():
|
| 145 |
+
icon = "✓" if ctype == "relay" else "•"
|
| 146 |
+
print(f" {icon} {ctype}: {count}")
|
| 147 |
+
|
| 148 |
+
# Diagnóstico
|
| 149 |
+
print("\n" + "="*60)
|
| 150 |
+
print("DIAGNÓSTICO")
|
| 151 |
+
print("="*60)
|
| 152 |
+
|
| 153 |
+
success = False
|
| 154 |
+
|
| 155 |
+
if not webrtc_state:
|
| 156 |
+
print("\n✗ FALHA: Peer connection não foi criada")
|
| 157 |
+
print(" → Verificar JavaScript do frontend")
|
| 158 |
+
|
| 159 |
+
elif webrtc_state['connectionState'] == 'connected':
|
| 160 |
+
print("\n✓ SUCESSO: WebRTC conectado!")
|
| 161 |
+
success = True
|
| 162 |
+
|
| 163 |
+
elif webrtc_state['iceConnectionState'] == 'failed':
|
| 164 |
+
print("\n✗ FALHA: ICE connection failed")
|
| 165 |
+
|
| 166 |
+
if len(candidates) == 0:
|
| 167 |
+
print(" → Problema: Nenhum candidato ICE gerado")
|
| 168 |
+
print(" → Solução: Verificar configuração STUN/TURN")
|
| 169 |
+
|
| 170 |
+
elif 'relay' not in candidate_types:
|
| 171 |
+
print(" → Problema: Nenhum candidato TURN (relay)")
|
| 172 |
+
print(" → Solução: Servidores TURN não estão funcionando")
|
| 173 |
+
print(" → Tentar outros servidores TURN")
|
| 174 |
+
|
| 175 |
+
else:
|
| 176 |
+
print(" → Problema: Candidatos gerados mas não conectam")
|
| 177 |
+
print(" → Solução: Problema de firewall/NAT no servidor")
|
| 178 |
+
print(" → Verificar port mapping UDP no vast.ai")
|
| 179 |
+
|
| 180 |
+
elif webrtc_state['connectionState'] == 'connecting':
|
| 181 |
+
print("\n⚠ TIMEOUT: Conexão travada em 'connecting'")
|
| 182 |
+
print(" → Problema: ICE negotiation timeout")
|
| 183 |
+
print(" → Causa provável: Port mapping UDP não configurado")
|
| 184 |
+
print(" → Solução: Expor portas UDP no vast.ai ou usar ngrok")
|
| 185 |
+
|
| 186 |
+
# Erros de página
|
| 187 |
+
if page_errors:
|
| 188 |
+
print("\n" + "="*60)
|
| 189 |
+
print("ERROS JAVASCRIPT")
|
| 190 |
+
print("="*60)
|
| 191 |
+
for err in page_errors:
|
| 192 |
+
print(err)
|
| 193 |
+
|
| 194 |
+
# Logs de rede
|
| 195 |
+
if network_logs:
|
| 196 |
+
print("\n" + "="*60)
|
| 197 |
+
print("LOGS DE REDE")
|
| 198 |
+
print("="*60)
|
| 199 |
+
for log in network_logs:
|
| 200 |
+
print(log)
|
| 201 |
+
|
| 202 |
+
# Logs do console
|
| 203 |
+
if console_logs:
|
| 204 |
+
print("\n" + "="*60)
|
| 205 |
+
print("LOGS DO CONSOLE (últimos 20)")
|
| 206 |
+
print("="*60)
|
| 207 |
+
for log in console_logs[-20:]:
|
| 208 |
+
print(log)
|
| 209 |
+
|
| 210 |
+
await browser.close()
|
| 211 |
+
return success
|
| 212 |
+
|
| 213 |
+
async def main():
|
| 214 |
+
success = await test_webrtc()
|
| 215 |
+
sys.exit(0 if success else 1)
|
| 216 |
+
|
| 217 |
+
if __name__ == "__main__":
|
| 218 |
+
asyncio.run(main())
|
interface/webrtc_skypilot.yaml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: webrtc-avatar
|
| 2 |
+
|
| 3 |
+
resources:
|
| 4 |
+
cloud: vast
|
| 5 |
+
accelerators: RTX_5090
|
| 6 |
+
disk_size: 200
|
| 7 |
+
ports:
|
| 8 |
+
- 8080 # HTTP + WebRTC
|
| 9 |
+
|
| 10 |
+
workdir: .
|
| 11 |
+
|
| 12 |
+
setup: |
|
| 13 |
+
set -e
|
| 14 |
+
sudo apt-get update -qq
|
| 15 |
+
sudo apt-get install -y ffmpeg libavdevice-dev libavfilter-dev libopus-dev libvpx-dev libsrtp2-dev
|
| 16 |
+
pip install aiohttp aiortc opencv-python numpy av websockets -q
|
| 17 |
+
|
| 18 |
+
run: |
|
| 19 |
+
python3 server.py
|