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 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 Real-time AI Avatar</title>
6
  <meta name="viewport" content="width=device-width,initial-scale=1" />
 
 
 
7
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  body {
9
- font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
10
- margin: 20px;
11
- background: #1a1a1a;
12
- color: #fff;
13
- }
14
- .container { max-width: 1200px; margin: 0 auto; }
15
- .header { text-align: center; margin-bottom: 30px; }
16
- .controls {
17
- display: flex;
18
- gap: 10px;
19
- margin-bottom: 20px;
20
- flex-wrap: wrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  align-items: center;
 
22
  }
23
- .video-container {
24
- display: flex;
25
- gap: 20px;
26
- margin-bottom: 20px;
27
- flex-wrap: wrap;
28
- }
29
- .video-box {
30
- flex: 1;
31
- min-width: 300px;
32
- background: #2a2a2a;
33
- border-radius: 8px;
34
- padding: 15px;
35
- }
36
- video, img, canvas {
37
- width: 100%;
38
- border-radius: 8px;
39
- background: #000;
 
40
  }
41
- .video-box video {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  width: 100%;
43
- height: 360px;
44
- object-fit: cover;
 
 
 
 
 
 
45
  }
46
- button {
47
- background: #007bff;
48
- color: white;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  border: none;
50
- padding: 10px 16px;
51
- border-radius: 5px;
 
 
52
  cursor: pointer;
53
- font-size: 14px;
54
- }
55
- button:hover { background: #0056b3; }
56
- button:disabled {
57
- background: #6c757d;
58
- cursor: not-allowed;
59
- }
60
- .status {
61
- padding: 10px;
62
- border-radius: 5px;
63
- margin: 10px 0;
64
- }
65
- .status.success { background: #28a745; }
66
- .status.error { background: #dc3545; }
67
- .status.info { background: #17a2b8; }
68
- #log {
69
- font: 11px/1.3 monospace;
70
- white-space: pre-line;
71
- background: #000;
72
- padding: 15px;
73
- border-radius: 8px;
74
- height: 200px;
75
- overflow-y: auto;
76
- color: #0f0;
77
- }
78
- .metrics {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  display: grid;
80
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
81
- gap: 15px;
82
- margin: 20px 0;
83
  }
84
- .metric-card {
85
- background: #2a2a2a;
86
- padding: 15px;
87
- border-radius: 8px;
88
- border-left: 4px solid #007bff;
 
 
89
  }
 
90
  .metric-value {
91
- font-size: 24px;
92
- font-weight: bold;
93
- color: #007bff;
 
 
94
  }
 
95
  .metric-label {
96
- font-size: 12px;
97
- color: #888;
98
  text-transform: uppercase;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  }
100
- input[type="file"] {
101
- margin: 10px 0;
 
102
  }
103
- .virtual-camera-info {
104
- background: #2a2a2a;
105
- padding: 15px;
106
- border-radius: 8px;
107
- margin: 20px 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  }
109
  </style>
110
  </head>
111
  <body>
112
- <div class="container">
113
- <div class="header">
114
- <h1>Mirage Realtime Avatar</h1>
115
- <p class="subtitle">Production Preview</p>
116
- </div>
 
117
 
118
- <div class="controls" id="controls">
119
- <input type="file" id="referenceInput" accept="image/*" title="Reference Image" />
120
- <button id="initBtn">Initialize AI Pipeline</button>
121
- <button id="debugBtn" title="Fetch /debug/models and log to console">Debug Models</button>
122
- <button id="connectBtn">Connect</button>
123
- <button id="disconnectBtn" disabled>Disconnect</button>
124
- <span id="statusText" style="margin-left:auto;font-size:12px;color:#888;">Idle</span>
125
- </div>
 
 
 
 
 
 
 
 
 
 
126
 
127
- <div class="video-container">
128
- <div class="video-box">
129
- <h3>Local</h3>
130
- <video id="localVideo" autoplay muted playsinline></video>
131
- </div>
132
- <div class="video-box">
133
- <h3>Avatar</h3>
134
- <video id="remoteVideo" autoplay playsinline muted></video>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </div>
136
  </div>
 
137
 
138
- <div id="perfBar" style="font-size:12px;color:#bbb;margin-top:10px;">Latency: -- ms Β· FPS: -- Β· GPU: --</div>
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
- /* Production-focused WebRTC client (replaces dev UI). */
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
- status: document.getElementById('statusText'),
25
- perf: document.getElementById('perfBar')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  };
27
- function setStatus(txt){ els.status.textContent = txt; }
28
- function log(...a){ console.log('[PROD]', ...a); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) return;
 
 
 
 
 
 
 
 
 
 
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
- setStatus('Uploading reference...');
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
- setStatus('Reference set');
 
108
  } else {
109
- setStatus('Reference upload failed');
 
110
  console.warn('set_reference response', resp.status, jr);
111
  }
112
  } catch(err){
113
  console.warn('set_reference error', err);
114
- setStatus('Reference upload error');
 
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
- setStatus('Requesting media');
130
- els.connect.disabled = true;
 
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
- state.localStream = await navigator.mediaDevices.getUserMedia({video:true,audio:true});
151
- els.localVideo.srcObject = state.localStream;
152
- try { els.localVideo.play && els.localVideo.play(); } catch(_) {}
153
- setStatus('Creating peer');
 
 
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
- // Only handle video track for remote video element
210
- setStatus('Video track received');
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 = () => log('video: playing');
241
- els.remoteVideo.onerror = (e) => log('video error:', e);
 
 
 
 
 
 
242
  els.remoteVideo.onstalled = () => log('video: stalled');
243
 
244
  // Monitor video track state changes
245
- tr.onended = () => log('video track ended');
246
- tr.onmute = () => log('video track muted');
247
- tr.onunmute = () => log('video track unmuted');
 
 
 
 
 
 
 
 
 
 
248
 
249
  } else if (tr && tr.kind === 'audio') {
250
- setStatus('Audio track received');
251
  }
