| <!DOCTYPE html> |
| <html lang="pt-BR"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>MuseTalk - Auto (WebRTC/H.264)</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); |
| min-height: 100vh; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| color: white; |
| } |
| .app-container { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 20px; |
| padding: 20px; |
| } |
| .avatar-section { |
| position: relative; |
| width: 400px; |
| height: 400px; |
| border-radius: 24px; |
| overflow: hidden; |
| background: #0f0f1a; |
| box-shadow: 0 20px 40px rgba(0,0,0,0.5); |
| } |
| #idle-video, #rtc-video, #ws-canvas { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| border-radius: 24px; |
| } |
| #idle-video { z-index: 5; } |
| #rtc-video, #ws-canvas { |
| z-index: 10; |
| opacity: 0; |
| transition: opacity 0.2s; |
| } |
| #rtc-video.active, #ws-canvas.active { opacity: 1; } |
| .status-bar { |
| position: absolute; |
| top: 12px; |
| left: 12px; |
| display: flex; |
| gap: 8px; |
| z-index: 20; |
| flex-wrap: wrap; |
| } |
| .status-item { |
| background: rgba(0,0,0,0.6); |
| padding: 4px 8px; |
| border-radius: 4px; |
| font-size: 11px; |
| display: flex; |
| align-items: center; |
| gap: 4px; |
| } |
| .status-dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: #666; |
| } |
| .status-dot.connected { background: #4ade80; } |
| .status-dot.streaming { background: #60a5fa; animation: pulse 1s infinite; } |
| .status-dot.error { background: #f87171; } |
| .status-dot.warning { background: #fbbf24; } |
| @keyframes pulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.5; } |
| } |
| .controls { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 16px; |
| } |
| #record-btn { |
| width: 80px; |
| height: 80px; |
| border-radius: 50%; |
| border: none; |
| background: linear-gradient(145deg, #ef4444, #dc2626); |
| color: white; |
| font-size: 14px; |
| font-weight: 600; |
| cursor: pointer; |
| box-shadow: 0 8px 20px rgba(239, 68, 68, 0.4); |
| transition: all 0.2s; |
| } |
| #record-btn:hover { transform: scale(1.05); } |
| #record-btn:active, #record-btn.recording { |
| background: linear-gradient(145deg, #dc2626, #b91c1c); |
| transform: scale(0.95); |
| } |
| #record-btn:disabled { |
| background: #666; |
| cursor: not-allowed; |
| box-shadow: none; |
| } |
| .response-text { |
| text-align: center; |
| font-size: 16px; |
| color: #94a3b8; |
| max-width: 400px; |
| min-height: 48px; |
| } |
| .stats { |
| display: flex; |
| gap: 16px; |
| font-size: 13px; |
| color: #64748b; |
| flex-wrap: wrap; |
| justify-content: center; |
| } |
| .stats span { font-weight: 600; color: #94a3b8; } |
| .mode-badge { |
| padding: 6px 16px; |
| border-radius: 12px; |
| font-size: 12px; |
| font-weight: 600; |
| } |
| .mode-badge.webrtc { background: #22c55e; color: white; } |
| .mode-badge.h264 { background: #8b5cf6; color: white; } |
| .mode-badge.jpeg { background: #f59e0b; color: white; } |
| .mode-badge.detecting { background: #64748b; color: white; } |
| .mode-info { |
| font-size: 11px; |
| color: #64748b; |
| text-align: center; |
| max-width: 350px; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="app-container"> |
| <div class="mode-badge detecting" id="mode-badge">Detectando melhor modo...</div> |
|
|
| <div class="avatar-section"> |
| <video id="idle-video" autoplay loop muted playsinline preload="auto" src="avatar_videos/idle.mp4"></video> |
| <video id="rtc-video" autoplay playsinline></video> |
| <canvas id="ws-canvas"></canvas> |
|
|
| <div class="status-bar"> |
| <div class="status-item"><span class="status-dot" id="ws-status"></span>WS</div> |
| <div class="status-item"><span class="status-dot" id="rtc-status"></span>RTC</div> |
| <div class="status-item"><span class="status-dot" id="udp-status"></span>UDP</div> |
| </div> |
| </div> |
|
|
| <div class="controls"> |
| <button id="record-btn" disabled>Detectando...</button> |
| <p class="response-text" id="response-text">Verificando conectividade...</p> |
| </div> |
|
|
| <div class="stats"> |
| <div>Modo: <span id="current-mode">-</span></div> |
| <div>Latência: <span id="latency">-</span></div> |
| <div>Frames: <span id="frame-count">0</span></div> |
| <div>FPS: <span id="fps">-</span></div> |
| </div> |
|
|
| <div class="mode-info" id="mode-info"> |
| Detectando automaticamente o melhor modo de streaming... |
| </div> |
| </div> |
|
|
| <audio id="audio-player" preload="none"></audio> |
|
|
| <script> |
| |
| const idleVideo = document.getElementById('idle-video'); |
| const rtcVideo = document.getElementById('rtc-video'); |
| const wsCanvas = document.getElementById('ws-canvas'); |
| const ctx = wsCanvas.getContext('2d'); |
| const recordBtn = document.getElementById('record-btn'); |
| const responseText = document.getElementById('response-text'); |
| const wsStatus = document.getElementById('ws-status'); |
| const rtcStatus = document.getElementById('rtc-status'); |
| const udpStatus = document.getElementById('udp-status'); |
| const modeBadge = document.getElementById('mode-badge'); |
| const modeInfo = document.getElementById('mode-info'); |
| const currentModeEl = document.getElementById('current-mode'); |
| const latencyEl = document.getElementById('latency'); |
| const frameCountEl = document.getElementById('frame-count'); |
| const fpsEl = document.getElementById('fps'); |
| const audioPlayer = document.getElementById('audio-player'); |
| |
| |
| let ws = null; |
| let pc = null; |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let isRecording = false; |
| let frameCount = 0; |
| let startTime = 0; |
| let totalBytes = 0; |
| |
| |
| let webCodecsSupported = 'VideoDecoder' in window; |
| let udpAvailable = false; |
| let webrtcAvailable = false; |
| let currentMode = 'detecting'; |
| |
| |
| let videoDecoder = null; |
| |
| |
| const iceServers = [ |
| { urls: 'stun:stun.l.google.com:19302' }, |
| { urls: 'stun:stun1.l.google.com:19302' }, |
| { |
| urls: 'turn:openrelay.metered.ca:80', |
| username: 'openrelayproject', |
| credential: 'openrelayproject' |
| }, |
| { |
| urls: 'turn:openrelay.metered.ca:443?transport=tcp', |
| username: 'openrelayproject', |
| credential: 'openrelayproject' |
| } |
| ]; |
| |
| |
| async function detectUDP() { |
| return new Promise((resolve) => { |
| const testPc = new RTCPeerConnection({ iceServers }); |
| let hasUdp = false; |
| let timeout = null; |
| |
| testPc.onicecandidate = (e) => { |
| if (e.candidate) { |
| const candidate = e.candidate.candidate; |
| console.log('[UDP Test] Candidate:', candidate); |
| if (candidate.includes('udp') && candidate.includes('srflx')) { |
| hasUdp = true; |
| udpStatus.className = 'status-dot connected'; |
| } |
| } |
| }; |
| |
| testPc.onicegatheringstatechange = () => { |
| if (testPc.iceGatheringState === 'complete') { |
| clearTimeout(timeout); |
| testPc.close(); |
| resolve(hasUdp); |
| } |
| }; |
| |
| |
| testPc.createDataChannel('test'); |
| testPc.createOffer().then(offer => testPc.setLocalDescription(offer)); |
| |
| |
| timeout = setTimeout(() => { |
| testPc.close(); |
| resolve(hasUdp); |
| }, 5000); |
| }); |
| } |
| |
| |
| async function detectBestMode() { |
| responseText.textContent = 'Testando conectividade UDP...'; |
| |
| udpAvailable = await detectUDP(); |
| console.log('[Mode] UDP available:', udpAvailable); |
| |
| if (udpAvailable) { |
| |
| currentMode = 'webrtc'; |
| modeBadge.className = 'mode-badge webrtc'; |
| modeBadge.textContent = 'WebRTC (UDP ~100ms)'; |
| modeInfo.textContent = 'UDP detectado! Usando WebRTC para latência mínima.'; |
| rtcStatus.className = 'status-dot connected'; |
| } else if (webCodecsSupported) { |
| |
| currentMode = 'h264'; |
| modeBadge.className = 'mode-badge h264'; |
| modeBadge.textContent = 'H.264 WebSocket (~200ms)'; |
| modeInfo.textContent = 'UDP não disponível. Usando H.264 via WebSocket.'; |
| udpStatus.className = 'status-dot warning'; |
| } else { |
| |
| currentMode = 'jpeg'; |
| modeBadge.className = 'mode-badge jpeg'; |
| modeBadge.textContent = 'JPEG WebSocket (~300ms)'; |
| modeInfo.textContent = 'WebCodecs não suportado. Usando JPEG.'; |
| udpStatus.className = 'status-dot error'; |
| } |
| |
| currentModeEl.textContent = currentMode.toUpperCase(); |
| return currentMode; |
| } |
| |
| |
| async function initH264Decoder(width, height) { |
| if (videoDecoder) videoDecoder.close(); |
| |
| videoDecoder = new VideoDecoder({ |
| output: (frame) => { |
| if (wsCanvas.width !== frame.displayWidth) { |
| wsCanvas.width = frame.displayWidth; |
| wsCanvas.height = frame.displayHeight; |
| } |
| ctx.drawImage(frame, 0, 0); |
| frame.close(); |
| frameCount++; |
| updateStats(); |
| }, |
| error: (e) => console.error('[H264 Decoder] Error:', e) |
| }); |
| |
| await videoDecoder.configure({ |
| codec: 'avc1.42E01E', |
| codedWidth: width, |
| codedHeight: height, |
| optimizeForLatency: true |
| }); |
| } |
| |
| |
| function connectWebSocket() { |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| const wsPath = window.location.pathname.replace(/\/[^\/]*$/, '') + '/ws/stream'; |
| const wsUrl = `${protocol}//${window.location.host}${wsPath}`; |
| |
| console.log('[WS] Connecting to:', wsUrl); |
| ws = new WebSocket(wsUrl); |
| ws.binaryType = 'arraybuffer'; |
| |
| ws.onopen = () => { |
| console.log('[WS] Connected'); |
| wsStatus.className = 'status-dot connected'; |
| recordBtn.disabled = false; |
| recordBtn.textContent = 'Falar'; |
| responseText.textContent = 'Pressione e fale'; |
| }; |
| |
| ws.onclose = () => { |
| console.log('[WS] Disconnected'); |
| wsStatus.className = 'status-dot error'; |
| recordBtn.disabled = true; |
| setTimeout(connectWebSocket, 3000); |
| }; |
| |
| ws.onerror = (e) => { |
| console.error('[WS] Error:', e); |
| wsStatus.className = 'status-dot error'; |
| }; |
| |
| ws.onmessage = async (event) => { |
| if (event.data instanceof ArrayBuffer) { |
| await handleBinaryFrame(event.data); |
| } else { |
| handleJsonMessage(JSON.parse(event.data)); |
| } |
| }; |
| |
| |
| setInterval(() => { |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ type: 'ping' })); |
| } |
| }, 10000); |
| } |
| |
| |
| async function handleBinaryFrame(data) { |
| if (currentMode !== 'h264') return; |
| |
| const view = new Uint8Array(data); |
| const frameType = view[0]; |
| const frameData = view.slice(1); |
| |
| if (frameType === 0x00) { |
| console.log('[H264] Codec config:', frameData.length, 'bytes'); |
| return; |
| } |
| |
| if (!videoDecoder || videoDecoder.state === 'closed') return; |
| |
| const isKeyframe = frameType === 0x01; |
| totalBytes += frameData.length; |
| |
| try { |
| const chunk = new EncodedVideoChunk({ |
| type: isKeyframe ? 'key' : 'delta', |
| timestamp: frameCount * 40000, |
| data: frameData |
| }); |
| videoDecoder.decode(chunk); |
| } catch (e) { |
| console.error('[H264] Decode error:', e); |
| } |
| } |
| |
| |
| async function handleJsonMessage(data) { |
| switch (data.type) { |
| case 'status': |
| responseText.textContent = data.message; |
| break; |
| |
| case 'transcription': |
| responseText.textContent = `"${data.text}"`; |
| break; |
| |
| case 'response': |
| responseText.textContent = data.text; |
| break; |
| |
| case 'audio': |
| const basePath = window.location.pathname.replace(/\/[^\/]*$/, ''); |
| audioPlayer.src = basePath + data.url; |
| audioPlayer.load(); |
| break; |
| |
| case 'info': |
| frameCount = 0; |
| totalBytes = 0; |
| startTime = Date.now(); |
| frameCountEl.textContent = `0/${data.total_frames}`; |
| |
| if (currentMode === 'h264' && data.codec === 'h264') { |
| wsCanvas.classList.add('active'); |
| await initH264Decoder(256, 256); |
| } else if (currentMode === 'jpeg') { |
| wsCanvas.classList.add('active'); |
| } |
| |
| audioPlayer.play().catch(e => console.log('[Audio] Autoplay blocked')); |
| break; |
| |
| case 'frame': |
| |
| if (currentMode === 'jpeg') { |
| const img = new Image(); |
| img.onload = () => { |
| if (wsCanvas.width !== img.width) { |
| wsCanvas.width = img.width; |
| wsCanvas.height = img.height; |
| } |
| ctx.drawImage(img, 0, 0); |
| frameCount++; |
| updateStats(); |
| }; |
| img.src = 'data:image/jpeg;base64,' + data.frame; |
| totalBytes += data.frame.length * 0.75; |
| } |
| break; |
| |
| case 'done': |
| latencyEl.textContent = `${Date.now() - startTime}ms`; |
| setTimeout(() => { |
| wsCanvas.classList.remove('active'); |
| rtcVideo.classList.remove('active'); |
| if (videoDecoder) { |
| videoDecoder.close(); |
| videoDecoder = null; |
| } |
| }, 1000); |
| wsStatus.className = 'status-dot connected'; |
| break; |
| |
| case 'rtc_offer': |
| await handleRTCOffer(data); |
| break; |
| |
| case 'rtc_candidate': |
| if (pc && data.candidate) { |
| await pc.addIceCandidate(new RTCIceCandidate(data.candidate)); |
| } |
| break; |
| |
| case 'pong': |
| break; |
| |
| case 'error': |
| console.error('[Server] Error:', data.message); |
| responseText.textContent = 'Erro: ' + data.message; |
| break; |
| } |
| } |
| |
| |
| async function handleRTCOffer(data) { |
| if (currentMode !== 'webrtc') return; |
| |
| pc = new RTCPeerConnection({ iceServers }); |
| |
| pc.ontrack = (event) => { |
| console.log('[WebRTC] Got track:', event.track.kind); |
| if (event.track.kind === 'video') { |
| rtcVideo.srcObject = event.streams[0]; |
| rtcVideo.classList.add('active'); |
| rtcStatus.className = 'status-dot streaming'; |
| } |
| }; |
| |
| pc.onicecandidate = (event) => { |
| if (event.candidate) { |
| ws.send(JSON.stringify({ |
| type: 'rtc_candidate', |
| candidate: event.candidate |
| })); |
| } |
| }; |
| |
| pc.oniceconnectionstatechange = () => { |
| console.log('[WebRTC] ICE state:', pc.iceConnectionState); |
| if (pc.iceConnectionState === 'connected') { |
| rtcStatus.className = 'status-dot connected'; |
| } else if (pc.iceConnectionState === 'failed') { |
| rtcStatus.className = 'status-dot error'; |
| |
| console.log('[WebRTC] Failed, falling back to H.264'); |
| currentMode = webCodecsSupported ? 'h264' : 'jpeg'; |
| modeBadge.className = `mode-badge ${currentMode}`; |
| modeBadge.textContent = currentMode === 'h264' ? 'H.264 (fallback)' : 'JPEG (fallback)'; |
| } |
| }; |
| |
| await pc.setRemoteDescription(new RTCSessionDescription(data.offer)); |
| const answer = await pc.createAnswer(); |
| await pc.setLocalDescription(answer); |
| |
| ws.send(JSON.stringify({ |
| type: 'rtc_answer', |
| answer: answer |
| })); |
| } |
| |
| |
| function updateStats() { |
| frameCountEl.textContent = frameCount; |
| const elapsed = (Date.now() - startTime) / 1000; |
| if (elapsed > 0) { |
| fpsEl.textContent = (frameCount / elapsed).toFixed(1); |
| } |
| } |
| |
| |
| async function startRecording() { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' }); |
| audioChunks = []; |
| |
| mediaRecorder.ondataavailable = (e) => { |
| if (e.data.size > 0) audioChunks.push(e.data); |
| }; |
| |
| mediaRecorder.onstop = async () => { |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); |
| const reader = new FileReader(); |
| reader.onloadend = () => { |
| const base64 = reader.result.split(',')[1]; |
| |
| |
| const codec = currentMode === 'webrtc' ? 'webrtc' : |
| currentMode === 'h264' ? 'h264' : 'jpeg'; |
| |
| ws.send(JSON.stringify({ |
| type: 'start', |
| audio_base64: base64, |
| codec: codec, |
| resolution: 256, |
| batch_size: 16 |
| })); |
| |
| responseText.textContent = 'Processando...'; |
| wsStatus.className = 'status-dot streaming'; |
| }; |
| reader.readAsDataURL(audioBlob); |
| stream.getTracks().forEach(t => t.stop()); |
| }; |
| |
| mediaRecorder.start(); |
| isRecording = true; |
| recordBtn.classList.add('recording'); |
| recordBtn.textContent = 'Soltar'; |
| responseText.textContent = 'Gravando...'; |
| } catch (e) { |
| console.error('[Mic] Error:', e); |
| responseText.textContent = 'Erro: permita acesso ao microfone'; |
| } |
| } |
| |
| function stopRecording() { |
| if (mediaRecorder && isRecording) { |
| mediaRecorder.stop(); |
| isRecording = false; |
| recordBtn.classList.remove('recording'); |
| recordBtn.textContent = 'Falar'; |
| } |
| } |
| |
| |
| recordBtn.addEventListener('mousedown', startRecording); |
| recordBtn.addEventListener('mouseup', stopRecording); |
| recordBtn.addEventListener('mouseleave', () => { if (isRecording) stopRecording(); }); |
| recordBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startRecording(); }); |
| recordBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopRecording(); }); |
| |
| |
| async function init() { |
| await detectBestMode(); |
| connectWebSocket(); |
| } |
| |
| init(); |
| </script> |
| </body> |
| </html> |
|
|