Spaces:
Paused
Paused
MacBook pro
commited on
Commit
Β·
766cb66
1
Parent(s):
662ffc5
feat: upgrade frontend to enterprise-grade premium design
Browse files- Redesigned UI with modern Inter font and professional color scheme
- Implemented responsive grid layout with mobile-first approach
- Added real-time status indicators with animated states
- Enhanced WebRTC interface with premium video controls
- Integrated comprehensive metrics dashboard
- Added toast notifications and loading states
- Improved error handling and user feedback
- Created enterprise-grade component library
- Maintained all existing WebRTC and face swap functionality
- Added micro-interactions and smooth animations
- .github/instructions.md +0 -17
- static/index.html +590 -108
- static/webrtc_enterprise.js +807 -0
- static/webrtc_prod.js +279 -53
.github/instructions.md
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
Prime Directive:
|
| 2 |
-
Deliver production-ready, systemic solutions to root causes. Prioritize core utility and absolute system integrity. There is zero tolerance for surface patches, brittle fixes, or non-functional code.
|
| 3 |
-
Mandatory Protocol:
|
| 4 |
-
Map the System: Before acting, map all relevant logic flows, data transformations, and dependencies. Identify all side effects.
|
| 5 |
-
Isolate Root Cause: Diagnose the fundamental issue with code-based evidence. Ensure the fix is systemic and permanent.
|
| 6 |
-
Align with Utility: Every change must advance the project's core objective. Reject low-impact optimizations.
|
| 7 |
-
Implementation Mandates:
|
| 8 |
-
Code Integrity: All code must be robust, generalizable, and directly executable. Prohibit all hardcoding, duplication, and placeholder logic.
|
| 9 |
-
Quality & Security: Enforce static typing, descriptive naming, and strict linting. Validate all I/O, eliminate unsafe calls, and add regression guards.
|
| 10 |
-
Testing: Test coverage must target both the symptom and its root cause. The full test suite must pass without warnings.
|
| 11 |
-
Execution Workflow:
|
| 12 |
-
Analyze system flow.
|
| 13 |
-
Confirm root cause.
|
| 14 |
-
Plan solution.
|
| 15 |
-
Implement the robust fix.
|
| 16 |
-
Validate with all tests.
|
| 17 |
-
Document systemic insights.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static/index.html
CHANGED
|
@@ -2,142 +2,624 @@
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
-
<title>Mirage
|
| 6 |
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
|
|
|
|
|
|
|
|
| 7 |
<style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
body {
|
| 9 |
-
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,
|
| 10 |
-
margin:
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
align-items: center;
|
|
|
|
| 22 |
}
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 40 |
}
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
width: 100%;
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
color:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
border: none;
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
cursor: pointer;
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
display: grid;
|
| 80 |
-
grid-template-columns: repeat(auto-fit, minmax(
|
| 81 |
-
gap:
|
| 82 |
-
margin: 20px 0;
|
| 83 |
}
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
| 89 |
}
|
|
|
|
| 90 |
.metric-value {
|
| 91 |
-
|
| 92 |
-
font-
|
| 93 |
-
|
|
|
|
|
|
|
| 94 |
}
|
|
|
|
| 95 |
.metric-label {
|
| 96 |
-
font-size:
|
| 97 |
-
color:
|
| 98 |
text-transform: uppercase;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
}
|
| 100 |
-
|
| 101 |
-
|
|
|
|
| 102 |
}
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
</style>
|
| 110 |
</head>
|
| 111 |
<body>
|
| 112 |
-
<div class="container">
|
| 113 |
-
<div class="
|
| 114 |
-
<
|
| 115 |
-
|
| 116 |
-
|
|
|
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
</div>
|
| 136 |
</div>
|
|
|
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
<script src="/static/webrtc_prod.js"></script>
|
| 141 |
</div>
|
|
|
|
|
|
|
| 142 |
</body>
|
| 143 |
</html>
|
|
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
+
<title>Mirage AI Avatar Studio</title>
|
| 6 |
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 10 |
<style>
|
| 11 |
+
:root {
|
| 12 |
+
--primary: #6366f1;
|
| 13 |
+
--primary-dark: #4f46e5;
|
| 14 |
+
--primary-light: #818cf8;
|
| 15 |
+
--secondary: #06b6d4;
|
| 16 |
+
--success: #10b981;
|
| 17 |
+
--warning: #f59e0b;
|
| 18 |
+
--error: #ef4444;
|
| 19 |
+
--gray-50: #f9fafb;
|
| 20 |
+
--gray-100: #f3f4f6;
|
| 21 |
+
--gray-200: #e5e7eb;
|
| 22 |
+
--gray-300: #d1d5db;
|
| 23 |
+
--gray-400: #9ca3af;
|
| 24 |
+
--gray-500: #6b7280;
|
| 25 |
+
--gray-600: #4b5563;
|
| 26 |
+
--gray-700: #374151;
|
| 27 |
+
--gray-800: #1f2937;
|
| 28 |
+
--gray-900: #111827;
|
| 29 |
+
--bg-primary: #0a0a0b;
|
| 30 |
+
--bg-secondary: #141417;
|
| 31 |
+
--bg-tertiary: #1c1c20;
|
| 32 |
+
--text-primary: #ffffff;
|
| 33 |
+
--text-secondary: #a1a1aa;
|
| 34 |
+
--text-tertiary: #71717a;
|
| 35 |
+
--border: #27272a;
|
| 36 |
+
--border-light: #3f3f46;
|
| 37 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
| 38 |
+
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
| 39 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
| 40 |
+
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
| 41 |
+
--gradient-primary: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
| 42 |
+
--gradient-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
* { box-sizing: border-box; }
|
| 46 |
+
|
| 47 |
body {
|
| 48 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 49 |
+
margin: 0;
|
| 50 |
+
padding: 0;
|
| 51 |
+
background: var(--bg-primary);
|
| 52 |
+
color: var(--text-primary);
|
| 53 |
+
line-height: 1.6;
|
| 54 |
+
overflow-x: hidden;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.app-container {
|
| 58 |
+
min-height: 100vh;
|
| 59 |
+
background: radial-gradient(ellipse at top, rgba(99, 102, 241, 0.1) 0%, transparent 70%);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.header {
|
| 63 |
+
padding: 2rem 0;
|
| 64 |
+
text-align: center;
|
| 65 |
+
position: relative;
|
| 66 |
+
z-index: 10;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
.header::before {
|
| 70 |
+
content: '';
|
| 71 |
+
position: absolute;
|
| 72 |
+
top: 0;
|
| 73 |
+
left: 50%;
|
| 74 |
+
transform: translateX(-50%);
|
| 75 |
+
width: 100px;
|
| 76 |
+
height: 4px;
|
| 77 |
+
background: var(--gradient-primary);
|
| 78 |
+
border-radius: 2px;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.header h1 {
|
| 82 |
+
font-size: clamp(2rem, 5vw, 3.5rem);
|
| 83 |
+
font-weight: 700;
|
| 84 |
+
margin: 1rem 0 0.5rem;
|
| 85 |
+
background: var(--gradient-primary);
|
| 86 |
+
-webkit-background-clip: text;
|
| 87 |
+
-webkit-text-fill-color: transparent;
|
| 88 |
+
background-clip: text;
|
| 89 |
+
letter-spacing: -0.02em;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.header .subtitle {
|
| 93 |
+
font-size: 1.125rem;
|
| 94 |
+
color: var(--text-secondary);
|
| 95 |
+
margin: 0;
|
| 96 |
+
font-weight: 400;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.container {
|
| 100 |
+
max-width: 1400px;
|
| 101 |
+
margin: 0 auto;
|
| 102 |
+
padding: 0 1.5rem;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.main-layout {
|
| 106 |
+
display: grid;
|
| 107 |
+
grid-template-columns: 1fr;
|
| 108 |
+
gap: 2rem;
|
| 109 |
+
margin-bottom: 3rem;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
@media (min-width: 1024px) {
|
| 113 |
+
.main-layout {
|
| 114 |
+
grid-template-columns: 1fr 320px;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.content-area {
|
| 119 |
+
min-width: 0;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.sidebar {
|
| 123 |
+
display: flex;
|
| 124 |
+
flex-direction: column;
|
| 125 |
+
gap: 1.5rem;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.control-panel {
|
| 129 |
+
background: var(--bg-secondary);
|
| 130 |
+
border: 1px solid var(--border);
|
| 131 |
+
border-radius: 1rem;
|
| 132 |
+
padding: 1.5rem;
|
| 133 |
+
box-shadow: var(--shadow-lg);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.control-panel h3 {
|
| 137 |
+
margin: 0 0 1rem;
|
| 138 |
+
font-size: 1.125rem;
|
| 139 |
+
font-weight: 600;
|
| 140 |
+
color: var(--text-primary);
|
| 141 |
+
display: flex;
|
| 142 |
align-items: center;
|
| 143 |
+
gap: 0.5rem;
|
| 144 |
}
|
| 145 |
+
|
| 146 |
+
.control-panel h3::before {
|
| 147 |
+
content: '';
|
| 148 |
+
width: 8px;
|
| 149 |
+
height: 8px;
|
| 150 |
+
background: var(--primary);
|
| 151 |
+
border-radius: 50%;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.controls {
|
| 155 |
+
display: flex;
|
| 156 |
+
flex-direction: column;
|
| 157 |
+
gap: 1rem;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.file-upload {
|
| 161 |
+
position: relative;
|
| 162 |
+
display: block;
|
| 163 |
}
|
| 164 |
+
|
| 165 |
+
.file-upload input[type="file"] {
|
| 166 |
+
position: absolute;
|
| 167 |
+
width: 1px;
|
| 168 |
+
height: 1px;
|
| 169 |
+
padding: 0;
|
| 170 |
+
margin: -1px;
|
| 171 |
+
overflow: hidden;
|
| 172 |
+
clip: rect(0,0,0,0);
|
| 173 |
+
border: 0;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.file-upload-button {
|
| 177 |
+
display: flex;
|
| 178 |
+
align-items: center;
|
| 179 |
+
justify-content: center;
|
| 180 |
+
gap: 0.75rem;
|
| 181 |
width: 100%;
|
| 182 |
+
padding: 1rem;
|
| 183 |
+
background: var(--bg-tertiary);
|
| 184 |
+
border: 2px dashed var(--border-light);
|
| 185 |
+
border-radius: 0.75rem;
|
| 186 |
+
color: var(--text-secondary);
|
| 187 |
+
cursor: pointer;
|
| 188 |
+
transition: all 0.2s ease;
|
| 189 |
+
font-weight: 500;
|
| 190 |
}
|
| 191 |
+
|
| 192 |
+
.file-upload-button:hover {
|
| 193 |
+
border-color: var(--primary);
|
| 194 |
+
color: var(--primary);
|
| 195 |
+
background: rgba(99, 102, 241, 0.05);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.file-upload-button.has-file {
|
| 199 |
+
border-color: var(--success);
|
| 200 |
+
color: var(--success);
|
| 201 |
+
background: rgba(16, 185, 129, 0.05);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.btn {
|
| 205 |
+
display: inline-flex;
|
| 206 |
+
align-items: center;
|
| 207 |
+
justify-content: center;
|
| 208 |
+
gap: 0.5rem;
|
| 209 |
+
padding: 0.75rem 1.5rem;
|
| 210 |
border: none;
|
| 211 |
+
border-radius: 0.75rem;
|
| 212 |
+
font-size: 0.875rem;
|
| 213 |
+
font-weight: 600;
|
| 214 |
+
text-decoration: none;
|
| 215 |
cursor: pointer;
|
| 216 |
+
transition: all 0.2s ease;
|
| 217 |
+
position: relative;
|
| 218 |
+
overflow: hidden;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.btn::before {
|
| 222 |
+
content: '';
|
| 223 |
+
position: absolute;
|
| 224 |
+
top: 0;
|
| 225 |
+
left: -100%;
|
| 226 |
+
width: 100%;
|
| 227 |
+
height: 100%;
|
| 228 |
+
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
|
| 229 |
+
transition: left 0.5s ease;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.btn:hover::before {
|
| 233 |
+
left: 100%;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.btn-primary {
|
| 237 |
+
background: var(--gradient-primary);
|
| 238 |
+
color: white;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.btn-primary:hover {
|
| 242 |
+
transform: translateY(-1px);
|
| 243 |
+
box-shadow: var(--shadow-lg);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.btn-secondary {
|
| 247 |
+
background: var(--bg-tertiary);
|
| 248 |
+
color: var(--text-primary);
|
| 249 |
+
border: 1px solid var(--border-light);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.btn-secondary:hover {
|
| 253 |
+
background: var(--bg-secondary);
|
| 254 |
+
border-color: var(--primary);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.btn-success {
|
| 258 |
+
background: var(--success);
|
| 259 |
+
color: white;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.btn-danger {
|
| 263 |
+
background: var(--error);
|
| 264 |
+
color: white;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.btn:disabled {
|
| 268 |
+
opacity: 0.5;
|
| 269 |
+
cursor: not-allowed;
|
| 270 |
+
transform: none !important;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.status-indicator {
|
| 274 |
+
display: flex;
|
| 275 |
+
align-items: center;
|
| 276 |
+
gap: 0.75rem;
|
| 277 |
+
padding: 1rem;
|
| 278 |
+
background: var(--bg-tertiary);
|
| 279 |
+
border: 1px solid var(--border);
|
| 280 |
+
border-radius: 0.75rem;
|
| 281 |
+
font-size: 0.875rem;
|
| 282 |
+
font-weight: 500;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
.status-dot {
|
| 286 |
+
width: 8px;
|
| 287 |
+
height: 8px;
|
| 288 |
+
border-radius: 50%;
|
| 289 |
+
animation: pulse 2s infinite;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.status-idle .status-dot { background: var(--gray-500); }
|
| 293 |
+
.status-connecting .status-dot { background: var(--warning); }
|
| 294 |
+
.status-connected .status-dot { background: var(--success); }
|
| 295 |
+
.status-error .status-dot { background: var(--error); }
|
| 296 |
+
|
| 297 |
+
@keyframes pulse {
|
| 298 |
+
0%, 100% { opacity: 1; }
|
| 299 |
+
50% { opacity: 0.5; }
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.video-container {
|
| 303 |
+
display: grid;
|
| 304 |
+
grid-template-columns: 1fr;
|
| 305 |
+
gap: 1.5rem;
|
| 306 |
+
margin-bottom: 2rem;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
@media (min-width: 768px) {
|
| 310 |
+
.video-container {
|
| 311 |
+
grid-template-columns: 1fr 1fr;
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.video-panel {
|
| 316 |
+
background: var(--bg-secondary);
|
| 317 |
+
border: 1px solid var(--border);
|
| 318 |
+
border-radius: 1rem;
|
| 319 |
+
padding: 1.5rem;
|
| 320 |
+
position: relative;
|
| 321 |
+
overflow: hidden;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.video-panel::before {
|
| 325 |
+
content: '';
|
| 326 |
+
position: absolute;
|
| 327 |
+
top: 0;
|
| 328 |
+
left: 0;
|
| 329 |
+
right: 0;
|
| 330 |
+
height: 1px;
|
| 331 |
+
background: var(--gradient-primary);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
.video-panel h3 {
|
| 335 |
+
margin: 0 0 1rem;
|
| 336 |
+
font-size: 1.125rem;
|
| 337 |
+
font-weight: 600;
|
| 338 |
+
display: flex;
|
| 339 |
+
align-items: center;
|
| 340 |
+
justify-content: space-between;
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.video-wrapper {
|
| 344 |
+
position: relative;
|
| 345 |
+
border-radius: 0.75rem;
|
| 346 |
+
overflow: hidden;
|
| 347 |
+
background: #000;
|
| 348 |
+
aspect-ratio: 16/9;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.video-wrapper video {
|
| 352 |
+
width: 100%;
|
| 353 |
+
height: 100%;
|
| 354 |
+
object-fit: cover;
|
| 355 |
+
border-radius: 0.75rem;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.video-overlay {
|
| 359 |
+
position: absolute;
|
| 360 |
+
top: 0;
|
| 361 |
+
left: 0;
|
| 362 |
+
right: 0;
|
| 363 |
+
bottom: 0;
|
| 364 |
+
background: rgba(0,0,0,0.7);
|
| 365 |
+
display: flex;
|
| 366 |
+
align-items: center;
|
| 367 |
+
justify-content: center;
|
| 368 |
+
color: var(--text-secondary);
|
| 369 |
+
font-size: 0.875rem;
|
| 370 |
+
opacity: 1;
|
| 371 |
+
transition: opacity 0.3s ease;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.video-wrapper.active .video-overlay {
|
| 375 |
+
opacity: 0;
|
| 376 |
+
pointer-events: none;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
.metrics-panel {
|
| 380 |
+
background: var(--bg-secondary);
|
| 381 |
+
border: 1px solid var(--border);
|
| 382 |
+
border-radius: 1rem;
|
| 383 |
+
padding: 1.5rem;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.metrics-grid {
|
| 387 |
display: grid;
|
| 388 |
+
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
| 389 |
+
gap: 1rem;
|
|
|
|
| 390 |
}
|
| 391 |
+
|
| 392 |
+
.metric-item {
|
| 393 |
+
text-align: center;
|
| 394 |
+
padding: 1rem;
|
| 395 |
+
background: var(--bg-tertiary);
|
| 396 |
+
border-radius: 0.5rem;
|
| 397 |
+
border: 1px solid var(--border);
|
| 398 |
}
|
| 399 |
+
|
| 400 |
.metric-value {
|
| 401 |
+
display: block;
|
| 402 |
+
font-size: 1.5rem;
|
| 403 |
+
font-weight: 700;
|
| 404 |
+
color: var(--primary);
|
| 405 |
+
margin-bottom: 0.25rem;
|
| 406 |
}
|
| 407 |
+
|
| 408 |
.metric-label {
|
| 409 |
+
font-size: 0.75rem;
|
| 410 |
+
color: var(--text-tertiary);
|
| 411 |
text-transform: uppercase;
|
| 412 |
+
letter-spacing: 0.05em;
|
| 413 |
+
font-weight: 500;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.loading-spinner {
|
| 417 |
+
display: inline-block;
|
| 418 |
+
width: 16px;
|
| 419 |
+
height: 16px;
|
| 420 |
+
border: 2px solid transparent;
|
| 421 |
+
border-top: 2px solid currentColor;
|
| 422 |
+
border-radius: 50%;
|
| 423 |
+
animation: spin 1s linear infinite;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
@keyframes spin {
|
| 427 |
+
to { transform: rotate(360deg); }
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.toast {
|
| 431 |
+
position: fixed;
|
| 432 |
+
bottom: 2rem;
|
| 433 |
+
right: 2rem;
|
| 434 |
+
background: var(--bg-secondary);
|
| 435 |
+
border: 1px solid var(--border);
|
| 436 |
+
border-radius: 0.75rem;
|
| 437 |
+
padding: 1rem 1.5rem;
|
| 438 |
+
box-shadow: var(--shadow-xl);
|
| 439 |
+
z-index: 1000;
|
| 440 |
+
transform: translateX(100%);
|
| 441 |
+
transition: transform 0.3s ease;
|
| 442 |
+
max-width: 400px;
|
| 443 |
}
|
| 444 |
+
|
| 445 |
+
.toast.show {
|
| 446 |
+
transform: translateX(0);
|
| 447 |
}
|
| 448 |
+
|
| 449 |
+
.toast-success {
|
| 450 |
+
border-left: 4px solid var(--success);
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.toast-error {
|
| 454 |
+
border-left: 4px solid var(--error);
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
.toast-warning {
|
| 458 |
+
border-left: 4px solid var(--warning);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
@media (max-width: 768px) {
|
| 462 |
+
.container {
|
| 463 |
+
padding: 0 1rem;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.header {
|
| 467 |
+
padding: 1.5rem 0;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.main-layout {
|
| 471 |
+
grid-template-columns: 1fr;
|
| 472 |
+
gap: 1.5rem;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
.sidebar {
|
| 476 |
+
order: -1;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
.toast {
|
| 480 |
+
bottom: 1rem;
|
| 481 |
+
right: 1rem;
|
| 482 |
+
left: 1rem;
|
| 483 |
+
max-width: none;
|
| 484 |
+
}
|
| 485 |
}
|
| 486 |
</style>
|
| 487 |
</head>
|
| 488 |
<body>
|
| 489 |
+
<div class="app-container">
|
| 490 |
+
<div class="container">
|
| 491 |
+
<div class="header">
|
| 492 |
+
<h1>Mirage AI Avatar Studio</h1>
|
| 493 |
+
<p class="subtitle">Enterprise-Grade Real-Time Identity Transformation</p>
|
| 494 |
+
</div>
|
| 495 |
|
| 496 |
+
<div class="main-layout">
|
| 497 |
+
<div class="content-area">
|
| 498 |
+
<div class="video-container">
|
| 499 |
+
<div class="video-panel">
|
| 500 |
+
<h3>
|
| 501 |
+
Local Camera
|
| 502 |
+
<span class="status-indicator status-idle" id="localStatus">
|
| 503 |
+
<span class="status-dot"></span>
|
| 504 |
+
<span id="localStatusText">Inactive</span>
|
| 505 |
+
</span>
|
| 506 |
+
</h3>
|
| 507 |
+
<div class="video-wrapper" id="localWrapper">
|
| 508 |
+
<video id="localVideo" autoplay muted playsinline></video>
|
| 509 |
+
<div class="video-overlay" id="localOverlay">
|
| 510 |
+
<span>Click Connect to start camera</span>
|
| 511 |
+
</div>
|
| 512 |
+
</div>
|
| 513 |
+
</div>
|
| 514 |
|
| 515 |
+
<div class="video-panel">
|
| 516 |
+
<h3>
|
| 517 |
+
AI Avatar
|
| 518 |
+
<span class="status-indicator status-idle" id="avatarStatus">
|
| 519 |
+
<span class="status-dot"></span>
|
| 520 |
+
<span id="avatarStatusText">Inactive</span>
|
| 521 |
+
</span>
|
| 522 |
+
</h3>
|
| 523 |
+
<div class="video-wrapper" id="avatarWrapper">
|
| 524 |
+
<video id="remoteVideo" autoplay playsinline muted></video>
|
| 525 |
+
<div class="video-overlay" id="avatarOverlay">
|
| 526 |
+
<span>Avatar feed will appear here</span>
|
| 527 |
+
</div>
|
| 528 |
+
</div>
|
| 529 |
+
</div>
|
| 530 |
+
</div>
|
| 531 |
+
|
| 532 |
+
<div class="metrics-panel">
|
| 533 |
+
<h3>Performance Metrics</h3>
|
| 534 |
+
<div class="metrics-grid">
|
| 535 |
+
<div class="metric-item">
|
| 536 |
+
<span class="metric-value" id="latencyValue">--</span>
|
| 537 |
+
<span class="metric-label">Latency (ms)</span>
|
| 538 |
+
</div>
|
| 539 |
+
<div class="metric-item">
|
| 540 |
+
<span class="metric-value" id="fpsValue">--</span>
|
| 541 |
+
<span class="metric-label">FPS</span>
|
| 542 |
+
</div>
|
| 543 |
+
<div class="metric-item">
|
| 544 |
+
<span class="metric-value" id="gpuValue">--</span>
|
| 545 |
+
<span class="metric-label">GPU Usage</span>
|
| 546 |
+
</div>
|
| 547 |
+
<div class="metric-item">
|
| 548 |
+
<span class="metric-value" id="qualityValue">--</span>
|
| 549 |
+
<span class="metric-label">Quality</span>
|
| 550 |
+
</div>
|
| 551 |
+
</div>
|
| 552 |
+
</div>
|
| 553 |
+
</div>
|
| 554 |
+
|
| 555 |
+
<div class="sidebar">
|
| 556 |
+
<div class="control-panel">
|
| 557 |
+
<h3>Reference Image</h3>
|
| 558 |
+
<label class="file-upload">
|
| 559 |
+
<input type="file" id="referenceInput" accept="image/*" />
|
| 560 |
+
<div class="file-upload-button" id="uploadButton">
|
| 561 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 562 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
| 563 |
+
<polyline points="17,8 12,3 7,8"/>
|
| 564 |
+
<line x1="12" y1="3" x2="12" y2="15"/>
|
| 565 |
+
</svg>
|
| 566 |
+
<span id="uploadText">Choose Reference Image</span>
|
| 567 |
+
</div>
|
| 568 |
+
</label>
|
| 569 |
+
</div>
|
| 570 |
+
|
| 571 |
+
<div class="control-panel">
|
| 572 |
+
<h3>Pipeline Control</h3>
|
| 573 |
+
<div class="controls">
|
| 574 |
+
<button id="initBtn" class="btn btn-secondary">
|
| 575 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 576 |
+
<circle cx="12" cy="12" r="3"/>
|
| 577 |
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1 1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
| 578 |
+
</svg>
|
| 579 |
+
Initialize Pipeline
|
| 580 |
+
</button>
|
| 581 |
+
|
| 582 |
+
<button id="connectBtn" class="btn btn-primary">
|
| 583 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 584 |
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
| 585 |
+
<polyline points="22,4 12,14.01 9,11.01"/>
|
| 586 |
+
</svg>
|
| 587 |
+
Connect
|
| 588 |
+
</button>
|
| 589 |
+
|
| 590 |
+
<button id="disconnectBtn" class="btn btn-danger" disabled>
|
| 591 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 592 |
+
<path d="M18 6 6 18"/>
|
| 593 |
+
<path d="M6 6l12 12"/>
|
| 594 |
+
</svg>
|
| 595 |
+
Disconnect
|
| 596 |
+
</button>
|
| 597 |
+
|
| 598 |
+
<button id="debugBtn" class="btn btn-secondary" title="Debug Models">
|
| 599 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 600 |
+
<path d="M20 6 9 17l-5-5"/>
|
| 601 |
+
</svg>
|
| 602 |
+
Debug
|
| 603 |
+
</button>
|
| 604 |
+
</div>
|
| 605 |
+
</div>
|
| 606 |
+
|
| 607 |
+
<div class="control-panel">
|
| 608 |
+
<h3>System Status</h3>
|
| 609 |
+
<div class="status-indicator" id="systemStatus">
|
| 610 |
+
<span class="status-dot"></span>
|
| 611 |
+
<span id="statusText">System Idle</span>
|
| 612 |
+
</div>
|
| 613 |
+
</div>
|
| 614 |
+
</div>
|
| 615 |
</div>
|
| 616 |
</div>
|
| 617 |
+
</div>
|
| 618 |
|
| 619 |
+
<div class="toast" id="toast">
|
| 620 |
+
<div id="toastContent"></div>
|
|
|
|
| 621 |
</div>
|
| 622 |
+
|
| 623 |
+
<script src="/static/webrtc_enterprise.js"></script>
|
| 624 |
</body>
|
| 625 |
</html>
|
static/webrtc_enterprise.js
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Enterprise-grade WebRTC client for Mirage AI Avatar Studio */
|
| 2 |
+
(function(){
|
| 3 |
+
'use strict';
|
| 4 |
+
|
| 5 |
+
// Application state
|
| 6 |
+
const state = {
|
| 7 |
+
pc: null,
|
| 8 |
+
control: null,
|
| 9 |
+
localStream: null,
|
| 10 |
+
metricsTimer: null,
|
| 11 |
+
referenceImage: null,
|
| 12 |
+
connected: false,
|
| 13 |
+
authToken: null,
|
| 14 |
+
connecting: false,
|
| 15 |
+
cancelled: false,
|
| 16 |
+
initialized: false
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
// URL parameters
|
| 20 |
+
const params = new URLSearchParams(location.search);
|
| 21 |
+
const FORCE_RELAY_URL = params.get('relay') === '1';
|
| 22 |
+
const VERBOSE = (window.MIRAGE_WEBRTC_VERBOSE === true) || (params.get('wv') === '1');
|
| 23 |
+
const STATS_INTERVAL_MS = window.MIRAGE_WEBRTC_STATS_INTERVAL_MS || 5000;
|
| 24 |
+
|
| 25 |
+
let statsTimer = null;
|
| 26 |
+
|
| 27 |
+
// DOM Elements
|
| 28 |
+
const els = {
|
| 29 |
+
// File upload
|
| 30 |
+
ref: document.getElementById('referenceInput'),
|
| 31 |
+
uploadButton: document.getElementById('uploadButton'),
|
| 32 |
+
uploadText: document.getElementById('uploadText'),
|
| 33 |
+
|
| 34 |
+
// Control buttons
|
| 35 |
+
init: document.getElementById('initBtn'),
|
| 36 |
+
debug: document.getElementById('debugBtn'),
|
| 37 |
+
connect: document.getElementById('connectBtn'),
|
| 38 |
+
disconnect: document.getElementById('disconnectBtn'),
|
| 39 |
+
|
| 40 |
+
// Video elements
|
| 41 |
+
localVideo: document.getElementById('localVideo'),
|
| 42 |
+
remoteVideo: document.getElementById('remoteVideo'),
|
| 43 |
+
localWrapper: document.getElementById('localWrapper'),
|
| 44 |
+
avatarWrapper: document.getElementById('avatarWrapper'),
|
| 45 |
+
localOverlay: document.getElementById('localOverlay'),
|
| 46 |
+
avatarOverlay: document.getElementById('avatarOverlay'),
|
| 47 |
+
|
| 48 |
+
// Status indicators
|
| 49 |
+
systemStatus: document.getElementById('systemStatus'),
|
| 50 |
+
localStatus: document.getElementById('localStatus'),
|
| 51 |
+
avatarStatus: document.getElementById('avatarStatus'),
|
| 52 |
+
statusText: document.getElementById('statusText'),
|
| 53 |
+
localStatusText: document.getElementById('localStatusText'),
|
| 54 |
+
avatarStatusText: document.getElementById('avatarStatusText'),
|
| 55 |
+
|
| 56 |
+
// Metrics
|
| 57 |
+
latencyValue: document.getElementById('latencyValue'),
|
| 58 |
+
fpsValue: document.getElementById('fpsValue'),
|
| 59 |
+
gpuValue: document.getElementById('gpuValue'),
|
| 60 |
+
qualityValue: document.getElementById('qualityValue'),
|
| 61 |
+
|
| 62 |
+
// Toast
|
| 63 |
+
toast: document.getElementById('toast'),
|
| 64 |
+
toastContent: document.getElementById('toastContent')
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
// Utility Functions
|
| 68 |
+
function log(...args) {
|
| 69 |
+
console.log('[MIRAGE]', ...args);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function vlog(...args) {
|
| 73 |
+
if(VERBOSE) console.log('[MIRAGE][VERBOSE]', ...args);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
// UI Helper Functions
|
| 77 |
+
function setSystemStatus(status, text) {
|
| 78 |
+
if (els.systemStatus) {
|
| 79 |
+
els.systemStatus.className = `status-indicator status-${status}`;
|
| 80 |
+
}
|
| 81 |
+
if (els.statusText) {
|
| 82 |
+
els.statusText.textContent = text;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function setLocalStatus(status, text) {
|
| 87 |
+
if (els.localStatus) {
|
| 88 |
+
els.localStatus.className = `status-indicator status-${status}`;
|
| 89 |
+
}
|
| 90 |
+
if (els.localStatusText) {
|
| 91 |
+
els.localStatusText.textContent = text;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function setAvatarStatus(status, text) {
|
| 96 |
+
if (els.avatarStatus) {
|
| 97 |
+
els.avatarStatus.className = `status-indicator status-${status}`;
|
| 98 |
+
}
|
| 99 |
+
if (els.avatarStatusText) {
|
| 100 |
+
els.avatarStatusText.textContent = text;
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function showToast(message, type = 'info') {
|
| 105 |
+
if (els.toastContent && els.toast) {
|
| 106 |
+
els.toastContent.textContent = message;
|
| 107 |
+
els.toast.className = `toast toast-${type} show`;
|
| 108 |
+
setTimeout(() => {
|
| 109 |
+
els.toast.classList.remove('show');
|
| 110 |
+
}, 4000);
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function updateMetrics(latency, fps, gpu, quality = 'HD') {
|
| 115 |
+
if (els.latencyValue) els.latencyValue.textContent = latency || '--';
|
| 116 |
+
if (els.fpsValue) els.fpsValue.textContent = fps || '--';
|
| 117 |
+
if (els.gpuValue) els.gpuValue.textContent = gpu || '--';
|
| 118 |
+
if (els.qualityValue) els.qualityValue.textContent = quality || '--';
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function setButtonLoading(button, loading = true) {
|
| 122 |
+
if (!button) return;
|
| 123 |
+
if (loading) {
|
| 124 |
+
button.disabled = true;
|
| 125 |
+
const originalText = button.innerHTML;
|
| 126 |
+
button.dataset.originalText = originalText;
|
| 127 |
+
button.innerHTML = '<span class="loading-spinner"></span> Processing...';
|
| 128 |
+
} else {
|
| 129 |
+
button.disabled = false;
|
| 130 |
+
if (button.dataset.originalText) {
|
| 131 |
+
button.innerHTML = button.dataset.originalText;
|
| 132 |
+
delete button.dataset.originalText;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function setStatus(txt) {
|
| 138 |
+
setSystemStatus('idle', txt);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// WebRTC Diagnostics
|
| 142 |
+
function attachPcDiagnostics(pc) {
|
| 143 |
+
if (!pc) return;
|
| 144 |
+
const evMap = ['signalingstatechange','iceconnectionstatechange','icegatheringstatechange','connectionstatechange','negotiationneeded'];
|
| 145 |
+
evMap.forEach(ev => {
|
| 146 |
+
pc.addEventListener(ev, () => { vlog('pc event', ev, diagSnapshot()); });
|
| 147 |
+
});
|
| 148 |
+
pc.addEventListener('track', ev => { vlog('pc track event', ev.track && ev.track.kind, ev.streams && ev.streams.length); });
|
| 149 |
+
pc.onicecandidate = (e) => {
|
| 150 |
+
if (e.candidate) {
|
| 151 |
+
vlog('ice candidate', e.candidate.type, e.candidate.protocol, e.candidate.address, e.candidate.relatedAddress||null);
|
| 152 |
+
} else {
|
| 153 |
+
vlog('ice candidate gathering complete');
|
| 154 |
+
}
|
| 155 |
+
};
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function diagSnapshot() {
|
| 159 |
+
if (!state.pc) return {};
|
| 160 |
+
return {
|
| 161 |
+
signaling: state.pc.signalingState,
|
| 162 |
+
iceConnection: state.pc.iceConnectionState,
|
| 163 |
+
iceGathering: state.pc.iceGatheringState,
|
| 164 |
+
connection: state.pc.connectionState,
|
| 165 |
+
localTracks: state.pc.getSenders().map(s => ({kind: s.track && s.track.kind, ready: s.track && s.track.readyState})),
|
| 166 |
+
remoteTracks: state.pc.getReceivers().map(r => ({kind: r.track && r.track.kind, ready: r.track && r.track.readyState}))
|
| 167 |
+
};
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
async function collectStats() {
|
| 171 |
+
if (!state.pc) return;
|
| 172 |
+
try {
|
| 173 |
+
const stats = await state.pc.getStats();
|
| 174 |
+
const summary = { ts: Date.now(), outbound: {}, inbound: {}, candidatePairs: [] };
|
| 175 |
+
stats.forEach(report => {
|
| 176 |
+
if (report.type === 'outbound-rtp' && !report.isRemote) {
|
| 177 |
+
summary.outbound[report.kind || report.mediaType || 'unknown'] = {
|
| 178 |
+
bitrateKbps: report.bytesSent && report.timestamp ? undefined : undefined,
|
| 179 |
+
frames: report.framesEncoded,
|
| 180 |
+
q: report.qualityLimitationReason,
|
| 181 |
+
packetsSent: report.packetsSent
|
| 182 |
+
};
|
| 183 |
+
} else if (report.type === 'inbound-rtp' && !report.isRemote) {
|
| 184 |
+
summary.inbound[report.kind || report.mediaType || 'unknown'] = {
|
| 185 |
+
jitter: report.jitter,
|
| 186 |
+
packetsLost: report.packetsLost,
|
| 187 |
+
frames: report.framesDecoded,
|
| 188 |
+
packetsReceived: report.packetsReceived
|
| 189 |
+
};
|
| 190 |
+
} else if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
| 191 |
+
summary.candidatePairs.push({
|
| 192 |
+
current: report.nominated,
|
| 193 |
+
bytesSent: report.bytesSent,
|
| 194 |
+
bytesReceived: report.bytesReceived,
|
| 195 |
+
rtt: report.currentRoundTripTime,
|
| 196 |
+
availableOutgoingBitrate: report.availableOutgoingBitrate
|
| 197 |
+
});
|
| 198 |
+
}
|
| 199 |
+
});
|
| 200 |
+
vlog('webrtc stats', summary);
|
| 201 |
+
} catch(e) {
|
| 202 |
+
vlog('stats error', e);
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
// Reference Image Handler
|
| 207 |
+
async function handleReference(e) {
|
| 208 |
+
const file = e.target.files && e.target.files[0];
|
| 209 |
+
if (!file) {
|
| 210 |
+
if (els.uploadButton) els.uploadButton.classList.remove('has-file');
|
| 211 |
+
if (els.uploadText) els.uploadText.textContent = 'Choose Reference Image';
|
| 212 |
+
return;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Update UI immediately
|
| 216 |
+
if (els.uploadButton) els.uploadButton.classList.add('has-file');
|
| 217 |
+
if (els.uploadText) els.uploadText.textContent = `β ${file.name}`;
|
| 218 |
+
showToast(`Reference image selected: ${file.name}`, 'success');
|
| 219 |
+
|
| 220 |
+
// Cache base64 for datachannel use
|
| 221 |
+
const buf = await file.arrayBuffer();
|
| 222 |
+
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
| 223 |
+
state.referenceImage = b64;
|
| 224 |
+
|
| 225 |
+
// Upload to server
|
| 226 |
+
try {
|
| 227 |
+
setSystemStatus('connecting', 'Uploading reference image...');
|
| 228 |
+
const fd = new FormData();
|
| 229 |
+
fd.append('file', new Blob([buf], {type: file.type || 'application/octet-stream'}), file.name || 'reference');
|
| 230 |
+
const resp = await fetch('/set_reference', {method: 'POST', body: fd});
|
| 231 |
+
const jr = await resp.json().catch(() => ({}));
|
| 232 |
+
|
| 233 |
+
if (resp.ok && jr && (jr.status === 'success' || jr.status === 'ok')) {
|
| 234 |
+
setSystemStatus('connected', 'Reference image uploaded successfully');
|
| 235 |
+
showToast('Reference image set successfully', 'success');
|
| 236 |
+
} else {
|
| 237 |
+
setSystemStatus('error', 'Reference upload failed');
|
| 238 |
+
showToast('Failed to upload reference image', 'error');
|
| 239 |
+
console.warn('set_reference response', resp.status, jr);
|
| 240 |
+
}
|
| 241 |
+
} catch(err) {
|
| 242 |
+
console.warn('set_reference error', err);
|
| 243 |
+
setSystemStatus('error', 'Reference upload error');
|
| 244 |
+
showToast('Error uploading reference image', 'error');
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// Send via data channel if already connected
|
| 248 |
+
try {
|
| 249 |
+
if (state.connected && state.control && state.control.readyState === 'open') {
|
| 250 |
+
state.control.send(JSON.stringify({type: 'set_reference', image_base64: state.referenceImage}));
|
| 251 |
+
showToast('Reference updated in live session', 'success');
|
| 252 |
+
}
|
| 253 |
+
} catch(_) {}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// WebRTC Connection
|
| 257 |
+
async function connect(options) {
|
| 258 |
+
const overrideRelay = options && options.forceRelay === true;
|
| 259 |
+
if (state.connected || state.connecting) return;
|
| 260 |
+
|
| 261 |
+
try {
|
| 262 |
+
setSystemStatus('connecting', 'Requesting camera access...');
|
| 263 |
+
setLocalStatus('connecting', 'Initializing');
|
| 264 |
+
setButtonLoading(els.connect, true);
|
| 265 |
+
els.disconnect.disabled = false;
|
| 266 |
+
state.cancelled = false;
|
| 267 |
+
state.connecting = true;
|
| 268 |
+
|
| 269 |
+
// Ping WebRTC router
|
| 270 |
+
try {
|
| 271 |
+
const ping = await fetch('/webrtc/ping');
|
| 272 |
+
if (ping.ok) {
|
| 273 |
+
const j = await ping.json();
|
| 274 |
+
log('webrtc ping', j);
|
| 275 |
+
}
|
| 276 |
+
} catch(_) {}
|
| 277 |
+
|
| 278 |
+
// Get auth token
|
| 279 |
+
let authToken = state.authToken;
|
| 280 |
+
try {
|
| 281 |
+
const t = await fetch('/webrtc/token');
|
| 282 |
+
if (t.ok) {
|
| 283 |
+
const j = await t.json();
|
| 284 |
+
authToken = j.token;
|
| 285 |
+
state.authToken = authToken;
|
| 286 |
+
} else if (t.status === 404) {
|
| 287 |
+
console.warn('Token endpoint 404 - proceeding without token');
|
| 288 |
+
}
|
| 289 |
+
} catch(_) {}
|
| 290 |
+
|
| 291 |
+
// Get user media
|
| 292 |
+
state.localStream = await navigator.mediaDevices.getUserMedia({video: true, audio: true});
|
| 293 |
+
els.localVideo.srcObject = state.localStream;
|
| 294 |
+
if (els.localWrapper) els.localWrapper.classList.add('active');
|
| 295 |
+
setLocalStatus('connected', 'Camera Active');
|
| 296 |
+
|
| 297 |
+
try {
|
| 298 |
+
els.localVideo.play && els.localVideo.play();
|
| 299 |
+
} catch(_) {}
|
| 300 |
+
|
| 301 |
+
setSystemStatus('connecting', 'Establishing connection...');
|
| 302 |
+
|
| 303 |
+
// Get ICE configuration
|
| 304 |
+
let iceCfg = {iceServers: [{urls: ['stun:stun.l.google.com:19302']}]};
|
| 305 |
+
try {
|
| 306 |
+
const ic = await fetch('/webrtc/ice_config');
|
| 307 |
+
if (ic.ok) { iceCfg = await ic.json(); }
|
| 308 |
+
} catch(_) {}
|
| 309 |
+
|
| 310 |
+
state._lastIceCfg = iceCfg;
|
| 311 |
+
if (overrideRelay || FORCE_RELAY_URL || iceCfg.forceRelay === true) {
|
| 312 |
+
iceCfg.iceTransportPolicy = 'relay';
|
| 313 |
+
}
|
| 314 |
+
log('ice config', iceCfg);
|
| 315 |
+
|
| 316 |
+
// Create peer connection
|
| 317 |
+
state.pc = new RTCPeerConnection(iceCfg);
|
| 318 |
+
attachPcDiagnostics(state.pc);
|
| 319 |
+
|
| 320 |
+
state._usedRelay = !!iceCfg.iceTransportPolicy && iceCfg.iceTransportPolicy === 'relay';
|
| 321 |
+
state._relayFallbackTried = !!overrideRelay || !!FORCE_RELAY_URL;
|
| 322 |
+
|
| 323 |
+
// Connection state handlers
|
| 324 |
+
state.pc.oniceconnectionstatechange = () => {
|
| 325 |
+
log('ice state', state.pc.iceConnectionState);
|
| 326 |
+
if (['failed', 'closed'].includes(state.pc.iceConnectionState)) {
|
| 327 |
+
if (!state.cancelled) disconnect();
|
| 328 |
+
}
|
| 329 |
+
if (state.pc.iceConnectionState === 'disconnected') {
|
| 330 |
+
vlog('ICE disconnected snapshot', diagSnapshot());
|
| 331 |
+
}
|
| 332 |
+
};
|
| 333 |
+
|
| 334 |
+
state.pc.onconnectionstatechange = () => {
|
| 335 |
+
const st = state.pc.connectionState;
|
| 336 |
+
log('pc state', st);
|
| 337 |
+
|
| 338 |
+
if (st === 'connected' && !statsTimer) {
|
| 339 |
+
statsTimer = setInterval(collectStats, STATS_INTERVAL_MS);
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
if (st === 'disconnected') {
|
| 343 |
+
vlog('PC disconnected snapshot', diagSnapshot());
|
| 344 |
+
try {
|
| 345 |
+
state.pc.restartIce && state.pc.restartIce();
|
| 346 |
+
vlog('Attempted ICE restart');
|
| 347 |
+
} catch(_) {}
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
if (['failed', 'closed'].includes(st)) {
|
| 351 |
+
if (statsTimer) {
|
| 352 |
+
clearInterval(statsTimer);
|
| 353 |
+
statsTimer = null;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
const hasTurn = state._lastIceCfg && (state._lastIceCfg.turnCount || 0) > 0;
|
| 357 |
+
const tryRelay = hasTurn && !state._usedRelay && !state._relayFallbackTried;
|
| 358 |
+
const snapshot = diagSnapshot();
|
| 359 |
+
vlog('Final failure snapshot', snapshot, {hasTurn, usedRelay: state._usedRelay, relayTried: state._relayFallbackTried});
|
| 360 |
+
|
| 361 |
+
disconnect().then(() => {
|
| 362 |
+
if (tryRelay) {
|
| 363 |
+
state._relayFallbackTried = true;
|
| 364 |
+
log('retrying with relay-only');
|
| 365 |
+
setSystemStatus('connecting', 'Retrying with TURN relay...');
|
| 366 |
+
connect({forceRelay: true});
|
| 367 |
+
} else if (!hasTurn && !state._usedRelay && !state._relayFallbackTried) {
|
| 368 |
+
log('skipping relay-only retry: no TURN servers available');
|
| 369 |
+
setSystemStatus('error', 'No TURN servers available');
|
| 370 |
+
showToast('Connection failed - no TURN servers available', 'error');
|
| 371 |
+
}
|
| 372 |
+
});
|
| 373 |
+
}
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
// Track handler
|
| 377 |
+
state.pc.ontrack = ev => {
|
| 378 |
+
try {
|
| 379 |
+
const tr = ev.track;
|
| 380 |
+
log('ontrack', tr && tr.kind, tr && tr.readyState, ev.streams && ev.streams.length);
|
| 381 |
+
|
| 382 |
+
if (tr && tr.kind === 'video') {
|
| 383 |
+
setSystemStatus('connected', 'Avatar stream received');
|
| 384 |
+
setAvatarStatus('connected', 'Active');
|
| 385 |
+
|
| 386 |
+
let stream;
|
| 387 |
+
if (ev.streams && ev.streams[0]) {
|
| 388 |
+
stream = ev.streams[0];
|
| 389 |
+
log('Using provided stream:', stream.id, 'tracks:', stream.getTracks().length);
|
| 390 |
+
} else {
|
| 391 |
+
stream = new MediaStream([ev.track]);
|
| 392 |
+
log('Created new MediaStream:', stream.id);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
// Set video source
|
| 396 |
+
log('Setting srcObject on video element');
|
| 397 |
+
els.remoteVideo.srcObject = null;
|
| 398 |
+
els.remoteVideo.srcObject = stream;
|
| 399 |
+
if (els.avatarWrapper) els.avatarWrapper.classList.add('active');
|
| 400 |
+
|
| 401 |
+
// Video event handlers
|
| 402 |
+
els.remoteVideo.onloadeddata = () => {
|
| 403 |
+
log('video: loadeddata, attempting play()');
|
| 404 |
+
els.remoteVideo.play().catch(e => {
|
| 405 |
+
log('play error', e.name, e.message);
|
| 406 |
+
setTimeout(() => {
|
| 407 |
+
log('Retry play() after error...');
|
| 408 |
+
els.remoteVideo.play().catch(e2 => log('Retry play failed:', e2.name));
|
| 409 |
+
}, 100);
|
| 410 |
+
});
|
| 411 |
+
};
|
| 412 |
+
|
| 413 |
+
els.remoteVideo.onplaying = () => {
|
| 414 |
+
log('video: playing');
|
| 415 |
+
showToast('Avatar stream connected successfully', 'success');
|
| 416 |
+
};
|
| 417 |
+
|
| 418 |
+
els.remoteVideo.onerror = (e) => {
|
| 419 |
+
log('video error:', e);
|
| 420 |
+
setAvatarStatus('error', 'Stream Error');
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
// Track state handlers
|
| 424 |
+
tr.onended = () => {
|
| 425 |
+
log('video track ended');
|
| 426 |
+
setAvatarStatus('idle', 'Disconnected');
|
| 427 |
+
if (els.avatarWrapper) els.avatarWrapper.classList.remove('active');
|
| 428 |
+
};
|
| 429 |
+
|
| 430 |
+
tr.onmute = () => {
|
| 431 |
+
log('video track muted');
|
| 432 |
+
setAvatarStatus('warning', 'Muted');
|
| 433 |
+
};
|
| 434 |
+
|
| 435 |
+
tr.onunmute = () => {
|
| 436 |
+
log('video track unmuted');
|
| 437 |
+
setAvatarStatus('connected', 'Active');
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
} else if (tr && tr.kind === 'audio') {
|
| 441 |
+
setSystemStatus('connected', 'Audio stream received');
|
| 442 |
+
}
|
| 443 |
+
} catch(e) {
|
| 444 |
+
log('ontrack error', e);
|
| 445 |
+
setAvatarStatus('error', 'Connection Error');
|
| 446 |
+
}
|
| 447 |
+
};
|
| 448 |
+
|
| 449 |
+
// Data channel setup
|
| 450 |
+
state.control = state.pc.createDataChannel('control');
|
| 451 |
+
|
| 452 |
+
state.control.onopen = () => {
|
| 453 |
+
setSystemStatus('connected', 'WebRTC connection established');
|
| 454 |
+
state.connected = true;
|
| 455 |
+
state.connecting = false;
|
| 456 |
+
setButtonLoading(els.connect, false);
|
| 457 |
+
els.connect.disabled = true;
|
| 458 |
+
els.disconnect.disabled = false;
|
| 459 |
+
showToast('WebRTC connection established', 'success');
|
| 460 |
+
|
| 461 |
+
// Send reference image if available
|
| 462 |
+
if (state.referenceImage) {
|
| 463 |
+
try {
|
| 464 |
+
state.control.send(JSON.stringify({type: 'set_reference', image_base64: state.referenceImage}));
|
| 465 |
+
showToast('Reference image sent to avatar', 'success');
|
| 466 |
+
} catch(e) {
|
| 467 |
+
showToast('Failed to send reference image', 'error');
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
// Start metrics polling
|
| 472 |
+
state.metricsTimer = setInterval(() => {
|
| 473 |
+
try {
|
| 474 |
+
state.control.send(JSON.stringify({type: 'metrics_request'}));
|
| 475 |
+
} catch(_) {}
|
| 476 |
+
}, 4000);
|
| 477 |
+
};
|
| 478 |
+
|
| 479 |
+
state.control.onmessage = (e) => {
|
| 480 |
+
try {
|
| 481 |
+
const data = JSON.parse(e.data);
|
| 482 |
+
if (data.type === 'metrics' && data.payload) {
|
| 483 |
+
updatePerf(data.payload);
|
| 484 |
+
} else if (data.type === 'reference_ack') {
|
| 485 |
+
setSystemStatus('connected', 'Reference acknowledged');
|
| 486 |
+
} else if (data.type === 'error' && data.message) {
|
| 487 |
+
setSystemStatus('error', 'Error: ' + data.message);
|
| 488 |
+
showToast('Avatar error: ' + data.message, 'error');
|
| 489 |
+
}
|
| 490 |
+
} catch(_) {}
|
| 491 |
+
};
|
| 492 |
+
|
| 493 |
+
// Add local tracks
|
| 494 |
+
state.localStream.getTracks().forEach(t => state.pc.addTrack(t, state.localStream));
|
| 495 |
+
|
| 496 |
+
// Create offer
|
| 497 |
+
const offer = await state.pc.createOffer({offerToReceiveAudio: true, offerToReceiveVideo: true});
|
| 498 |
+
await state.pc.setLocalDescription(offer);
|
| 499 |
+
|
| 500 |
+
// Wait for ICE gathering
|
| 501 |
+
setSystemStatus('connecting', 'Gathering ICE candidates...');
|
| 502 |
+
await new Promise((resolve) => {
|
| 503 |
+
if (state.pc.iceGatheringState === 'complete') return resolve();
|
| 504 |
+
const timeout = setTimeout(() => { resolve(); }, 7000);
|
| 505 |
+
state.pc.onicegatheringstatechange = () => {
|
| 506 |
+
if (state.pc.iceGatheringState === 'complete') {
|
| 507 |
+
clearTimeout(timeout);
|
| 508 |
+
resolve();
|
| 509 |
+
}
|
| 510 |
+
};
|
| 511 |
+
});
|
| 512 |
+
|
| 513 |
+
// Send offer to server
|
| 514 |
+
setSystemStatus('connecting', 'Negotiating connection...');
|
| 515 |
+
const headers = {'Content-Type': 'application/json'};
|
| 516 |
+
if (authToken) headers['X-Auth-Token'] = authToken;
|
| 517 |
+
|
| 518 |
+
const ld = state.pc.localDescription;
|
| 519 |
+
const r = await fetch('/webrtc/offer', {
|
| 520 |
+
method: 'POST',
|
| 521 |
+
headers,
|
| 522 |
+
body: JSON.stringify({sdp: ld.sdp, type: ld.type})
|
| 523 |
+
});
|
| 524 |
+
|
| 525 |
+
if (!r.ok) {
|
| 526 |
+
let bodyText = '';
|
| 527 |
+
try { bodyText = await r.text(); } catch(_) {}
|
| 528 |
+
if (r.status === 401 || r.status === 403) {
|
| 529 |
+
setSystemStatus('error', 'Unauthorized (check API key/token)');
|
| 530 |
+
showToast('Authentication failed', 'error');
|
| 531 |
+
} else {
|
| 532 |
+
setSystemStatus('error', `Server error: ${r.status}`);
|
| 533 |
+
showToast(`Connection failed: ${r.status}`, 'error');
|
| 534 |
+
}
|
| 535 |
+
throw new Error(`Server returned ${r.status}: ${bodyText}`);
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
const answer = await r.json();
|
| 539 |
+
await state.pc.setRemoteDescription(new RTCSessionDescription(answer));
|
| 540 |
+
log('WebRTC negotiation complete');
|
| 541 |
+
|
| 542 |
+
} catch(e) {
|
| 543 |
+
log('connect error', e);
|
| 544 |
+
setSystemStatus('error', 'Connection failed');
|
| 545 |
+
showToast('Failed to establish connection', 'error');
|
| 546 |
+
state.connecting = false;
|
| 547 |
+
setButtonLoading(els.connect, false);
|
| 548 |
+
throw e;
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
// Disconnect
|
| 553 |
+
async function disconnect() {
|
| 554 |
+
if (state.cancelled) return;
|
| 555 |
+
state.cancelled = true;
|
| 556 |
+
log('disconnecting...');
|
| 557 |
+
|
| 558 |
+
// Clear timers
|
| 559 |
+
if (statsTimer) {
|
| 560 |
+
clearInterval(statsTimer);
|
| 561 |
+
statsTimer = null;
|
| 562 |
+
}
|
| 563 |
+
if (state.metricsTimer) {
|
| 564 |
+
clearInterval(state.metricsTimer);
|
| 565 |
+
state.metricsTimer = null;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
// Close connections
|
| 569 |
+
if (state.control) {
|
| 570 |
+
try {
|
| 571 |
+
state.control.onmessage = null;
|
| 572 |
+
state.control.close();
|
| 573 |
+
} catch(_) {}
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
if (state.pc) {
|
| 577 |
+
try {
|
| 578 |
+
state.pc.ontrack = null;
|
| 579 |
+
state.pc.onconnectionstatechange = null;
|
| 580 |
+
state.pc.oniceconnectionstatechange = null;
|
| 581 |
+
state.pc.onicegatheringstatechange = null;
|
| 582 |
+
state.pc.close();
|
| 583 |
+
} catch(_) {}
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
if (state.localStream) {
|
| 587 |
+
try {
|
| 588 |
+
state.localStream.getTracks().forEach(t => t.stop());
|
| 589 |
+
} catch(_) {}
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
// Clear media elements and UI state
|
| 593 |
+
try {
|
| 594 |
+
els.localVideo.srcObject = null;
|
| 595 |
+
if (els.localWrapper) els.localWrapper.classList.remove('active');
|
| 596 |
+
setLocalStatus('idle', 'Inactive');
|
| 597 |
+
} catch(_) {}
|
| 598 |
+
|
| 599 |
+
try {
|
| 600 |
+
if (els.remoteVideo.srcObject) {
|
| 601 |
+
els.remoteVideo.pause();
|
| 602 |
+
els.remoteVideo.srcObject = null;
|
| 603 |
+
}
|
| 604 |
+
if (els.avatarWrapper) els.avatarWrapper.classList.remove('active');
|
| 605 |
+
setAvatarStatus('idle', 'Inactive');
|
| 606 |
+
} catch(_) {}
|
| 607 |
+
|
| 608 |
+
// Reset metrics
|
| 609 |
+
updateMetrics('--', '--', '--', '--');
|
| 610 |
+
|
| 611 |
+
// Server cleanup
|
| 612 |
+
try {
|
| 613 |
+
const hdrs = {};
|
| 614 |
+
if (state.authToken) hdrs['X-Auth-Token'] = state.authToken;
|
| 615 |
+
await fetch('/webrtc/cleanup', {method: 'POST', headers: hdrs});
|
| 616 |
+
} catch(_) {}
|
| 617 |
+
|
| 618 |
+
// Reset state
|
| 619 |
+
state.pc = null;
|
| 620 |
+
state.control = null;
|
| 621 |
+
state.localStream = null;
|
| 622 |
+
state.connected = false;
|
| 623 |
+
state.connecting = false;
|
| 624 |
+
|
| 625 |
+
setButtonLoading(els.connect, false);
|
| 626 |
+
els.connect.disabled = false;
|
| 627 |
+
els.disconnect.disabled = true;
|
| 628 |
+
setSystemStatus('idle', 'Disconnected');
|
| 629 |
+
showToast('Connection terminated', 'warning');
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
// Performance metrics update
|
| 633 |
+
function updatePerf(metrics) {
|
| 634 |
+
try {
|
| 635 |
+
const latency = metrics.latency_ms ? `${Math.round(metrics.latency_ms)}` : '--';
|
| 636 |
+
const fps = metrics.fps ? `${Math.round(metrics.fps)}` : '--';
|
| 637 |
+
const gpu = metrics.gpu_memory_used_mb ? `${Math.round(metrics.gpu_memory_used_mb)}MB` : '--';
|
| 638 |
+
const quality = metrics.quality || (fps > 25 ? 'HD' : fps > 15 ? 'SD' : 'Low');
|
| 639 |
+
|
| 640 |
+
updateMetrics(latency, fps, gpu, quality);
|
| 641 |
+
|
| 642 |
+
// Update connection quality
|
| 643 |
+
if (metrics.latency_ms) {
|
| 644 |
+
if (metrics.latency_ms < 100) {
|
| 645 |
+
setAvatarStatus('connected', 'Excellent');
|
| 646 |
+
} else if (metrics.latency_ms < 250) {
|
| 647 |
+
setAvatarStatus('connected', 'Good');
|
| 648 |
+
} else {
|
| 649 |
+
setAvatarStatus('warning', 'High Latency');
|
| 650 |
+
}
|
| 651 |
+
}
|
| 652 |
+
} catch(e) {
|
| 653 |
+
console.warn('updatePerf error', e);
|
| 654 |
+
}
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
// Event Handlers
|
| 658 |
+
function initializeEventListeners() {
|
| 659 |
+
// Reference image upload
|
| 660 |
+
if (els.ref) {
|
| 661 |
+
els.ref.addEventListener('change', handleReference);
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
// Initialize pipeline
|
| 665 |
+
if (els.init) {
|
| 666 |
+
els.init.addEventListener('click', async () => {
|
| 667 |
+
try {
|
| 668 |
+
setSystemStatus('connecting', 'Initializing AI pipeline...');
|
| 669 |
+
setButtonLoading(els.init, true);
|
| 670 |
+
|
| 671 |
+
const r = await fetch('/initialize', {method: 'POST'});
|
| 672 |
+
const j = await r.json().catch(() => ({}));
|
| 673 |
+
|
| 674 |
+
if (r.ok && j && (j.status === 'success' || j.status === 'already_initialized')) {
|
| 675 |
+
state.initialized = true;
|
| 676 |
+
setSystemStatus('connected', j.message || 'Pipeline initialized');
|
| 677 |
+
showToast('AI pipeline initialized successfully', 'success');
|
| 678 |
+
|
| 679 |
+
els.init.classList.remove('btn-secondary');
|
| 680 |
+
els.init.classList.add('btn-success');
|
| 681 |
+
els.init.innerHTML = `
|
| 682 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 683 |
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
| 684 |
+
<polyline points="22,4 12,14.01 9,11.01"/>
|
| 685 |
+
</svg>
|
| 686 |
+
Pipeline Ready
|
| 687 |
+
`;
|
| 688 |
+
} else {
|
| 689 |
+
setSystemStatus('error', 'Pipeline initialization failed');
|
| 690 |
+
showToast('Failed to initialize AI pipeline', 'error');
|
| 691 |
+
console.warn('initialize response', r.status, j);
|
| 692 |
+
}
|
| 693 |
+
} catch(e) {
|
| 694 |
+
setSystemStatus('error', 'Pipeline initialization error');
|
| 695 |
+
showToast('Error initializing AI pipeline', 'error');
|
| 696 |
+
} finally {
|
| 697 |
+
setButtonLoading(els.init, false);
|
| 698 |
+
}
|
| 699 |
+
});
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
// Debug
|
| 703 |
+
if (els.debug) {
|
| 704 |
+
els.debug.addEventListener('click', async () => {
|
| 705 |
+
try {
|
| 706 |
+
setSystemStatus('connecting', 'Fetching debug information...');
|
| 707 |
+
setButtonLoading(els.debug, true);
|
| 708 |
+
|
| 709 |
+
const r = await fetch('/debug/models');
|
| 710 |
+
const j = await r.json();
|
| 711 |
+
console.log('[DEBUG] /debug/models', j);
|
| 712 |
+
|
| 713 |
+
const modelCount = Object.keys(j.files || {}).length;
|
| 714 |
+
const existingModels = Object.values(j.files || {}).filter(f => f.exists).length;
|
| 715 |
+
showToast(`Debug: ${existingModels}/${modelCount} models loaded`, 'info');
|
| 716 |
+
|
| 717 |
+
const inswapper = j.files?.['inswapper_128_fp16.onnx'] || j.files?.['inswapper_128.onnx'];
|
| 718 |
+
const codeformer = j.files?.['codeformer.pth'];
|
| 719 |
+
|
| 720 |
+
const statusText = `Models: InSwapper=${inswapper?.exists?'β':'β'}, CodeFormer=${codeformer?.exists?'β':'β'}`;
|
| 721 |
+
setSystemStatus(inswapper?.exists ? 'connected' : 'warning', statusText);
|
| 722 |
+
|
| 723 |
+
if (!inswapper?.exists) {
|
| 724 |
+
setSystemStatus('connecting', 'Downloading models...');
|
| 725 |
+
try {
|
| 726 |
+
const d = await fetch('/debug/download_models', {method: 'POST'});
|
| 727 |
+
const dj = await d.json().catch(() => ({}));
|
| 728 |
+
console.log('[DEBUG] /debug/download_models', dj);
|
| 729 |
+
showToast('Model download initiated', 'info');
|
| 730 |
+
|
| 731 |
+
setTimeout(async () => {
|
| 732 |
+
const r2 = await fetch('/debug/models');
|
| 733 |
+
const j2 = await r2.json();
|
| 734 |
+
const inswapper2 = j2.files?.['inswapper_128_fp16.onnx'] || j2.files?.['inswapper_128.onnx'];
|
| 735 |
+
const newStatus = `Models refreshed: InSwapper=${inswapper2?.exists?'β':'β'}`;
|
| 736 |
+
setSystemStatus(inswapper2?.exists ? 'connected' : 'warning', newStatus);
|
| 737 |
+
}, 2000);
|
| 738 |
+
} catch(e) {
|
| 739 |
+
showToast('Model download failed', 'error');
|
| 740 |
+
console.warn('download_models failed', e);
|
| 741 |
+
}
|
| 742 |
+
}
|
| 743 |
+
} catch(e) {
|
| 744 |
+
setSystemStatus('error', 'Debug fetch failed');
|
| 745 |
+
showToast('Failed to fetch debug information', 'error');
|
| 746 |
+
} finally {
|
| 747 |
+
setButtonLoading(els.debug, false);
|
| 748 |
+
}
|
| 749 |
+
});
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
// Connect/Disconnect
|
| 753 |
+
if (els.connect) {
|
| 754 |
+
els.connect.addEventListener('click', () => connect());
|
| 755 |
+
}
|
| 756 |
+
if (els.disconnect) {
|
| 757 |
+
els.disconnect.addEventListener('click', () => disconnect());
|
| 758 |
+
}
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
// Auto-initialization
|
| 762 |
+
async function autoInitialize() {
|
| 763 |
+
try {
|
| 764 |
+
setSystemStatus('connecting', 'Auto-initializing system...');
|
| 765 |
+
|
| 766 |
+
const r = await fetch('/initialize', {method: 'POST'});
|
| 767 |
+
const j = await r.json().catch(() => ({}));
|
| 768 |
+
|
| 769 |
+
if (r.ok && j && (j.status === 'success' || j.status === 'already_initialized')) {
|
| 770 |
+
state.initialized = true;
|
| 771 |
+
setSystemStatus('connected', j.message || 'System ready');
|
| 772 |
+
|
| 773 |
+
if (els.init) {
|
| 774 |
+
els.init.classList.remove('btn-secondary');
|
| 775 |
+
els.init.classList.add('btn-success');
|
| 776 |
+
els.init.innerHTML = `
|
| 777 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 778 |
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
| 779 |
+
<polyline points="22,4 12,14.01 9,11.01"/>
|
| 780 |
+
</svg>
|
| 781 |
+
Pipeline Ready
|
| 782 |
+
`;
|
| 783 |
+
}
|
| 784 |
+
} else {
|
| 785 |
+
console.warn('auto-initialize response', r.status, j);
|
| 786 |
+
setSystemStatus('idle', 'Click Initialize to start');
|
| 787 |
+
}
|
| 788 |
+
} catch(e) {
|
| 789 |
+
setSystemStatus('idle', 'Click Initialize to start');
|
| 790 |
+
}
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
// Initialize application
|
| 794 |
+
function init() {
|
| 795 |
+
log('Initializing Mirage AI Avatar Studio');
|
| 796 |
+
initializeEventListeners();
|
| 797 |
+
autoInitialize();
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
// Start the application when DOM is ready
|
| 801 |
+
if (document.readyState === 'loading') {
|
| 802 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 803 |
+
} else {
|
| 804 |
+
init();
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
})();
|
static/webrtc_prod.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
/*
|
| 2 |
(function(){
|
| 3 |
const state = {
|
| 4 |
pc: null,
|
|
@@ -9,23 +9,130 @@
|
|
| 9 |
connected: false,
|
| 10 |
authToken: null,
|
| 11 |
connecting: false,
|
| 12 |
-
cancelled: false
|
|
|
|
| 13 |
};
|
|
|
|
| 14 |
const params = new URLSearchParams(location.search);
|
| 15 |
const FORCE_RELAY_URL = params.get('relay') === '1';
|
|
|
|
| 16 |
const els = {
|
|
|
|
| 17 |
ref: document.getElementById('referenceInput'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
init: document.getElementById('initBtn'),
|
| 19 |
debug: document.getElementById('debugBtn'),
|
| 20 |
connect: document.getElementById('connectBtn'),
|
| 21 |
disconnect: document.getElementById('disconnectBtn'),
|
|
|
|
|
|
|
| 22 |
localVideo: document.getElementById('localVideo'),
|
| 23 |
remoteVideo: document.getElementById('remoteVideo'),
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
};
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
// Verbose toggle (can be overridden by backend-provided global or URL param ?wv=1)
|
| 30 |
const VERBOSE = (window.MIRAGE_WEBRTC_VERBOSE === true) || (new URLSearchParams(location.search).get('wv')==='1');
|
| 31 |
const STATS_INTERVAL_MS = (window.MIRAGE_WEBRTC_STATS_INTERVAL_MS) || 5000;
|
|
@@ -91,28 +198,43 @@
|
|
| 91 |
|
| 92 |
async function handleReference(e){
|
| 93 |
const file = e.target.files && e.target.files[0];
|
| 94 |
-
if(!file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
// Cache base64 for datachannel use
|
| 96 |
const buf = await file.arrayBuffer();
|
| 97 |
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
| 98 |
state.referenceImage = b64;
|
|
|
|
| 99 |
// Also POST to HTTP endpoint so the pipeline has the reference even before WebRTC connects
|
| 100 |
try {
|
| 101 |
-
|
| 102 |
const fd = new FormData();
|
| 103 |
fd.append('file', new Blob([buf], {type: file.type||'application/octet-stream'}), file.name||'reference');
|
| 104 |
const resp = await fetch('/set_reference', {method:'POST', body: fd});
|
| 105 |
const jr = await resp.json().catch(()=>({}));
|
| 106 |
if (resp.ok && jr && (jr.status==='success' || jr.status==='ok')){
|
| 107 |
-
|
|
|
|
| 108 |
} else {
|
| 109 |
-
|
|
|
|
| 110 |
console.warn('set_reference response', resp.status, jr);
|
| 111 |
}
|
| 112 |
} catch(err){
|
| 113 |
console.warn('set_reference error', err);
|
| 114 |
-
|
|
|
|
| 115 |
}
|
|
|
|
| 116 |
// If already connected, also send via data channel for immediate in-session update
|
| 117 |
try {
|
| 118 |
if (state.connected && state.control && state.control.readyState === 'open') {
|
|
@@ -126,8 +248,9 @@
|
|
| 126 |
if(state.connected) return;
|
| 127 |
if(state.connecting) return;
|
| 128 |
try {
|
| 129 |
-
|
| 130 |
-
|
|
|
|
| 131 |
els.disconnect.disabled = false; // allow cancel during negotiation
|
| 132 |
state.cancelled = false; state.connecting = true;
|
| 133 |
// Quick ping to verify router is mounted
|
|
@@ -147,10 +270,12 @@
|
|
| 147 |
console.warn('Token endpoint 404 - proceeding without token');
|
| 148 |
}
|
| 149 |
} catch(_){}
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
let iceCfg = {iceServers:[{urls:['stun:stun.l.google.com:19302']}]};
|
| 155 |
try {
|
| 156 |
const ic = await fetch('/webrtc/ice_config');
|
|
@@ -206,8 +331,8 @@
|
|
| 206 |
const tr = ev.track;
|
| 207 |
log('ontrack', tr && tr.kind, tr && tr.readyState, ev.streams && ev.streams.length);
|
| 208 |
if (tr && tr.kind === 'video') {
|
| 209 |
-
|
| 210 |
-
|
| 211 |
let stream;
|
| 212 |
if(ev.streams && ev.streams[0]){
|
| 213 |
stream = ev.streams[0];
|
|
@@ -221,6 +346,7 @@
|
|
| 221 |
log('Setting srcObject on video element, current value:', els.remoteVideo.srcObject);
|
| 222 |
els.remoteVideo.srcObject = null;
|
| 223 |
els.remoteVideo.srcObject = stream;
|
|
|
|
| 224 |
log('srcObject set, waiting for loadeddata...');
|
| 225 |
|
| 226 |
// Add more event listeners for debugging
|
|
@@ -237,27 +363,56 @@
|
|
| 237 |
}, 100);
|
| 238 |
});
|
| 239 |
};
|
| 240 |
-
els.remoteVideo.onplaying = () =>
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
els.remoteVideo.onstalled = () => log('video: stalled');
|
| 243 |
|
| 244 |
// Monitor video track state changes
|
| 245 |
-
tr.onended = () =>
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
} else if (tr && tr.kind === 'audio') {
|
| 250 |
-
|
| 251 |
}
|
| 252 |
-
} catch(e){
|
|
|
|
|
|
|
|
|
|
| 253 |
};
|
| 254 |
state.control = state.pc.createDataChannel('control');
|
| 255 |
state.control.onopen = ()=>{
|
| 256 |
-
|
| 257 |
state.connected = true;
|
|
|
|
|
|
|
|
|
|
| 258 |
els.disconnect.disabled = false;
|
|
|
|
|
|
|
| 259 |
if(state.referenceImage){
|
| 260 |
-
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
}
|
| 262 |
// Metrics polling
|
| 263 |
state.metricsTimer = setInterval(()=>{
|
|
@@ -342,80 +497,151 @@
|
|
| 342 |
if(state.control){ try { state.control.onmessage=null; state.control.close(); }catch(_){} }
|
| 343 |
if(state.pc){ try { state.pc.ontrack=null; state.pc.onconnectionstatechange=null; state.pc.oniceconnectionstatechange=null; state.pc.onicegatheringstatechange=null; state.pc.close(); }catch(_){} }
|
| 344 |
if(state.localStream){ try { state.localStream.getTracks().forEach(t=>t.stop()); } catch(_){} }
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
try {
|
| 348 |
if (els.remoteVideo.srcObject) {
|
| 349 |
els.remoteVideo.pause();
|
| 350 |
els.remoteVideo.srcObject = null;
|
| 351 |
}
|
|
|
|
|
|
|
| 352 |
} catch(_){}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
// Best-effort server cleanup
|
| 354 |
try {
|
| 355 |
const hdrs = {};
|
| 356 |
if (state.authToken) hdrs['X-Auth-Token'] = state.authToken;
|
| 357 |
await fetch('/webrtc/cleanup', {method:'POST', headers: hdrs});
|
| 358 |
} catch(_){ }
|
|
|
|
| 359 |
state.pc=null; state.control=null; state.localStream=null; state.connected=false; state.connecting=false;
|
| 360 |
-
els.connect
|
|
|
|
|
|
|
|
|
|
| 361 |
}
|
| 362 |
|
| 363 |
els.ref.addEventListener('change', handleReference);
|
| 364 |
if (els.init) {
|
| 365 |
els.init.addEventListener('click', async ()=>{
|
| 366 |
try {
|
| 367 |
-
|
| 368 |
-
els.init
|
| 369 |
const r = await fetch('/initialize', {method:'POST'});
|
| 370 |
const j = await r.json().catch(()=>({}));
|
| 371 |
if (r.ok && j && (j.status==='success' || j.status==='already_initialized')){
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
} else {
|
| 374 |
-
|
|
|
|
| 375 |
console.warn('initialize response', r.status, j);
|
| 376 |
}
|
| 377 |
} catch(e){
|
| 378 |
-
|
|
|
|
| 379 |
} finally {
|
| 380 |
-
els.init
|
| 381 |
}
|
| 382 |
});
|
| 383 |
}
|
| 384 |
if (els.debug) {
|
| 385 |
els.debug.addEventListener('click', async ()=>{
|
| 386 |
try {
|
| 387 |
-
|
|
|
|
| 388 |
const r = await fetch('/debug/models');
|
| 389 |
const j = await r.json();
|
| 390 |
console.log('[DEBUG] /debug/models', j);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
const app = j.files?.['appearance_feature_extractor.onnx'];
|
| 392 |
const motion = j.files?.['motion_extractor.onnx'];
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
| 398 |
try {
|
| 399 |
const d = await fetch('/debug/download_models', {method:'POST'});
|
| 400 |
const dj = await d.json().catch(()=>({}));
|
| 401 |
console.log('[DEBUG] /debug/download_models', dj);
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
console.warn('download_models failed', e);
|
| 411 |
-
setStatus('Download failed');
|
| 412 |
}
|
| 413 |
}
|
| 414 |
} catch(e){
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
| 416 |
}
|
| 417 |
});
|
| 418 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
els.connect.addEventListener('click', connect);
|
| 420 |
els.disconnect.addEventListener('click', disconnect);
|
| 421 |
|
|
|
|
| 1 |
+
/* Enterprise-grade WebRTC client with premium UI integration */
|
| 2 |
(function(){
|
| 3 |
const state = {
|
| 4 |
pc: null,
|
|
|
|
| 9 |
connected: false,
|
| 10 |
authToken: null,
|
| 11 |
connecting: false,
|
| 12 |
+
cancelled: false,
|
| 13 |
+
initialized: false
|
| 14 |
};
|
| 15 |
+
|
| 16 |
const params = new URLSearchParams(location.search);
|
| 17 |
const FORCE_RELAY_URL = params.get('relay') === '1';
|
| 18 |
+
|
| 19 |
const els = {
|
| 20 |
+
// File upload
|
| 21 |
ref: document.getElementById('referenceInput'),
|
| 22 |
+
uploadButton: document.getElementById('uploadButton'),
|
| 23 |
+
uploadText: document.getElementById('u // Auto-initialize on page load (idempotent)
|
| 24 |
+
(async ()=>{
|
| 25 |
+
try {
|
| 26 |
+
setSystemStatus('connecting', 'Auto-initializing pipeline...');
|
| 27 |
+
const r = await fetch('/initialize', {method:'POST'});
|
| 28 |
+
const j = await r.json().catch(()=>({}));
|
| 29 |
+
if (r.ok && j && (j.status==='success' || j.status==='already_initialized')){
|
| 30 |
+
state.initialized = true;
|
| 31 |
+
setSystemStatus('connected', j.message || 'System ready');
|
| 32 |
+
if (els.init) {
|
| 33 |
+
els.init.classList.remove('btn-secondary');
|
| 34 |
+
els.init.classList.add('btn-success');
|
| 35 |
+
els.init.innerHTML = `
|
| 36 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 37 |
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
| 38 |
+
<polyline points="22,4 12,14.01 9,11.01"/>
|
| 39 |
+
</svg>
|
| 40 |
+
Pipeline Ready
|
| 41 |
+
`;
|
| 42 |
+
}
|
| 43 |
+
} else {
|
| 44 |
+
console.warn('auto-initialize response', r.status, j);
|
| 45 |
+
setSystemStatus('idle', 'Click Initialize to start');
|
| 46 |
+
}
|
| 47 |
+
} catch(e){
|
| 48 |
+
setSystemStatus('idle', 'Click Initialize to start');
|
| 49 |
+
}
|
| 50 |
+
})(); // Control buttons
|
| 51 |
init: document.getElementById('initBtn'),
|
| 52 |
debug: document.getElementById('debugBtn'),
|
| 53 |
connect: document.getElementById('connectBtn'),
|
| 54 |
disconnect: document.getElementById('disconnectBtn'),
|
| 55 |
+
|
| 56 |
+
// Video elements
|
| 57 |
localVideo: document.getElementById('localVideo'),
|
| 58 |
remoteVideo: document.getElementById('remoteVideo'),
|
| 59 |
+
localWrapper: document.getElementById('localWrapper'),
|
| 60 |
+
avatarWrapper: document.getElementById('avatarWrapper'),
|
| 61 |
+
localOverlay: document.getElementById('localOverlay'),
|
| 62 |
+
avatarOverlay: document.getElementById('avatarOverlay'),
|
| 63 |
+
|
| 64 |
+
// Status indicators
|
| 65 |
+
systemStatus: document.getElementById('systemStatus'),
|
| 66 |
+
localStatus: document.getElementById('localStatus'),
|
| 67 |
+
avatarStatus: document.getElementById('avatarStatus'),
|
| 68 |
+
statusText: document.getElementById('statusText'),
|
| 69 |
+
localStatusText: document.getElementById('localStatusText'),
|
| 70 |
+
avatarStatusText: document.getElementById('avatarStatusText'),
|
| 71 |
+
|
| 72 |
+
// Metrics
|
| 73 |
+
latencyValue: document.getElementById('latencyValue'),
|
| 74 |
+
fpsValue: document.getElementById('fpsValue'),
|
| 75 |
+
gpuValue: document.getElementById('gpuValue'),
|
| 76 |
+
qualityValue: document.getElementById('qualityValue'),
|
| 77 |
+
|
| 78 |
+
// Toast
|
| 79 |
+
toast: document.getElementById('toast'),
|
| 80 |
+
toastContent: document.getElementById('toastContent')
|
| 81 |
};
|
| 82 |
+
|
| 83 |
+
// UI Helper Functions
|
| 84 |
+
function setSystemStatus(status, text) {
|
| 85 |
+
els.systemStatus.className = `status-indicator status-${status}`;
|
| 86 |
+
els.statusText.textContent = text;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function setLocalStatus(status, text) {
|
| 90 |
+
els.localStatus.className = `status-indicator status-${status}`;
|
| 91 |
+
els.localStatusText.textContent = text;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function setAvatarStatus(status, text) {
|
| 95 |
+
els.avatarStatus.className = `status-indicator status-${status}`;
|
| 96 |
+
els.avatarStatusText.textContent = text;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function showToast(message, type = 'info') {
|
| 100 |
+
els.toastContent.textContent = message;
|
| 101 |
+
els.toast.className = `toast toast-${type} show`;
|
| 102 |
+
setTimeout(() => {
|
| 103 |
+
els.toast.classList.remove('show');
|
| 104 |
+
}, 4000);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function updateMetrics(latency, fps, gpu, quality = 'HD') {
|
| 108 |
+
els.latencyValue.textContent = latency || '--';
|
| 109 |
+
els.fpsValue.textContent = fps || '--';
|
| 110 |
+
els.gpuValue.textContent = gpu || '--';
|
| 111 |
+
els.qualityValue.textContent = quality || '--';
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
function setButtonLoading(button, loading = true) {
|
| 115 |
+
if (loading) {
|
| 116 |
+
button.disabled = true;
|
| 117 |
+
const originalText = button.innerHTML;
|
| 118 |
+
button.dataset.originalText = originalText;
|
| 119 |
+
button.innerHTML = '<span class="loading-spinner"></span> Processing...';
|
| 120 |
+
} else {
|
| 121 |
+
button.disabled = false;
|
| 122 |
+
if (button.dataset.originalText) {
|
| 123 |
+
button.innerHTML = button.dataset.originalText;
|
| 124 |
+
delete button.dataset.originalText;
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
function setStatus(txt) {
|
| 130 |
+
setSystemStatus('idle', txt);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function log(...a) {
|
| 134 |
+
console.log('[MIRAGE]', ...a);
|
| 135 |
+
}
|
| 136 |
// Verbose toggle (can be overridden by backend-provided global or URL param ?wv=1)
|
| 137 |
const VERBOSE = (window.MIRAGE_WEBRTC_VERBOSE === true) || (new URLSearchParams(location.search).get('wv')==='1');
|
| 138 |
const STATS_INTERVAL_MS = (window.MIRAGE_WEBRTC_STATS_INTERVAL_MS) || 5000;
|
|
|
|
| 198 |
|
| 199 |
async function handleReference(e){
|
| 200 |
const file = e.target.files && e.target.files[0];
|
| 201 |
+
if(!file) {
|
| 202 |
+
els.uploadButton.classList.remove('has-file');
|
| 203 |
+
els.uploadText.textContent = 'Choose Reference Image';
|
| 204 |
+
return;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// Update UI immediately
|
| 208 |
+
els.uploadButton.classList.add('has-file');
|
| 209 |
+
els.uploadText.textContent = `β ${file.name}`;
|
| 210 |
+
showToast(`Reference image selected: ${file.name}`, 'success');
|
| 211 |
+
|
| 212 |
// Cache base64 for datachannel use
|
| 213 |
const buf = await file.arrayBuffer();
|
| 214 |
const b64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
|
| 215 |
state.referenceImage = b64;
|
| 216 |
+
|
| 217 |
// Also POST to HTTP endpoint so the pipeline has the reference even before WebRTC connects
|
| 218 |
try {
|
| 219 |
+
setSystemStatus('connecting', 'Uploading reference image...');
|
| 220 |
const fd = new FormData();
|
| 221 |
fd.append('file', new Blob([buf], {type: file.type||'application/octet-stream'}), file.name||'reference');
|
| 222 |
const resp = await fetch('/set_reference', {method:'POST', body: fd});
|
| 223 |
const jr = await resp.json().catch(()=>({}));
|
| 224 |
if (resp.ok && jr && (jr.status==='success' || jr.status==='ok')){
|
| 225 |
+
setSystemStatus('connected', 'Reference image uploaded successfully');
|
| 226 |
+
showToast('Reference image set successfully', 'success');
|
| 227 |
} else {
|
| 228 |
+
setSystemStatus('error', 'Reference upload failed');
|
| 229 |
+
showToast('Failed to upload reference image', 'error');
|
| 230 |
console.warn('set_reference response', resp.status, jr);
|
| 231 |
}
|
| 232 |
} catch(err){
|
| 233 |
console.warn('set_reference error', err);
|
| 234 |
+
setSystemStatus('error', 'Reference upload error');
|
| 235 |
+
showToast('Error uploading reference image', 'error');
|
| 236 |
}
|
| 237 |
+
|
| 238 |
// If already connected, also send via data channel for immediate in-session update
|
| 239 |
try {
|
| 240 |
if (state.connected && state.control && state.control.readyState === 'open') {
|
|
|
|
| 248 |
if(state.connected) return;
|
| 249 |
if(state.connecting) return;
|
| 250 |
try {
|
| 251 |
+
setSystemStatus('connecting', 'Requesting camera access...');
|
| 252 |
+
setLocalStatus('connecting', 'Initializing');
|
| 253 |
+
setButtonLoading(els.connect, true);
|
| 254 |
els.disconnect.disabled = false; // allow cancel during negotiation
|
| 255 |
state.cancelled = false; state.connecting = true;
|
| 256 |
// Quick ping to verify router is mounted
|
|
|
|
| 270 |
console.warn('Token endpoint 404 - proceeding without token');
|
| 271 |
}
|
| 272 |
} catch(_){}
|
| 273 |
+
state.localStream = await navigator.mediaDevices.getUserMedia({video:true,audio:true});
|
| 274 |
+
els.localVideo.srcObject = state.localStream;
|
| 275 |
+
els.localWrapper.classList.add('active');
|
| 276 |
+
setLocalStatus('connected', 'Camera Active');
|
| 277 |
+
try { els.localVideo.play && els.localVideo.play(); } catch(_) {}
|
| 278 |
+
setSystemStatus('connecting', 'Establishing connection...');
|
| 279 |
let iceCfg = {iceServers:[{urls:['stun:stun.l.google.com:19302']}]};
|
| 280 |
try {
|
| 281 |
const ic = await fetch('/webrtc/ice_config');
|
|
|
|
| 331 |
const tr = ev.track;
|
| 332 |
log('ontrack', tr && tr.kind, tr && tr.readyState, ev.streams && ev.streams.length);
|
| 333 |
if (tr && tr.kind === 'video') {
|
| 334 |
+
setSystemStatus('connected', 'Avatar stream received');
|
| 335 |
+
setAvatarStatus('connected', 'Active');
|
| 336 |
let stream;
|
| 337 |
if(ev.streams && ev.streams[0]){
|
| 338 |
stream = ev.streams[0];
|
|
|
|
| 346 |
log('Setting srcObject on video element, current value:', els.remoteVideo.srcObject);
|
| 347 |
els.remoteVideo.srcObject = null;
|
| 348 |
els.remoteVideo.srcObject = stream;
|
| 349 |
+
els.avatarWrapper.classList.add('active');
|
| 350 |
log('srcObject set, waiting for loadeddata...');
|
| 351 |
|
| 352 |
// Add more event listeners for debugging
|
|
|
|
| 363 |
}, 100);
|
| 364 |
});
|
| 365 |
};
|
| 366 |
+
els.remoteVideo.onplaying = () => {
|
| 367 |
+
log('video: playing');
|
| 368 |
+
showToast('Avatar stream connected successfully', 'success');
|
| 369 |
+
};
|
| 370 |
+
els.remoteVideo.onerror = (e) => {
|
| 371 |
+
log('video error:', e);
|
| 372 |
+
setAvatarStatus('error', 'Stream Error');
|
| 373 |
+
};
|
| 374 |
els.remoteVideo.onstalled = () => log('video: stalled');
|
| 375 |
|
| 376 |
// Monitor video track state changes
|
| 377 |
+
tr.onended = () => {
|
| 378 |
+
log('video track ended');
|
| 379 |
+
setAvatarStatus('idle', 'Disconnected');
|
| 380 |
+
els.avatarWrapper.classList.remove('active');
|
| 381 |
+
};
|
| 382 |
+
tr.onmute = () => {
|
| 383 |
+
log('video track muted');
|
| 384 |
+
setAvatarStatus('warning', 'Muted');
|
| 385 |
+
};
|
| 386 |
+
tr.onunmute = () => {
|
| 387 |
+
log('video track unmuted');
|
| 388 |
+
setAvatarStatus('connected', 'Active');
|
| 389 |
+
};
|
| 390 |
|
| 391 |
} else if (tr && tr.kind === 'audio') {
|
| 392 |
+
setSystemStatus('connected', 'Audio stream received');
|
| 393 |
}
|
| 394 |
+
} catch(e){
|
| 395 |
+
log('ontrack error', e);
|
| 396 |
+
setAvatarStatus('error', 'Connection Error');
|
| 397 |
+
}
|
| 398 |
};
|
| 399 |
state.control = state.pc.createDataChannel('control');
|
| 400 |
state.control.onopen = ()=>{
|
| 401 |
+
setSystemStatus('connected', 'WebRTC connection established');
|
| 402 |
state.connected = true;
|
| 403 |
+
state.connecting = false;
|
| 404 |
+
setButtonLoading(els.connect, false);
|
| 405 |
+
els.connect.disabled = true;
|
| 406 |
els.disconnect.disabled = false;
|
| 407 |
+
showToast('WebRTC connection established', 'success');
|
| 408 |
+
|
| 409 |
if(state.referenceImage){
|
| 410 |
+
try {
|
| 411 |
+
state.control.send(JSON.stringify({type:'set_reference', image_base64: state.referenceImage}));
|
| 412 |
+
showToast('Reference image sent to avatar', 'success');
|
| 413 |
+
} catch(e) {
|
| 414 |
+
showToast('Failed to send reference image', 'error');
|
| 415 |
+
}
|
| 416 |
}
|
| 417 |
// Metrics polling
|
| 418 |
state.metricsTimer = setInterval(()=>{
|
|
|
|
| 497 |
if(state.control){ try { state.control.onmessage=null; state.control.close(); }catch(_){} }
|
| 498 |
if(state.pc){ try { state.pc.ontrack=null; state.pc.onconnectionstatechange=null; state.pc.oniceconnectionstatechange=null; state.pc.onicegatheringstatechange=null; state.pc.close(); }catch(_){} }
|
| 499 |
if(state.localStream){ try { state.localStream.getTracks().forEach(t=>t.stop()); } catch(_){} }
|
| 500 |
+
|
| 501 |
+
// Clear media elements and UI state
|
| 502 |
+
try {
|
| 503 |
+
els.localVideo.srcObject = null;
|
| 504 |
+
els.localWrapper.classList.remove('active');
|
| 505 |
+
setLocalStatus('idle', 'Inactive');
|
| 506 |
+
} catch(_){}
|
| 507 |
try {
|
| 508 |
if (els.remoteVideo.srcObject) {
|
| 509 |
els.remoteVideo.pause();
|
| 510 |
els.remoteVideo.srcObject = null;
|
| 511 |
}
|
| 512 |
+
els.avatarWrapper.classList.remove('active');
|
| 513 |
+
setAvatarStatus('idle', 'Inactive');
|
| 514 |
} catch(_){}
|
| 515 |
+
|
| 516 |
+
// Reset metrics
|
| 517 |
+
updateMetrics('--', '--', '--', '--');
|
| 518 |
+
|
| 519 |
// Best-effort server cleanup
|
| 520 |
try {
|
| 521 |
const hdrs = {};
|
| 522 |
if (state.authToken) hdrs['X-Auth-Token'] = state.authToken;
|
| 523 |
await fetch('/webrtc/cleanup', {method:'POST', headers: hdrs});
|
| 524 |
} catch(_){ }
|
| 525 |
+
|
| 526 |
state.pc=null; state.control=null; state.localStream=null; state.connected=false; state.connecting=false;
|
| 527 |
+
setButtonLoading(els.connect, false);
|
| 528 |
+
els.connect.disabled=false; els.disconnect.disabled=true;
|
| 529 |
+
setSystemStatus('idle', 'Disconnected');
|
| 530 |
+
showToast('Connection terminated', 'warning');
|
| 531 |
}
|
| 532 |
|
| 533 |
els.ref.addEventListener('change', handleReference);
|
| 534 |
if (els.init) {
|
| 535 |
els.init.addEventListener('click', async ()=>{
|
| 536 |
try {
|
| 537 |
+
setSystemStatus('connecting', 'Initializing AI pipeline...');
|
| 538 |
+
setButtonLoading(els.init, true);
|
| 539 |
const r = await fetch('/initialize', {method:'POST'});
|
| 540 |
const j = await r.json().catch(()=>({}));
|
| 541 |
if (r.ok && j && (j.status==='success' || j.status==='already_initialized')){
|
| 542 |
+
state.initialized = true;
|
| 543 |
+
setSystemStatus('connected', j.message || 'Pipeline initialized');
|
| 544 |
+
showToast('AI pipeline initialized successfully', 'success');
|
| 545 |
+
els.init.classList.remove('btn-secondary');
|
| 546 |
+
els.init.classList.add('btn-success');
|
| 547 |
+
els.init.innerHTML = `
|
| 548 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 549 |
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
| 550 |
+
<polyline points="22,4 12,14.01 9,11.01"/>
|
| 551 |
+
</svg>
|
| 552 |
+
Pipeline Ready
|
| 553 |
+
`;
|
| 554 |
} else {
|
| 555 |
+
setSystemStatus('error', 'Pipeline initialization failed');
|
| 556 |
+
showToast('Failed to initialize AI pipeline', 'error');
|
| 557 |
console.warn('initialize response', r.status, j);
|
| 558 |
}
|
| 559 |
} catch(e){
|
| 560 |
+
setSystemStatus('error', 'Pipeline initialization error');
|
| 561 |
+
showToast('Error initializing AI pipeline', 'error');
|
| 562 |
} finally {
|
| 563 |
+
setButtonLoading(els.init, false);
|
| 564 |
}
|
| 565 |
});
|
| 566 |
}
|
| 567 |
if (els.debug) {
|
| 568 |
els.debug.addEventListener('click', async ()=>{
|
| 569 |
try {
|
| 570 |
+
setSystemStatus('connecting', 'Fetching debug information...');
|
| 571 |
+
setButtonLoading(els.debug, true);
|
| 572 |
const r = await fetch('/debug/models');
|
| 573 |
const j = await r.json();
|
| 574 |
console.log('[DEBUG] /debug/models', j);
|
| 575 |
+
|
| 576 |
+
// Show debug info in toast
|
| 577 |
+
const modelCount = Object.keys(j.files || {}).length;
|
| 578 |
+
const existingModels = Object.values(j.files || {}).filter(f => f.exists).length;
|
| 579 |
+
showToast(`Debug: ${existingModels}/${modelCount} models loaded`, 'info');
|
| 580 |
+
|
| 581 |
const app = j.files?.['appearance_feature_extractor.onnx'];
|
| 582 |
const motion = j.files?.['motion_extractor.onnx'];
|
| 583 |
+
const inswapper = j.files?.['inswapper_128_fp16.onnx'] || j.files?.['inswapper_128.onnx'];
|
| 584 |
+
|
| 585 |
+
let statusText = `Models: InSwapper=${inswapper?.exists?'β':'β'}, App=${app?.exists?'β':'β'}, Motion=${motion?.exists?'β':'β'}`;
|
| 586 |
+
setSystemStatus(inswapper?.exists ? 'connected' : 'warning', statusText);
|
| 587 |
+
|
| 588 |
+
// If missing critical models, try to download
|
| 589 |
+
if (!inswapper?.exists) {
|
| 590 |
+
setSystemStatus('connecting', 'Downloading models...');
|
| 591 |
try {
|
| 592 |
const d = await fetch('/debug/download_models', {method:'POST'});
|
| 593 |
const dj = await d.json().catch(()=>({}));
|
| 594 |
console.log('[DEBUG] /debug/download_models', dj);
|
| 595 |
+
showToast('Model download initiated', 'info');
|
| 596 |
+
|
| 597 |
+
// Refresh model status
|
| 598 |
+
setTimeout(async () => {
|
| 599 |
+
const r2 = await fetch('/debug/models');
|
| 600 |
+
const j2 = await r2.json();
|
| 601 |
+
const inswapper2 = j2.files?.['inswapper_128_fp16.onnx'] || j2.files?.['inswapper_128.onnx'];
|
| 602 |
+
const newStatus = `Models refreshed: InSwapper=${inswapper2?.exists?'β':'β'}`;
|
| 603 |
+
setSystemStatus(inswapper2?.exists ? 'connected' : 'warning', newStatus);
|
| 604 |
+
}, 2000);
|
| 605 |
+
} catch(e) {
|
| 606 |
+
showToast('Model download failed', 'error');
|
| 607 |
console.warn('download_models failed', e);
|
|
|
|
| 608 |
}
|
| 609 |
}
|
| 610 |
} catch(e){
|
| 611 |
+
setSystemStatus('error', 'Debug fetch failed');
|
| 612 |
+
showToast('Failed to fetch debug information', 'error');
|
| 613 |
+
} finally {
|
| 614 |
+
setButtonLoading(els.debug, false);
|
| 615 |
}
|
| 616 |
});
|
| 617 |
}
|
| 618 |
+
|
| 619 |
+
// Update performance metrics display
|
| 620 |
+
function updatePerf(metrics) {
|
| 621 |
+
try {
|
| 622 |
+
const latency = metrics.latency_ms ? `${Math.round(metrics.latency_ms)}` : '--';
|
| 623 |
+
const fps = metrics.fps ? `${Math.round(metrics.fps)}` : '--';
|
| 624 |
+
const gpu = metrics.gpu_memory_used_mb ? `${Math.round(metrics.gpu_memory_used_mb)}MB` : '--';
|
| 625 |
+
const quality = metrics.quality || (fps > 25 ? 'HD' : fps > 15 ? 'SD' : 'Low');
|
| 626 |
+
|
| 627 |
+
updateMetrics(latency, fps, gpu, quality);
|
| 628 |
+
|
| 629 |
+
// Update connection quality indicator
|
| 630 |
+
if (metrics.latency_ms) {
|
| 631 |
+
if (metrics.latency_ms < 100) {
|
| 632 |
+
setAvatarStatus('connected', 'Excellent');
|
| 633 |
+
} else if (metrics.latency_ms < 250) {
|
| 634 |
+
setAvatarStatus('connected', 'Good');
|
| 635 |
+
} else {
|
| 636 |
+
setAvatarStatus('warning', 'High Latency');
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
} catch(e) {
|
| 640 |
+
console.warn('updatePerf error', e);
|
| 641 |
+
}
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
// Event listeners
|
| 645 |
els.connect.addEventListener('click', connect);
|
| 646 |
els.disconnect.addEventListener('click', disconnect);
|
| 647 |
|