252
- } catch(e){ log('ontrack error', e); }
 
 
 
253
  };
254
  state.control = state.pc.createDataChannel('control');
255
  state.control.onopen = ()=>{
256
- setStatus('Connected');
257
  state.connected = true;
 
 
 
258
  els.disconnect.disabled = false;
 
 
259
  if(state.referenceImage){
260
- try { state.control.send(JSON.stringify({type:'set_reference', image_base64: state.referenceImage})); } catch(e) {}
 
 
 
 
 
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
- // Clear media elements
346
- try { els.localVideo.srcObject = null; } catch(_){}
 
 
 
 
 
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.disabled=false; els.disconnect.disabled=true; setStatus('Idle');
 
 
 
361
  }
362
 
363
  els.ref.addEventListener('change', handleReference);
364
  if (els.init) {
365
  els.init.addEventListener('click', async ()=>{
366
  try {
367
- setStatus('Initializing pipeline...');
368
- els.init.disabled = true;
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
- setStatus(j.message || 'Initialized');
 
 
 
 
 
 
 
 
 
 
 
373
  } else {
374
- setStatus('Init failed');
 
375
  console.warn('initialize response', r.status, j);
376
  }
377
  } catch(e){
378
- setStatus('Init error');
 
379
  } finally {
380
- els.init.disabled = false;
381
  }
382
  });
383
  }
384
  if (els.debug) {
385
  els.debug.addEventListener('click', async ()=>{
386
  try {
387
- setStatus('Fetching debug info...');
 
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
- let statusText = `ONNX: app=${app?.exists?'βœ”':'βœ–'}(${app?.size_bytes||0}), motion=${motion?.exists?'βœ”':'βœ–'}(${motion?.size_bytes||0})`;
394
- setStatus(statusText);
395
- // If missing, try to force a download now
396
- if (!app?.exists) {
397
- setStatus('Downloading models...');
 
 
 
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
- // Refresh presence
403
- const r2 = await fetch('/debug/models');
404
- const j2 = await r2.json();
405
- const app2 = j2.files?.['appearance_feature_extractor.onnx'];
406
- const motion2 = j2.files?.['motion_extractor.onnx'];
407
- statusText = `ONNX: app=${app2?.exists?'βœ”':'βœ–'}(${app2?.size_bytes||0}), motion=${motion2?.exists?'βœ”':'βœ–'}(${motion2?.size_bytes||0})`;
408
- setStatus(statusText);
409
- } catch(e){
 
 
 
 
410
  console.warn('download_models failed', e);
411
- setStatus('Download failed');
412
  }
413
  }
414
  } catch(e){
415
- setStatus('Debug fetch failed');
 
 
 
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