marcosremar2's picture
Add WebRTC streaming interface with vast.ai deployment
e62aafd
Raw
History Blame Contribute Delete
13.2 kB
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Avatar - WebRTC</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, sans-serif;
background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3a 100%);
color: #fff;
min-height: 100vh;
padding: 20px;
}
.container { max-width: 900px; margin: 0 auto; }
.status {
text-align: center;
padding: 10px;
margin-bottom: 15px;
border-radius: 8px;
font-size: 14px;
background: rgba(255,255,255,0.1);
}
.status.connected { background: rgba(0,255,100,0.2); color: #0f0; }
.status.busy { background: rgba(255,200,0,0.2); color: #fc0; }
.status.error { background: rgba(255,0,0,0.2); color: #f55; }
.video-box {
background: #000;
border-radius: 10px;
overflow: hidden;
margin-bottom: 20px;
aspect-ratio: 16/9;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.placeholder {
color: #666;
font-size: 14px;
position: absolute;
}
.controls {
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.call-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
textarea {
flex: 1;
min-width: 200px;
padding: 12px;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a2e;
color: #fff;
font-size: 14px;
resize: none;
height: 60px;
}
select {
padding: 12px;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a2e;
color: #fff;
font-size: 14px;
}
button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: opacity 0.2s;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-call { background: #00aaff; color: #fff; }
.btn-call.active { background: #ff4444; }
.btn-generate { background: #00ff88; color: #000; flex: 0 0 auto; }
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
padding: 12px;
background: #1a1a2e;
border-radius: 8px;
font-size: 12px;
}
.metric { display: flex; justify-content: space-between; }
.val { color: #00ff88; font-family: monospace; }
</style>
</head>
<body>
<div class="container">
<div class="status" id="status">Desconectado</div>
<div class="video-box">
<video id="video" autoplay playsinline></video>
<div class="placeholder" id="placeholder">Clique em "Conectar" para iniciar</div>
</div>
<div class="call-controls">
<button class="btn-call" id="btnConnect">Conectar</button>
</div>
<div class="controls">
<textarea id="text" placeholder="Digite o texto para o avatar falar...">Hello! I am testing the WebRTC streaming avatar with VP9 codec.</textarea>
<select id="voice">
<option value="tara">Tara</option>
<option value="leah">Leah</option>
<option value="jess">Jess</option>
<option value="leo">Leo</option>
<option value="dan">Dan</option>
</select>
<button class="btn-generate" id="btnGenerate" disabled>Gerar</button>
</div>
<div class="metrics">
<div class="metric"><span>WebRTC:</span><span class="val" id="mWebrtc">--</span></div>
<div class="metric"><span>Video:</span><span class="val" id="mVideo">--</span></div>
<div class="metric"><span>Audio:</span><span class="val" id="mAudio">--</span></div>
<div class="metric"><span>Latencia:</span><span class="val" id="mLatency">--</span></div>
</div>
</div>
<script>
const video = document.getElementById('video');
const status = document.getElementById('status');
const placeholder = document.getElementById('placeholder');
const btnConnect = document.getElementById('btnConnect');
const btnGenerate = document.getElementById('btnGenerate');
// Global variables (para debug)
window.pc = null;
let pc = null;
let sessionId = null;
let isConnected = false;
let isConnecting = false; // Flag para prevenir múltiplas chamadas simultâneas
function setStatus(txt, cls) {
status.textContent = txt;
status.className = 'status ' + (cls || '');
}
function setMetric(id, val) {
document.getElementById(id).textContent = val;
}
async function connect() {
if (isConnected) {
disconnect();
return;
}
// Prevenir múltiplas chamadas simultâneas
if (isConnecting) {
console.log('Conexão já em andamento, ignorando...');
return;
}
isConnecting = true;
setStatus('Conectando...', 'busy');
try {
// Criar RTCPeerConnection com STUN + TURN múltiplos
pc = window.pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
// Servidores TURN públicos - múltiplas opções
{
urls: [
'turn:openrelay.metered.ca:80',
'turn:openrelay.metered.ca:443',
'turn:openrelay.metered.ca:443?transport=tcp'
],
username: 'openrelayproject',
credential: 'openrelayproject'
},
// Servidor TURN alternativo (Twilio)
{
urls: 'turn:global.turn.twilio.com:3478?transport=udp',
username: 'f4b4035eaa76f4a55de5f4351567653ee4ff6fa97b50b6b334fcc1be9c27212d',
credential: 'w1uxM55V9yVoqyVFjt+mxDBV0F87AUCemaYVQGxsPLw='
}
],
iceCandidatePoolSize: 10,
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
});
// Handler para tracks recebidos
pc.ontrack = (event) => {
console.log('Track recebido:', event.track.kind);
if (event.track.kind === 'video') {
video.srcObject = event.streams[0];
placeholder.style.display = 'none';
setMetric('mVideo', 'Ativo');
}
if (event.track.kind === 'audio') {
setMetric('mAudio', 'Ativo');
}
};
// Handler para mudancas de estado
pc.onconnectionstatechange = () => {
console.log('Estado WebRTC:', pc.connectionState);
setMetric('mWebrtc', pc.connectionState);
if (pc.connectionState === 'connected') {
isConnected = true;
isConnecting = false; // Reset flag quando conectado
updateConnectButton();
setStatus('Conectado - Streaming ativo', 'connected');
btnGenerate.disabled = false;
startStatsMonitor();
} else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
isConnecting = false; // Reset flag em caso de falha
disconnect();
}
};
pc.oniceconnectionstatechange = () => {
console.log('ICE State:', pc.iceConnectionState);
};
// Debug: Log ICE candidates
pc.onicecandidate = (event) => {
if (event.candidate) {
console.log('ICE Candidate:', {
type: event.candidate.type,
protocol: event.candidate.protocol,
address: event.candidate.address,
port: event.candidate.port
});
}
};
// Criar transceiver para receber video e audio
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
// Criar offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Aguardar alguns candidatos ICE (mas não esperar complete)
// Trickle ICE: enviamos o offer logo e candidatos vão depois
await new Promise(resolve => setTimeout(resolve, 500));
console.log('Enviando offer para servidor...');
// Enviar offer para o servidor
const response = await fetch('/offer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sdp: pc.localDescription.sdp,
type: pc.localDescription.type
})
});
if (!response.ok) {
throw new Error('Erro ao conectar: ' + response.status);
}
const answer = await response.json();
sessionId = answer.session_id;
// Aplicar answer
await pc.setRemoteDescription(new RTCSessionDescription({
sdp: answer.sdp,
type: answer.type
}));
console.log('Session ID:', sessionId);
} catch (err) {
console.error('Erro ao conectar:', err);
isConnecting = false; // Reset flag em caso de erro
setStatus('Erro: ' + err.message, 'error');
disconnect();
}
}
function disconnect() {
if (pc) {
pc.close();
pc = null;
}
isConnected = false;
sessionId = null;
video.srcObject = null;
placeholder.style.display = 'block';
updateConnectButton();
btnGenerate.disabled = true;
setStatus('Desconectado');
setMetric('mWebrtc', '--');
setMetric('mVideo', '--');
setMetric('mAudio', '--');
setMetric('mLatency', '--');
}
function updateConnectButton() {
if (isConnected) {
btnConnect.textContent = 'Desconectar';
btnConnect.classList.add('active');
} else {
btnConnect.textContent = 'Conectar';
btnConnect.classList.remove('active');
}
}
async function generate() {
const text = document.getElementById('text').value.trim();
if (!text || !sessionId) return;
setStatus('Gerando fala...', 'busy');
btnGenerate.disabled = true;
try {
const response = await fetch('/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
text: text,
voice: document.getElementById('voice').value
})
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Erro ao gerar');
}
setStatus('Reproduzindo...', 'connected');
} catch (err) {
console.error('Erro ao gerar:', err);
setStatus('Erro: ' + err.message, 'error');
} finally {
btnGenerate.disabled = false;
}
}
function startStatsMonitor() {
setInterval(async () => {
if (!pc || !isConnected) return;
try {
const stats = await pc.getStats();
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
const fps = report.framesPerSecond || 0;
const width = report.frameWidth || 0;
const height = report.frameHeight || 0;
if (width && height) {
setMetric('mVideo', `${width}x${height} @${fps.toFixed(0)}fps`);
}
}
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
const rtt = report.currentRoundTripTime;
if (rtt) {
setMetric('mLatency', `${(rtt * 1000).toFixed(0)}ms`);
}
}
});
} catch (e) {
// Ignorar erros de stats
}
}, 1000);
}
// Event listeners
btnConnect.onclick = connect;
btnGenerate.onclick = generate;
// Atalho Enter no textarea
document.getElementById('text').onkeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!btnGenerate.disabled) {
generate();
}
}
};
</script>
</body>
</html>